@erclx/canon 4.40.2 → 4.42.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +1 -0
- package/claude/skills/claude-teach/SKILL.md +9 -2
- package/claude/skills/claude-teach/references/lesson-craft.md +1 -3
- package/docs/agents/commands.md +7 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +9 -0
- package/docs/agents/teach.md +25 -4
- package/docs/target-projects.md +13 -0
- package/package.json +1 -1
- package/src/commands/migrate.ts +150 -0
- package/src/commands/teach.ts +91 -0
- package/src/migrate/rule-layout.ts +305 -0
- package/src/sync/layout.ts +9 -6
- package/src/teach/nav.ts +778 -0
- package/tooling/astro/configs/src/components/dev/scenarios.astro +127 -0
- package/tooling/astro/reference.md +7 -0
package/src/teach/nav.ts
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
import { buildDesignCss } from '@/design/css'
|
|
5
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
6
|
+
import {
|
|
7
|
+
listWorkspaces,
|
|
8
|
+
readWorkspace,
|
|
9
|
+
TEACH_ASSETS,
|
|
10
|
+
TEACH_LESSONS,
|
|
11
|
+
TEACH_MISSION,
|
|
12
|
+
TEACH_REFERENCE,
|
|
13
|
+
TEACH_STYLESHEET,
|
|
14
|
+
teachDir,
|
|
15
|
+
type TeachRefused,
|
|
16
|
+
type WorkspaceDetail,
|
|
17
|
+
type WorkspaceSummary,
|
|
18
|
+
} from '@/teach/workspace'
|
|
19
|
+
|
|
20
|
+
const FAVICON = `<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" />`
|
|
21
|
+
|
|
22
|
+
const CARET =
|
|
23
|
+
'<svg class="caret" width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M2 4l3 3 3-3"/></svg>'
|
|
24
|
+
|
|
25
|
+
const THEME_BUTTON =
|
|
26
|
+
'<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>'
|
|
27
|
+
|
|
28
|
+
const THEME_SCRIPT =
|
|
29
|
+
'<script>(function(){var r=document.documentElement;try{var s=localStorage.getItem("course-theme");if(s)r.dataset.theme=s;}catch(e){}document.addEventListener("click",function(e){var b=e.target.closest(".theme");if(!b)return;var d=r.dataset.theme==="dark"||(!r.dataset.theme&&matchMedia("(prefers-color-scheme: dark)").matches);r.dataset.theme=d?"light":"dark";try{localStorage.setItem("course-theme",r.dataset.theme);}catch(e){}});})();</script>'
|
|
30
|
+
|
|
31
|
+
const CLOSE_OUTSIDE_CLICK_SCRIPT = `<script>
|
|
32
|
+
(function () {
|
|
33
|
+
function close(except) {
|
|
34
|
+
document.querySelectorAll("details.jump[open]").forEach(function (d) {
|
|
35
|
+
if (d !== except) d.open = false;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
document.addEventListener("click", function (e) {
|
|
39
|
+
var inside = e.target.closest("details.jump");
|
|
40
|
+
close(inside);
|
|
41
|
+
});
|
|
42
|
+
document.addEventListener("keydown", function (e) {
|
|
43
|
+
if (e.key !== "Escape") return;
|
|
44
|
+
var open = document.querySelector("details.jump[open]");
|
|
45
|
+
if (!open) return;
|
|
46
|
+
open.open = false;
|
|
47
|
+
var s = open.querySelector("summary");
|
|
48
|
+
if (s) s.focus();
|
|
49
|
+
});
|
|
50
|
+
})();
|
|
51
|
+
</script>`
|
|
52
|
+
|
|
53
|
+
const QUIZ_SCRIPT =
|
|
54
|
+
'<script>document.querySelectorAll(".q").forEach(function(q){var f=q.querySelector(".fb");q.querySelectorAll(".opt").forEach(function(b){b.addEventListener("click",function(){if(f.classList.contains("show"))return;q.querySelectorAll(".opt").forEach(function(o){o.dataset.state=o.dataset.a==="1"?"right":(o===b?"chosen":"wrong");});f.classList.add("show");});});});</script>'
|
|
55
|
+
|
|
56
|
+
const GLOSSARY_FILTER_SCRIPT = `<script>
|
|
57
|
+
(function () {
|
|
58
|
+
var input = document.getElementById("gfilter");
|
|
59
|
+
var list = document.getElementById("gloss");
|
|
60
|
+
if (!input || !list) return;
|
|
61
|
+
input.addEventListener("input", function () {
|
|
62
|
+
var q = input.value.toLowerCase();
|
|
63
|
+
var n = 0;
|
|
64
|
+
list.querySelectorAll(".gterm").forEach(function (entry) {
|
|
65
|
+
var match = entry.textContent.toLowerCase().includes(q);
|
|
66
|
+
entry.style.display = match ? "" : "none";
|
|
67
|
+
if (match) n++;
|
|
68
|
+
});
|
|
69
|
+
list.classList.toggle("none", n === 0);
|
|
70
|
+
});
|
|
71
|
+
var clear = list.querySelector(".clear");
|
|
72
|
+
if (clear) {
|
|
73
|
+
clear.addEventListener("click", function () {
|
|
74
|
+
input.value = "";
|
|
75
|
+
input.dispatchEvent(new Event("input"));
|
|
76
|
+
input.focus();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
</script>`
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The outline rail's focus-line ramp, as the JavaScript source `OUTLINE_SCRIPT`
|
|
84
|
+
* embeds verbatim, so there is one copy of the formula rather than a TS
|
|
85
|
+
* reimplementation that could drift from what a browser actually runs.
|
|
86
|
+
*
|
|
87
|
+
* It ramps the line from near the top at scroll 0 to the viewport's bottom
|
|
88
|
+
* edge at max scroll, so the last heading is reachable regardless of how
|
|
89
|
+
* little content trails it. The prior formula ended the ramp 120px short of
|
|
90
|
+
* the edge, which left a heading followed by under 120px of trailing content
|
|
91
|
+
* permanently unmarked, since its top never fell below the line even at max
|
|
92
|
+
* scroll.
|
|
93
|
+
*/
|
|
94
|
+
const FOCUS_LINE_BODY = `if (max <= 0) return innerHeight;
|
|
95
|
+
var progress = Math.min(1, Math.max(0, scrollY / max));
|
|
96
|
+
return 120 + progress * Math.max(0, innerHeight - 120);`
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compiles and runs `FOCUS_LINE_BODY`, so a test exercises the exact source
|
|
100
|
+
* the browser runs rather than a parallel copy of it.
|
|
101
|
+
*/
|
|
102
|
+
export function focusLine(
|
|
103
|
+
scrollY: number,
|
|
104
|
+
max: number,
|
|
105
|
+
innerHeight: number,
|
|
106
|
+
): number {
|
|
107
|
+
const compiled = new Function(
|
|
108
|
+
'scrollY',
|
|
109
|
+
'max',
|
|
110
|
+
'innerHeight',
|
|
111
|
+
FOCUS_LINE_BODY,
|
|
112
|
+
) as (scrollY: number, max: number, innerHeight: number) => number
|
|
113
|
+
|
|
114
|
+
return compiled(scrollY, max, innerHeight)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const OUTLINE_SCRIPT = `<script>
|
|
118
|
+
(function () {
|
|
119
|
+
var hs = Array.prototype.slice.call(document.querySelectorAll("main h2"));
|
|
120
|
+
if (hs.length < 3) return;
|
|
121
|
+
var nav = document.createElement("nav");
|
|
122
|
+
nav.className = "outline";
|
|
123
|
+
nav.innerHTML = '<button class="to-top" type="button">On this page</button>';
|
|
124
|
+
nav.querySelector(".to-top").addEventListener("click", function () {
|
|
125
|
+
scrollTo({ top: 0, behavior: "smooth" });
|
|
126
|
+
});
|
|
127
|
+
hs.forEach(function (h, i) {
|
|
128
|
+
if (!h.id) h.id = "s" + i;
|
|
129
|
+
var a = document.createElement("a");
|
|
130
|
+
a.href = "#" + h.id;
|
|
131
|
+
a.textContent = h.textContent.trim();
|
|
132
|
+
nav.appendChild(a);
|
|
133
|
+
});
|
|
134
|
+
document.body.appendChild(nav);
|
|
135
|
+
var links = Array.prototype.slice.call(nav.querySelectorAll("a"));
|
|
136
|
+
|
|
137
|
+
function mark(i) {
|
|
138
|
+
links.forEach(function (l, j) { l.classList.toggle("on", j === i); });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function focusLine() {
|
|
142
|
+
var max = document.documentElement.scrollHeight - innerHeight;
|
|
143
|
+
${FOCUS_LINE_BODY}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sync() {
|
|
147
|
+
var best = 0;
|
|
148
|
+
var line = focusLine();
|
|
149
|
+
for (var i = 0; i < hs.length; i++) {
|
|
150
|
+
if (hs[i].getBoundingClientRect().top <= line) best = i;
|
|
151
|
+
}
|
|
152
|
+
mark(best);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
links.forEach(function (l, i) {
|
|
156
|
+
l.addEventListener("click", function () { mark(i); });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
addEventListener("scroll", sync, { passive: true });
|
|
160
|
+
addEventListener("resize", sync);
|
|
161
|
+
sync();
|
|
162
|
+
})();
|
|
163
|
+
</script>`
|
|
164
|
+
|
|
165
|
+
function escapeHtml(text: string): string {
|
|
166
|
+
return text
|
|
167
|
+
.replace(/&/g, '&')
|
|
168
|
+
.replace(/</g, '<')
|
|
169
|
+
.replace(/>/g, '>')
|
|
170
|
+
.replace(/"/g, '"')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stripTags(text: string): string {
|
|
174
|
+
return text.replace(/<[^>]+>/g, '')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Sentence case off a kebab slug: only the first word capitalized, matching
|
|
179
|
+
* `titleFor` in `@/teach/workspace`, which is not exported for reuse here.
|
|
180
|
+
*/
|
|
181
|
+
function titleCase(slug: string): string {
|
|
182
|
+
const words = slug.split('-')
|
|
183
|
+
return [
|
|
184
|
+
words[0].charAt(0).toUpperCase() + words[0].slice(1),
|
|
185
|
+
...words.slice(1),
|
|
186
|
+
].join(' ')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface JumpEntry {
|
|
190
|
+
readonly ordinal: string
|
|
191
|
+
readonly label: string
|
|
192
|
+
readonly href: string | undefined
|
|
193
|
+
readonly done: boolean
|
|
194
|
+
readonly at: boolean
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderJumpList(entries: readonly JumpEntry[]): string {
|
|
198
|
+
return entries
|
|
199
|
+
.map((entry) => {
|
|
200
|
+
const dot = `<span class="dot${entry.done ? ' done' : ''}"></span>`
|
|
201
|
+
const body = `<span class="n">${entry.ordinal}</span><span>${escapeHtml(entry.label)}</span>${dot}`
|
|
202
|
+
|
|
203
|
+
if (entry.href === undefined) {
|
|
204
|
+
return `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${body}</a></li>`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return `<li${entry.at ? ' class="at"' : ''}><a href="${entry.href}">${body}</a></li>`
|
|
208
|
+
})
|
|
209
|
+
.join('')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function renderJump(ariaLabel: string, entries: readonly JumpEntry[]): string {
|
|
213
|
+
return `<details class="jump" name="mast"><summary aria-label="${escapeHtml(ariaLabel)}">${CARET}</summary><ul class="jump-list">${renderJumpList(entries)}</ul></details>`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface CrumbSegment {
|
|
217
|
+
readonly label: string
|
|
218
|
+
readonly href?: string
|
|
219
|
+
readonly jump?: {
|
|
220
|
+
readonly ariaLabel: string
|
|
221
|
+
readonly entries: readonly JumpEntry[]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Every ancestor renders as a link, and the current page renders as plain
|
|
227
|
+
* text, whether or not it still carries a jump widget of its own.
|
|
228
|
+
*/
|
|
229
|
+
function renderBreadcrumb(segments: readonly CrumbSegment[]): string {
|
|
230
|
+
return segments
|
|
231
|
+
.map((segment, index) => {
|
|
232
|
+
const text =
|
|
233
|
+
segment.href !== undefined
|
|
234
|
+
? `<a class="crumb" href="${segment.href}">${escapeHtml(segment.label)}</a>`
|
|
235
|
+
: `<span class="crumb crumb-here">${escapeHtml(segment.label)}</span>`
|
|
236
|
+
|
|
237
|
+
const jump = segment.jump
|
|
238
|
+
? renderJump(segment.jump.ariaLabel, segment.jump.entries)
|
|
239
|
+
: ''
|
|
240
|
+
|
|
241
|
+
const sep =
|
|
242
|
+
index < segments.length - 1
|
|
243
|
+
? '<span class="crumb-sep" aria-hidden="true">/</span>'
|
|
244
|
+
: ''
|
|
245
|
+
|
|
246
|
+
return `<span class="crumb-item">${text}${jump}</span>${sep}`
|
|
247
|
+
})
|
|
248
|
+
.join('')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type TrackState = 'done' | 'here' | ''
|
|
252
|
+
|
|
253
|
+
function renderTrack(states: readonly TrackState[]): string {
|
|
254
|
+
const dots = states
|
|
255
|
+
.map((state) => `<i${state ? ` class="${state}"` : ''}></i>`)
|
|
256
|
+
.join('')
|
|
257
|
+
|
|
258
|
+
return `<div class="track" role="presentation">${dots}</div>`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function renderHeader(
|
|
262
|
+
segments: readonly CrumbSegment[],
|
|
263
|
+
track: readonly TrackState[],
|
|
264
|
+
): string {
|
|
265
|
+
return `<header class="bar">
|
|
266
|
+
<div class="mast">
|
|
267
|
+
<span class="mast-left">${renderBreadcrumb(segments)}</span>
|
|
268
|
+
<span class="mast-right">${THEME_BUTTON}</span>
|
|
269
|
+
</div>
|
|
270
|
+
${renderTrack(track)}
|
|
271
|
+
</header>`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderScripts(
|
|
275
|
+
includeQuiz: boolean,
|
|
276
|
+
includeGlossaryFilter: boolean,
|
|
277
|
+
): string {
|
|
278
|
+
const scripts = [THEME_SCRIPT, CLOSE_OUTSIDE_CLICK_SCRIPT, OUTLINE_SCRIPT]
|
|
279
|
+
if (includeGlossaryFilter) scripts.push(GLOSSARY_FILTER_SCRIPT)
|
|
280
|
+
if (includeQuiz) scripts.push(QUIZ_SCRIPT)
|
|
281
|
+
return scripts.join('\n')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function pageHead(
|
|
285
|
+
title: string,
|
|
286
|
+
cssHref: string | undefined,
|
|
287
|
+
embeddedCss: string | undefined,
|
|
288
|
+
): string {
|
|
289
|
+
const style =
|
|
290
|
+
embeddedCss === undefined
|
|
291
|
+
? `<link rel="stylesheet" href="${cssHref}">`
|
|
292
|
+
: `<style>\n${embeddedCss}\n</style>`
|
|
293
|
+
|
|
294
|
+
return `<!doctype html>
|
|
295
|
+
<html lang="en">
|
|
296
|
+
<head>
|
|
297
|
+
<meta charset="utf-8">
|
|
298
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
299
|
+
<title>${escapeHtml(title)}</title>
|
|
300
|
+
${FAVICON}
|
|
301
|
+
${style}
|
|
302
|
+
</head>
|
|
303
|
+
`
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Whether a workspace has a mission yet. A missing one has nothing to teach. */
|
|
307
|
+
function hasMission(workspace: WorkspaceSummary): boolean {
|
|
308
|
+
return !workspace.missing.includes(TEACH_MISSION)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function hrefForWorkspace(
|
|
312
|
+
workspace: WorkspaceSummary,
|
|
313
|
+
prefix: string,
|
|
314
|
+
): string | undefined {
|
|
315
|
+
return hasMission(workspace)
|
|
316
|
+
? `${prefix}${workspace.slug}/index.html`
|
|
317
|
+
: undefined
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function ordinalOf(workspace: WorkspaceSummary): string {
|
|
321
|
+
return Number.isNaN(workspace.ordinal)
|
|
322
|
+
? '??'
|
|
323
|
+
: String(workspace.ordinal).padStart(2, '0')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function workspaceJumpEntries(
|
|
327
|
+
workspaces: readonly WorkspaceSummary[],
|
|
328
|
+
prefix: string,
|
|
329
|
+
currentSlug: string | undefined,
|
|
330
|
+
): JumpEntry[] {
|
|
331
|
+
return workspaces.map((workspace) => ({
|
|
332
|
+
ordinal: ordinalOf(workspace),
|
|
333
|
+
label: titleCase(workspace.topic),
|
|
334
|
+
href: hrefForWorkspace(workspace, prefix),
|
|
335
|
+
done: workspace.lessons > 0,
|
|
336
|
+
at: workspace.slug === currentSlug,
|
|
337
|
+
}))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interface LessonMeta {
|
|
341
|
+
readonly file: string
|
|
342
|
+
readonly title: string
|
|
343
|
+
readonly lede: string
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function basenameTitle(file: string): string {
|
|
347
|
+
return titleCase(file.replace(/^\d+-/, '').replace(/\.html$/, ''))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* A lesson's title and lede, read back off its own `<h1>` and `<p
|
|
352
|
+
* class="lede">` rather than tracked anywhere else. Nothing in the workspace
|
|
353
|
+
* records what a lesson covers apart from the lesson itself.
|
|
354
|
+
*/
|
|
355
|
+
function extractLessonMeta(file: string, html: string): LessonMeta {
|
|
356
|
+
const h1 = /<h1>([\s\S]*?)<\/h1>/.exec(html)
|
|
357
|
+
const lede = /<p class="lede">([\s\S]*?)<\/p>/.exec(html)
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
file,
|
|
361
|
+
title: h1 ? stripTags(h1[1]).trim() : basenameTitle(file),
|
|
362
|
+
lede: lede ? stripTags(lede[1]).trim() : '',
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function lessonJumpEntries(
|
|
367
|
+
metas: readonly LessonMeta[],
|
|
368
|
+
workspacePrefix: string,
|
|
369
|
+
currentFile: string | undefined,
|
|
370
|
+
): JumpEntry[] {
|
|
371
|
+
return metas.map((meta, index) => ({
|
|
372
|
+
ordinal: String(index + 1).padStart(2, '0'),
|
|
373
|
+
label: meta.title,
|
|
374
|
+
href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
|
|
375
|
+
done: true,
|
|
376
|
+
at: meta.file === currentFile,
|
|
377
|
+
}))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function renderFootNav(metas: readonly LessonMeta[], index: number): string {
|
|
381
|
+
const previous = index > 0 ? metas[index - 1] : undefined
|
|
382
|
+
const next = index < metas.length - 1 ? metas[index + 1] : undefined
|
|
383
|
+
|
|
384
|
+
const previousHtml = previous
|
|
385
|
+
? `<a href="${previous.file}"><span class="lbl">Previous</span><span><span class="arrow">←</span>${escapeHtml(previous.title)}</span></a>`
|
|
386
|
+
: ''
|
|
387
|
+
|
|
388
|
+
const nextHtml = next
|
|
389
|
+
? `<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>`
|
|
390
|
+
: '<span class="end">End of the lessons written so far</span>'
|
|
391
|
+
|
|
392
|
+
return `<nav class="nav">${previousHtml}${nextHtml}</nav>`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function readTitle(path: string, fallback: string): Promise<string> {
|
|
396
|
+
if (!existsSync(path)) return fallback
|
|
397
|
+
const frontmatter = parseFrontmatter(await readFile(path, 'utf8'))
|
|
398
|
+
return readField(frontmatter, 'title') ?? fallback
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function renderGlossaryEntry(entry: string): string {
|
|
402
|
+
const match = /^\*\*(.+?)\*\*:?\s*([\s\S]*)$/.exec(entry)
|
|
403
|
+
const term = match ? match[1] : entry
|
|
404
|
+
const definition = match ? match[2] : ''
|
|
405
|
+
|
|
406
|
+
return `<div class="gterm"><b>${escapeHtml(term)}</b><span>${escapeHtml(definition)}</span></div>`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function renderGlossarySection(entries: readonly string[]): string {
|
|
410
|
+
const rendered = entries.map(renderGlossaryEntry).join('')
|
|
411
|
+
|
|
412
|
+
return `<h2>Glossary <span class="count">${entries.length}</span></h2>
|
|
413
|
+
<input class="filter" type="search" id="gfilter" aria-label="Filter glossary terms" aria-controls="gloss" placeholder="term">
|
|
414
|
+
<div class="gloss" id="gloss"><p class="empty">No term matches that. <button type="button" class="clear">Clear the filter</button></p>${rendered}</div>
|
|
415
|
+
`
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Every workspace, generated wholesale rather than spliced. Nothing here is
|
|
420
|
+
* authored by hand, so there is no region to preserve.
|
|
421
|
+
*/
|
|
422
|
+
function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
|
|
423
|
+
const segments: CrumbSegment[] = [
|
|
424
|
+
{
|
|
425
|
+
label: 'Workspaces',
|
|
426
|
+
jump: {
|
|
427
|
+
ariaLabel: 'Open a workspace',
|
|
428
|
+
entries: workspaceJumpEntries(workspaces, '', undefined),
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
const track = workspaces.map<TrackState>((workspace) =>
|
|
434
|
+
workspace.lessons > 0 ? 'done' : '',
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
const rows = workspaces
|
|
438
|
+
.map((workspace) => {
|
|
439
|
+
const href = hrefForWorkspace(workspace, '')
|
|
440
|
+
const state = !hasMission(workspace)
|
|
441
|
+
? 'Stub'
|
|
442
|
+
: workspace.lessons > 0
|
|
443
|
+
? 'Live'
|
|
444
|
+
: 'Open'
|
|
445
|
+
const blurb = `${workspace.lessons} lesson(s) · ${workspace.reference} reference page(s) · ${workspace.terms} term(s)`
|
|
446
|
+
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(titleCase(workspace.topic))}</b><span class="state${state === 'Live' ? ' done' : ''}">${state}</span><span class="blurb">${blurb}</span>`
|
|
447
|
+
|
|
448
|
+
return href === undefined
|
|
449
|
+
? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
|
|
450
|
+
: `<li><a href="${href}">${inner}</a></li>`
|
|
451
|
+
})
|
|
452
|
+
.join('')
|
|
453
|
+
|
|
454
|
+
return `${pageHead('Learning workspaces', undefined, buildDesignCss(undefined, { embedFonts: true }))}<body>
|
|
455
|
+
${renderHeader(segments, track)}
|
|
456
|
+
<main class="wide-body">
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
<h1>Learning workspaces</h1>
|
|
460
|
+
<p class="lede">One folder per subject. Each carries its own mission, sources, glossary, and lessons.</p>
|
|
461
|
+
|
|
462
|
+
<ul class="toc">${rows}</ul>
|
|
463
|
+
|
|
464
|
+
</main>
|
|
465
|
+
${renderScripts(false, false)}
|
|
466
|
+
</body>
|
|
467
|
+
</html>
|
|
468
|
+
`
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Every lesson's title and lede, read once per rewrite of the workspace. */
|
|
472
|
+
async function readLessonMetas(
|
|
473
|
+
root: string,
|
|
474
|
+
detail: WorkspaceDetail,
|
|
475
|
+
): Promise<LessonMeta[]> {
|
|
476
|
+
return Promise.all(
|
|
477
|
+
detail.lessonFiles.map(async (file) => {
|
|
478
|
+
const text = await readFile(
|
|
479
|
+
join(root, detail.path, TEACH_LESSONS, file),
|
|
480
|
+
'utf8',
|
|
481
|
+
)
|
|
482
|
+
return extractLessonMeta(file, text)
|
|
483
|
+
}),
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* A workspace's contents page, generated wholesale from what the folder holds
|
|
489
|
+
* on disk: the mission, the lessons already written, the reference pages, and
|
|
490
|
+
* the glossary. A workspace with no `index.html` yet gets one the same way a
|
|
491
|
+
* workspace that already had one gets its rewrite, since both read the same
|
|
492
|
+
* sources.
|
|
493
|
+
*/
|
|
494
|
+
async function renderContentsPage(
|
|
495
|
+
root: string,
|
|
496
|
+
workspaces: readonly WorkspaceSummary[],
|
|
497
|
+
detail: WorkspaceDetail,
|
|
498
|
+
metas: readonly LessonMeta[],
|
|
499
|
+
): Promise<string> {
|
|
500
|
+
const missionPath = join(root, detail.path, TEACH_MISSION)
|
|
501
|
+
const description = existsSync(missionPath)
|
|
502
|
+
? readField(
|
|
503
|
+
parseFrontmatter(await readFile(missionPath, 'utf8')),
|
|
504
|
+
'description',
|
|
505
|
+
)
|
|
506
|
+
: undefined
|
|
507
|
+
|
|
508
|
+
const title = detail.title ?? titleCase(detail.topic)
|
|
509
|
+
|
|
510
|
+
const segments: CrumbSegment[] = [
|
|
511
|
+
{
|
|
512
|
+
label: 'Workspaces',
|
|
513
|
+
href: '../index.html',
|
|
514
|
+
jump: {
|
|
515
|
+
ariaLabel: 'Switch workspace',
|
|
516
|
+
entries: workspaceJumpEntries(workspaces, '../', detail.slug),
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
label: titleCase(detail.topic),
|
|
521
|
+
jump: {
|
|
522
|
+
ariaLabel: 'Jump to a lesson',
|
|
523
|
+
entries: lessonJumpEntries(metas, '', undefined),
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
const track = metas.map<TrackState>(() => 'done')
|
|
529
|
+
|
|
530
|
+
const lessonRows = metas
|
|
531
|
+
.map(
|
|
532
|
+
(meta, index) =>
|
|
533
|
+
`<li><a href="${TEACH_LESSONS}/${meta.file}"><span class="num">${String(index + 1).padStart(2, '0')}</span><b>${escapeHtml(meta.title)}</b><span class="state done">Written</span><span class="blurb">${escapeHtml(meta.lede)}</span></a></li>`,
|
|
534
|
+
)
|
|
535
|
+
.join('')
|
|
536
|
+
|
|
537
|
+
const referenceRows = (
|
|
538
|
+
await Promise.all(
|
|
539
|
+
detail.referenceFiles.map(async (file, index) => {
|
|
540
|
+
const title = await readTitle(
|
|
541
|
+
join(root, detail.path, TEACH_REFERENCE, file),
|
|
542
|
+
basenameTitle(file),
|
|
543
|
+
)
|
|
544
|
+
return `<li><a href="${TEACH_REFERENCE}/${file}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
|
|
545
|
+
}),
|
|
546
|
+
)
|
|
547
|
+
).join('')
|
|
548
|
+
|
|
549
|
+
const successRows = detail.success
|
|
550
|
+
.map((line) => `<li>${escapeHtml(line)}</li>`)
|
|
551
|
+
.join('')
|
|
552
|
+
|
|
553
|
+
const sections = [
|
|
554
|
+
description ? `<p class="lede">${escapeHtml(description)}</p>` : '',
|
|
555
|
+
successRows
|
|
556
|
+
? `<h2>Finished when you can do all of these unaided</h2>\n<ol class="succ">${successRows}</ol>`
|
|
557
|
+
: '',
|
|
558
|
+
lessonRows ? `<h2>Lessons</h2>\n<ul class="toc">${lessonRows}</ul>` : '',
|
|
559
|
+
referenceRows
|
|
560
|
+
? `<h2>Reference pages</h2>\n<ul class="toc">${referenceRows}</ul>`
|
|
561
|
+
: '',
|
|
562
|
+
renderGlossarySection(detail.glossary),
|
|
563
|
+
]
|
|
564
|
+
.filter((section) => section !== '')
|
|
565
|
+
.join('\n\n')
|
|
566
|
+
|
|
567
|
+
return `${pageHead(`${title}, contents`, `${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined)}<body>
|
|
568
|
+
${renderHeader(segments, track)}
|
|
569
|
+
<main class="wide-body">
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
<h1>${escapeHtml(title)}</h1>
|
|
573
|
+
${sections}
|
|
574
|
+
|
|
575
|
+
</main>
|
|
576
|
+
${renderScripts(false, true)}
|
|
577
|
+
</body>
|
|
578
|
+
</html>
|
|
579
|
+
`
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const REGIONS = ['style', 'header', 'footnav', 'scripts'] as const
|
|
583
|
+
type Region = (typeof REGIONS)[number]
|
|
584
|
+
|
|
585
|
+
function regionPattern(region: Region): RegExp {
|
|
586
|
+
return new RegExp(
|
|
587
|
+
`(<!-- canon:teach:${region} -->)[\\s\\S]*?(<!-- /canon:teach:${region} -->)`,
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Replaces one marked chrome region, keeping every other byte of the lesson
|
|
593
|
+
* untouched. Returns `undefined` when the marker pair is absent, which is the
|
|
594
|
+
* signal a caller reads as a refusal rather than a rewrite.
|
|
595
|
+
*/
|
|
596
|
+
function spliceRegion(
|
|
597
|
+
html: string,
|
|
598
|
+
region: Region,
|
|
599
|
+
content: string,
|
|
600
|
+
): string | undefined {
|
|
601
|
+
const pattern = regionPattern(region)
|
|
602
|
+
if (!pattern.test(html)) return undefined
|
|
603
|
+
|
|
604
|
+
return html.replace(
|
|
605
|
+
pattern,
|
|
606
|
+
(_match, open, close) => `${open}\n${content}\n${close}`,
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
interface LessonRewritten {
|
|
611
|
+
readonly ok: true
|
|
612
|
+
readonly file: string
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
interface LessonRefused {
|
|
616
|
+
readonly ok: false
|
|
617
|
+
readonly file: string
|
|
618
|
+
readonly missing: Region
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Splices the four chrome regions into one lesson file, in a fixed order so a
|
|
623
|
+
* missing marker is always reported against the same region a session can
|
|
624
|
+
* check first. Nothing is written when any region is missing, which is what
|
|
625
|
+
* keeps a partially-spliced file off disk.
|
|
626
|
+
*/
|
|
627
|
+
async function rewriteLesson(
|
|
628
|
+
root: string,
|
|
629
|
+
detail: WorkspaceDetail,
|
|
630
|
+
index: number,
|
|
631
|
+
metas: readonly LessonMeta[],
|
|
632
|
+
workspaces: readonly WorkspaceSummary[],
|
|
633
|
+
css: string,
|
|
634
|
+
): Promise<LessonRewritten | LessonRefused> {
|
|
635
|
+
const file = metas[index].file
|
|
636
|
+
const path = join(root, detail.path, TEACH_LESSONS, file)
|
|
637
|
+
let html = await readFile(path, 'utf8')
|
|
638
|
+
|
|
639
|
+
const teachPrefix = '../../'
|
|
640
|
+
const workspacePrefix = '../'
|
|
641
|
+
|
|
642
|
+
const header = renderHeader(
|
|
643
|
+
[
|
|
644
|
+
{
|
|
645
|
+
label: 'Workspaces',
|
|
646
|
+
href: `${teachPrefix}index.html`,
|
|
647
|
+
jump: {
|
|
648
|
+
ariaLabel: 'Switch workspace',
|
|
649
|
+
entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
label: titleCase(detail.topic),
|
|
654
|
+
href: `${workspacePrefix}index.html`,
|
|
655
|
+
jump: {
|
|
656
|
+
ariaLabel: 'Jump to a lesson',
|
|
657
|
+
entries: lessonJumpEntries(metas, workspacePrefix, file),
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
{ label: `Lesson ${index + 1} of ${metas.length}` },
|
|
661
|
+
],
|
|
662
|
+
metas.map<TrackState>((_, i) => (i === index ? 'here' : 'done')),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
const regions: ReadonlyArray<readonly [Region, string]> = [
|
|
666
|
+
['style', `<style>\n${css}\n</style>`],
|
|
667
|
+
['header', header],
|
|
668
|
+
['footnav', renderFootNav(metas, index)],
|
|
669
|
+
['scripts', renderScripts(html.includes('class="quiz"'), false)],
|
|
670
|
+
]
|
|
671
|
+
|
|
672
|
+
for (const [region, content] of regions) {
|
|
673
|
+
const spliced = spliceRegion(html, region, content)
|
|
674
|
+
if (spliced === undefined) return { ok: false, file, missing: region }
|
|
675
|
+
html = spliced
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
await writeFile(path, html)
|
|
679
|
+
return { ok: true, file }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export interface LessonSkipped {
|
|
683
|
+
readonly file: string
|
|
684
|
+
readonly missing: string
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export interface NavGenerated {
|
|
688
|
+
readonly ok: true
|
|
689
|
+
/** Relative to the root, so a caller prints a path a reader can open. */
|
|
690
|
+
readonly root: string
|
|
691
|
+
readonly contents: readonly string[]
|
|
692
|
+
readonly lessons: number
|
|
693
|
+
/** A lesson file missing a chrome marker, refused rather than rewritten. */
|
|
694
|
+
readonly skipped: readonly LessonSkipped[]
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export type NavOutcome = NavGenerated | TeachRefused
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Rewrites the teach-root listing, every workspace's contents page, and each
|
|
701
|
+
* lesson's chrome, from what the folder holds on disk.
|
|
702
|
+
*
|
|
703
|
+
* The root page always reflects every workspace, whether or not `selector`
|
|
704
|
+
* scopes the run to one of them, since a global page is cheap to regenerate
|
|
705
|
+
* and reading it as stale after a scoped run would be a second kind of nav
|
|
706
|
+
* drift this verb exists to end.
|
|
707
|
+
*/
|
|
708
|
+
export async function generateNav(
|
|
709
|
+
root: string,
|
|
710
|
+
selector?: string,
|
|
711
|
+
): Promise<NavOutcome> {
|
|
712
|
+
const listed = await listWorkspaces(root)
|
|
713
|
+
if (!listed.ok) return listed
|
|
714
|
+
|
|
715
|
+
let targetSlugs: readonly string[]
|
|
716
|
+
|
|
717
|
+
if (selector === undefined) {
|
|
718
|
+
targetSlugs = listed.workspaces.map((workspace) => workspace.slug)
|
|
719
|
+
} else {
|
|
720
|
+
const found = await readWorkspace(root, selector)
|
|
721
|
+
if (!found.ok) return found
|
|
722
|
+
targetSlugs = [found.workspace.slug]
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const dir = teachDir(root)
|
|
726
|
+
const rootPath = join(dir, 'index.html')
|
|
727
|
+
await writeFile(rootPath, renderRootPage(listed.workspaces))
|
|
728
|
+
|
|
729
|
+
const contents: string[] = []
|
|
730
|
+
const skipped: LessonSkipped[] = []
|
|
731
|
+
let lessons = 0
|
|
732
|
+
|
|
733
|
+
for (const slug of targetSlugs) {
|
|
734
|
+
const found = await readWorkspace(root, slug)
|
|
735
|
+
if (!found.ok) continue
|
|
736
|
+
|
|
737
|
+
const detail = found.workspace
|
|
738
|
+
const metas = await readLessonMetas(root, detail)
|
|
739
|
+
|
|
740
|
+
const contentsPath = join(root, detail.path, 'index.html')
|
|
741
|
+
await writeFile(
|
|
742
|
+
contentsPath,
|
|
743
|
+
await renderContentsPage(root, listed.workspaces, detail, metas),
|
|
744
|
+
)
|
|
745
|
+
contents.push(relative(root, contentsPath))
|
|
746
|
+
|
|
747
|
+
const cssPath = join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET)
|
|
748
|
+
const css = existsSync(cssPath) ? await readFile(cssPath, 'utf8') : ''
|
|
749
|
+
|
|
750
|
+
for (let index = 0; index < metas.length; index += 1) {
|
|
751
|
+
const outcome = await rewriteLesson(
|
|
752
|
+
root,
|
|
753
|
+
detail,
|
|
754
|
+
index,
|
|
755
|
+
metas,
|
|
756
|
+
listed.workspaces,
|
|
757
|
+
css,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
if (outcome.ok) {
|
|
761
|
+
lessons += 1
|
|
762
|
+
} else {
|
|
763
|
+
skipped.push({
|
|
764
|
+
file: join(detail.path, TEACH_LESSONS, outcome.file),
|
|
765
|
+
missing: `canon:teach:${outcome.missing}`,
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
ok: true,
|
|
773
|
+
root: relative(root, rootPath),
|
|
774
|
+
contents,
|
|
775
|
+
lessons,
|
|
776
|
+
skipped,
|
|
777
|
+
}
|
|
778
|
+
}
|