@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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -4
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +0 -3
- package/claude/skills/context-fold/REQUIREMENT.md +0 -1
- package/claude/skills/design-taste/references/kinds.md +0 -1
- package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
- package/claude/skills/draw-figure/SKILL.md +4 -5
- package/claude/skills/draw-figure/references/figures.md +0 -1
- package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
- package/claude/skills/pick-by-eye/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-update/REQUIREMENT.md +33 -0
- package/claude/skills/target-update/SKILL.md +136 -0
- package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
- package/claude/skills/teach-craft/SKILL.md +39 -0
- package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
- package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
- package/docs/agents/audits.md +0 -1
- package/docs/agents/migrate.md +2 -1
- package/docs/target/migrations.md +12 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +10 -22
- package/docs/workflow/skill-map.md +13 -14
- package/package.json +1 -4
- package/scripts/core/regen-web-previews.ts +2 -67
- package/src/audits/catalog.ts +0 -1
- package/src/browser/engine.ts +3 -6
- package/src/capture/render.ts +1 -1
- package/src/claude/cases/authoring.ts +0 -12
- package/src/claude/cases/misc.ts +0 -17
- package/src/claude/cases/setup.ts +7 -0
- package/src/claude/cases/workflow.ts +2 -38
- package/src/claude/skills-audit.ts +0 -1
- package/src/cli/help.ts +0 -7
- package/src/cli.ts +0 -6
- package/src/commands/labels.ts +9 -7
- package/src/commands/records.ts +0 -1
- package/src/commands/serve.ts +2 -2
- package/src/design/components.ts +8 -1514
- package/src/design/css.ts +5 -6
- package/src/design/fonts.ts +3 -5
- package/src/driver/drive.ts +3 -4
- package/src/driver/steps.ts +2 -5
- package/src/inventory/walk.ts +1 -1
- package/src/markdown/scope.ts +4 -31
- package/src/migrate/status.ts +5 -1
- package/src/records/validate.ts +4 -176
- package/src/sandbox/equivalence.ts +0 -5
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
- package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
- package/claude/skills/draft-and-pick/SKILL.md +0 -11
- package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
- package/claude/skills/draft-doc/SKILL.md +0 -11
- package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
- package/claude/skills/draft-figure/SKILL.md +0 -11
- package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
- package/claude/skills/draft-identity/SKILL.md +0 -11
- package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
- package/claude/skills/draft-ready/SKILL.md +0 -11
- package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
- package/claude/skills/draft-screencast/SKILL.md +0 -11
- package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
- package/claude/skills/draft-slides/SKILL.md +0 -11
- package/claude/skills/make-deck/REQUIREMENT.md +0 -36
- package/claude/skills/make-deck/SKILL.md +0 -80
- package/claude/skills/make-deck/references/deck.md +0 -183
- package/claude/skills/read-frames/REQUIREMENT.md +0 -30
- package/claude/skills/read-frames/SKILL.md +0 -55
- package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
- package/claude/skills/record-screencast/SKILL.md +0 -99
- package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
- package/claude/skills/script-screencast/SKILL.md +0 -70
- package/claude/skills/script-screencast/references/beats-template.md +0 -119
- package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
- package/claude/skills/teach-workspace/SKILL.md +0 -196
- package/claude/skills/teach-workspace/references/glossary.md +0 -90
- package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
- package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
- package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
- package/claude/skills/teach-workspace/references/promotion.md +0 -116
- package/claude/skills/teach-workspace/references/revisit.md +0 -28
- package/claude/skills/teach-workspace/references/teach.md +0 -211
- package/claude/skills/video-craft/REQUIREMENT.md +0 -35
- package/claude/skills/video-craft/SKILL.md +0 -96
- package/claude/skills/video-craft/references/adopted.md +0 -110
- package/src/commands/demo.ts +0 -542
- package/src/commands/slides.ts +0 -184
- package/src/commands/teach.ts +0 -1401
- package/src/demo/beats.ts +0 -135
- package/src/demo/compile.ts +0 -350
- package/src/demo/container.ts +0 -219
- package/src/demo/cursors.ts +0 -55
- package/src/demo/drive.ts +0 -442
- package/src/demo/pointer.ts +0 -178
- package/src/demo/theme.ts +0 -112
- package/src/demo/timeline.ts +0 -64
- package/src/slides/convert/chart.ts +0 -165
- package/src/slides/convert/deck.ts +0 -244
- package/src/slides/convert/export.ts +0 -567
- package/src/slides/convert/master.ts +0 -198
- package/src/slides/convert/shapes.ts +0 -804
- package/src/slides/convert/svg.ts +0 -38
- package/src/slides/convert/walk.ts +0 -671
- package/src/slides/decks.ts +0 -110
- package/src/slides/open.ts +0 -18
- package/src/slides/package/fonts.ts +0 -301
- package/src/slides/package/motion.ts +0 -316
- package/src/slides/package/test-face.ts +0 -113
- package/src/teach/browser/compile.ts +0 -44
- package/src/teach/browser/dismiss.ts +0 -76
- package/src/teach/browser/focus-line.ts +0 -19
- package/src/teach/browser/glossary-filter.ts +0 -44
- package/src/teach/browser/head.ts +0 -30
- package/src/teach/browser/quiz.ts +0 -19
- package/src/teach/browser/reload.ts +0 -9
- package/src/teach/browser/sidebar.ts +0 -233
- package/src/teach/browser/theme.ts +0 -18
- package/src/teach/components/code.tsx +0 -24
- package/src/teach/components/heading.tsx +0 -11
- package/src/teach/components/list.tsx +0 -17
- package/src/teach/components/paragraph.tsx +0 -29
- package/src/teach/components/refs.tsx +0 -28
- package/src/teach/cross-references.ts +0 -78
- package/src/teach/fonts.ts +0 -16
- package/src/teach/highlight.ts +0 -71
- package/src/teach/html/jsx-dev-runtime.ts +0 -13
- package/src/teach/html/jsx-runtime.ts +0 -113
- package/src/teach/html/raw.ts +0 -28
- package/src/teach/lesson.ts +0 -178
- package/src/teach/nav.ts +0 -1337
- package/src/teach/render.ts +0 -240
- package/src/teach/workspace.ts +0 -1164
- 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, '&')
|
|
133
|
-
.replace(/</g, '<')
|
|
134
|
-
.replace(/>/g, '>')
|
|
135
|
-
.replace(/"/g, '"')
|
|
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">☰</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(' · ') : 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">←</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">→</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) · ${workspace.reference} reference page(s) · ${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('<', '<')
|
|
818
|
-
.replaceAll('>', '>')
|
|
819
|
-
.replaceAll('"', '"')
|
|
820
|
-
.replaceAll(''', "'")
|
|
821
|
-
.replaceAll('&', '&')
|
|
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
|
-
}
|