Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions apps/website/scripts/course-from-spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
// nobody edits by hand:
//
// src/lib/course.generated.ts what src/pages/Course.tsx draws, every course
// src/lib/courseRoutes.generated.ts the course addresses alone, for the router, the
// starfield and the menu (src/main.tsx etc.), so none of them
// lists courses by hand and none loads the courses to route
//
// and holds the menu (src/components/Navigation.tsx) to an entry for every course address.
// public/learn/<id>.t27 each course spec, byte for byte (its pages link to it)
//
// Run: node scripts/course-from-spec.mjs (write)
Expand All @@ -38,6 +43,8 @@ import { runSpecTests } from './viewport-from-spec.mjs'
const WASM = 'public/t27/t27_compiler.wasm'
export const CATALOG_SPEC = 'specs/course/courses.t27'
export const TS_OUT = 'src/lib/course.generated.ts'
export const ROUTES_OUT = 'src/lib/courseRoutes.generated.ts'
export const NAV_TSX = 'src/components/Navigation.tsx'
export const GALLERY_TS = 'src/lib/queenWidgets.generated.ts'
export const LESSON_SPEC_ROOT = 'public/t27/files'
export const SHARE_BASE = 'learn'
Expand Down Expand Up @@ -97,7 +104,7 @@ export function catalogProblems(c, file = CATALOG_SPEC) {
const p = []
if (c.KIND !== 'courses') p.push(`${file}: KIND must be "courses"`)
if (c.SCHEMA_VERSION !== 1) p.push(`${file}: SCHEMA_VERSION must be 1`)
if (!same(c.GENERATED, [TS_OUT])) p.push(`${file}: GENERATED must be ${JSON.stringify([TS_OUT])}`)
if (!same(c.GENERATED, [TS_OUT, ROUTES_OUT])) p.push(`${file}: GENERATED must be ${JSON.stringify([TS_OUT, ROUTES_OUT])}`)
if (c.SHARE_BASE !== SHARE_BASE) p.push(`${file}: SHARE_BASE must be ${SHARE_BASE}`)
for (const name of ['COURSE_IDS', 'COURSE_SPECS']) if (c[name].length !== c.COURSE_COUNT) p.push(`${file}: ${name}.length ${c[name].length} != COURSE_COUNT ${c.COURSE_COUNT}`)
if (!uniq(c.COURSE_IDS)) p.push(`${file}: COURSE_IDS must be unique`)
Expand Down Expand Up @@ -287,6 +294,17 @@ export const chainOf = (courses) => courses.map((c, i) => ({ ...c, prev: linkOf(

export const renderTs = (courses) => `// GENERATED by scripts/course-from-spec.mjs from ${CATALOG_SPEC}, ${courses.map((c) => c.source.spec).join(', ')} and their Russian bundles\n// spec sha256 ${courses.map((c) => `${c.id} ${c.source.sha256}`).join(', ')}\n// Do not edit: change the .t27 source (or a Russian bundle) and regenerate.\n\nexport const COURSES = ${JSON.stringify(courses, null, 2)} as const\n\n/** The course every older link means: the first in the catalog. */\nexport const COURSE = COURSES[0]\n\nexport type Course = (typeof COURSES)[number]\nexport type CourseLesson = Course['lessons'][number]\nexport type CourseFrame = CourseLesson['widget']\n`

export const renderRoutesTs = (courses) => `// GENERATED by scripts/course-from-spec.mjs from ${CATALOG_SPEC}
// Do not edit: change the .t27 source and regenerate.

/** Every course's address in the app (#/<route>, #/<route>/<lesson>), in reading order. */
export const COURSE_ROUTES = ${JSON.stringify(courses.map((c) => c.route))} as const
`

/** The menu offers every course: a course with an address but no entry is found only by its chain link. */
export const navProblems = (courses, navText, file = NAV_TSX) =>
courses.filter((c) => !navText.includes(`href: '#/${c.route}'`)).map((c) => `${file}: no menu entry for #/${c.route} (${c.id})`)

function readSpec(name, text, analyze, required) {
const problems = []
const analysis = analyze(text)
Expand Down Expand Up @@ -335,7 +353,7 @@ function readCourse(id, { specText, ruText, bundleText }, catalog, analyze, gall
return { id, file, f, r, bundle, problems, specSha: sha256(Buffer.from(specText, 'utf8')), tests: [course.tests, ru.tests] }
}

export async function buildCourses({ catalogText, courses: inputs, analyze, gallery, root = SITE }) {
export async function buildCourses({ catalogText, courses: inputs, navText, analyze, gallery, root = SITE }) {
const catalog = readSpec(CATALOG_SPEC, catalogText, analyze, CATALOG_REQUIRED)
const problems = [...catalog.problems]
if (catalog.analysis.ast?.name !== 'courses') problems.push(`${CATALOG_SPEC}: module must be courses`)
Expand All @@ -347,12 +365,15 @@ export async function buildCourses({ catalogText, courses: inputs, analyze, gall
for (const x of read) problems.push(...x.problems)
if (problems.length === 0) problems.push(...acrossProblems(read, gallery))
const playerBuild = gallery.widgets.find((w) => w.kind === 'player')?.preview?.match(/[?&]v=([A-Za-z0-9]+)/)?.[1] ?? ''
const out = problems.length ? null : chainOf(read.map((x) => courseOf(x.f, x.r, x.bundle, gallery, x.specSha, playerBuild)))
let out = problems.length ? null : chainOf(read.map((x) => courseOf(x.f, x.r, x.bundle, gallery, x.specSha, playerBuild)))
if (out) problems.push(...navProblems(out, navText ?? ''))
if (problems.length) out = null
const all = [catalog.tests, ...read.flatMap((x) => x.tests)]
return {
problems, courses: out,
tests: { tests: all.reduce((s, t) => s + t.tests, 0), asserts: all.reduce((s, t) => s + t.asserts, 0) },
ts: out ? renderTs(out) : null,
routesTs: out ? renderRoutesTs(out) : null,
publicSpecs: out ? c.COURSE_IDS.map((id, i) => [publicSpecOf(id), inputs[i].specText]) : null,
}
}
Expand All @@ -368,6 +389,7 @@ export async function readInputs(root = SITE) {
const bundleRel = ruText.match(/pub const BUNDLE_PATH : str = "([^"]+)";/)?.[1] ?? ''
return { specText: read(courseSpecOf(id)), ruText, bundleText: bundleRel ? read(bundleRel.replace(/^apps\/website\//, '')) : '' }
}),
navText: read(NAV_TSX),
gallery: readGallery(root),
}
}
Expand All @@ -383,7 +405,7 @@ async function main() {
process.exit(1)
}
if (json) { process.stdout.write(JSON.stringify(out.courses, null, 2) + '\n'); return }
const targets = [[TS_OUT, out.ts], ...out.publicSpecs]
const targets = [[TS_OUT, out.ts], [ROUTES_OUT, out.routesTs], ...out.publicSpecs]
if (check) {
const stale = targets.filter(([rel, text]) => !existsSync(join(SITE, rel)) || readFileSync(join(SITE, rel), 'utf8') !== text)
if (stale.length) {
Expand Down
21 changes: 20 additions & 1 deletion apps/website/scripts/course-from-spec.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { SITE, loadCompiler } from './agents-from-specs.mjs'
import { TS_OUT, buildCourses, digitsOf, publicSpecOf, readInputs } from './course-from-spec.mjs'
import { ROUTES_OUT, TS_OUT, buildCourses, digitsOf, publicSpecOf, readInputs } from './course-from-spec.mjs'

const analyze = await loadCompiler(readFileSync(join(SITE, 'public/t27/t27_compiler.wasm')))
const inputs = await readInputs()
Expand All @@ -27,6 +27,7 @@ test('the committed specs build clean and the derived files are current', async
const out = await build()
assert.deepEqual(out.problems, [])
assert.equal(readFileSync(join(SITE, TS_OUT), 'utf8'), out.ts)
assert.equal(readFileSync(join(SITE, ROUTES_OUT), 'utf8'), out.routesTs)
for (const [rel, text] of out.publicSpecs) assert.equal(readFileSync(join(SITE, rel), 'utf8'), text)
for (const c of out.courses) assert.equal(c.sendsNothing, true)
})
Expand Down Expand Up @@ -167,3 +168,21 @@ test('a Russian superlative fails the build', async () => {
test('digitsOf reads a number once, whatever its separators', () => {
assert.deepEqual(digitsOf('68.4% of 1,234 and 7'), ['1234', '684', '7'])
})

// 2026-10-10: the router listed 4 of the 7 courses by hand, and "Next course" after GoldenFloat
// opened #/verify-hardware, which had no route. Every course address now comes from the catalog.
test('every course has an address in the app: the router and the starfield import the generated list', async () => {
const out = await build()
assert.deepEqual(out.routesTs.match(/COURSE_ROUTES = (\[[^\]]*\])/) && JSON.parse(out.routesTs.match(/COURSE_ROUTES = (\[[^\]]*\])/)[1]), out.courses.map((c) => c.route))
for (const file of ['src/main.tsx', 'src/components/GlobalStarfield.tsx']) {
const text = readFileSync(join(SITE, file), 'utf8')
assert.match(text, /from '\.\.?\/lib\/courseRoutes\.generated'/, `${file} imports the generated course routes`)
for (const c of out.courses) assert.ok(!text.includes(`'/${c.route}'`) && !text.includes(`"/${c.route}"`), `${file} names /${c.route} by hand`)
}
})

test('a course with no menu entry fails the build', async () => {
const route = inputs.courses.length && (await build()).courses.at(-1).route
const navText = replaced(inputs.navText, `href: '#/${route}'`, `href: '#/${route}-gone'`)
await fails({ navText }, `no menu entry for #/${route}`)
})
8 changes: 6 additions & 2 deletions apps/website/specs/course/courses.t27
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ module courses;

pub const KIND : str = "courses";
pub const SCHEMA_VERSION : u8 = 1;
; The generator writes every course into this one file; each course spec names its own copy.
pub const GENERATED : [1]str = ["src/lib/course.generated.ts"];
; The generator writes every course into the first file; each course spec names its own copy.
; The second is the course addresses alone: the router, the starfield and the menu import it,
; so a course named here has an address in the app without loading the courses. On
; 2026-10-10 the router listed 4 of the 7 by hand and "Next course" after GoldenFloat opened
; nothing (#/verify-hardware had no route).
pub const GENERATED : [2]str = ["src/lib/course.generated.ts", "src/lib/courseRoutes.generated.ts"];

pub const COURSE_COUNT : u8 = 7;

Expand Down
5 changes: 3 additions & 2 deletions apps/website/src/components/GlobalStarfield.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useLocation } from 'react-router-dom'
import PhiStarfield from './PhiStarfield'
import { COURSE_ROUTES } from '../lib/courseRoutes.generated'

// Звёздное поле было только на главной, хотя оно не украшение: положения звёзд
// берутся из спирали Фогеля с углом 137.5077° = круг, делённый на φ², — то же
Expand All @@ -20,8 +21,8 @@ const OWN_CANVAS = new Set([
'/tree',
])

// The course routes of specs/course/courses.t27, kept here so the starfield does not load the courses.
const COURSE_ROOTS = ['/t27-basics', '/course', '/ai-numbers', '/goldenfloat']
// The course routes of specs/course/courses.t27, from the small generated list, so the starfield does not load the courses.
const COURSE_ROOTS = COURSE_ROUTES.map((r) => `/${r}`)

export default function GlobalStarfield() {
const { pathname } = useLocation()
Expand Down
3 changes: 3 additions & 0 deletions apps/website/src/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const PAGES: PageLink[] = [
{ href: '#/course', en: 'Course: FPGA', ru: 'Курс: FPGA', note: 'From zero to a chip, 27 lessons', noteRu: 'С нуля до чипа, 27 уроков' },
{ href: '#/ai-numbers', en: 'Course: AI numbers', ru: 'Курс: ИИ-числа', note: 'How AI chips store numbers, 27 lessons', noteRu: 'Как ИИ-чипы хранят числа, 27 уроков' },
{ href: '#/goldenfloat', en: 'Course: GoldenFloat', ru: 'Курс: GoldenFloat', note: 'A float family split by phi, 27 lessons', noteRu: 'Семейство чисел, разрезанных по phi, 27 уроков' },
{ href: '#/verify-hardware', en: 'Course: verifying hardware', ru: 'Курс: проверка железа', note: 'Testbenches, waveforms, formal, mutation, 27 lessons', noteRu: 'Тестбенчи, временные диаграммы, формальные методы, мутации, 27 уроков' },
{ href: '#/clocks-and-cdc', en: 'Course: clocks and CDC', ru: 'Курс: такты и CDC', note: 'Clocking, resets, clock-domain crossings, 27 lessons', noteRu: 'Тактирование, сброс, пересечения тактовых доменов, 27 уроков' },
{ href: '#/buses-and-peripherals', en: 'Course: buses and peripherals', ru: 'Курс: шины и периферия', note: 'UART, SPI, APB, AXI4, Ethernet, 27 lessons', noteRu: 'UART, SPI, APB, AXI4, Ethernet, 27 уроков' },
{ href: '#/foundry', en: 'Golden Foundry', ru: 'Золотая Литейная', note: 'A club for people who build on silicon', noteRu: 'Клуб разработчиков на кремнии', color: '#C9A24B' },
{ href: '#/about', en: 'About', ru: 'Об авторе', note: 'Background, papers, contact', noteRu: 'Биография, статьи, контакты' },
{ href: '#/resources', en: 'Resources', ru: 'Материалы', note: 'Papers and datasets, each with a DOI', noteRu: 'Статьи и датасеты, у каждого DOI' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export const body: Block[] = [
"In the app: t27.ai/#/verify-hardware",
"The course spec: t27.ai/learn/verify-hardware.t27"
]
},
{
"kind": "h",
"text": "Correction, 2026-10-10"
},
{
"kind": "p",
"text": "The app address above opened nothing from 2026-10-07 to 2026-10-10. The router listed course addresses by hand and held 4 of the 7 courses, so \"Next course\" at the end of GoldenFloat led to #/verify-hardware, which had no route; clocks-and-cdc and buses-and-peripherals had none either. The share pages under learn/ were never affected. The router, the starfield and the menu now take every course address from one list that scripts/course-from-spec.mjs writes from specs/course/courses.t27, and the same check fails when a course has no menu entry."
}
]

Expand Down Expand Up @@ -187,5 +195,13 @@ export const ruBody: Block[] = [
"В приложении: t27.ai/#/verify-hardware",
"Spec курса: t27.ai/learn/verify-hardware.t27"
]
},
{
"kind": "h",
"text": "Поправка, 10.10.2026"
},
{
"kind": "p",
"text": "Адрес в приложении выше с 07.10 по 10.10.2026 не открывал ничего. Маршрутизатор перечислял адреса курсов вручную и знал 4 курса из 7, поэтому «Следующий курс» в конце GoldenFloat вёл на #/verify-hardware, у которого не было маршрута; у clocks-and-cdc и buses-and-peripherals тоже. Страницы для ссылок в learn/ это не задело. Теперь маршрутизатор, звёздный фон и меню берут адреса всех курсов из одного списка, который scripts/course-from-spec.mjs пишет из specs/course/courses.t27, и та же проверка падает, если у курса нет пункта меню."
}
]
5 changes: 5 additions & 0 deletions apps/website/src/lib/courseRoutes.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// GENERATED by scripts/course-from-spec.mjs from specs/course/courses.t27
// Do not edit: change the .t27 source and regenerate.

/** Every course's address in the app (#/<route>, #/<route>/<lesson>), in reading order. */
export const COURSE_ROUTES = ["t27-basics","course","ai-numbers","goldenfloat","verify-hardware","clocks-and-cdc","buses-and-peripherals"] as const
14 changes: 6 additions & 8 deletions apps/website/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { handExplorerLinksToQueen } from './lib/queenFrame'
import { redirectLegacyQueen } from './lib/legacyQueenRedirect'
import { triIdentity } from './lib/triIdentity'
import { installBotLinkCarry, reportArrival } from './lib/trafficArrival'
import { COURSE_ROUTES } from './lib/courseRoutes.generated'

// THE QUEEN MOVED to https://app.t27.ai/queen/, which now builds this same
// bundle and serves the board on the app's own origin. #/queen here is the old
Expand Down Expand Up @@ -142,14 +143,11 @@ createRoot(document.getElementById('root')!).render(
<Route path="/verification" element={<HardwareVerification />} />
{/* Each course is a t27 spec (specs/course/courses.t27 names them);
the paid training that used to live here moved to /fpga-training. */}
<Route path="/t27-basics" element={<Course />} />
<Route path="/t27-basics/:lessonId" element={<Course />} />
<Route path="/course" element={<Course />} />
<Route path="/course/:lessonId" element={<Course />} />
<Route path="/ai-numbers" element={<Course />} />
<Route path="/ai-numbers/:lessonId" element={<Course />} />
<Route path="/goldenfloat" element={<Course />} />
<Route path="/goldenfloat/:lessonId" element={<Course />} />
{/* Every course of specs/course/courses.t27: a hand list held 4 of 7 and "Next course" opened nothing. */}
{COURSE_ROUTES.flatMap((route) => [
<Route key={route} path={`/${route}`} element={<Course />} />,
<Route key={`${route}/lesson`} path={`/${route}/:lessonId`} element={<Course />} />,
])}
<Route path="/fpga-training" element={<FpgaTraining />} />
<Route path="/cases" element={<CaseStudies />} />
<Route path="/gft" element={<GFT />} />
Expand Down
Loading