@erclx/canon 5.51.0 → 5.53.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 (142) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -4
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +0 -3
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-taste/references/kinds.md +0 -1
  7. package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
  8. package/claude/skills/draw-figure/SKILL.md +4 -5
  9. package/claude/skills/draw-figure/references/figures.md +0 -1
  10. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  11. package/claude/skills/git-pr/references/pr.md +1 -1
  12. package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
  13. package/claude/skills/pick-by-eye/SKILL.md +1 -1
  14. package/claude/skills/review-address/SKILL.md +1 -1
  15. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  16. package/claude/skills/review-pr/references/post.md +2 -2
  17. package/claude/skills/target-check/SKILL.md +2 -0
  18. package/claude/skills/target-setup/SKILL.md +1 -1
  19. package/claude/skills/target-update/REQUIREMENT.md +33 -0
  20. package/claude/skills/target-update/SKILL.md +136 -0
  21. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  22. package/claude/skills/teach-craft/SKILL.md +39 -0
  23. package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
  24. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
  25. package/docs/agents/audits.md +0 -1
  26. package/docs/agents/migrate.md +2 -1
  27. package/docs/target/migrations.md +12 -1
  28. package/docs/target/projects.md +1 -1
  29. package/docs/target/sync.md +1 -1
  30. package/docs/workflow/parallel-features.md +1 -1
  31. package/docs/workflow/skill-map-on-request.md +10 -22
  32. package/docs/workflow/skill-map.md +13 -14
  33. package/package.json +1 -4
  34. package/scripts/core/regen-web-previews.ts +2 -67
  35. package/src/audits/catalog.ts +0 -1
  36. package/src/browser/engine.ts +3 -6
  37. package/src/capture/render.ts +1 -1
  38. package/src/claude/cases/authoring.ts +0 -12
  39. package/src/claude/cases/misc.ts +0 -17
  40. package/src/claude/cases/setup.ts +7 -0
  41. package/src/claude/cases/workflow.ts +2 -38
  42. package/src/claude/skills-audit.ts +0 -1
  43. package/src/cli/help.ts +0 -7
  44. package/src/cli.ts +0 -6
  45. package/src/commands/labels.ts +9 -7
  46. package/src/commands/records.ts +0 -1
  47. package/src/commands/serve.ts +2 -2
  48. package/src/design/components.ts +8 -1514
  49. package/src/design/css.ts +5 -6
  50. package/src/design/fonts.ts +3 -5
  51. package/src/driver/drive.ts +3 -4
  52. package/src/driver/steps.ts +2 -5
  53. package/src/inventory/walk.ts +1 -1
  54. package/src/markdown/scope.ts +4 -31
  55. package/src/migrate/status.ts +5 -1
  56. package/src/records/validate.ts +4 -176
  57. package/src/sandbox/equivalence.ts +0 -5
  58. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
  59. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  60. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  61. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  62. package/claude/skills/draft-doc/SKILL.md +0 -11
  63. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  64. package/claude/skills/draft-figure/SKILL.md +0 -11
  65. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  66. package/claude/skills/draft-identity/SKILL.md +0 -11
  67. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  68. package/claude/skills/draft-ready/SKILL.md +0 -11
  69. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  70. package/claude/skills/draft-screencast/SKILL.md +0 -11
  71. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  72. package/claude/skills/draft-slides/SKILL.md +0 -11
  73. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  74. package/claude/skills/make-deck/SKILL.md +0 -80
  75. package/claude/skills/make-deck/references/deck.md +0 -183
  76. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  77. package/claude/skills/read-frames/SKILL.md +0 -55
  78. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  79. package/claude/skills/record-screencast/SKILL.md +0 -99
  80. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  81. package/claude/skills/script-screencast/SKILL.md +0 -70
  82. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  83. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  84. package/claude/skills/teach-workspace/SKILL.md +0 -196
  85. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  86. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  87. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  88. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  89. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  90. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  91. package/claude/skills/teach-workspace/references/teach.md +0 -211
  92. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  93. package/claude/skills/video-craft/SKILL.md +0 -96
  94. package/claude/skills/video-craft/references/adopted.md +0 -110
  95. package/src/commands/demo.ts +0 -542
  96. package/src/commands/slides.ts +0 -184
  97. package/src/commands/teach.ts +0 -1401
  98. package/src/demo/beats.ts +0 -135
  99. package/src/demo/compile.ts +0 -350
  100. package/src/demo/container.ts +0 -219
  101. package/src/demo/cursors.ts +0 -55
  102. package/src/demo/drive.ts +0 -442
  103. package/src/demo/pointer.ts +0 -178
  104. package/src/demo/theme.ts +0 -112
  105. package/src/demo/timeline.ts +0 -64
  106. package/src/slides/convert/chart.ts +0 -165
  107. package/src/slides/convert/deck.ts +0 -244
  108. package/src/slides/convert/export.ts +0 -567
  109. package/src/slides/convert/master.ts +0 -198
  110. package/src/slides/convert/shapes.ts +0 -804
  111. package/src/slides/convert/svg.ts +0 -38
  112. package/src/slides/convert/walk.ts +0 -671
  113. package/src/slides/decks.ts +0 -110
  114. package/src/slides/open.ts +0 -18
  115. package/src/slides/package/fonts.ts +0 -301
  116. package/src/slides/package/motion.ts +0 -316
  117. package/src/slides/package/test-face.ts +0 -113
  118. package/src/teach/browser/compile.ts +0 -44
  119. package/src/teach/browser/dismiss.ts +0 -76
  120. package/src/teach/browser/focus-line.ts +0 -19
  121. package/src/teach/browser/glossary-filter.ts +0 -44
  122. package/src/teach/browser/head.ts +0 -30
  123. package/src/teach/browser/quiz.ts +0 -19
  124. package/src/teach/browser/reload.ts +0 -9
  125. package/src/teach/browser/sidebar.ts +0 -233
  126. package/src/teach/browser/theme.ts +0 -18
  127. package/src/teach/components/code.tsx +0 -24
  128. package/src/teach/components/heading.tsx +0 -11
  129. package/src/teach/components/list.tsx +0 -17
  130. package/src/teach/components/paragraph.tsx +0 -29
  131. package/src/teach/components/refs.tsx +0 -28
  132. package/src/teach/cross-references.ts +0 -78
  133. package/src/teach/fonts.ts +0 -16
  134. package/src/teach/highlight.ts +0 -71
  135. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  136. package/src/teach/html/jsx-runtime.ts +0 -113
  137. package/src/teach/html/raw.ts +0 -28
  138. package/src/teach/lesson.ts +0 -178
  139. package/src/teach/nav.ts +0 -1337
  140. package/src/teach/render.ts +0 -240
  141. package/src/teach/workspace.ts +0 -1164
  142. package/src/teach/write-if-changed.ts +0 -17
