@erclx/canon 4.113.0 → 4.115.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 (63) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/codebase-layout/SKILL.md +1 -1
  3. package/claude/skills/document-health/references/axes.md +1 -1
  4. package/claude/skills/target-setup/SKILL.md +3 -1
  5. package/claude/skills/target-setup/references/detect.md +43 -9
  6. package/claude/skills/target-setup/references/verify.md +26 -10
  7. package/claude/skills/teach-workspace/SKILL.md +5 -3
  8. package/claude/skills/test-craft/SKILL.md +1 -1
  9. package/docs/agents/audits.md +4 -2
  10. package/docs/agents/commands.md +2 -2
  11. package/docs/agents/context-audit-checks.md +6 -6
  12. package/docs/agents/context-audit.md +1 -1
  13. package/docs/agents/context-classify.md +1 -1
  14. package/docs/agents/counts.md +3 -3
  15. package/docs/agents/install-and-sync.md +2 -0
  16. package/docs/agents/records.md +22 -15
  17. package/docs/agents/teach.md +12 -4
  18. package/docs/target-projects.md +11 -2
  19. package/docs/workflow/ai-workflow.md +1 -1
  20. package/governance/rules/lang/130-go.md +40 -0
  21. package/governance/rules/lang/140-php.md +31 -0
  22. package/governance/rules/lib/335-testing-go.md +35 -0
  23. package/governance/rules/lib/336-testing-php.md +30 -0
  24. package/governance/rules/lib/360-security-server.md +2 -0
  25. package/governance/rules/lib/370-database.md +2 -0
  26. package/governance/rules/ui/410-a11y.md +1 -0
  27. package/governance/rules/ui/440-surface-capture.md +2 -0
  28. package/governance/stacks/nextjs.toml +2 -0
  29. package/governance/stacks/react.toml +1 -1
  30. package/package.json +1 -1
  31. package/src/audits/catalog.ts +52 -0
  32. package/src/commands/context.ts +30 -10
  33. package/src/commands/records.ts +18 -1
  34. package/src/commands/teach.ts +4 -1
  35. package/src/commands/tooling.ts +25 -2
  36. package/src/context/audit.ts +54 -18
  37. package/src/design/components.ts +17 -0
  38. package/src/design/tokens.ts +1 -1
  39. package/src/gate/measures.ts +13 -2
  40. package/src/records/backup.ts +197 -27
  41. package/src/records/push-guard.ts +71 -0
  42. package/src/teach/nav.ts +278 -34
  43. package/src/tooling/inject.ts +43 -4
  44. package/src/tooling/scan.ts +35 -0
  45. package/src/tooling/subfolder.ts +110 -0
  46. package/standards/teach.md +13 -12
  47. package/tooling/base/configs/.husky/post-merge +5 -1
  48. package/tooling/go/configs/.golangci.yml +25 -0
  49. package/tooling/go/configs/scripts/verify.sh +95 -0
  50. package/tooling/go/manifest.toml +13 -0
  51. package/tooling/go/reference.md +60 -0
  52. package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
  53. package/tooling/go/seeds/smoke_test.go +9 -0
  54. package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
  55. package/tooling/php/configs/phpstan.neon +5 -0
  56. package/tooling/php/configs/phpunit.xml +18 -0
  57. package/tooling/php/configs/scripts/verify.sh +100 -0
  58. package/tooling/php/manifest.toml +15 -0
  59. package/tooling/php/reference.md +74 -0
  60. package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
  61. package/tooling/php/seeds/tests/SmokeTest.php +13 -0
  62. package/tooling/python/configs/scripts/verify.sh +32 -15
  63. package/tooling/web/configs/scripts/verify.sh +32 -15
package/src/teach/nav.ts CHANGED
@@ -1,12 +1,13 @@
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'
12
13
  import { TEACH_FONT_FACES } from '@/teach/fonts'
