@erclx/canon 4.114.0 → 4.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/document-health/references/axes.md +1 -1
  3. package/claude/skills/target-setup/SKILL.md +1 -1
  4. package/claude/skills/target-setup/references/detect.md +43 -9
  5. package/claude/skills/teach-workspace/SKILL.md +10 -6
  6. package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
  7. package/docs/agents/audits.md +4 -2
  8. package/docs/agents/commands.md +4 -2
  9. package/docs/agents/context-audit-checks.md +6 -6
  10. package/docs/agents/context-audit.md +1 -1
  11. package/docs/agents/context-classify.md +1 -1
  12. package/docs/agents/index.md +1 -1
  13. package/docs/agents/sandbox.md +1 -1
  14. package/docs/agents/sessions.md +84 -1
  15. package/docs/agents/teach.md +26 -4
  16. package/docs/target-projects.md +4 -2
  17. package/package.json +1 -1
  18. package/src/audits/catalog.ts +52 -0
  19. package/src/commands/context.ts +30 -10
  20. package/src/commands/sessions.ts +222 -0
  21. package/src/commands/teach.ts +22 -4
  22. package/src/context/audit.ts +54 -18
  23. package/src/records/backup.ts +1 -1
  24. package/src/sandbox/census.ts +1 -1
  25. package/src/sessions/registry.ts +11 -7
  26. package/src/sessions/transfer/bundle.ts +546 -0
  27. package/src/sessions/transfer/transcript.ts +148 -0
  28. package/src/teach/components/paragraph.tsx +19 -2
  29. package/src/teach/components/refs.tsx +28 -0
  30. package/src/teach/cross-references.ts +78 -0
  31. package/src/teach/lesson.ts +1 -1
  32. package/src/teach/nav.ts +278 -16
  33. package/src/teach/render.ts +94 -6
  34. package/standards/teach.md +13 -12
  35. package/tooling/go/configs/.golangci.yml +25 -0
  36. package/tooling/go/configs/scripts/verify.sh +95 -0
  37. package/tooling/go/manifest.toml +13 -0
  38. package/tooling/go/reference.md +60 -0
  39. package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
  40. package/tooling/go/seeds/smoke_test.go +9 -0
  41. package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
  42. package/tooling/php/configs/phpstan.neon +5 -0
  43. package/tooling/php/configs/phpunit.xml +18 -0
  44. package/tooling/php/configs/scripts/verify.sh +100 -0
  45. package/tooling/php/manifest.toml +15 -0
  46. package/tooling/php/reference.md +74 -0
  47. package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
  48. package/tooling/php/seeds/tests/SmokeTest.php +13 -0
@@ -4,9 +4,26 @@ import type { Child, Element } from '@/teach/html/jsx-runtime'
4
4
  export interface ParagraphProps {
5
5
  /** The lesson's dek, read back by `extractLessonMeta` in `@/teach/nav`. */
6
6
  readonly lede?: boolean
7
+ /** Reference numbers, each linking to the `Refs` item carrying that id. */
8
+ readonly cites?: readonly number[]
7
9
  readonly children: Child
8
10
  }
9
11
 