package/src/teach/nav.ts DELETED
@@ -1,1337 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { readdir, readFile, rm } from 'node:fs/promises'
3
- import { join, posix, relative } from 'node:path'
4
- import {
5
- TEACH_SIDEBAR_BREAKPOINT,
6
- TEACH_STYLESHEET_COMPONENTS,
7
- } from '@/design/components'
8
- import { buildDesignCss } from '@/design/css'
9
- import { brandFavicon } from '@/design/brand-favicon'
10
- import { faviconLink } from '@/design/favicon'
11
- import { stripFrontmatter } from '@/markdown/frontmatter'
12
- import { parseFrontmatter, readField } from '@/indexes/frontmatter'
13
- import { compileScript } from '@/teach/browser/compile'
14
- import { dismiss } from '@/teach/browser/dismiss'
15
- import { focusLine } from '@/teach/browser/focus-line'
16
- import { glossaryFilter } from '@/teach/browser/glossary-filter'
17
- import { head } from '@/teach/browser/head'
18
- import { quiz } from '@/teach/browser/quiz'
19
- import { sidebar } from '@/teach/browser/sidebar'
20
- import { theme } from '@/teach/browser/theme'
21
- import { linkLessonReferences } from '@/teach/cross-references'
22
- import { TEACH_FONT_FACES } from '@/teach/fonts'
23
- import { LESSON_NUMBER } from '@/teach/lesson'
24
- import {
25
- listWorkspaces,
26
- readWorkspace,
27
- TEACH_ASSETS,
28
- TEACH_GLOSSARY,
29
- TEACH_LESSONS,
30
- TEACH_MISSION,
31
- TEACH_REFERENCE,
32
- TEACH_STYLESHEET,
33
- TEACH_STYLESHEET_BASE,
34
- teachDir,
35
- type TeachRefused,
36
- type WorkspaceDetail,
37
- type WorkspaceSummary,
38
- writeStylesheet,
39
- } from '@/teach/workspace'
40
- import { writeIfChanged } from '@/teach/write-if-changed'
41
-
42
- /**
43
- * The link `teach-workspace` once had a session write by hand into every page.
44
- * A lesson keeps whatever sits outside its marked regions, so the old icon
45
- * would stay beside the spliced one. Matching the exact string the skill
46
- * mandated removes it without touching a link an author chose on purpose.
47
- */
48
- 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" />`
49
-
50
- /**
51
- * The stylesheet link lessons used to carry by hand outside every region. The
52
- * `style` region now links the base sheet itself, so keeping this one would
53
- * load the base twice.
54
- */
55
- const HAND_WRITTEN_STYLESHEET = `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET}" />`
56
-
57
- const HAND_WRITTEN_HEAD = [HAND_WRITTEN_ICON, HAND_WRITTEN_STYLESHEET]
58
-
59
- function dropHandWrittenHead(html: string): string {
60
- return HAND_WRITTEN_HEAD.reduce(
61
- (text, link) => text.replaceAll(link, ''),
62
- html
63
- .split('\n')
64
- .filter((line) => !HAND_WRITTEN_HEAD.includes(line.trim()))
65
- .join('\n'),
66
- )
67
- }
68
-
69
- /** Every teach page carries the icon the landing page does rather than a copy of its own. */
70
- function teachFavicon(): string {
71
- return faviconLink(brandFavicon())
72
- }
73
-
74
- /**
75
- * Narrower and steeper than the mark it replaces, with round caps and joins.
76
- * The old one painted a 5.4 by 2.7 pixel butt-capped dash whose only claim to
77
- * pointing was the mitre at its vertex, and the path is shifted so its ink
78
- * centres in its own viewBox rather than sitting a pixel below the cap band of
79
- * the label beside it.
80
- */
81
- const CARET =
82
- '<svg class="caret" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.6 3.7l2.4 2.6 2.4-2.6"/></svg>'
83
-
84
- const THEME_BUTTON =
85
- '<button class="theme" type="button" aria-label="Switch between light and dark"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>'
86
-
87
- /**
88
- * The stepper, as CSS over the radio inputs a lesson's quiz is written from.
89
- *
90
- * A quiz showing every question at once lets a later stem answer an earlier
91
- * question, which is a leak no wording of the questions closes. Gating on
92
- * `:has()` rather than on a script keeps the page working with nothing to bind,
93
- * and the radio input is what records an answer with no handler in the loop.
94
- *
95
- * The general sibling combinator is what keeps the gate from depending on the
96
- * markup contract holding. It selects the same questions as `+` for a quiz
97
- * written correctly, and it goes on gating when anything sits between two of
98
- * them, where `+` matches nothing and shows every later question at once. That
99
- * is the leak this block exists to close, arriving through a shape nothing
100
- * rejects.
101
- *
102
- * The two hide rules select the unanswered state alone, so a workspace's own
103
- * `display` for a question survives being stepped through. Feedback has to be
104
- * shown by a rule naming a value, because every stylesheet grown under the
105
- * retired script carries `.fb { display: none }` and nothing adds the class
106
- * that revealed it. Appearance stays the workspace's: this block hides and
107
- * shows and sets nothing else.
108
- *
109
- * `@supports` wraps every rule, so an engine without `:has()` renders every
110
- * question rather than a stepper showing nothing. Feedback there follows the
111
- * workspace: a fresh stylesheet shows it and one grown under the retired script
112
- * keeps it hidden. Showing it unconditionally outside the wrapper was measured
113
- * and reverted, since that rule outranks `.fb { display: none }` on every
114
- * engine and would reveal the feedback in the button-shape lessons the script
115
- * still drives.
116
- */
117
- const QUIZ_CSS = `@supports selector(:has(*)) {
118
- .quiz .q:not(:has(input[type="radio"]:checked)) ~ .q { display: none; }
119
- .quiz .q:not(:has(input[type="radio"]:checked)) .fb { display: none; }
120
- .quiz .q:has(input[type="radio"]:checked) > .fb { display: block; }
121
- }`
122
-
123
- /**
124
- * The button shape a lesson written before the stepper carries. It is a
125
- * detector rather than a mode: a lesson has one shape or the other, and which
126
- * one it has decides both the style and the scripts region.
127
- */
128
- const LEGACY_OPTION = '<button class="opt"'
129
-
130
- function escapeHtml(text: string): string {
131
- return text
132
- .replace(/&/g, '&amp;')
133
- .replace(/</g, '&lt;')
134
- .replace(/>/g, '&gt;')
135
- .replace(/"/g, '&quot;')
136
- }
137
-
138
- function stripTags(text: string): string {
139
- return text.replace(/<[^>]+>/g, '')
140
- }
141
-
142
- /**
143
- * Sentence case off a kebab slug: only the first word capitalized, matching
144
- * `titleFor` in `@/teach/workspace`, which is not exported for reuse here.
145
- */
146
- function titleCase(slug: string): string {
147
- const words = slug.split('-')
148
- return [
149
- words[0].charAt(0).toUpperCase() + words[0].slice(1),
150
- ...words.slice(1),
151
- ].join(' ')
152
- }
153
-
154
- /** The mission's own title, falling back to the slug where it names none. */
155
- function workspaceLabel(workspace: WorkspaceSummary): string {
156
- return workspace.title ?? titleCase(workspace.topic)
157
- }
158
-
159
- interface JumpEntry {
160
- readonly ordinal: string
161
- readonly label: string
162
- readonly href: string | undefined
163
- readonly at: boolean
164
- /**
165
- * The row's third column. The workspace menu puts a lesson count here and the
166
- * lesson menu leaves it empty, which is what retired the status dot that used
167
- * to sit there reading the same value on every row.
168
- */
169
- readonly trailing?: string
170
- }
171
-
172
- function renderJumpList(entries: readonly JumpEntry[]): string {
173
- return entries
174
- .map((entry) => {
175
- const trailing =
176
- entry.trailing === undefined
177
- ? ''
178
- : `<span class="ct">${escapeHtml(entry.trailing)}</span>`
179
- const body = `<span class="n">${entry.ordinal}</span><span>${escapeHtml(entry.label)}</span>${trailing}`
180
-
181
- if (entry.href === undefined) {
182
- return `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${body}</a></li>`
183
- }
184
-
185
- return `<li${entry.at ? ' class="at"' : ''}><a href="${entry.href}">${body}</a></li>`
186
- })
187
- .join('')
188
- }
189
-
190
- function renderJump(ariaLabel: string, entries: readonly JumpEntry[]): string {
191
- return `<details class="jump" name="mast"><summary aria-label="${escapeHtml(ariaLabel)}">${CARET}</summary><ul class="jump-list">${renderJumpList(entries)}</ul></details>`
192
- }
193
-
194
- interface CrumbSegment {
195
- readonly label: string
196
- readonly href?: string
197
- readonly jump?: {
198
- readonly ariaLabel: string
199
- readonly entries: readonly JumpEntry[]
200
- }
201
- }
202
-
203
- /**
204
- * Every ancestor renders as a link, and the current page renders as plain
205
- * text, whether or not it still carries a jump widget of its own. A link's
206
- * label sits in its own span because `text-box` is ignored on the
207
- * `inline-flex` link, so the cap trim needs a plain box to land on.
208
- */
209
- function renderBreadcrumb(segments: readonly CrumbSegment[]): string {
210
- return segments
211
- .map((segment, index) => {
212
- const text =
213
- segment.href !== undefined
214
- ? `<a class="crumb" href="${segment.href}"><span class="crumb-t">${escapeHtml(segment.label)}</span></a>`
215
- : `<span class="crumb crumb-here">${escapeHtml(segment.label)}</span>`
216
-
217
- const jump = segment.jump
218
- ? renderJump(segment.jump.ariaLabel, segment.jump.entries)
219
- : ''
220
-
221
- const sep =
222
- index < segments.length - 1
223
- ? '<span class="crumb-sep" aria-hidden="true">/</span>'
224
- : ''
225
-
226
- return `<span class="crumb-item">${text}${jump}</span>${sep}`
227
- })
228
- .join('')
229
- }
230
-
231
- interface SidebarItem {
232
- readonly ordinal: string
233
- readonly label: string
234
- readonly href: string
235
- readonly at: boolean
236
- }
237
-
238
- interface Sidebar {
239
- /** The switcher's own label: the workspace on a page inside one, else the root. */
240
- readonly heading: string
241
- readonly switcher: readonly JumpEntry[]
242
- readonly meta: string
243
- readonly items: readonly SidebarItem[]
244
- readonly foot: string | undefined
245
- }
246
-
247
- /**
248
- * Past this many rows a reader scans rather than reads, which is where a filter
249
- * starts earning the width it takes from the list.
250
- */
251
- const FILTER_FLOOR = 8
252
-
253
- /**
254
- * The course, as a column. It replaces a segmented progress strip that gave
255
- * each lesson one `flex: 1` segment and rendered fifty of them as a row of
256
- * dots, and the right-hand outline rail that only existed above 1420px. The
257
- * outline folds under the lesson being read, so one column reports position in
258
- * the course and position inside the lesson at every window width.
259
- */
260
- function renderSidebar(sidebar: Sidebar): string {
261
- const filter =
262
- sidebar.items.length > FILTER_FLOOR
263
- ? '<div class="sb-filter"><input type="search" placeholder="Filter lessons" aria-label="Filter lessons"></div>'
264
- : ''
265
-
266
- const rows = sidebar.items
267
- .map((item) => {
268
- const slot = item.at ? '<div class="sb-out-slot"></div>' : ''
269
- return `<li><a class="sb-l${item.at ? ' sb-on' : ''}" href="${item.href}"><span class="sb-n">${item.ordinal}</span>${escapeHtml(item.label)}</a>${slot}</li>`
270
- })
271
- .join('')
272
-
273
- const body = rows
274
- ? `<ol class="sb-list">${rows}</ol>`
275
- : '<p class="sb-empty">No lessons yet.</p>'
276
-
277
- const foot =
278
- sidebar.foot === undefined
279
- ? ''
280
- : `<div class="sb-foot">${sidebar.foot}</div>`
281
-
282
- return `<aside class="sb">
283
- <div class="sb-top">
284
- <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>
285
- </div>
286
- <div class="sb-scroll">
287
- <div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
288
- ${filter}
289
- <nav class="sb-nav">${body}</nav>
290
- </div>
291
- ${foot}
292
- <button class="sb-grip" type="button" role="separator" aria-orientation="vertical" aria-label="Resize the course panel"></button>
293
- </aside>`
294
- }
295
-
296
- /**
297
- * Everything but the sidebar sits inside one pane, so the two lay out as a flex
298
- * row and the sticky sidebar's containing block is the flex container rather
299
- * than a body carrying bottom padding it could never travel into. The opening
300
- * tag rides with the header and `PANE_CLOSE` shuts it after the footer
301
- * navigation, which on a lesson is a separate spliced region.
302
- */
303
- const PANE_CLOSE = '</div>'
304
-
305
- function renderHeader(
306
- segments: readonly CrumbSegment[],
307
- sidebar: Sidebar,
308
- ): string {
309
- return `${renderSidebar(sidebar)}
310
- <div class="pane">
311
- <header class="bar">
312
- <div class="mast">
313
- <span class="mast-left"><button class="sb-fold" type="button" aria-label="Toggle the course panel">&#9776;</button>${renderBreadcrumb(segments)}</span>
314
- <span class="mast-right">${THEME_BUTTON}</span>
315
- </div>
316
- </header>`
317
- }
318
-
319
- function headScript(page: 'index' | 'lesson'): string {
320
- return compileScript(head, [page, TEACH_SIDEBAR_BREAKPOINT])
321
- }
322
-
323
- function renderScripts(
324
- includeQuiz: boolean,
325
- includeGlossaryFilter: boolean,
326
- ): string {
327
- const scripts = [
328
- compileScript(theme),
329
- compileScript(dismiss),
330
- compileScript(sidebar, [TEACH_SIDEBAR_BREAKPOINT], [focusLine]),
331
- ]
332
- if (includeGlossaryFilter) scripts.push(compileScript(glossaryFilter))
333
- if (includeQuiz) scripts.push(compileScript(quiz))
334
- return scripts.join('\n')
335
- }
336
-
337
- function pageHead(
338
- title: string,
339
- cssHref: string | undefined,
340
- embeddedCss: string | undefined,
341
- page: 'index' | 'lesson' = 'index',
342
- extraHead = '',
343
- ): string {
344
- const style =
345
- embeddedCss === undefined
346
- ? `<link rel="stylesheet" href="${cssHref}">`
347
- : `<style>\n${embeddedCss}\n</style>`
348
-
349
- return `<!doctype html>
350
- <html lang="en">
351
- <head>
352
- <meta charset="utf-8">
353
- <meta name="viewport" content="width=device-width, initial-scale=1">
354
- ${extraHead}<title>${escapeHtml(title)}</title>
355
- ${teachFavicon()}
356
- ${style}
357
- ${headScript(page)}
358
- </head>
359
- `
360
- }
361
-
362
- /** Whether a workspace has a mission yet. A missing one has nothing to teach. */
363
- function hasMission(workspace: WorkspaceSummary): boolean {
364
- return !workspace.missing.includes(TEACH_MISSION)
365
- }
366
-
367
- function hrefForWorkspace(
368
- workspace: WorkspaceSummary,
369
- prefix: string,
370
- ): string | undefined {
371
- return hasMission(workspace)
372
- ? `${prefix}${workspace.slug}/index.html`
373
- : undefined
374
- }
375
-
376
- function ordinalOf(workspace: WorkspaceSummary): string {
377
- return Number.isNaN(workspace.ordinal)
378
- ? '??'
379
- : String(workspace.ordinal).padStart(2, '0')
380
- }
381
-
382
- function workspaceJumpEntries(
383
- workspaces: readonly WorkspaceSummary[],
384
- prefix: string,
385
- currentSlug: string | undefined,
386
- ): JumpEntry[] {
387
- return workspaces.map((workspace) => ({
388
- ordinal: ordinalOf(workspace),
389
- label: workspaceLabel(workspace),
390
- href: hrefForWorkspace(workspace, prefix),
391
- at: workspace.slug === currentSlug,
392
- trailing: String(workspace.lessons),
393
- }))
394
- }
395
-
396
- /**
397
- * What the workspace holds beyond its lessons, for the foot of the sidebar.
398
- * Undefined where it holds neither, so the panel closes on the list rather than
399
- * on an empty rule.
400
- */
401
- function workspaceFoot(detail: WorkspaceDetail): string | undefined {
402
- const bits: string[] = []
403
-
404
- if (detail.terms > 0) {
405
- bits.push(`${detail.terms} term${detail.terms === 1 ? '' : 's'}`)
406
- }
407
- if (detail.records > 0) {
408
- bits.push(`${detail.records} session${detail.records === 1 ? '' : 's'}`)
409
- }
410
-
411
- return bits.length > 0 ? bits.join(' &middot; ') : undefined
412
- }
413
-
414
- interface LessonMeta {
415
- readonly file: string
416
- readonly title: string
417
- readonly lede: string
418
- }
419
-
420
- function basenameTitle(file: string): string {
421
- return titleCase(file.replace(/^\d+-/, '').replace(/\.html$/, ''))
422
- }
423
-
424
- /**
425
- * A lesson's title and lede, read back off its own `<h1>` and `<p
426
- * class="lede">` rather than tracked anywhere else. Nothing in the workspace
427
- * records what a lesson covers apart from the lesson itself.
428
- */
429
- function extractLessonMeta(file: string, html: string): LessonMeta {
430
- const h1 = /<h1>([\s\S]*?)<\/h1>/.exec(html)
431
- const lede = /<p class="lede">([\s\S]*?)<\/p>/.exec(html)
432
-
433
- return {
434
- file,
435
- title: h1 ? stripTags(h1[1]).trim() : basenameTitle(file),
436
- lede: lede ? stripTags(lede[1]).trim() : '',
437
- }
438
- }
439
-
440
- function lessonJumpEntries(
441
- metas: readonly LessonMeta[],
442
- workspacePrefix: string,
443
- currentFile: string | undefined,
444
- ): JumpEntry[] {
445
- return metas.map((meta, index) => ({
446
- ordinal: String(index + 1).padStart(2, '0'),
447
- label: meta.title,
448
- href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
449
- at: meta.file === currentFile,
450
- }))
451
- }
452
-
453
- function renderFootNav(metas: readonly LessonMeta[], index: number): string {
454
- const previous = index > 0 ? metas[index - 1] : undefined
455
- const next = index < metas.length - 1 ? metas[index + 1] : undefined
456
-
457
- const previousHtml = previous
458
- ? `<a href="${previous.file}"><span class="lbl">Previous</span><span><span class="arrow">&larr;</span>${escapeHtml(previous.title)}</span></a>`
459
- : ''
460
-
461
- const nextHtml = next
462
- ? `<a class="to-next" href="${next.file}"><span class="lbl">Next</span><span>${escapeHtml(next.title)}<span class="arrow" style="padding:0 0 0 .35rem">&rarr;</span></span></a>`
463
- : '<span class="end">End of the lessons written so far</span>'
464
-
465
- return `<nav class="nav">${previousHtml}${nextHtml}</nav>`
466
- }
467
-
468
- async function readTitle(path: string, fallback: string): Promise<string> {
469
- if (!existsSync(path)) return fallback
470
- const frontmatter = parseFrontmatter(await readFile(path, 'utf8'))
471
- return readField(frontmatter, 'title') ?? fallback
472
- }
473
-
474
- function renderGlossaryEntry(entry: string): string {
475
- const match = /^\*\*(.+?)\*\*:?\s*([\s\S]*)$/.exec(entry)
476
- const term = match ? match[1] : entry
477
- const definition = match ? match[2] : ''
478
-
479
- return `<div class="gterm"><b>${escapeHtml(term)}</b><span>${escapeHtml(definition)}</span></div>`
480
- }
481
-
482
- const FIRST_SEEN_PATTERN = / First seen in (.+)\.$/
483
-
484
- /**
485
- * The lesson or reference page an entry's own "First seen in" sentence
486
- * names, absent when the entry predates that citation convention.
487
- */
488
- function firstSeenFile(entry: string): string | undefined {
489
- return FIRST_SEEN_PATTERN.exec(entry)?.[1]
490
- }
491
-
492
- /**
493
- * `firstSeenFile` names a page free-form, per the `--first-seen` flag it
494
- * comes from, so it may carry a directory prefix a lesson's own `file` does
495
- * not. Comparing basenames is what keeps `lessons/0001-x.html` and
496
- * `0001-x.html` resolving to the same lesson without a suffix match risking
497
- * a false hit across two differently-prefixed filenames.
498
- */
499
- function matchingLesson(
500
- file: string,
501
- metas: readonly LessonMeta[],
502
- ): LessonMeta | undefined {
503
- const basename = file.split('/').pop()
504
- return metas.find((meta) => meta.file === basename)
505
- }
506
-
507
- interface GlossaryGroup {
508
- readonly heading: string
509
- readonly entries: readonly string[]
510
- }
511
-
512
- const OTHER_TERMS_HEADING = 'Other terms'
513
-
514
- /**
515
- * Groups already-alphabetical glossary entries by the lesson their own
516
- * "First seen in" sentence names, in lesson order. An entry naming a
517
- * reference page instead, or carrying no citation at all, cannot be
518
- * attributed to a lesson and trails in its own group, keeping the
519
- * alphabetical order the source entries already carry.
520
- */
521
- function groupGlossaryEntries(
522
- entries: readonly string[],
523
- metas: readonly LessonMeta[],
524
- ): readonly GlossaryGroup[] {
525
- const byLesson = new Map<string, string[]>()
526
- const other: string[] = []
527
-
528
- for (const entry of entries) {
529
- const file = firstSeenFile(entry)
530
- const lesson = file ? matchingLesson(file, metas) : undefined
531
-
532
- if (lesson) {
533
- const list = byLesson.get(lesson.file) ?? []
534
- list.push(entry)
535
- byLesson.set(lesson.file, list)
536
- } else {
537
- other.push(entry)
538
- }
539
- }
540
-
541
- const groups: GlossaryGroup[] = []
542
- for (const meta of metas) {
543
- const list = byLesson.get(meta.file)
544
- if (list) groups.push({ heading: meta.title, entries: list })
545
- }
546
- if (other.length > 0) {
547
- groups.push({ heading: OTHER_TERMS_HEADING, entries: other })
548
- }
549
-
550
- return groups
551
- }
552
-
553
- function renderGlossaryGroup(group: GlossaryGroup): string {
554
- const entries = group.entries.map(renderGlossaryEntry).join('')
555
-
556
- return `<h3 class="gloss-group">${escapeHtml(group.heading)}</h3>${entries}`
557
- }
558
-
559
- function termCount(shown: number, total: number): string {
560
- const noun = total === 1 ? 'term' : 'terms'
561
-
562
- return shown === total ? `${total} ${noun}` : `${shown} of ${total} ${noun}`
563
- }
564
-
565
- function renderGlossarySection(
566
- entries: readonly string[],
567
- metas: readonly LessonMeta[],
568
- ): string {
569
- const rendered = groupGlossaryEntries(entries, metas)
570
- .map(renderGlossaryGroup)
571
- .join('')
572
-
573
- return `<h2>Glossary</h2>
574
- <div class="filter-row"><input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="Filter terms"><span class="filter-count" id="gloss-count" aria-live="polite">${termCount(entries.length, entries.length)}</span></div>
575
- <div class="gloss" id="gloss"><p class="empty">No term matches that. <button type="button" class="clear">Clear the filter</button></p>${rendered}</div>
576
- `
577
- }
578
-
579
- /**
580
- * Every workspace, generated wholesale rather than spliced. Nothing here is
581
- * authored by hand, so there is no region to preserve.
582
- */
583
- function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
584
- const segments: CrumbSegment[] = [
585
- {
586
- label: 'Workspaces',
587
- jump: {
588
- ariaLabel: 'Open a workspace',
589
- entries: workspaceJumpEntries(workspaces, '', undefined),
590
- },
591
- },
592
- ]
593
-
594
- const sidebar: Sidebar = {
595
- heading: 'Workspaces',
596
- switcher: workspaceJumpEntries(workspaces, '', undefined),
597
- meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
598
- items: workspaces.map((workspace) => ({
599
- ordinal: ordinalOf(workspace),
600
- label: workspaceLabel(workspace),
601
- href: hrefForWorkspace(workspace, '') ?? '#',
602
- at: false,
603
- })),
604
- foot: undefined,
605
- }
606
-
607
- const rows = workspaces
608
- .map((workspace) => {
609
- const href = hrefForWorkspace(workspace, '')
610
- const state = !hasMission(workspace)
611
- ? 'Stub'
612
- : workspace.lessons > 0
613
- ? 'Live'
614
- : 'Open'
615
- const blurb = `${workspace.lessons} lesson(s) &middot; ${workspace.reference} reference page(s) &middot; ${workspace.terms} term(s)`
616
- const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(workspaceLabel(workspace))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
617
-
618
- return href === undefined
619
- ? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
620
- : `<li><a href="${href}">${inner}</a></li>`
621
- })
622
- .join('')
623
-
624
- return `${pageHead('Learning workspaces', TEACH_STYLESHEET, undefined)}<body>
625
- ${renderHeader(segments, sidebar)}
626
- <main class="wide-body">
627
-
628
-
629
- <h1>Learning workspaces</h1>
630
- <p class="lede">One folder per subject. Each carries its own mission, sources, glossary, and lessons.</p>
631
-
632
- <ul class="toc">${rows}</ul>
633
-
634
- </main>
635
- ${PANE_CLOSE}
636
- ${renderScripts(false, false)}
637
- </body>
638
- </html>
639
- `
640
- }
641
-
642
- /** Every lesson's title and lede, read once per rewrite of the workspace. */
643
- async function readLessonMetas(
644
- root: string,
645
- detail: WorkspaceDetail,
646
- ): Promise<LessonMeta[]> {
647
- return Promise.all(
648
- detail.lessonFiles.map(async (file) => {
649
- const text = await readFile(
650
- join(root, detail.path, TEACH_LESSONS, file),
651
- 'utf8',
652
- )
653
- return extractLessonMeta(file, text)
654
- }),
655
- )
656
- }
657
-
658
- /**
659
- * A workspace's contents page, generated wholesale from what the folder holds
660
- * on disk: the mission, the lessons already written, the reference pages, and
661
- * the glossary. A workspace with no `index.html` yet gets one the same way a
662
- * workspace that already had one gets its rewrite, since both read the same
663
- * sources. A reference row links the rendered sibling, or the markdown itself
664
- * on a Bun that cannot render one, so no row points at a file never written.
665
- */
666
- async function renderContentsPage(
667
- root: string,
668
- workspaces: readonly WorkspaceSummary[],
669
- detail: WorkspaceDetail,
670
- metas: readonly LessonMeta[],
671
- canRenderMarkdown: boolean,
672
- ): Promise<string> {
673
- const missionPath = join(root, detail.path, TEACH_MISSION)
674
- const description = existsSync(missionPath)
675
- ? readField(
676
- parseFrontmatter(await readFile(missionPath, 'utf8')),
677
- 'description',
678
- )
679
- : undefined
680
-
681
- const title = workspaceLabel(detail)
682
-
683
- const segments: CrumbSegment[] = [
684
- {
685
- label: 'Workspaces',
686
- href: '../index.html',
687
- jump: {
688
- ariaLabel: 'Switch workspace',
689
- entries: workspaceJumpEntries(workspaces, '../', detail.slug),
690
- },
691
- },
692
- {
693
- label: workspaceLabel(detail),
694
- jump: {
695
- ariaLabel: 'Jump to a lesson',
696
- entries: lessonJumpEntries(metas, '', undefined),
697
- },
698
- },
699
- ]
700
-
701
- const sidebar: Sidebar = {
702
- heading: workspaceLabel(detail),
703
- switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
704
- meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
705
- items: metas.map((meta, index) => ({
706
- ordinal: String(index + 1).padStart(2, '0'),
707
- label: meta.title,
708
- href: `${TEACH_LESSONS}/${meta.file}`,
709
- at: false,
710
- })),
711
- foot: workspaceFoot(detail),
712
- }
713
-
714
- const lessonRows = metas
715
- .map(
716
- (meta, index) =>
717
- `<li><a href="${TEACH_LESSONS}/${meta.file}"><span class="num">${String(index + 1).padStart(2, '0')}</span><b>${escapeHtml(meta.title)}</b><span class="blurb">${escapeHtml(meta.lede)}</span></a></li>`,
718
- )
719
- .join('')
720
-
721
- const referenceRows = (
722
- await Promise.all(
723
- detail.referenceFiles.map(async (file, index) => {
724
- const title = await readTitle(
725
- join(root, detail.path, TEACH_REFERENCE, file),
726
- referenceFallbackTitle(file),
727
- )
728
- const target = canRenderMarkdown ? renderedReferenceName(file) : file
729
- return `<li><a href="${TEACH_REFERENCE}/${target}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
730
- }),
731
- )
732
- ).join('')
733
-
734
- const successRows = detail.success
735
- .map((line) => `<li>${escapeHtml(line)}</li>`)
736
- .join('')
737
-
738
- const sections = [
739
- description ? `<p class="lede">${escapeHtml(description)}</p>` : '',
740
- successRows
741
- ? `<h2>Finished when you can do all of these unaided</h2>\n<ol class="succ">${successRows}</ol>`
742
- : '',
743
- lessonRows ? `<h2>Lessons</h2>\n<ul class="toc">${lessonRows}</ul>` : '',
744
- referenceRows
745
- ? `<h2>Reference pages</h2>\n<ul class="toc">${referenceRows}</ul>`
746
- : '',
747
- renderGlossarySection(detail.glossary, metas),
748
- ]
749
- .filter((section) => section !== '')
750
- .join('\n\n')
751
-
752
- return `${pageHead(`${title}, contents`, `${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined)}<body>
753
- ${renderHeader(segments, sidebar)}
754
- <main class="wide-body">
755
-
756
-
757
- <h1>${escapeHtml(title)}</h1>
758
- ${sections}
759
-
760
- </main>
761
- ${PANE_CLOSE}
762
- ${renderScripts(false, true)}
763
- </body>
764
- </html>
765
- `
766
- }
767
-
768
- /**
769
- * Marks a reference page as nav's own output, which is the one test nav applies
770
- * before deleting a page whose markdown source is gone. A hand-written HTML
771
- * file beside the reference pages carries none and is never touched.
772
- */
773
- const REFERENCE_GENERATOR = '<meta name="generator" content="canon teach nav">'
774
-
775
- /** A formatter closes the tag as `/>`, which still marks the page as generated. */
776
- const REFERENCE_GENERATOR_PATTERN =
777
- /<meta name="generator" content="canon teach nav"\s*\/?>/
778
-
779
- function renderedReferenceName(file: string): string {
780
- return file.replace(/\.md$/, '.html')
781
- }
782
-
783
- function referenceFallbackTitle(file: string): string {
784
- return titleCase(file.replace(/\.md$/, ''))
785
- }
786
-
787
- /**
788
- * A relative href landing on another reference page's markdown is retargeted
789
- * to its rendered sibling, and one landing on the workspace glossary goes to
790
- * the glossary the contents page renders under `#gloss`, dropping any fragment
791
- * since the entries there carry no per-term anchor. The resources page has no
792
- * rendered form, so a blanket suffix swap would break a link to it.
793
- */
794
- function retargetReferenceLinks(
795
- html: string,
796
- referenceFiles: ReadonlySet<string>,
797
- ): string {
798
- return html.replace(
799
- /href="([^"#?:]+)\.md([#?][^"]*)?"/g,
800
- (match, stem: string, suffix: string | undefined) => {
801
- const target = posix.normalize(posix.join(TEACH_REFERENCE, `${stem}.md`))
802
- if (target === TEACH_GLOSSARY) return 'href="../index.html#gloss"'
803
-
804
- const [folder, file, ...rest] = target.split('/')
805
- if (folder !== TEACH_REFERENCE || rest.length > 0) return match
806
- if (!referenceFiles.has(file)) return match
807
- return `href="${stem}.html${suffix ?? ''}"`
808
- },
809
- )
810
- }
811
-
812
- const FENCED_CODE =
813
- /<pre><code class="language-([^"]+)">([\s\S]*?)<\/code><\/pre>/g
814
-
815
- function decodeHtml(html: string): string {
816
- return html
817
- .replaceAll('&lt;', '<')
818
- .replaceAll('&gt;', '>')
819
- .replaceAll('&quot;', '"')
820
- .replaceAll('&#39;', "'")
821
- .replaceAll('&amp;', '&')
822
- }
823
-
824
- /**
825
- * Highlights the fenced blocks `Bun.markdown` emitted, decoding each once
826
- * since the renderer already escaped it. A fence in a language the highlighter
827
- * does not register is left exactly as the markdown rendered it. The module is
828
- * imported here rather than at the top so a verb that never reaches a
829
- * reference page does not load its grammars.
830
- */
831
- async function highlightFences(html: string): Promise<string> {
832
- if (!html.includes('<pre><code class="language-')) return html
833
- const { highlight } = await import('@/teach/highlight')
834
- return html.replace(FENCED_CODE, (match, lang: string, escaped: string) => {
835
- const result = highlight(decodeHtml(escaped), lang)
836
- if (result.language === undefined) return match
837
- return `<pre><code class="hljs language-${lang}">${result.html}</code></pre>`
838
- })
839
- }
840
-
841
- /**
842
- * One reference page as a styled page carrying the workspace chrome. The
843
- * markdown beside it stays the durable, promotable half, and this file is
844
- * regenerated from it on every run. Raw HTML in the markdown is escaped rather
845
- * than passed through, since a reference page is plain markdown by contract.
846
- */
847
- async function renderReferencePage(
848
- root: string,
849
- workspaces: readonly WorkspaceSummary[],
850
- detail: WorkspaceDetail,
851
- metas: readonly LessonMeta[],
852
- file: string,
853
- ): Promise<string> {
854
- const source = await readFile(
855
- join(root, detail.path, TEACH_REFERENCE, file),
856
- 'utf8',
857
- )
858
- const title =
859
- readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
860
-
861
- const rendered = retargetReferenceLinks(
862
- await highlightFences(
863
- Bun.markdown.html(stripFrontmatter(source), {
864
- noHtmlBlocks: true,
865
- noHtmlSpans: true,
866
- }),
867
- ),
868
- new Set(detail.referenceFiles),
869
- )
870
- const body = /<h1[\s>]/.test(rendered)
871
- ? rendered
872
- : `<h1>${escapeHtml(title)}</h1>\n${rendered}`
873
-
874
- const teachPrefix = '../../'
875
- const workspacePrefix = '../'
876
-
877
- const header = renderHeader(
878
- [
879
- {
880
- label: 'Workspaces',
881
- href: `${teachPrefix}index.html`,
882
- jump: {
883
- ariaLabel: 'Switch workspace',
884
- entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
885
- },
886
- },
887
- {
888
- label: workspaceLabel(detail),
889
- href: `${workspacePrefix}index.html`,
890
- jump: {
891
- ariaLabel: 'Jump to a lesson',
892
- entries: lessonJumpEntries(metas, workspacePrefix, undefined),
893
- },
894
- },
895
- { label: title },
896
- ],
897
- {
898
- heading: workspaceLabel(detail),
899
- switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
900
- meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
901
- items: metas.map((meta, index) => ({
902
- ordinal: String(index + 1).padStart(2, '0'),
903
- label: meta.title,
904
- href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
905
- at: false,
906
- })),
907
- foot: workspaceFoot(detail),
908
- },
909
- )
910
-
911
- // The sidebar opens as on a lesson, since this page lists no lessons itself.
912
- return `${pageHead(title, `${workspacePrefix}${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined, 'lesson', `${REFERENCE_GENERATOR}\n`)}<body>
913
- ${header}
914
- <main class="ref">
915
- ${body.trim()}
916
- </main>
917
- ${PANE_CLOSE}
918
- ${renderScripts(false, false)}
919
- </body>
920
- </html>
921
- `
922
- }
923
-
924
- /**
925
- * Deletes a generated reference page whose markdown source is gone, so a
926
- * renamed or removed page leaves nothing no contents row reaches.
927
- */
928
- async function removeOrphanReferencePages(
929
- root: string,
930
- detail: WorkspaceDetail,
931
- ): Promise<void> {
932
- const dir = join(root, detail.path, TEACH_REFERENCE)
933
- if (!existsSync(dir)) return
934
-
935
- const sources = new Set(detail.referenceFiles.map(renderedReferenceName))
936
- const orphans = (await readdir(dir)).filter(
937
- (file) => file.endsWith('.html') && !sources.has(file),
938
- )
939
-
940
- await Promise.all(
941
- orphans.map(async (file) => {
942
- const path = join(dir, file)
943
- if (REFERENCE_GENERATOR_PATTERN.test(await readFile(path, 'utf8'))) {
944
- await rm(path)
945
- }
946
- }),
947
- )
948
- }
949
-
950
- const REGIONS = ['style', 'header', 'footnav', 'scripts'] as const
951
- type Region = (typeof REGIONS)[number]
952
-
953
- function regionPattern(region: Region): RegExp {
954
- return new RegExp(
955
- `(<!-- canon:teach:${region} -->)[\\s\\S]*?(<!-- /canon:teach:${region} -->)`,
956
- )
957
- }
958
-
959
- /**
960
- * Replaces one marked chrome region, keeping every other byte of the lesson
961
- * untouched. Returns `undefined` when the marker pair is absent, which is the
962
- * signal a caller reads as a refusal rather than a rewrite.
963
- */
964
- function spliceRegion(
965
- html: string,
966
- region: Region,
967
- content: string,
968
- ): string | undefined {
969
- const pattern = regionPattern(region)
970
- if (!pattern.test(html)) return undefined
971
-
972
- return html.replace(
973
- pattern,
974
- (_match, open, close) => `${open}\n${content}\n${close}`,
975
- )
976
- }
977
-
978
- interface LessonRewritten {
979
- readonly ok: true
980
- readonly file: string
981
- readonly unresolved: readonly string[]
982
- }
983
-
984
- interface LessonRefused {
985
- readonly ok: false
986
- readonly file: string
987
- readonly missing: Region
988
- }
989
-
990
- /**
991
- * A lesson embeds the workspace stylesheet, and an `@import` resolves against
992
- * the lesson's own folder there rather than the assets folder, so it never
993
- * loaded anything. The base sheet reaches the page through the `<link>` the
994
- * `style` region carries ahead of the embedded rules.
995
- */
996
- function stripImports(css: string): string {
997
- return css.replace(/^@import[^;]*;[ \t]*\n?/gm, '')
998
- }
999
-
1000
- const HEADER_CLOSE = '<!-- /canon:teach:header -->'
1001
- const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
1002
-
1003
- /**
1004
- * The authored body between the header and footnav regions, wrapped in
1005
- * `<main>` when it holds none. The skill has a session write the body bare,
1006
- * and the column width hangs off `main`. A body already holding one is left
1007
- * alone, which keeps a second run byte-identical.
1008
- */
1009
- function wrapMain(html: string): string {
1010
- const bounds = authoredBounds(html)
1011
- if (bounds === undefined) return html
1012
-
1013
- const body = html.slice(bounds.start, bounds.end)
1014
- if (/<main[\s>]/.test(body)) return html
1015
-
1016
- return `${html.slice(0, bounds.start)}\n<main>\n${body.trim()}\n</main>\n${html.slice(bounds.end)}`
1017
- }
1018
-
1019
- function authoredBounds(
1020
- html: string,
1021
- ): { readonly start: number; readonly end: number } | undefined {
1022
- const start = html.indexOf(HEADER_CLOSE)
1023
- const end = html.indexOf(FOOTNAV_OPEN, start)
1024
- if (start === -1 || end === -1) return undefined
1025
- return { start: start + HEADER_CLOSE.length, end }
1026
- }
1027
-
1028
- /** Each lesson number to the file carrying it, the first in filename order. */
1029
- function lessonTargets(
1030
- lessonFiles: readonly string[],
1031
- ): ReadonlyMap<string, string> {
1032
- const targets = new Map<string, string>()
1033
- for (const file of [...lessonFiles].sort()) {
1034
- const number = LESSON_NUMBER.exec(file)?.[1]
1035
- if (number !== undefined && !targets.has(number)) targets.set(number, file)
1036
- }
1037
- return targets
1038
- }
1039
-
1040
- /**
1041
- * Links the "lesson NNNN" mentions in the authored region, the one rewrite nav
1042
- * makes between the header and footnav markers besides the `<main>` wrap.
1043
- */
1044
- function linkAuthoredRegion(
1045
- html: string,
1046
- targets: ReadonlyMap<string, string>,
1047
- file: string,
1048
- ): { readonly html: string; readonly unresolved: readonly string[] } {
1049
- const bounds = authoredBounds(html)
1050
- if (bounds === undefined) return { html, unresolved: [] }
1051
-
1052
- const linked = linkLessonReferences(
1053
- html.slice(bounds.start, bounds.end),
1054
- targets,
1055
- file,
1056
- )
1057
- return {
1058
- html: `${html.slice(0, bounds.start)}${linked.html}${html.slice(bounds.end)}`,
1059
- unresolved: linked.unresolved,
1060
- }
1061
- }
1062
-
1063
- /**
1064
- * Splices the four chrome regions into one lesson file, in a fixed order so a
1065
- * missing marker is always reported against the same region a session can
1066
- * check first, then wraps the authored body in `<main>` where it holds none.
1067
- * Nothing is written when any region is missing, which is what keeps a
1068
- * partially-spliced file off disk.
1069
- */
1070
- async function rewriteLesson(
1071
- root: string,
1072
- detail: WorkspaceDetail,
1073
- index: number,
1074
- metas: readonly LessonMeta[],
1075
- workspaces: readonly WorkspaceSummary[],
1076
- css: string,
1077
- targets: ReadonlyMap<string, string>,
1078
- ): Promise<LessonRewritten | LessonRefused> {
1079
- const file = metas[index].file
1080
- const path = join(root, detail.path, TEACH_LESSONS, file)
1081
- let html = dropHandWrittenHead(await readFile(path, 'utf8'))
1082
-
1083
- const teachPrefix = '../../'
1084
- const workspacePrefix = '../'
1085
-
1086
- const header = renderHeader(
1087
- [
1088
- {
1089
- label: 'Workspaces',
1090
- href: `${teachPrefix}index.html`,
1091
- jump: {
1092
- ariaLabel: 'Switch workspace',
1093
- entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1094
- },
1095
- },
1096
- {
1097
- label: workspaceLabel(detail),
1098
- href: `${workspacePrefix}index.html`,
1099
- jump: {
1100
- ariaLabel: 'Jump to a lesson',
1101
- entries: lessonJumpEntries(metas, workspacePrefix, file),
1102
- },
1103
- },
1104
- { label: `Lesson ${index + 1} of ${metas.length}` },
1105
- ],
1106
- {
1107
- heading: workspaceLabel(detail),
1108
- switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
1109
- meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
1110
- items: metas.map((meta, i) => ({
1111
- ordinal: String(i + 1).padStart(2, '0'),
1112
- label: meta.title,
1113
- href: meta.file,
1114
- at: i === index,
1115
- })),
1116
- foot: workspaceFoot(detail),
1117
- },
1118
- )
1119
-
1120
- // One detector decides both mechanisms, because a lesson carries one quiz
1121
- // shape or the other and the two cannot share a page. The stepper's rules
1122
- // match a button question that can never hold a checked radio, so a lesson
1123
- // given both would hide every question past the first for good and outrank
1124
- // the class the script reveals feedback with.
1125
- const legacy = html.includes(LEGACY_OPTION)
1126
-
1127
- // Linked ahead of the embedded rules so those still win the cascade.
1128
- // Embedding it instead repeats a sheet carrying its fonts inline in every
1129
- // lesson. A workspace seeded before the pair has no base sheet to link.
1130
- const hasBase = existsSync(
1131
- join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET_BASE),
1132
- )
1133
- const baseLink = hasBase
1134
- ? `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET_BASE}">\n`
1135
- : ''
1136
-
1137
- const regions: ReadonlyArray<readonly [Region, string]> = [
1138
- // The stepper follows the workspace stylesheet so it wins the cascade at
1139
- // equal specificity, which is what reaches a workspace seeded before it.
1140
- // The icon and the head script both belong in `<head>`, so they ride in the
1141
- // one region that sits there rather than a fifth marker every seeded
1142
- // lesson would lack.
1143
- [
1144
- 'style',
1145
- `${teachFavicon()}\n${baseLink}<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
1146
- ],
1147
- ['header', header],
1148
- ['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
1149
- ['scripts', renderScripts(legacy, false)],
1150
- ]
1151
-
1152
- for (const [region, content] of regions) {
1153
- const spliced = spliceRegion(html, region, content)
1154
- if (spliced === undefined) return { ok: false, file, missing: region }
1155
- html = spliced
1156
- }
1157
-
1158
- const linked = linkAuthoredRegion(wrapMain(html), targets, file)
1159
- await writeIfChanged(path, linked.html)
1160
- return { ok: true, file, unresolved: linked.unresolved }
1161
- }
1162
-
1163
- export interface LessonSkipped {
1164
- readonly file: string
1165
- readonly missing: string
1166
- }
1167
-
1168
- export interface NavGenerated {
1169
- readonly ok: true
1170
- /** Relative to the root, so a caller prints a path a reader can open. */
1171
- readonly root: string
1172
- readonly contents: readonly string[]
1173
- readonly lessons: number
1174
- /** Reference pages rendered to their `.html` sibling. */
1175
- readonly reference: number
1176
- /**
1177
- * A lesson file missing a chrome marker, refused rather than rewritten, or a
1178
- * reference page left unrendered on a Bun carrying no markdown renderer.
1179
- */
1180
- readonly skipped: readonly LessonSkipped[]
1181
- /**
1182
- * A "lesson NNNN" mention naming no lesson in its workspace, one per distinct
1183
- * number per lesson. The lesson still rewrote, so it counts in `lessons`.
1184
- */
1185
- readonly unresolved: readonly LessonReferenceUnresolved[]
1186
- }
1187
-
1188
- export interface LessonReferenceUnresolved {
1189
- /** Relative to the root, the way a `skipped` entry is. */
1190
- readonly file: string
1191
- readonly lesson: string
1192
- }
1193
-
1194
- const MARKDOWN_RENDERER = 'Bun.markdown'
1195
-
1196
- /** An older Bun carries no `Bun.markdown`, and calling it there throws. */
1197
- function hasMarkdownRenderer(): boolean {
1198
- return typeof Bun.markdown?.html === 'function'
1199
- }
1200
-
1201
- export type NavOutcome = NavGenerated | TeachRefused
1202
-
1203
- /**
1204
- * Rewrites the teach-root listing, every workspace's contents page, and each
1205
- * lesson's chrome, from what the folder holds on disk.
1206
- *
1207
- * The root page always reflects every workspace, whether or not `selector`
1208
- * scopes the run to one of them, since a global page is cheap to regenerate
1209
- * and reading it as stale after a scoped run would be a second kind of nav
1210
- * drift this verb exists to end.
1211
- */
1212
- export async function generateNav(
1213
- root: string,
1214
- selector?: string,
1215
- ): Promise<NavOutcome> {
1216
- const listed = await listWorkspaces(root)
1217
- if (!listed.ok) return listed
1218
-
1219
- let targetSlugs: readonly string[]
1220
-
1221
- if (selector === undefined) {
1222
- targetSlugs = listed.workspaces.map((workspace) => workspace.slug)
1223
- } else {
1224
- const found = await readWorkspace(root, selector)
1225
- if (!found.ok) return found
1226
- targetSlugs = [found.workspace.slug]
1227
- }
1228
-
1229
- const dir = teachDir(root)
1230
- const rootPath = join(dir, 'index.html')
1231
- await writeIfChanged(rootPath, renderRootPage(listed.workspaces))
1232
-
1233
- await writeIfChanged(
1234
- join(dir, TEACH_STYLESHEET),
1235
- buildDesignCss(undefined, {
1236
- embedFonts: TEACH_FONT_FACES,
1237
- components: TEACH_STYLESHEET_COMPONENTS,
1238
- }),
1239
- )
1240
-
1241
- const contents: string[] = []
1242
- const skipped: LessonSkipped[] = []
1243
- const unresolved: LessonReferenceUnresolved[] = []
1244
- let lessons = 0
1245
- let reference = 0
1246
- const canRenderMarkdown = hasMarkdownRenderer()
1247
-
1248
- for (const slug of targetSlugs) {
1249
- const found = await readWorkspace(root, slug)
1250
- if (!found.ok) continue
1251
-
1252
- const detail = found.workspace
1253
- const metas = await readLessonMetas(root, detail)
1254
-
1255
- // The generated base sheet is rewritten on every run so a CLI upgrade
1256
- // reaches it, while a legacy workspace holding only a hand-authored
1257
- // course.css is never handed a base sheet its lessons do not link.
1258
- const cssPath = join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET)
1259
- const basePath = join(
1260
- root,
1261
- detail.path,
1262
- TEACH_ASSETS,
1263
- TEACH_STYLESHEET_BASE,
1264
- )
1265
- if (existsSync(basePath) || !existsSync(cssPath)) {
1266
- await writeStylesheet(root, detail.slug)
1267
- }
1268
-
1269
- const contentsPath = join(root, detail.path, 'index.html')
1270
- await writeIfChanged(
1271
- contentsPath,
1272
- await renderContentsPage(
1273
- root,
1274
- listed.workspaces,
1275
- detail,
1276
- metas,
1277
- canRenderMarkdown,
1278
- ),
1279
- )
1280
- contents.push(relative(root, contentsPath))
1281
-
1282
- for (const file of detail.referenceFiles) {
1283
- const source = join(detail.path, TEACH_REFERENCE, file)
1284
- if (!canRenderMarkdown) {
1285
- skipped.push({ file: source, missing: MARKDOWN_RENDERER })
1286
- continue
1287
- }
1288
-
1289
- await writeIfChanged(
1290
- join(root, detail.path, TEACH_REFERENCE, renderedReferenceName(file)),
1291
- await renderReferencePage(root, listed.workspaces, detail, metas, file),
1292
- )
1293
- reference += 1
1294
- }
1295
- await removeOrphanReferencePages(root, detail)
1296
-
1297
- const css = stripImports(await readFile(cssPath, 'utf8'))
1298
- const targets = lessonTargets(detail.lessonFiles)
1299
-
1300
- for (let index = 0; index < metas.length; index += 1) {
1301
- const outcome = await rewriteLesson(
1302
- root,
1303
- detail,
1304
- index,
1305
- metas,
1306
- listed.workspaces,
1307
- css,
1308
- targets,
1309
- )
1310
-
1311
- if (outcome.ok) {
1312
- lessons += 1
1313
- for (const lesson of outcome.unresolved) {
1314
- unresolved.push({
1315
- file: join(detail.path, TEACH_LESSONS, outcome.file),
1316
- lesson,
1317
- })
1318
- }
1319
- } else {
1320
- skipped.push({
1321
- file: join(detail.path, TEACH_LESSONS, outcome.file),
1322
- missing: `canon:teach:${outcome.missing}`,
1323
- })
1324
- }
1325
- }
1326
- }
1327
-
1328
- return {
1329
- ok: true,
1330
- root: relative(root, rootPath),
1331
- contents,
1332
- lessons,
1333
- reference,
1334
- skipped,
1335
- unresolved,
1336
- }
1337
- }