@@ -14,10 +15,12 @@ import {
14
15
  listWorkspaces,
15
16
  readWorkspace,
16
17
  TEACH_ASSETS,
18
+ TEACH_GLOSSARY,
17
19
  TEACH_LESSONS,
18
20
  TEACH_MISSION,
19
21
  TEACH_REFERENCE,
20
22
  TEACH_STYLESHEET,
23
+ TEACH_STYLESHEET_BASE,
21
24
  teachDir,
22
25
  type TeachRefused,
23
26
  type WorkspaceDetail,
@@ -35,12 +38,23 @@ const BRAND_MARK = 'assets/brand/mark.svg'
35
38
  */
36
39
  const HAND_WRITTEN_ICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='10 10 80 80'%3E%3Cpath d='M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z' fill='rgb(224,114,75)' /%3E%3Crect x='44' y='15' width='12' height='70' rx='2' fill='rgb(224,114,75)' /%3E%3C/svg%3E" />`
37
40
 
38
- function dropHandWrittenIcon(html: string): string {
39
- return html
40
- .split('\n')
41
- .filter((line) => line.trim() !== HAND_WRITTEN_ICON)
42
- .join('\n')
43
- .replaceAll(HAND_WRITTEN_ICON, '')
41
+ /**
42
+ * The stylesheet link lessons used to carry by hand outside every region. The
43
+ * `style` region now links the base sheet itself, so keeping this one would
44
+ * load the base twice.
45
+ */
46
+ const HAND_WRITTEN_STYLESHEET = `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET}" />`
47
+
48
+ const HAND_WRITTEN_HEAD = [HAND_WRITTEN_ICON, HAND_WRITTEN_STYLESHEET]
49
+
50
+ function dropHandWrittenHead(html: string): string {
51
+ return HAND_WRITTEN_HEAD.reduce(
52
+ (text, link) => text.replaceAll(link, ''),
53
+ html
54
+ .split('\n')
55
+ .filter((line) => !HAND_WRITTEN_HEAD.includes(line.trim()))
56
+ .join('\n'),
57
+ )
44
58
  }
45
59
 
46
60
  /**
@@ -504,6 +518,11 @@ function titleCase(slug: string): string {
504
518
  ].join(' ')
505
519
  }
506
520
 
521
+ /** The mission's own title, falling back to the slug where it names none. */
522
+ function workspaceLabel(workspace: WorkspaceSummary): string {
523
+ return workspace.title ?? titleCase(workspace.topic)
524
+ }
525
+
507
526
  interface JumpEntry {
508
527
  readonly ordinal: string
509
528
  readonly label: string
@@ -629,7 +648,7 @@ function renderSidebar(sidebar: Sidebar): string {
629
648
 
630
649
  return `<aside class="sb">
631
650
  <div class="sb-top">
632
- <details class="sb-ws" name="mast"><summary><span class="ws-name">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
651
+ <details class="sb-ws" name="mast"><summary><span class="ws-name" title="${escapeHtml(sidebar.heading)}">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
633
652
  </div>
634
653
  <div class="sb-scroll">
635
654
  <div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
@@ -678,6 +697,8 @@ function pageHead(
678
697
  title: string,
679
698
  cssHref: string | undefined,
680
699
  embeddedCss: string | undefined,
700
+ page: 'index' | 'lesson' = 'index',
701
+ extraHead = '',
681
702
  ): string {
682
703
  const style =
683
704
  embeddedCss === undefined
@@ -689,10 +710,10 @@ function pageHead(
689
710
  <head>
690
711
  <meta charset="utf-8">
691
712
  <meta name="viewport" content="width=device-width, initial-scale=1">
692
- <title>${escapeHtml(title)}</title>
713
+ ${extraHead}<title>${escapeHtml(title)}</title>
693
714
  ${teachFavicon()}
694
715
  ${style}
695
- ${headScript('index')}
716
+ ${headScript(page)}
696
717
  </head>
697
718
  `
698
719
  }
@@ -724,7 +745,7 @@ function workspaceJumpEntries(
724
745
  ): JumpEntry[] {
725
746
  return workspaces.map((workspace) => ({
726
747
  ordinal: ordinalOf(workspace),
727
- label: titleCase(workspace.topic),
748
+ label: workspaceLabel(workspace),
728
749
  href: hrefForWorkspace(workspace, prefix),
729
750
  at: workspace.slug === currentSlug,
730
751
  trailing: String(workspace.lessons),
@@ -935,7 +956,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
935
956
  meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
936
957
  items: workspaces.map((workspace) => ({
937
958
  ordinal: ordinalOf(workspace),
938
- label: titleCase(workspace.topic),
959
+ label: workspaceLabel(workspace),
939
960
  href: hrefForWorkspace(workspace, '') ?? '#',
940
961
  at: false,
941
962
  })),
@@ -951,7 +972,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
951
972
  ? 'Live'
952
973
  : 'Open'
953
974
  const blurb = `${workspace.lessons} lesson(s) &middot; ${workspace.reference} reference page(s) &middot; ${workspace.terms} term(s)`
954
- const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(titleCase(workspace.topic))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
975
+ const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(workspaceLabel(workspace))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
955
976
 
956
977
  return href === undefined
957
978
  ? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
@@ -998,13 +1019,15 @@ async function readLessonMetas(
998
1019
  * on disk: the mission, the lessons already written, the reference pages, and
999
1020
  * the glossary. A workspace with no `index.html` yet gets one the same way a
1000
1021
  * workspace that already had one gets its rewrite, since both read the same
1001
- * sources.
1022
+ * sources. A reference row links the rendered sibling, or the markdown itself
1023
+ * on a Bun that cannot render one, so no row points at a file never written.
1002
1024
  */
1003
1025
  async function renderContentsPage(
1004
1026
  root: string,
1005
1027
  workspaces: readonly WorkspaceSummary[],
1006
1028
  detail: WorkspaceDetail,
1007
1029
  metas: readonly LessonMeta[],
1030
+ canRenderMarkdown: boolean,
1008
1031
  ): Promise<string> {
1009
1032
  const missionPath = join(root, detail.path, TEACH_MISSION)
1010
1033
  const description = existsSync(missionPath)
@@ -1014,7 +1037,7 @@ async function renderContentsPage(
1014
1037
  )
1015
1038
  : undefined
1016
1039
 
1017
- const title = detail.title ?? titleCase(detail.topic)
1040
+ const title = workspaceLabel(detail)
1018
1041
 
1019
1042
  const segments: CrumbSegment[] = [
1020
1043
  {
@@ -1026,7 +1049,7 @@ async function renderContentsPage(
1026
1049
  },
1027
1050
  },
1028
1051
  {
1029
- label: titleCase(detail.topic),
1052
+ label: workspaceLabel(detail),
1030
1053
  jump: {
1031
1054
  ariaLabel: 'Jump to a lesson',
1032
1055
  entries: lessonJumpEntries(metas, '', undefined),
@@ -1035,7 +1058,7 @@ async function renderContentsPage(
1035
1058
  ]
1036
1059
 
1037
1060
  const sidebar: Sidebar = {
1038
- heading: titleCase(detail.topic),
1061
+ heading: workspaceLabel(detail),
1039
1062
  switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
1040
1063
  meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1041
1064
  items: metas.map((meta, index) => ({
@@ -1059,9 +1082,10 @@ async function renderContentsPage(
1059
1082
  detail.referenceFiles.map(async (file, index) => {
1060
1083
  const title = await readTitle(
1061
1084
  join(root, detail.path, TEACH_REFERENCE, file),
1062
- basenameTitle(file),
1085
+ referenceFallbackTitle(file),
1063
1086
  )
1064
- return `<li><a href="${TEACH_REFERENCE}/${file}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
1087
+ const target = canRenderMarkdown ? renderedReferenceName(file) : file
1088
+ return `<li><a href="${TEACH_REFERENCE}/${target}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
1065
1089
  }),
1066
1090
  )
1067
1091
  ).join('')
@@ -1100,6 +1124,157 @@ ${renderScripts(false, true)}
1100
1124
  `
1101
1125
  }
1102
1126
 
1127
+ /**
1128
+ * Marks a reference page as nav's own output, which is the one test nav applies
1129
+ * before deleting a page whose markdown source is gone. A hand-written HTML
1130
+ * file beside the reference pages carries none and is never touched.
1131
+ */
1132
+ const REFERENCE_GENERATOR = '<meta name="generator" content="canon teach nav">'
1133
+
1134
+ /** A formatter closes the tag as `/>`, which still marks the page as generated. */
1135
+ const REFERENCE_GENERATOR_PATTERN =
1136
+ /<meta name="generator" content="canon teach nav"\s*\/?>/
1137
+
1138
+ function renderedReferenceName(file: string): string {
1139
+ return file.replace(/\.md$/, '.html')
1140
+ }
1141
+
1142
+ function referenceFallbackTitle(file: string): string {
1143
+ return titleCase(file.replace(/\.md$/, ''))
1144
+ }
1145
+
1146
+ /**
1147
+ * A relative href landing on another reference page's markdown is retargeted
1148
+ * to its rendered sibling, and one landing on the workspace glossary goes to
1149
+ * the glossary the contents page renders under `#gloss`, dropping any fragment
1150
+ * since the entries there carry no per-term anchor. The resources page has no
1151
+ * rendered form, so a blanket suffix swap would break a link to it.
1152
+ */
1153
+ function retargetReferenceLinks(
1154
+ html: string,
1155
+ referenceFiles: ReadonlySet<string>,
1156
+ ): string {
1157
+ return html.replace(
1158
+ /href="([^"#?:]+)\.md([#?][^"]*)?"/g,
1159
+ (match, stem: string, suffix: string | undefined) => {
1160
+ const target = posix.normalize(posix.join(TEACH_REFERENCE, `${stem}.md`))
1161
+ if (target === TEACH_GLOSSARY) return 'href="../index.html#gloss"'
1162
+
1163
+ const [folder, file, ...rest] = target.split('/')
1164
+ if (folder !== TEACH_REFERENCE || rest.length > 0) return match
1165
+ if (!referenceFiles.has(file)) return match
1166
+ return `href="${stem}.html${suffix ?? ''}"`
1167
+ },
1168
+ )
1169
+ }
1170
+
1171
+ /**
1172
+ * One reference page as a styled page carrying the workspace chrome. The
1173
+ * markdown beside it stays the durable, promotable half, and this file is
1174
+ * regenerated from it on every run. Raw HTML in the markdown is escaped rather
1175
+ * than passed through, since a reference page is plain markdown by contract.
1176
+ */
1177
+ async function renderReferencePage(
1178
+ root: string,
1179
+ workspaces: readonly WorkspaceSummary[],
1180
+ detail: WorkspaceDetail,
1181
+ metas: readonly LessonMeta[],
1182
+ file: string,
1183
+ ): Promise<string> {
1184
+ const source = await readFile(
1185
+ join(root, detail.path, TEACH_REFERENCE, file),
1186
+ 'utf8',
1187
+ )
1188
+ const title =
1189
+ readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
1190
+
1191
+ const rendered = retargetReferenceLinks(
1192
+ Bun.markdown.html(stripFrontmatter(source), {
1193
+ noHtmlBlocks: true,
1194
+ noHtmlSpans: true,
1195
+ }),
1196
+ new Set(detail.referenceFiles),
1197
+ )
1198
+ const body = /<h1[\s>]/.test(rendered)
1199
+ ? rendered
1200
+ : `<h1>${escapeHtml(title)}</h1>\n${rendered}`
1201
+
1202
+ const teachPrefix = '../../'
1203
+ const workspacePrefix = '../'
1204
+
1205
+ const header = renderHeader(
1206
+ [
1207
+ {
1208
+ label: 'Workspaces',
1209
+ href: `${teachPrefix}index.html`,
1210
+ jump: {
1211
+ ariaLabel: 'Switch workspace',
1212
+ entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1213
+ },
1214
+ },
1215
+ {
1216
+ label: workspaceLabel(detail),
1217
+ href: `${workspacePrefix}index.html`,
1218
+ jump: {
1219
+ ariaLabel: 'Jump to a lesson',
1220
+ entries: lessonJumpEntries(metas, workspacePrefix, undefined),
1221
+ },
1222
+ },
1223
+ { label: title },
1224
+ ],
1225
+ {
1226
+ heading: workspaceLabel(detail),
1227
+ switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1228
+ meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1229
+ items: metas.map((meta, index) => ({
1230
+ ordinal: String(index + 1).padStart(2, '0'),
1231
+ label: meta.title,
1232
+ href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
1233
+ at: false,
1234
+ })),
1235
+ foot: workspaceFoot(detail),
1236
+ },
1237
+ )
1238
+
1239
+ // The sidebar opens as on a lesson, since this page lists no lessons itself.
1240
+ return `${pageHead(title, `${workspacePrefix}${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined, 'lesson', `${REFERENCE_GENERATOR}\n`)}<body>
1241
+ ${header}
1242
+ <main class="ref">
1243
+ ${body.trim()}
1244
+ </main>
1245
+ ${PANE_CLOSE}
1246
+ ${renderScripts(false, false)}
1247
+ </body>
1248
+ </html>
1249
+ `
1250
+ }
1251
+
1252
+ /**
1253
+ * Deletes a generated reference page whose markdown source is gone, so a
1254
+ * renamed or removed page leaves nothing no contents row reaches.
1255
+ */
1256
+ async function removeOrphanReferencePages(
1257
+ root: string,
1258
+ detail: WorkspaceDetail,
1259
+ ): Promise<void> {
1260
+ const dir = join(root, detail.path, TEACH_REFERENCE)
1261
+ if (!existsSync(dir)) return
1262
+
1263
+ const sources = new Set(detail.referenceFiles.map(renderedReferenceName))
1264
+ const orphans = (await readdir(dir)).filter(
1265
+ (file) => file.endsWith('.html') && !sources.has(file),
1266
+ )
1267
+
1268
+ await Promise.all(
1269
+ orphans.map(async (file) => {
1270
+ const path = join(dir, file)
1271
+ if (REFERENCE_GENERATOR_PATTERN.test(await readFile(path, 'utf8'))) {
1272
+ await rm(path)
1273
+ }
1274
+ }),
1275
+ )
1276
+ }
1277
+
1103
1278
  const REGIONS = ['style', 'header', 'footnav', 'scripts'] as const
1104
1279
  type Region = (typeof REGIONS)[number]
1105
1280
 
@@ -1139,21 +1314,44 @@ interface LessonRefused {
1139
1314
  readonly missing: Region
1140
1315
  }
1141
1316
 
1142
- /**
1143
- * Splices the four chrome regions into one lesson file, in a fixed order so a
1144
- * missing marker is always reported against the same region a session can
1145
- * check first. Nothing is written when any region is missing, which is what
1146
- * keeps a partially-spliced file off disk.
1147
- */
1148
1317
  /**
1149
1318
  * A lesson embeds the workspace stylesheet, and an `@import` resolves against
1150
1319
  * the lesson's own folder there rather than the assets folder, so it never
1151
- * loaded anything. The base sheet reaches the page through the `<link>`.
1320
+ * loaded anything. The base sheet reaches the page through the `<link>` the
1321
+ * `style` region carries ahead of the embedded rules.
1152
1322
  */
1153
1323
  function stripImports(css: string): string {
1154
1324
  return css.replace(/^@import[^;]*;[ \t]*\n?/gm, '')
1155
1325
  }
1156
1326
 
1327
+ const HEADER_CLOSE = '<!-- /canon:teach:header -->'
1328
+ const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
1329
+
1330
+ /**
1331
+ * The authored body between the header and footnav regions, wrapped in
1332
+ * `<main>` when it holds none. The skill has a session write the body bare,
1333
+ * and the column width hangs off `main`. A body already holding one is left
1334
+ * alone, which keeps a second run byte-identical.
1335
+ */
1336
+ function wrapMain(html: string): string {
1337
+ const start = html.indexOf(HEADER_CLOSE)
1338
+ const end = html.indexOf(FOOTNAV_OPEN, start)
1339
+ if (start === -1 || end === -1) return html
1340
+
1341
+ const bodyStart = start + HEADER_CLOSE.length
1342
+ const body = html.slice(bodyStart, end)
1343
+ if (/<main[\s>]/.test(body)) return html
1344
+
1345
+ return `${html.slice(0, bodyStart)}\n<main>\n${body.trim()}\n</main>\n${html.slice(end)}`
1346
+ }
1347
+
1348
+ /**
1349
+ * Splices the four chrome regions into one lesson file, in a fixed order so a
1350
+ * missing marker is always reported against the same region a session can
1351
+ * check first, then wraps the authored body in `<main>` where it holds none.
1352
+ * Nothing is written when any region is missing, which is what keeps a
1353
+ * partially-spliced file off disk.
1354
+ */
1157
1355
  async function rewriteLesson(
1158
1356
  root: string,
1159
1357
  detail: WorkspaceDetail,
@@ -1164,7 +1362,7 @@ async function rewriteLesson(
1164
1362
  ): Promise<LessonRewritten | LessonRefused> {
1165
1363
  const file = metas[index].file
1166
1364
  const path = join(root, detail.path, TEACH_LESSONS, file)
1167
- let html = dropHandWrittenIcon(await readFile(path, 'utf8'))
1365
+ let html = dropHandWrittenHead(await readFile(path, 'utf8'))
1168
1366
 
1169
1367
  const teachPrefix = '../../'
1170
1368
  const workspacePrefix = '../'
@@ -1180,7 +1378,7 @@ async function rewriteLesson(
1180
1378
  },
1181
1379
  },
1182
1380
  {
1183
- label: titleCase(detail.topic),
1381
+ label: workspaceLabel(detail),
1184
1382
  href: `${workspacePrefix}index.html`,
1185
1383
  jump: {
1186
1384
  ariaLabel: 'Jump to a lesson',
@@ -1190,7 +1388,7 @@ async function rewriteLesson(
1190
1388
  { label: `Lesson ${index + 1} of ${metas.length}` },
1191
1389
  ],
1192
1390
  {
1193
- heading: titleCase(detail.topic),
1391
+ heading: workspaceLabel(detail),
1194
1392
  switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1195
1393
  meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1196
1394
  items: metas.map((meta, i) => ({
@@ -1210,6 +1408,16 @@ async function rewriteLesson(
1210
1408
  // the class the script reveals feedback with.
1211
1409
  const legacy = html.includes(LEGACY_OPTION)
1212
1410
 
1411
+ // Linked ahead of the embedded rules so those still win the cascade.
1412
+ // Embedding it instead repeats a sheet carrying its fonts inline in every
1413
+ // lesson. A workspace seeded before the pair has no base sheet to link.
1414
+ const hasBase = existsSync(
1415
+ join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET_BASE),
1416
+ )
1417
+ const baseLink = hasBase
1418
+ ? `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET_BASE}">\n`
1419
+ : ''
1420
+
1213
1421
  const regions: ReadonlyArray<readonly [Region, string]> = [
1214
1422
  // The stepper follows the workspace stylesheet so it wins the cascade at
1215
1423
  // equal specificity, which is what reaches a workspace seeded before it.
@@ -1218,7 +1426,7 @@ async function rewriteLesson(
1218
1426
  // lesson would lack.
1219
1427
  [
1220
1428
  'style',
1221
- `${teachFavicon()}\n<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1429
+ `${teachFavicon()}\n${baseLink}<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1222
1430
  ],
1223
1431
  ['header', header],
1224
1432
  ['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
@@ -1231,7 +1439,7 @@ async function rewriteLesson(
1231
1439
  html = spliced
1232
1440
  }
1233
1441
 
1234
- await writeFile(path, html)
1442
+ await writeFile(path, wrapMain(html))
1235
1443
  return { ok: true, file }
1236
1444
  }
1237
1445
 
@@ -1246,10 +1454,22 @@ export interface NavGenerated {
1246
1454
  readonly root: string
1247
1455
  readonly contents: readonly string[]
1248
1456
  readonly lessons: number
1249
- /** A lesson file missing a chrome marker, refused rather than rewritten. */
1457
+ /** Reference pages rendered to their `.html` sibling. */
1458
+ readonly reference: number
1459
+ /**
1460
+ * A lesson file missing a chrome marker, refused rather than rewritten, or a
1461
+ * reference page left unrendered on a Bun carrying no markdown renderer.
1462
+ */
1250
1463
  readonly skipped: readonly LessonSkipped[]
1251
1464
  }
1252
1465
 
1466
+ const MARKDOWN_RENDERER = 'Bun.markdown'
1467
+
1468
+ /** An older Bun carries no `Bun.markdown`, and calling it there throws. */
1469
+ function hasMarkdownRenderer(): boolean {
1470
+ return typeof Bun.markdown?.html === 'function'
1471
+ }
1472
+
1253
1473
  export type NavOutcome = NavGenerated | TeachRefused
1254
1474
 
1255
1475
  /**
@@ -1293,6 +1513,8 @@ export async function generateNav(
1293
1513
  const contents: string[] = []
1294
1514
  const skipped: LessonSkipped[] = []
1295
1515
  let lessons = 0
1516
+ let reference = 0
1517
+ const canRenderMarkdown = hasMarkdownRenderer()
1296
1518
 
1297
1519
  for (const slug of targetSlugs) {
1298
1520
  const found = await readWorkspace(root, slug)
@@ -1307,10 +1529,31 @@ export async function generateNav(
1307
1529
  const contentsPath = join(root, detail.path, 'index.html')
1308
1530
  await writeFile(
1309
1531
  contentsPath,
1310
- await renderContentsPage(root, listed.workspaces, detail, metas),
1532
+ await renderContentsPage(
1533
+ root,
1534
+ listed.workspaces,
1535
+ detail,
1536
+ metas,
1537
+ canRenderMarkdown,
1538
+ ),
1311
1539
  )
1312
1540
  contents.push(relative(root, contentsPath))
1313
1541
 
1542
+ for (const file of detail.referenceFiles) {
1543
+ const source = join(detail.path, TEACH_REFERENCE, file)
1544
+ if (!canRenderMarkdown) {
1545
+ skipped.push({ file: source, missing: MARKDOWN_RENDERER })
1546
+ continue
1547
+ }
1548
+
1549
+ await writeFile(
1550
+ join(root, detail.path, TEACH_REFERENCE, renderedReferenceName(file)),
1551
+ await renderReferencePage(root, listed.workspaces, detail, metas, file),
1552
+ )
1553
+ reference += 1
1554
+ }
1555
+ await removeOrphanReferencePages(root, detail)
1556
+
1314
1557
  const css = stripImports(await readFile(cssPath, 'utf8'))
1315
1558
 
1316
1559
  for (let index = 0; index < metas.length; index += 1) {
@@ -1339,6 +1582,7 @@ export async function generateNav(
1339
1582
  root: relative(root, rootPath),
1340
1583
  contents,
1341
1584
  lessons,
1585
+ reference,
1342
1586
  skipped,
1343
1587
  }
1344
1588
  }
@@ -10,19 +10,30 @@ import { ancestorsFirst, listFiles, type Manifest } from '@/tooling/manifest'
10
10
  import {
11
11
  applyScripts,
12
12
  collectDeps,
13
+ collectScripts,
13
14
  readPackage,
14
15
  serializePackage,
15
16
  } from '@/tooling/package'
16
- import { logAdd, logRemove, logStep } from '@/ui'
17
+ import {
18
+ hasSpellConfig,
19
+ isSubfolderTarget,
20
+ isWithheldInSubfolder,
21
+ SUBFOLDER_SPELL_CONFIG,
22
+ subfolderSpellConfig,
23
+ } from '@/tooling/subfolder'
24
+ import { logAdd, logRemove, logStep, logWarn } from '@/ui'
17
25
 
18
26
  export async function injectConfigs(
19
27
  chain: readonly Manifest[],
20
28
  target: string,
21
29
  ): Promise<string[]> {
22
30
  const applied: string[] = []
31
+ const isSubfolder = isSubfolderTarget(target)
23
32
 
24
33
  for (const manifest of ancestorsFirst(chain)) {
25
- const files = listFiles(manifest.configsDir)
34
+ const files = listFiles(manifest.configsDir).filter(
35
+ (rel) => !(isSubfolder && isWithheldInSubfolder(rel)),
36
+ )
26
37
  if (files.length === 0) continue
27
38
 
28
39
  logStep(`Applying ${manifest.name} configs`)
@@ -100,6 +111,16 @@ export async function injectSeeds(
100
111
  }
101
112
  }
102
113
 
114
+ const spellConfig = isSubfolderTarget(target)
115
+ ? subfolderSpellConfig(chain)
116
+ : undefined
117
+ if (spellConfig !== undefined && !hasSpellConfig(target)) {
118
+ logStep('Applying subfolder spell config')
119
+ await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
120
+ logAdd(SUBFOLDER_SPELL_CONFIG)
121
+ applied.push(SUBFOLDER_SPELL_CONFIG)
122
+ }
123
+
103
124
  return applied
104
125
  }
105
126
 
@@ -158,8 +179,9 @@ export async function pruneGitignore(
158
179
 
159
180
  /**
160
181
  * Installs missing dev dependencies, fills package scripts, and merges
161
- * gitignore entries. This is the `inject_tooling_manifest` sequence, which
162
- * assumed a target that already has a package.json.
182
+ * gitignore entries. A target with no package.json gets the gitignore alone,
183
+ * plus a warning naming every install it skipped, since seeding a package.json
184
+ * would pick a package manager and a project name on the target's behalf.
163
185
  */
164
186
  export async function injectManifest(
165
187
  chain: readonly Manifest[],
@@ -192,4 +214,21 @@ export async function injectManifest(
192
214
  }
193
215
 
194
216
  await injectGitignore(chain, target)
217
+
218
+ if (!pkg) warnSkippedInstalls(chain, target)
219
+ }
220
+
221
+ function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
222
+ const deps = collectDeps(chain, {}).map((dep) => dep.spec)
223
+ const scripts = collectScripts(chain, {}).map((script) => script.key)
224
+ if (deps.length === 0 && scripts.length === 0) return
225
+
226
+ logStep('Skipped installs')
227
+ if (deps.length > 0) {
228
+ logWarn(`Dev dependencies not installed: ${deps.join(', ')}`)
229
+ }
230
+ if (scripts.length > 0) {
231
+ logWarn(`Scripts not added: ${scripts.join(', ')}`)
232
+ }
233
+ logWarn(`No package.json: run 'bun init' in ${target}, then sync again`)
195
234
  }
@@ -10,6 +10,13 @@ import {
10
10
  readPackage,
11
11
  type ScriptState,
12
12
  } from '@/tooling/package'
13
+ import {
14
+ hasSpellConfig,
15
+ isSubfolderTarget,
16
+ isWithheldInSubfolder,
17
+ SUBFOLDER_SPELL_CONFIG,
18
+ subfolderSpellConfig,
19
+ } from '@/tooling/subfolder'
13
20
 
14
21
  export interface ConfigState {
15
22
  readonly rel: string
@@ -27,8 +34,15 @@ export interface EntryState {
27
34
  readonly state: 'missing' | 'present'
28
35
  }
29
36
 
37
+ export interface WithheldState {
38
+ readonly rel: string
39
+ readonly stack: string
40
+ readonly present: boolean
41
+ }
42
+
30
43
  export interface ScanResult {
31
44
  readonly configs: readonly ConfigState[]
45
+ readonly withheld: readonly WithheldState[]
32
46
  readonly seeds: readonly SeedState[]
33
47
  readonly scripts: readonly ScriptState[]
34
48
  readonly deps: readonly DepState[]
@@ -48,9 +62,16 @@ function isIdentical(a: string, b: string): boolean {
48
62
  * category and mirrors the bash: configs, seeds, and scripts resolve nearest
49
63
  * stack first, while dependencies and gitignore entries resolve from the
50
64
  * furthest ancestor inward.
65
+ *
66
+ * A target below its git toplevel is scanned as a subfolder: a path GitHub
67
+ * reads only at the root moves to `withheld` and counts as no change, and the
68
+ * nested spell config joins the seeds. The scope is read here so every caller
69
+ * of `scan` agrees without passing a flag.
51
70
  */
52
71
  export function scan(chain: readonly Manifest[], target: string): ScanResult {
72
+ const isSubfolder = isSubfolderTarget(target)
53
73
  const configs: ConfigState[] = []
74
+ const withheld: WithheldState[] = []
54
75
  const seenConfigs = new Set<string>()
55
76
 
56
77
  for (const manifest of chain) {
@@ -60,6 +81,12 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
60
81
 
61
82
  const source = join(manifest.configsDir, rel)
62
83
  const dest = join(target, rel)
84
+
85
+ if (isSubfolder && isWithheldInSubfolder(rel)) {
86
+ withheld.push({ rel, stack: manifest.name, present: existsSync(dest) })
87
+ continue
88
+ }
89
+
63
90
  const state = !existsSync(dest)
64
91
  ? 'new'
65
92
  : isIdentical(source, dest)
@@ -87,6 +114,13 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
87
114
  }
88
115
  }
89
116
 
117
+ if (isSubfolder && subfolderSpellConfig(chain) !== undefined) {
118
+ seeds.push({
119
+ rel: SUBFOLDER_SPELL_CONFIG,
120
+ state: hasSpellConfig(target) ? 'present' : 'missing',
121
+ })
122
+ }
123
+
90
124
  const packagePath = join(target, 'package.json')
91
125
  const pkg = readPackage(packagePath)
92
126
  const scripts = pkg ? collectScripts(chain, pkg) : []
@@ -107,6 +141,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
107
141
 
108
142
  return {
109
143
  configs,
144
+ withheld,
110
145
  seeds,
111
146
  scripts,
112
147
  deps,