diff --git a/apps/website/scripts/course-from-spec.mjs b/apps/website/scripts/course-from-spec.mjs index 2f1be2244e..9782f7d12b 100644 --- a/apps/website/scripts/course-from-spec.mjs +++ b/apps/website/scripts/course-from-spec.mjs @@ -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/.t27 each course spec, byte for byte (its pages link to it) // // Run: node scripts/course-from-spec.mjs (write) @@ -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' @@ -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`) @@ -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 (#/, #//), 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) @@ -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`) @@ -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, } } @@ -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), } } @@ -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) { diff --git a/apps/website/scripts/course-from-spec.test.mjs b/apps/website/scripts/course-from-spec.test.mjs index 897a90ccbc..e5720be26e 100644 --- a/apps/website/scripts/course-from-spec.test.mjs +++ b/apps/website/scripts/course-from-spec.test.mjs @@ -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() @@ -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) }) @@ -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}`) +}) diff --git a/apps/website/specs/course/courses.t27 b/apps/website/specs/course/courses.t27 index fef8a0ba5e..2b47f33b13 100644 --- a/apps/website/specs/course/courses.t27 +++ b/apps/website/specs/course/courses.t27 @@ -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; diff --git a/apps/website/src/components/GlobalStarfield.tsx b/apps/website/src/components/GlobalStarfield.tsx index ba9a771313..cef6d8408b 100644 --- a/apps/website/src/components/GlobalStarfield.tsx +++ b/apps/website/src/components/GlobalStarfield.tsx @@ -1,5 +1,6 @@ import { useLocation } from 'react-router-dom' import PhiStarfield from './PhiStarfield' +import { COURSE_ROUTES } from '../lib/courseRoutes.generated' // Звёздное поле было только на главной, хотя оно не украшение: положения звёзд // берутся из спирали Фогеля с углом 137.5077° = круг, делённый на φ², — то же @@ -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() diff --git a/apps/website/src/components/Navigation.tsx b/apps/website/src/components/Navigation.tsx index c67a8e648c..8093131259 100644 --- a/apps/website/src/components/Navigation.tsx +++ b/apps/website/src/components/Navigation.tsx @@ -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' }, diff --git a/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts b/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts index 3fbc58c5c9..9e9bae96a0 100644 --- a/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts +++ b/apps/website/src/data/blog/bodies/course-three-verifying-hardware.ts @@ -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." } ] @@ -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, и та же проверка падает, если у курса нет пункта меню." } ] diff --git a/apps/website/src/lib/courseRoutes.generated.ts b/apps/website/src/lib/courseRoutes.generated.ts new file mode 100644 index 0000000000..4a793a5643 --- /dev/null +++ b/apps/website/src/lib/courseRoutes.generated.ts @@ -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 (#/, #//), in reading order. */ +export const COURSE_ROUTES = ["t27-basics","course","ai-numbers","goldenfloat","verify-hardware","clocks-and-cdc","buses-and-peripherals"] as const diff --git a/apps/website/src/main.tsx b/apps/website/src/main.tsx index 6836048001..fc843c4922 100644 --- a/apps/website/src/main.tsx +++ b/apps/website/src/main.tsx @@ -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 @@ -142,14 +143,11 @@ createRoot(document.getElementById('root')!).render( } /> {/* Each course is a t27 spec (specs/course/courses.t27 names them); the paid training that used to live here moved to /fpga-training. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Every course of specs/course/courses.t27: a hand list held 4 of 7 and "Next course" opened nothing. */} + {COURSE_ROUTES.flatMap((route) => [ + } />, + } />, + ])} } /> } /> } />