10
- export function Paragraph({ lede, children }: ParagraphProps): Element {
11
- return lede ? <p class="lede">{children}</p> : <p>{children}</p>
12
+ export function Paragraph({ lede, cites, children }: ParagraphProps): Element {
13
+ const markers = (cites ?? []).map((cite) => (
14
+ <sup class="cite">
15
+ <a href={`#r${cite}`}>{cite}</a>
16
+ </sup>
17
+ ))
18
+ return lede ? (
19
+ <p class="lede">
20
+ {children}
21
+ {markers}
22
+ </p>
23
+ ) : (
24
+ <p>
25
+ {children}
26
+ {markers}
27
+ </p>
28
+ )
12
29
  }
@@ -0,0 +1,28 @@
1
+ /** @jsxImportSource ../html */
2
+ import type { Element } from '@/teach/html/jsx-runtime'
3
+
4
+ export interface Reference {
5
+ readonly title: string
6
+ readonly url?: string
7
+ readonly note?: string
8
+ }
9
+
10
+ export interface RefsProps {
11
+ readonly items: readonly Reference[]
12
+ }
13
+
14
+ /**
15
+ * Numbers each item from 1 so its `id` matches the `#r<n>` a cited
16
+ * `Paragraph` links to. The caller has already checked every `url` parses as
17
+ * http or https, since attribute escaping does not stop a `javascript:` href.
18
+ */
19
+ export function Refs({ items }: RefsProps): Element {
20
+ const rendered = items.map(({ title, url, note }, index) => (
21
+ <li id={`r${index + 1}`}>
22
+ <cite>{title}</cite>
23
+ {note === undefined ? null : `. ${note}`}
24
+ {url === undefined ? null : [' ', <a href={url}>{new URL(url).host}</a>]}
25
+ </li>
26
+ ))
27
+ return <ol class="refs">{rendered}</ol>
28
+ }
@@ -0,0 +1,78 @@
1
+ export interface LessonReferencesLinked {
2
+ readonly html: string
3
+ /** Four-digit numbers naming no lesson, each once, in first-seen order. */
4
+ readonly unresolved: readonly string[]
5
+ }
6
+
7
+ /**
8
+ * A mention a learner would read as pointing at another lesson. The whitespace
9
+ * is captured rather than normalized, since a prettier-wrapped lesson can break
10
+ * a mention across a line.
11
+ */
12
+ const MENTION = /\b(lesson|Lesson)(\s+)(\d{4})\b/g
13
+
14
+ /**
15
+ * A link nav wrote on an earlier run, and the only kind it may rewrite. The
16
+ * close tag allows whitespace before its `>`, which is how prettier wraps it.
17
+ */
18
+ const OWNED_LINK = /<a\s[^>]*\bdata-lesson-ref\b[^>]*>([\s\S]*?)<\/a\s*>/g
19
+
20
+ /**
21
+ * A comment, a raw-text element taken whole so a `<` inside a script never
22
+ * reads as a tag, any other tag, or the text between tags.
23
+ */
24
+ const TOKEN =
25
+ /<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>|<\/?[a-zA-Z][^>]*>|[^<]+|</g
26
+
27
+ const TAG_NAME = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/
28
+
29
+ /**
30
+ * Elements whose text stays plain. A link inside a quiz `label` navigates on
31
+ * the click meant to pick an option, and one inside `summary` fights the
32
+ * toggle.
33
+ */
34
+ const SKIPPED = new Set(['a', 'code', 'pre', 'label', 'summary'])
35
+
36
+ /**
37
+ * Links every "lesson NNNN" mention in a lesson's authored region to the
38
+ * sibling file carrying that number, and reports the numbers naming no lesson.
39
+ * A link carrying `data-lesson-ref` is nav's own, so it is unwrapped and
40
+ * resolved again from its text, which re-points it after a slug rename and
41
+ * drops it once its target is gone. An author's own `<a>` is never touched.
42
+ */
43
+ export function linkLessonReferences(
44
+ html: string,
45
+ targets: ReadonlyMap<string, string>,
46
+ current: string,
47
+ ): LessonReferencesLinked {
48
+ const unresolved: string[] = []
49
+ let depth = 0
50
+
51
+ const linkText = (text: string): string =>
52
+ text.replace(MENTION, (mention, word, space, number: string) => {
53
+ const target = targets.get(number)
54
+ if (target === current) return mention
55
+ if (target === undefined) {
56
+ if (!unresolved.includes(number)) unresolved.push(number)
57
+ return mention
58
+ }
59
+ return `<a href="${target}" data-lesson-ref>${word}${space}${number}</a>`
60
+ })
61
+
62
+ const unwrapped = html.replace(OWNED_LINK, (_link, text: string) => text)
63
+
64
+ const rewritten = unwrapped.replace(TOKEN, (token) => {
65
+ if (!token.startsWith('<') || token === '<') {
66
+ return depth === 0 ? linkText(token) : token
67
+ }
68
+
69
+ const tag = TAG_NAME.exec(token)
70
+ if (tag === null || !SKIPPED.has(tag[2].toLowerCase())) return token
71
+
72
+ if (tag[1] === '/') depth = Math.max(0, depth - 1)
73
+ else if (!token.endsWith('/>')) depth += 1
74
+ return token
75
+ })
76
+
77
+ return { html: rewritten, unresolved }
78
+ }
@@ -13,7 +13,7 @@ import {
13
13
  const LESSON_WIDTH = 4
14
14
 
15
15
  /** The ordinal a lesson filename opens with, which fixes its read order. */
16
- const LESSON_NUMBER = /^(\d{4})-/
16
+ export const LESSON_NUMBER = /^(\d{4})-/
17
17
 
18
18
  const LESSON_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
19
19
 
package/src/teach/nav.ts CHANGED
@@ -1,19 +1,23 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
- import { readFile, writeFile } from 'node:fs/promises'
3
- import { join, relative } from 'node:path'
2
+ import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { join, posix, relative } from 'node:path'
4
4
  import {
5
5
  TEACH_SIDEBAR_BREAKPOINT,
6
6
  TEACH_STYLESHEET_COMPONENTS,
7
7
  } from '@/design/components'
8
8
  import { buildDesignCss } from '@/design/css'
9
9
  import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
10
+ import { stripFrontmatter } from '@/frontmatter'
10
11
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
11
12
  import { PROJECT_ROOT } from '@/project-root'
13
+ import { linkLessonReferences } from '@/teach/cross-references'
12
14
  import { TEACH_FONT_FACES } from '@/teach/fonts'
15
+ import { LESSON_NUMBER } from '@/teach/lesson'
13
16
  import {
14
17
  listWorkspaces,
15
18
  readWorkspace,
16
19
  TEACH_ASSETS,
20
+ TEACH_GLOSSARY,
17
21
  TEACH_LESSONS,
18
22
  TEACH_MISSION,
19
23
  TEACH_REFERENCE,
@@ -695,6 +699,8 @@ function pageHead(
695
699
  title: string,
696
700
  cssHref: string | undefined,
697
701
  embeddedCss: string | undefined,
702
+ page: 'index' | 'lesson' = 'index',
703
+ extraHead = '',
698
704
  ): string {
699
705
  const style =
700
706
  embeddedCss === undefined
@@ -706,10 +712,10 @@ function pageHead(
706
712
  <head>
707
713
  <meta charset="utf-8">
708
714
  <meta name="viewport" content="width=device-width, initial-scale=1">
709
- <title>${escapeHtml(title)}</title>
715
+ ${extraHead}<title>${escapeHtml(title)}</title>
710
716
  ${teachFavicon()}
711
717
  ${style}
712
- ${headScript('index')}
718
+ ${headScript(page)}
713
719
  </head>
714
720
  `
715
721
  }
@@ -1015,13 +1021,15 @@ async function readLessonMetas(
1015
1021
  * on disk: the mission, the lessons already written, the reference pages, and
1016
1022
  * the glossary. A workspace with no `index.html` yet gets one the same way a
1017
1023
  * workspace that already had one gets its rewrite, since both read the same
1018
- * sources.
1024
+ * sources. A reference row links the rendered sibling, or the markdown itself
1025
+ * on a Bun that cannot render one, so no row points at a file never written.
1019
1026
  */
1020
1027
  async function renderContentsPage(
1021
1028
  root: string,
1022
1029
  workspaces: readonly WorkspaceSummary[],
1023
1030
  detail: WorkspaceDetail,
1024
1031
  metas: readonly LessonMeta[],
1032
+ canRenderMarkdown: boolean,
1025
1033
  ): Promise<string> {
1026
1034
  const missionPath = join(root, detail.path, TEACH_MISSION)
1027
1035
  const description = existsSync(missionPath)
@@ -1076,9 +1084,10 @@ async function renderContentsPage(
1076
1084
  detail.referenceFiles.map(async (file, index) => {
1077
1085
  const title = await readTitle(
1078
1086
  join(root, detail.path, TEACH_REFERENCE, file),
1079
- basenameTitle(file),
1087
+ referenceFallbackTitle(file),
1080
1088
  )
1081
- return `<li><a href="${TEACH_REFERENCE}/${file}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
1089
+ const target = canRenderMarkdown ? renderedReferenceName(file) : file
1090
+ return `<li><a href="${TEACH_REFERENCE}/${target}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
1082
1091
  }),
1083
1092
  )
1084
1093
  ).join('')
@@ -1117,6 +1126,157 @@ ${renderScripts(false, true)}
1117
1126
  `
1118
1127
  }
1119
1128
 
1129
+ /**
1130
+ * Marks a reference page as nav's own output, which is the one test nav applies
1131
+ * before deleting a page whose markdown source is gone. A hand-written HTML
1132
+ * file beside the reference pages carries none and is never touched.
1133
+ */
1134
+ const REFERENCE_GENERATOR = '<meta name="generator" content="canon teach nav">'
1135
+
1136
+ /** A formatter closes the tag as `/>`, which still marks the page as generated. */
1137
+ const REFERENCE_GENERATOR_PATTERN =
1138
+ /<meta name="generator" content="canon teach nav"\s*\/?>/
1139
+
1140
+ function renderedReferenceName(file: string): string {
1141
+ return file.replace(/\.md$/, '.html')
1142
+ }
1143
+
1144
+ function referenceFallbackTitle(file: string): string {
1145
+ return titleCase(file.replace(/\.md$/, ''))
1146
+ }
1147
+
1148
+ /**
1149
+ * A relative href landing on another reference page's markdown is retargeted
1150
+ * to its rendered sibling, and one landing on the workspace glossary goes to
1151
+ * the glossary the contents page renders under `#gloss`, dropping any fragment
1152
+ * since the entries there carry no per-term anchor. The resources page has no
1153
+ * rendered form, so a blanket suffix swap would break a link to it.
1154
+ */
1155
+ function retargetReferenceLinks(
1156
+ html: string,
1157
+ referenceFiles: ReadonlySet<string>,
1158
+ ): string {
1159
+ return html.replace(
1160
+ /href="([^"#?:]+)\.md([#?][^"]*)?"/g,
1161
+ (match, stem: string, suffix: string | undefined) => {
1162
+ const target = posix.normalize(posix.join(TEACH_REFERENCE, `${stem}.md`))
1163
+ if (target === TEACH_GLOSSARY) return 'href="../index.html#gloss"'
1164
+
1165
+ const [folder, file, ...rest] = target.split('/')
1166
+ if (folder !== TEACH_REFERENCE || rest.length > 0) return match
1167
+ if (!referenceFiles.has(file)) return match
1168
+ return `href="${stem}.html${suffix ?? ''}"`
1169
+ },
1170
+ )
1171
+ }
1172
+
1173
+ /**
1174
+ * One reference page as a styled page carrying the workspace chrome. The
1175
+ * markdown beside it stays the durable, promotable half, and this file is
1176
+ * regenerated from it on every run. Raw HTML in the markdown is escaped rather
1177
+ * than passed through, since a reference page is plain markdown by contract.
1178
+ */
1179
+ async function renderReferencePage(
1180
+ root: string,
1181
+ workspaces: readonly WorkspaceSummary[],
1182
+ detail: WorkspaceDetail,
1183
+ metas: readonly LessonMeta[],
1184
+ file: string,
1185
+ ): Promise<string> {
1186
+ const source = await readFile(
1187
+ join(root, detail.path, TEACH_REFERENCE, file),
1188
+ 'utf8',
1189
+ )
1190
+ const title =
1191
+ readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
1192
+
1193
+ const rendered = retargetReferenceLinks(
1194
+ Bun.markdown.html(stripFrontmatter(source), {
1195
+ noHtmlBlocks: true,
1196
+ noHtmlSpans: true,
1197
+ }),
1198
+ new Set(detail.referenceFiles),
1199
+ )
1200
+ const body = /<h1[\s>]/.test(rendered)
1201
+ ? rendered
1202
+ : `<h1>${escapeHtml(title)}</h1>\n${rendered}`
1203
+
1204
+ const teachPrefix = '../../'
1205
+ const workspacePrefix = '../'
1206
+
1207
+ const header = renderHeader(
1208
+ [
1209
+ {
1210
+ label: 'Workspaces',
1211
+ href: `${teachPrefix}index.html`,
1212
+ jump: {
1213
+ ariaLabel: 'Switch workspace',
1214
+ entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1215
+ },
1216
+ },
1217
+ {
1218
+ label: workspaceLabel(detail),
1219
+ href: `${workspacePrefix}index.html`,
1220
+ jump: {
1221
+ ariaLabel: 'Jump to a lesson',
1222
+ entries: lessonJumpEntries(metas, workspacePrefix, undefined),
1223
+ },
1224
+ },
1225
+ { label: title },
1226
+ ],
1227
+ {
1228
+ heading: workspaceLabel(detail),
1229
+ switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1230
+ meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1231
+ items: metas.map((meta, index) => ({
1232
+ ordinal: String(index + 1).padStart(2, '0'),
1233
+ label: meta.title,
1234
+ href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
1235
+ at: false,
1236
+ })),
1237
+ foot: workspaceFoot(detail),
1238
+ },
1239
+ )
1240
+
1241
+ // The sidebar opens as on a lesson, since this page lists no lessons itself.
1242
+ return `${pageHead(title, `${workspacePrefix}${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined, 'lesson', `${REFERENCE_GENERATOR}\n`)}<body>
1243
+ ${header}
1244
+ <main class="ref">
1245
+ ${body.trim()}
1246
+ </main>
1247
+ ${PANE_CLOSE}
1248
+ ${renderScripts(false, false)}
1249
+ </body>
1250
+ </html>
1251
+ `
1252
+ }
1253
+
1254
+ /**
1255
+ * Deletes a generated reference page whose markdown source is gone, so a
1256
+ * renamed or removed page leaves nothing no contents row reaches.
1257
+ */
1258
+ async function removeOrphanReferencePages(
1259
+ root: string,
1260
+ detail: WorkspaceDetail,
1261
+ ): Promise<void> {
1262
+ const dir = join(root, detail.path, TEACH_REFERENCE)
1263
+ if (!existsSync(dir)) return
1264
+
1265
+ const sources = new Set(detail.referenceFiles.map(renderedReferenceName))
1266
+ const orphans = (await readdir(dir)).filter(
1267
+ (file) => file.endsWith('.html') && !sources.has(file),
1268
+ )
1269
+
1270
+ await Promise.all(
1271
+ orphans.map(async (file) => {
1272
+ const path = join(dir, file)
1273
+ if (REFERENCE_GENERATOR_PATTERN.test(await readFile(path, 'utf8'))) {
1274
+ await rm(path)
1275
+ }
1276
+ }),
1277
+ )
1278
+ }
1279
+
1120
1280
  const REGIONS = ['style', 'header', 'footnav', 'scripts'] as const
1121
1281
  type Region = (typeof REGIONS)[number]
1122
1282
 
@@ -1148,6 +1308,7 @@ function spliceRegion(
1148
1308
  interface LessonRewritten {
1149
1309
  readonly ok: true
1150
1310
  readonly file: string
1311
+ readonly unresolved: readonly string[]
1151
1312
  }
1152
1313
 
1153
1314
  interface LessonRefused {
@@ -1176,15 +1337,57 @@ const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
1176
1337
  * alone, which keeps a second run byte-identical.
1177
1338
  */
1178
1339
  function wrapMain(html: string): string {
1340
+ const bounds = authoredBounds(html)
1341
+ if (bounds === undefined) return html
1342
+
1343
+ const body = html.slice(bounds.start, bounds.end)
1344
+ if (/<main[\s>]/.test(body)) return html
1345
+
1346
+ return `${html.slice(0, bounds.start)}\n<main>\n${body.trim()}\n</main>\n${html.slice(bounds.end)}`
1347
+ }
1348
+
1349
+ function authoredBounds(
1350
+ html: string,
1351
+ ): { readonly start: number; readonly end: number } | undefined {
1179
1352
  const start = html.indexOf(HEADER_CLOSE)
1180
1353
  const end = html.indexOf(FOOTNAV_OPEN, start)
1181
- if (start === -1 || end === -1) return html
1354
+ if (start === -1 || end === -1) return undefined
1355
+ return { start: start + HEADER_CLOSE.length, end }
1356
+ }
1182
1357
 
1183
- const bodyStart = start + HEADER_CLOSE.length
1184
- const body = html.slice(bodyStart, end)
1185
- if (/<main[\s>]/.test(body)) return html
1358
+ /** Each lesson number to the file carrying it, the first in filename order. */
1359
+ function lessonTargets(
1360
+ lessonFiles: readonly string[],
1361
+ ): ReadonlyMap<string, string> {
1362
+ const targets = new Map<string, string>()
1363
+ for (const file of [...lessonFiles].sort()) {
1364
+ const number = LESSON_NUMBER.exec(file)?.[1]
1365
+ if (number !== undefined && !targets.has(number)) targets.set(number, file)
1366
+ }
1367
+ return targets
1368
+ }
1369
+
1370
+ /**
1371
+ * Links the "lesson NNNN" mentions in the authored region, the one rewrite nav
1372
+ * makes between the header and footnav markers besides the `<main>` wrap.
1373
+ */
1374
+ function linkAuthoredRegion(
1375
+ html: string,
1376
+ targets: ReadonlyMap<string, string>,
1377
+ file: string,
1378
+ ): { readonly html: string; readonly unresolved: readonly string[] } {
1379
+ const bounds = authoredBounds(html)
1380
+ if (bounds === undefined) return { html, unresolved: [] }
1186
1381
 
1187
- return `${html.slice(0, bodyStart)}\n<main>\n${body.trim()}\n</main>\n${html.slice(end)}`
1382
+ const linked = linkLessonReferences(
1383
+ html.slice(bounds.start, bounds.end),
1384
+ targets,
1385
+ file,
1386
+ )
1387
+ return {
1388
+ html: `${html.slice(0, bounds.start)}${linked.html}${html.slice(bounds.end)}`,
1389
+ unresolved: linked.unresolved,
1390
+ }
1188
1391
  }
1189
1392
 
1190
1393
  /**
@@ -1201,6 +1404,7 @@ async function rewriteLesson(
1201
1404
  metas: readonly LessonMeta[],
1202
1405
  workspaces: readonly WorkspaceSummary[],
1203
1406
  css: string,
1407
+ targets: ReadonlyMap<string, string>,
1204
1408
  ): Promise<LessonRewritten | LessonRefused> {
1205
1409
  const file = metas[index].file
1206
1410
  const path = join(root, detail.path, TEACH_LESSONS, file)
@@ -1281,8 +1485,9 @@ async function rewriteLesson(
1281
1485
  html = spliced
1282
1486
  }
1283
1487
 
1284
- await writeFile(path, wrapMain(html))
1285
- return { ok: true, file }
1488
+ const linked = linkAuthoredRegion(wrapMain(html), targets, file)
1489
+ await writeFile(path, linked.html)
1490
+ return { ok: true, file, unresolved: linked.unresolved }
1286
1491
  }
1287
1492
 
1288
1493
  export interface LessonSkipped {
@@ -1296,8 +1501,31 @@ export interface NavGenerated {
1296
1501
  readonly root: string
1297
1502
  readonly contents: readonly string[]
1298
1503
  readonly lessons: number
1299
- /** A lesson file missing a chrome marker, refused rather than rewritten. */
1504
+ /** Reference pages rendered to their `.html` sibling. */
1505
+ readonly reference: number
1506
+ /**
1507
+ * A lesson file missing a chrome marker, refused rather than rewritten, or a
1508
+ * reference page left unrendered on a Bun carrying no markdown renderer.
1509
+ */
1300
1510
  readonly skipped: readonly LessonSkipped[]
1511
+ /**
1512
+ * A "lesson NNNN" mention naming no lesson in its workspace, one per distinct
1513
+ * number per lesson. The lesson still rewrote, so it counts in `lessons`.
1514
+ */
1515
+ readonly unresolved: readonly LessonReferenceUnresolved[]
1516
+ }
1517
+
1518
+ export interface LessonReferenceUnresolved {
1519
+ /** Relative to the root, the way a `skipped` entry is. */
1520
+ readonly file: string
1521
+ readonly lesson: string
1522
+ }
1523
+
1524
+ const MARKDOWN_RENDERER = 'Bun.markdown'
1525
+
1526
+ /** An older Bun carries no `Bun.markdown`, and calling it there throws. */
1527
+ function hasMarkdownRenderer(): boolean {
1528
+ return typeof Bun.markdown?.html === 'function'
1301
1529
  }
1302
1530
 
1303
1531
  export type NavOutcome = NavGenerated | TeachRefused
@@ -1342,7 +1570,10 @@ export async function generateNav(
1342
1570
 
1343
1571
  const contents: string[] = []
1344
1572
  const skipped: LessonSkipped[] = []
1573
+ const unresolved: LessonReferenceUnresolved[] = []
1345
1574
  let lessons = 0
1575
+ let reference = 0
1576
+ const canRenderMarkdown = hasMarkdownRenderer()
1346
1577
 
1347
1578
  for (const slug of targetSlugs) {
1348
1579
  const found = await readWorkspace(root, slug)
@@ -1357,11 +1588,33 @@ export async function generateNav(
1357
1588
  const contentsPath = join(root, detail.path, 'index.html')
1358
1589
  await writeFile(
1359
1590
  contentsPath,
1360
- await renderContentsPage(root, listed.workspaces, detail, metas),
1591
+ await renderContentsPage(
1592
+ root,
1593
+ listed.workspaces,
1594
+ detail,
1595
+ metas,
1596
+ canRenderMarkdown,
1597
+ ),
1361
1598
  )
1362
1599
  contents.push(relative(root, contentsPath))
1363
1600
 
1601
+ for (const file of detail.referenceFiles) {
1602
+ const source = join(detail.path, TEACH_REFERENCE, file)
1603
+ if (!canRenderMarkdown) {
1604
+ skipped.push({ file: source, missing: MARKDOWN_RENDERER })
1605
+ continue
1606
+ }
1607
+
1608
+ await writeFile(
1609
+ join(root, detail.path, TEACH_REFERENCE, renderedReferenceName(file)),
1610
+ await renderReferencePage(root, listed.workspaces, detail, metas, file),
1611
+ )
1612
+ reference += 1
1613
+ }
1614
+ await removeOrphanReferencePages(root, detail)
1615
+
1364
1616
  const css = stripImports(await readFile(cssPath, 'utf8'))
1617
+ const targets = lessonTargets(detail.lessonFiles)
1365
1618
 
1366
1619
  for (let index = 0; index < metas.length; index += 1) {
1367
1620
  const outcome = await rewriteLesson(
@@ -1371,10 +1624,17 @@ export async function generateNav(
1371
1624
  metas,
1372
1625
  listed.workspaces,
1373
1626
  css,
1627
+ targets,
1374
1628
  )
1375
1629
 
1376
1630
  if (outcome.ok) {
1377
1631
  lessons += 1
1632
+ for (const lesson of outcome.unresolved) {
1633
+ unresolved.push({
1634
+ file: join(detail.path, TEACH_LESSONS, outcome.file),
1635
+ lesson,
1636
+ })
1637
+ }
1378
1638
  } else {
1379
1639
  skipped.push({
1380
1640
  file: join(detail.path, TEACH_LESSONS, outcome.file),
@@ -1389,6 +1649,8 @@ export async function generateNav(
1389
1649
  root: relative(root, rootPath),
1390
1650
  contents,
1391
1651
  lessons,
1652
+ reference,
1392
1653
  skipped,
1654
+ unresolved,
1393
1655
  }
1394
1656
  }