diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 9e8f7a8dcc..e11f991218 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -131,6 +131,15 @@ jobs: - name: ARIA references resolve run: npm run check:aria + # A post's body module must live at bodies/.ts: the reader resolves + # it by file name while the static build resolves it through posts.ts, and + # the two disagreed once -- two posts (buses-and-peripherals, + # clocks-resets-and-cdc) rendered statically but died in the reader on + # every opening, because their modules were named course-*.ts. The gate + # runs its own negative control first. + - name: every post opens in the reader + run: npm run check:blog-bodies + # Not a gate on the count, a gate on the DIRECTION, and per file: a scalar # total passes when one file is fixed and another breaks by the same # amount. --selftest first, so a gate nobody has watched fail is not diff --git a/apps/website/package.json b/apps/website/package.json index a64e93152d..9eca2a1efa 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -34,6 +34,7 @@ "check:render": "node scripts/render-check.mjs", "check:deployed": "node scripts/deployed-check.mjs", "check:aria": "node scripts/aria-refs-check.mjs", + "check:blog-bodies": "node scripts/blog-bodies-check.mjs --self-test && node scripts/blog-bodies-check.mjs", "check:service-entry": "node scripts/service-entry-check.mjs", "typecheck:update": "node scripts/typecheck-ratchet.mjs --update", "audit:en": "node qa/lang_audit.mjs", diff --git a/apps/website/scripts/blog-bodies-check.mjs b/apps/website/scripts/blog-bodies-check.mjs new file mode 100644 index 0000000000..5d743c10f4 --- /dev/null +++ b/apps/website/scripts/blog-bodies-check.mjs @@ -0,0 +1,82 @@ +// The invariant: a post's interactive body is looked up BY SLUG, so the body +// module must live at src/data/blog/bodies/.ts. +// +// Two components resolve bodies independently, and they once disagreed. The +// static build goes through posts.ts, whose import table happily mapped the +// slug 'buses-and-peripherals' to a file named course-buses-and-peripherals.ts +// -- and the site rendered fine. The reader's SPA (Blog.tsx) resolves the same +// body through import.meta.glob keyed by FILE NAME, found no +// buses-and-peripherals.ts, and every opening of that post ended on +// "Интерактивная версия не смогла загрузить этот пост". The build could not +// see it: posts.ts was consistent. This gate holds the file name to the slug +// so the two resolutions cannot drift apart again. + +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadPublishedPosts } from './update-blog-cover-versions.mjs' + +const ROOT = join(new URL('.', import.meta.url).pathname, '..') + +/** + * Pure half, so the failure mode is tested instead of trusted. + * + * missing -- published slugs with no bodies/.ts (breaks the SPA reader) + * orphans -- body modules no post loads; they still ship as dead chunks + */ +export function checkBlogBodies({ slugs, bodiesDir }) { + const files = new Set( + readdirSync(bodiesDir) + .filter((f) => f.endsWith('.ts')) + .map((f) => f.replace(/\.ts$/, '')), + ) + const missing = slugs.filter((slug) => !files.has(slug)) + const orphans = [...files].filter((f) => !slugs.includes(f)).sort() + return { missing, orphans } +} + +function report({ slugs, missing, orphans }) { + for (const slug of missing) { + console.error(`::error::post "${slug}" has no src/data/blog/bodies/${slug}.ts -- the reader cannot open it`) + } + if (missing.length) { + console.error('Rename the body module to the slug (git mv), or rename the slug: posts.ts maps imports, the reader glob does not.') + process.exit(1) + } + if (orphans.length) { + console.log(`::warning::${orphans.length} body module(s) no post loads (they still ship as chunks):`) + for (const f of orphans) console.log(` ${f}`) + } + console.log(`blog bodies: every published post of ${slugs.length} opens in the reader`) +} + +// A gate nobody has watched fail is not a gate: plant a slug with no file and +// require the detector to report it before trusting the real run. +function selfTest() { + const dir = mkdtempSync(join(tmpdir(), 't27-blog-bodies-')) + try { + writeFileSync(join(dir, 'a-real-post.ts'), 'export const body = []\n') + writeFileSync(join(dir, 'an-orphan.ts'), 'export const body = []\n') + writeFileSync(join(dir, 'not-a-module.md'), 'not a body module\n') + const { missing, orphans } = checkBlogBodies({ + slugs: ['a-real-post', 'a-post-with-no-file'], + bodiesDir: dir, + }) + if (missing.length !== 1 || missing[0] !== 'a-post-with-no-file') { + throw new Error(`self-test: expected the fileless slug to be missing, got ${JSON.stringify(missing)}`) + } + if (orphans.length !== 1 || orphans[0] !== 'an-orphan') { + throw new Error(`self-test: expected exactly the orphan module, got ${JSON.stringify(orphans)}`) + } + console.log('self-test: a fileless slug is reported, an orphan is listed') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +if (process.argv[2] === '--self-test') { + selfTest() +} else { + const slugs = loadPublishedPosts().map((p) => p.slug) + report({ slugs, ...checkBlogBodies({ slugs, bodiesDir: join(ROOT, 'src/data/blog/bodies') }) }) +} diff --git a/apps/website/src/data/blog/bodies/course-buses-and-peripherals.ts b/apps/website/src/data/blog/bodies/buses-and-peripherals.ts similarity index 100% rename from apps/website/src/data/blog/bodies/course-buses-and-peripherals.ts rename to apps/website/src/data/blog/bodies/buses-and-peripherals.ts diff --git a/apps/website/src/data/blog/bodies/course-3-clocks-resets-and-cdc.ts b/apps/website/src/data/blog/bodies/clocks-resets-and-cdc.ts similarity index 100% rename from apps/website/src/data/blog/bodies/course-3-clocks-resets-and-cdc.ts rename to apps/website/src/data/blog/bodies/clocks-resets-and-cdc.ts diff --git a/apps/website/src/data/blog/posts.ts b/apps/website/src/data/blog/posts.ts index b88647a9c6..f0cb43ab98 100644 --- a/apps/website/src/data/blog/posts.ts +++ b/apps/website/src/data/blog/posts.ts @@ -107,8 +107,8 @@ import { body as body_the_control_that_could_not_fail, ruBody as ruBody_the_cont import { body as body_the_scanner_scored_what_it_could_not_see, ruBody as ruBody_the_scanner_scored_what_it_could_not_see } from './bodies/the-scanner-scored-what-it-could-not-see' import { body as body_i_wrote_the_post_then_did_the_thing, ruBody as ruBody_i_wrote_the_post_then_did_the_thing } from './bodies/i-wrote-the-post-then-did-the-thing' -import { body as body_course_buses_and_peripherals, ruBody as ruBody_course_buses_and_peripherals } from './bodies/course-buses-and-peripherals' -import { body as body_clocks_resets_and_cdc, ruBody as ruBody_clocks_resets_and_cdc } from './bodies/course-3-clocks-resets-and-cdc' +import { body as body_course_buses_and_peripherals, ruBody as ruBody_course_buses_and_peripherals } from './bodies/buses-and-peripherals' +import { body as body_clocks_resets_and_cdc, ruBody as ruBody_clocks_resets_and_cdc } from './bodies/clocks-resets-and-cdc' import { body as body_real_value_in_integer_container, ruBody as ruBody_real_value_in_integer_container } from './bodies/real-value-in-integer-container' import { body as body_features_that_change_no_bits, ruBody as ruBody_features_that_change_no_bits } from './bodies/features-that-change-no-bits' import { body as body_one_commit_nine_workflow_outcomes, ruBody as ruBody_one_commit_nine_workflow_outcomes } from './bodies/one-commit-nine-workflow-outcomes'