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
9 changes: 9 additions & 0 deletions .github/workflows/website-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ jobs:
- name: ARIA references resolve
run: npm run check:aria

# A post's body module must live at bodies/<slug>.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
Expand Down
1 change: 1 addition & 0 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions apps/website/scripts/blog-bodies-check.mjs
Original file line number Diff line number Diff line change
@@ -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/<slug>.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/<slug>.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') }) })
}
4 changes: 2 additions & 2 deletions apps/website/src/data/blog/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading