@erclx/canon 5.52.0 → 5.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -4
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +0 -3
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-taste/references/kinds.md +0 -1
  7. package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
  8. package/claude/skills/draw-figure/SKILL.md +4 -5
  9. package/claude/skills/draw-figure/references/figures.md +0 -1
  10. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  11. package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
  12. package/claude/skills/pick-by-eye/SKILL.md +1 -1
  13. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  14. package/claude/skills/teach-craft/SKILL.md +39 -0
  15. package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
  16. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
  17. package/docs/agents/audits.md +0 -1
  18. package/docs/target/migrations.md +10 -1
  19. package/docs/workflow/parallel-features.md +1 -1
  20. package/docs/workflow/skill-map-on-request.md +10 -22
  21. package/docs/workflow/skill-map.md +12 -14
  22. package/package.json +1 -4
  23. package/scripts/core/regen-web-previews.ts +2 -67
  24. package/src/audits/catalog.ts +0 -1
  25. package/src/browser/engine.ts +3 -6
  26. package/src/capture/render.ts +1 -1
  27. package/src/claude/cases/authoring.ts +0 -12
  28. package/src/claude/cases/misc.ts +0 -17
  29. package/src/claude/cases/workflow.ts +2 -38
  30. package/src/claude/skills-audit.ts +0 -1
  31. package/src/cli/help.ts +0 -7
  32. package/src/cli.ts +0 -6
  33. package/src/commands/records.ts +0 -1
  34. package/src/commands/serve.ts +2 -2
  35. package/src/design/components.ts +8 -1514
  36. package/src/design/css.ts +5 -6
  37. package/src/design/fonts.ts +3 -5
  38. package/src/driver/drive.ts +3 -4
  39. package/src/driver/steps.ts +2 -5
  40. package/src/inventory/walk.ts +1 -1
  41. package/src/markdown/scope.ts +4 -31
  42. package/src/records/validate.ts +4 -176
  43. package/src/sandbox/equivalence.ts +0 -5
  44. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  45. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  46. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  47. package/claude/skills/draft-doc/SKILL.md +0 -11
  48. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  49. package/claude/skills/draft-figure/SKILL.md +0 -11
  50. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  51. package/claude/skills/draft-identity/SKILL.md +0 -11
  52. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  53. package/claude/skills/draft-ready/SKILL.md +0 -11
  54. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  55. package/claude/skills/draft-screencast/SKILL.md +0 -11
  56. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  57. package/claude/skills/draft-slides/SKILL.md +0 -11
  58. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  59. package/claude/skills/make-deck/SKILL.md +0 -80
  60. package/claude/skills/make-deck/references/deck.md +0 -183
  61. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  62. package/claude/skills/read-frames/SKILL.md +0 -55
  63. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  64. package/claude/skills/record-screencast/SKILL.md +0 -99
  65. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  66. package/claude/skills/script-screencast/SKILL.md +0 -70
  67. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  68. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  69. package/claude/skills/teach-workspace/SKILL.md +0 -196
  70. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  71. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  72. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  73. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  74. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  75. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  76. package/claude/skills/teach-workspace/references/teach.md +0 -211
  77. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  78. package/claude/skills/video-craft/SKILL.md +0 -96
  79. package/claude/skills/video-craft/references/adopted.md +0 -110
  80. package/src/commands/demo.ts +0 -542
  81. package/src/commands/slides.ts +0 -184
  82. package/src/commands/teach.ts +0 -1401
  83. package/src/demo/beats.ts +0 -135
  84. package/src/demo/compile.ts +0 -350
  85. package/src/demo/container.ts +0 -219
  86. package/src/demo/cursors.ts +0 -55
  87. package/src/demo/drive.ts +0 -442
  88. package/src/demo/pointer.ts +0 -178
  89. package/src/demo/theme.ts +0 -112
  90. package/src/demo/timeline.ts +0 -64
  91. package/src/slides/convert/chart.ts +0 -165
  92. package/src/slides/convert/deck.ts +0 -244
  93. package/src/slides/convert/export.ts +0 -567
  94. package/src/slides/convert/master.ts +0 -198
  95. package/src/slides/convert/shapes.ts +0 -804
  96. package/src/slides/convert/svg.ts +0 -38
  97. package/src/slides/convert/walk.ts +0 -671
  98. package/src/slides/decks.ts +0 -110
  99. package/src/slides/open.ts +0 -18
  100. package/src/slides/package/fonts.ts +0 -301
  101. package/src/slides/package/motion.ts +0 -316
  102. package/src/slides/package/test-face.ts +0 -113
  103. package/src/teach/browser/compile.ts +0 -44
  104. package/src/teach/browser/dismiss.ts +0 -76
  105. package/src/teach/browser/focus-line.ts +0 -19
  106. package/src/teach/browser/glossary-filter.ts +0 -44
  107. package/src/teach/browser/head.ts +0 -30
  108. package/src/teach/browser/quiz.ts +0 -19
  109. package/src/teach/browser/reload.ts +0 -9
  110. package/src/teach/browser/sidebar.ts +0 -233
  111. package/src/teach/browser/theme.ts +0 -18
  112. package/src/teach/components/code.tsx +0 -24
  113. package/src/teach/components/heading.tsx +0 -11
  114. package/src/teach/components/list.tsx +0 -17
  115. package/src/teach/components/paragraph.tsx +0 -29
  116. package/src/teach/components/refs.tsx +0 -28
  117. package/src/teach/cross-references.ts +0 -78
  118. package/src/teach/fonts.ts +0 -16
  119. package/src/teach/highlight.ts +0 -71
  120. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  121. package/src/teach/html/jsx-runtime.ts +0 -113
  122. package/src/teach/html/raw.ts +0 -28
  123. package/src/teach/lesson.ts +0 -178
  124. package/src/teach/nav.ts +0 -1337
  125. package/src/teach/render.ts +0 -240
  126. package/src/teach/workspace.ts +0 -1164
  127. package/src/teach/write-if-changed.ts +0 -17
@@ -1,1401 +0,0 @@
1
- import { readdirSync, statSync } from 'node:fs'
2
- import { isAbsolute, join, relative, resolve, sep } from 'node:path'
3
- import type { Command } from 'commander'
4
- import { LiveStream } from '@/serve/live'
5
- import {
6
- logServing,
7
- parsePort,
8
- serveFields,
9
- waitForInterrupt,
10
- } from '@/serve/report'
11
- import { DEFAULT_PORT, LIVE_EVENTS_PATH, startServer } from '@/serve/static'
12
- import { compileScript } from '@/teach/browser/compile'
13
- import { reload } from '@/teach/browser/reload'
14
- import { type LessonOutcome, planLesson } from '@/teach/lesson'
15
- import { type NavGenerated, type NavOutcome, generateNav } from '@/teach/nav'
16
- import type { RenderOutcome } from '@/teach/render'
17
- import {
18
- defineTerms,
19
- type ListOutcome,
20
- listWorkspaces,
21
- type OpenOutcome,
22
- openWorkspace,
23
- type ReadOutcome,
24
- readWorkspace,
25
- recordSources,
26
- type RevisitItem,
27
- type Source,
28
- type SourceOutcome,
29
- type TeachRefused,
30
- type Term,
31
- type TermOutcome,
32
- teachDir,
33
- type WorkspaceSummary,
34
- writeStylesheet,
35
- } from '@/teach/workspace'
36
- import {
37
- intro,
38
- logAdd,
39
- logError,
40
- logInfo,
41
- logStep,
42
- logWarn,
43
- outro,
44
- pipeOutput,
45
- } from '@/cli/ui'
46
- import { mainWorktreeRoot } from '@/git/worktree'
47
-
48
- /**
49
- * A pair as the command line spells it, split on the first `=` so a value
50
- * carrying its own separator survives. A source URL is the case that needs it.
51
- */
52
- const PAIR = /^([^=]+)=([\s\S]+)$/
53
-
54
- interface ListCommandOptions {
55
- readonly json?: boolean
56
- readonly root?: string
57
- }
58
-
59
- interface OpenCommandOptions {
60
- readonly date?: string
61
- readonly json?: boolean
62
- readonly outOfScope?: readonly string[]
63
- readonly root?: string
64
- readonly startingPoint?: string
65
- readonly subject?: string
66
- readonly success?: readonly string[]
67
- readonly title?: string
68
- }
69
-
70
- interface ResourceCommandOptions {
71
- readonly json?: boolean
72
- readonly lead?: readonly string[]
73
- readonly read?: readonly string[]
74
- readonly root?: string
75
- }
76
-
77
- interface LessonCommandOptions {
78
- readonly json?: boolean
79
- readonly options?: string
80
- readonly questions?: string
81
- readonly root?: string
82
- readonly slug?: string
83
- }
84
-
85
- interface GlossaryCommandOptions {
86
- readonly firstSeen?: string
87
- readonly json?: boolean
88
- readonly root?: string
89
- readonly term?: readonly string[]
90
- }
91
-
92
- interface NavCommandOptions {
93
- readonly json?: boolean
94
- readonly root?: string
95
- }
96
-
97
- interface UpCommandOptions {
98
- readonly json?: boolean
99
- readonly port?: string
100
- readonly root?: string
101
- }
102
-
103
- interface RenderCommandOptions {
104
- readonly json?: boolean
105
- }
106
-
107
- export function register(program: Command): void {
108
- const teach = program
109
- .command('teach')
110
- .description('Manage learning workspaces in .canon/teach/')
111
- .helpOption('-h, --help', 'Show this help message')
112
- .addHelpText(
113
- 'after',
114
- [
115
- '',
116
- 'Viewing a workspace:',
117
- ' canon teach up <topic>',
118
- '',
119
- 'It refreshes the chrome and serves the workspace. To serve without',
120
- 'rewriting anything, canon teach list <topic> prints the canon serve',
121
- 'line, with the teach folder written from where you stand.',
122
- '',
123
- 'Reasons, on stderr or in the JSON record:',
124
- ' no-teach the root carries no .canon/teach/ folder',
125
- ' no-workspace no workspace matches the topic',
126
- ' ambiguous two workspaces claim one topic',
127
- ' exists a workspace already covers the topic an open names',
128
- ' no-file the workspace carries no file the verb writes into',
129
- ' no-section RESOURCES.md carries no heading the entries belong under',
130
- ' listed a URL is already read, or a lead is offered as listed',
131
- ' defined a term already carries a glossary entry',
132
- ' bad-input the command line is malformed',
133
- ' no-port up found every port in reach taken',
134
- ' bind-failed up could not bind, such as a privileged port',
135
- '',
136
- ].join('\n'),
137
- )
138
-
139
- teach
140
- .command('list')
141
- .description('List learning workspaces, or what one workspace holds')
142
- .argument('[topic]', 'Workspace folder or topic, as in regular-expressions')
143
- .helpOption('-h, --help', 'Show this help message')
144
- .option('--json', 'Emit a machine-readable record on stdout')
145
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
146
- .addHelpText(
147
- 'after',
148
- [
149
- '',
150
- 'Exit codes:',
151
- ' 0 the workspaces or their contents were listed',
152
- ' 1 refused, with the reason on stderr or in the JSON record',
153
- '',
154
- 'With no topic it reports one line per workspace and the ordinal an',
155
- 'open would take. With one it reports the files behind each count.',
156
- '',
157
- 'Every record carries due, what the learning records schedule, each',
158
- 'entry with its date, its rung, and the date to write on a hit and',
159
- 'on a miss, so a session picks the next lesson from the schedule',
160
- 'rather than from the last session alone.',
161
- '',
162
- 'A workspace not named NN-<topic> is still listed. It sorts last and',
163
- 'moves no ordinal, since dropping it hides the folder needing a fix.',
164
- '',
165
- 'The human output ends with the canon serve line that opens a',
166
- 'workspace, a template with no topic and the exact line with one.',
167
- '',
168
- 'Examples:',
169
- ' canon teach list',
170
- ' canon teach list regular-expressions --json',
171
- '',
172
- ].join('\n'),
173
- )
174
- .action(async (topic: string | undefined, opts: ListCommandOptions) => {
175
- process.exitCode = await runList(topic, opts)
176
- })
177
-
178
- teach
179
- .command('open')
180
- .description('Open a workspace at the next ordinal with its required files')
181
- .argument('<topic>', 'Kebab-case topic, as in regular-expressions')
182
- .helpOption('-h, --help', 'Show this help message')
183
- .option('--subject <line>', 'One line stating what the workspace covers')
184
- .option('--starting-point <text>', 'What the learner already knows')
185
- .option(
186
- '--success <line>',
187
- 'Observable thing the learner will be able to do, repeatable',
188
- collect,
189
- [] as string[],
190
- )
191
- .option(
192
- '--out-of-scope <line>',
193
- 'What this workspace does not cover, repeatable',
194
- collect,
195
- [] as string[],
196
- )
197
- .option('--title <text>', 'Title, defaulting to the topic in sentence case')
198
- .option('--date <YYYY-MM-DD>', 'Opening date, defaulting to today')
199
- .option('--json', 'Emit a machine-readable record on stdout')
200
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
201
- .addHelpText(
202
- 'after',
203
- [
204
- '',
205
- 'Exit codes:',
206
- ' 0 the workspace was created',
207
- ' 1 refused, with the reason on stderr or in the JSON record',
208
- '',
209
- 'It derives the ordinal from the highest already present and writes',
210
- 'MISSION.md, RESOURCES.md, and GLOSSARY.md. A topic another workspace',
211
- 'already covers is refused, since a second one forks the records.',
212
- 'It creates a workspace and serves nothing: canon teach up views one.',
213
- '',
214
- 'Examples:',
215
- ' canon teach open regular-expressions --subject "Reading and writing regular expressions" \\',
216
- ' --starting-point "Comfortable with the shell, has never written a group" \\',
217
- ' --success "Write a pattern matching a date" --success "Explain a backreference"',
218
- '',
219
- ].join('\n'),
220
- )
221
- .action(async (topic: string, opts: OpenCommandOptions) => {
222
- process.exitCode = await runOpen(topic, opts)
223
- })
224
-
225
- teach
226
- .command('resource')
227
- .description('Record sources and leads in a workspace RESOURCES.md')
228
- .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
229
- .helpOption('-h, --help', 'Show this help message')
230
- .option(
231
- '--read <title=url>',
232
- 'Source that stands behind the material, repeatable',
233
- collect,
234
- [] as string[],
235
- )
236
- .option(
237
- '--lead <title=url>',
238
- 'Source found and not opened, repeatable',
239
- collect,
240
- [] as string[],
241
- )
242
- .option('--json', 'Emit a machine-readable record on stdout')
243
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
244
- .addHelpText(
245
- 'after',
246
- [
247
- '',
248
- 'Exit codes:',
249
- ' 0 every source was written',
250
- ' 1 refused, with the reason on stderr or in the JSON record',
251
- '',
252
- 'The pair splits on the first =, so a URL carrying one survives. Say',
253
- 'in the title which claims rest on the source, since the entry is the',
254
- 'only place a later session reads that from.',
255
- '',
256
- 'A URL already listed under either heading is refused rather than',
257
- 'repeated, because two entries split what rests on one source. The one',
258
- 'exception is a --read URL listed under Leads, which moves to Read under',
259
- 'its new title.',
260
- '',
261
- 'Examples:',
262
- ' canon teach resource regular-expressions --read "MDN regular expressions=https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions"',
263
- ' canon teach resource regular-expressions --lead "RE2 syntax=https://github.com/google/re2/wiki/Syntax" --json',
264
- '',
265
- ].join('\n'),
266
- )
267
- .action(async (topic: string, opts: ResourceCommandOptions) => {
268
- process.exitCode = await runResource(topic, opts)
269
- })
270
-
271
- teach
272
- .command('glossary')
273
- .description('Add terms to a workspace GLOSSARY.md, alphabetically')
274
- .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
275
- .helpOption('-h, --help', 'Show this help message')
276
- .option(
277
- '--term <term=definition>',
278
- 'Term the subject defines, repeatable',
279
- collect,
280
- [] as string[],
281
- )
282
- .option(
283
- '--first-seen <file>',
284
- 'Lesson or reference page the batch first defines these in',
285
- )
286
- .option('--json', 'Emit a machine-readable record on stdout')
287
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
288
- .addHelpText(
289
- 'after',
290
- [
291
- '',
292
- 'Exit codes:',
293
- ' 0 every term now carries an entry',
294
- ' 1 refused, with the reason on stderr or in the JSON record',
295
- '',
296
- 'One call writes one file, which is what keeps a batch from racing on',
297
- 'the glossary every term shares. --first-seen names one page for the',
298
- 'whole batch, since a batch comes from one lesson.',
299
- '',
300
- 'A term already defined is refused rather than replaced. A definition',
301
- 'the subject has moved under is a revision of the entry it has.',
302
- '',
303
- 'Examples:',
304
- ' canon teach glossary regular-expressions --term "capture group=A parenthesised part of a pattern whose match is kept" --first-seen 0002-groups.html',
305
- '',
306
- ].join('\n'),
307
- )
308
- .action(async (topic: string, opts: GlossaryCommandOptions) => {
309
- process.exitCode = await runGlossary(topic, opts)
310
- })
311
-
312
- teach
313
- .command('lesson')
314
- .description('Resolve what the next lesson needs before it is written')
315
- .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
316
- .helpOption('-h, --help', 'Show this help message')
317
- .option('--slug <kebab>', "The lesson's own topic, as in capture-groups")
318
- .option('--questions <n>', 'How many questions the quiz carries')
319
- .option('--options <n>', 'How many options each question carries', '4')
320
- .option('--json', 'Emit a machine-readable record on stdout')
321
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
322
- .addHelpText(
323
- 'after',
324
- [
325
- '',
326
- 'Exit codes:',
327
- ' 0 the lesson was planned',
328
- ' 1 refused, with the reason on stderr or in the JSON record',
329
- '',
330
- 'It writes nothing. It reports the numbered path the lesson takes, the',
331
- 'shared stylesheet with whether that file exists yet, the mission',
332
- 'success lines to report progress against, and one option order per',
333
- 'question.',
334
- '',
335
- 'Write the correct option first and present the options in the order',
336
- 'reported. Position is drawn here rather than chosen, so an answer',
337
- 'cannot settle into the first slot.',
338
- '',
339
- 'Examples:',
340
- ' canon teach lesson regular-expressions --slug capture-groups --questions 3 --json',
341
- '',
342
- ].join('\n'),
343
- )
344
- .action(async (topic: string, opts: LessonCommandOptions) => {
345
- process.exitCode = await runLesson(topic, opts)
346
- })
347
-
348
- teach
349
- .command('stylesheet')
350
- .description('Seed a workspace stylesheet from the design source')
351
- .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
352
- .helpOption('-h, --help', 'Show this help message')
353
- .option('--force', 'Rewrite a stylesheet the workspace already carries')
354
- .option('--json', 'Emit a machine-readable record on stdout')
355
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
356
- .addHelpText(
357
- 'after',
358
- [
359
- '',
360
- 'Exit codes:',
361
- ' 0 the workspace carries a stylesheet, written now or already there',
362
- ' 1 refused, with the reason on stderr or in the JSON record',
363
- '',
364
- 'The file it writes carries the design tokens as custom properties and',
365
- 'the component rules built on them, so a workspace renders in the same',
366
- 'system every other surface does. Add lesson rules under the seed and',
367
- 'read a value through its property rather than restating the hex.',
368
- '',
369
- 'An existing stylesheet is left alone, since a workspace adds to this',
370
- 'file as it goes. Pass --force to take the seed back over it.',
371
- '',
372
- 'Examples:',
373
- ' canon teach stylesheet regular-expressions --json',
374
- '',
375
- ].join('\n'),
376
- )
377
- .action(async (topic: string, opts: StylesheetCommandOptions) => {
378
- process.exitCode = await runStylesheet(topic, opts)
379
- })
380
-
381
- teach
382
- .command('nav')
383
- .description('Rewrite the teach root, contents pages, and lesson chrome')
384
- .argument('[topic]', 'Workspace folder or topic, scoping the run to one')
385
- .helpOption('-h, --help', 'Show this help message')
386
- .option('--json', 'Emit a machine-readable record on stdout')
387
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
388
- .addHelpText(
389
- 'after',
390
- [
391
- '',
392
- 'Exit codes:',
393
- ' 0 the root, the contents page(s), and every found lesson chrome were written',
394
- ' 1 refused, with the reason on stderr or in the JSON record',
395
- '',
396
- 'Rewrites the teach-root listing, the contents page of every workspace',
397
- "or of the one topic named, and each of its lessons' chrome: the",
398
- 'embedded stylesheet, the header with its breadcrumb and jump menus,',
399
- 'the prev/next footer nav, and the behavior scripts. The authored',
400
- '<h1>, lede, body, and quiz are left untouched.',
401
- '',
402
- "A workspace's generated assets/base.css is rewritten on every run, so",
403
- 'a CLI upgrade reaches it. Its course.css is never rewritten, and a',
404
- 'workspace holding only a course.css is not given a base.css.',
405
- '',
406
- 'A lesson missing one of the four chrome markers is refused rather',
407
- "than rewritten, reported by name in the JSON record's skipped list",
408
- 'and on stderr, while every other lesson still rewrites.',
409
- '',
410
- 'Examples:',
411
- ' canon teach nav',
412
- ' canon teach nav regular-expressions --json',
413
- '',
414
- ].join('\n'),
415
- )
416
- .action(async (topic: string | undefined, opts: NavCommandOptions) => {
417
- process.exitCode = await runNav(topic, opts)
418
- })
419
-
420
- teach
421
- .command('up')
422
- .description('Refresh the chrome with nav, then serve the teach folder')
423
- .argument('[topic]', 'Workspace folder or topic the printed link opens')
424
- .helpOption('-h, --help', 'Show this help message')
425
- .option('--port <number>', `Port to try first, default ${DEFAULT_PORT}`)
426
- .option('--json', 'Emit a machine-readable record on stdout')
427
- .option('--root <path>', 'Teach root, defaulting to the main worktree')
428
- .addHelpText(
429
- 'after',
430
- [
431
- '',
432
- 'Exit codes:',
433
- ' 0 the server stopped after running',
434
- ' 1 refused, with the reason on stderr or in the JSON record',
435
- '',
436
- 'Runs the nav pass for the topic, or for every workspace when none is',
437
- 'named, then serves the teach folder through the same server as',
438
- 'canon serve. The link opens the topic contents page, or the root',
439
- 'listing with no topic. A lesson nav skips is reported and the server',
440
- 'still starts, while a nav refusal starts no server.',
441
- '',
442
- 'Viewing writes: every page and generated stylesheet nav owns is',
443
- 'rewritten first. canon teach list <topic> prints a canon serve line',
444
- 'that serves without rewriting.',
445
- '',
446
- 'It runs until interrupted. A session wanting the link without waiting',
447
- 'starts it in the background and reads the record off stdout.',
448
- '',
449
- 'Examples:',
450
- ' canon teach up regular-expressions',
451
- ' canon teach up --port 4000 --json',
452
- '',
453
- ].join('\n'),
454
- )
455
- .action(async (topic: string | undefined, opts: UpCommandOptions) => {
456
- process.exitCode = await runUp(topic, opts)
457
- })
458
-
459
- teach
460
- .command('render')
461
- .description('Render a lesson body block list to HTML')
462
- .helpOption('-h, --help', 'Show this help message')
463
- .option('--json', 'Emit a machine-readable record on stdout')
464
- .addHelpText(
465
- 'after',
466
- [
467
- '',
468
- 'Exit codes:',
469
- ' 0 the block list rendered',
470
- ' 1 refused, with the reason on stderr or in the JSON record',
471
- '',
472
- 'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
473
- 'list, refs, code, or raw block, and renders it through the same',
474
- 'components the fixture lesson is generated from. Content the',
475
- 'structured blocks cannot express takes type raw, carrying its own',
476
- 'html verbatim, unescaped.',
477
- '',
478
- 'A code block takes text and an optional lang, a highlight.js name or',
479
- 'alias matching ^[a-z0-9+#-]+$, and renders highlighted at build time',
480
- 'with no script. An unregistered lang renders as plain escaped code.',
481
- '',
482
- 'A paragraph takes an optional cites array of reference numbers,',
483
- 'rendered as footnote markers after its text. The one refs block',
484
- 'lists { title, url?, note? } items numbered from 1, and a cite that',
485
- 'resolves to no item, a cite on a lede, or a second refs block is',
486
- 'refused. A url must be http or https.',
487
- '',
488
- 'Takes no topic and no --root: the verb is a stateless transform,',
489
- 'reading nothing off a workspace on disk.',
490
- '',
491
- 'Examples:',
492
- ' echo \'[{"type":"heading","level":1,"text":"Compass bearings"}]\' | canon teach render --json',
493
- '',
494
- ].join('\n'),
495
- )
496
- .action(async (opts: RenderCommandOptions) => {
497
- process.exitCode = await runRender(opts)
498
- })
499
- }
500
-
501
- interface StylesheetCommandOptions {
502
- readonly force?: boolean
503
- readonly json?: boolean
504
- readonly root?: string
505
- }
506
-
507
- async function runStylesheet(
508
- topic: string,
509
- opts: StylesheetCommandOptions,
510
- ): Promise<number> {
511
- const emitJson = opts.json ?? false
512
- const root = await rootFor(opts.root)
513
- const outcome = await writeStylesheet(root, topic, opts.force ?? false)
514
-
515
- if (!outcome.ok) {
516
- return reportRefusal('canon teach stylesheet', outcome, emitJson, root)
517
- }
518
-
519
- if (emitJson) {
520
- process.stdout.write(
521
- `${JSON.stringify({
522
- ok: true,
523
- root,
524
- slug: outcome.slug,
525
- path: outcome.path,
526
- basePath: outcome.basePath,
527
- written: outcome.written,
528
- })}\n`,
529
- )
530
- return 0
531
- }
532
-
533
- intro('canon teach stylesheet')
534
- logStep(outcome.written ? 'Written' : 'Already present, left alone')
535
- logInfo(outcome.path)
536
- logInfo(outcome.basePath)
537
- outro()
538
- return 0
539
- }
540
-
541
- function collect(value: string, previous: string[]): string[] {
542
- return [...previous, value]
543
- }
544
-
545
- function readStdin(): Promise<string> {
546
- return new Promise((resolveStream, rejectStream) => {
547
- const chunks: Buffer[] = []
548
- process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
549
- process.stdin.on('end', () =>
550
- resolveStream(Buffer.concat(chunks).toString('utf8')),
551
- )
552
- process.stdin.on('error', rejectStream)
553
- })
554
- }
555
-
556
- /**
557
- * Splits every pair or reports the ones that carry no separator. Both halves
558
- * are reported together, so a caller passing four pairs learns about all the
559
- * broken ones from one run rather than from four.
560
- */
561
- function parsePairs(
562
- raw: readonly string[],
563
- ): { pairs: Array<[string, string]> } | { invalid: string[] } {
564
- const pairs: Array<[string, string]> = []
565
- const invalid: string[] = []
566
-
567
- for (const entry of raw) {
568
- const match = PAIR.exec(entry)
569
- const left = match?.[1].trim()
570
- const right = match?.[2].trim()
571
-
572
- if (!left || !right) {
573
- invalid.push(entry)
574
- continue
575
- }
576
-
577
- pairs.push([left, right])
578
- }
579
-
580
- return invalid.length > 0 ? { invalid } : { pairs }
581
- }
582
-
583
- function badInput(
584
- message: string,
585
- detail: readonly string[] = [],
586
- ): TeachRefused {
587
- return { ok: false, reason: 'bad-input', message, detail }
588
- }
589
-
590
- async function rootFor(given: string | undefined): Promise<string> {
591
- return given ?? (await mainWorktreeRoot())
592
- }
593
-
594
- async function runList(
595
- topic: string | undefined,
596
- opts: ListCommandOptions,
597
- ): Promise<number> {
598
- const emitJson = opts.json ?? false
599
- const root = await rootFor(opts.root)
600
-
601
- if (topic === undefined) {
602
- return reportList(await listWorkspaces(root), emitJson, root)
603
- }
604
-
605
- return reportWorkspace(await readWorkspace(root, topic), emitJson, root)
606
- }
607
-
608
- async function runOpen(
609
- topic: string,
610
- opts: OpenCommandOptions,
611
- ): Promise<number> {
612
- const emitJson = opts.json ?? false
613
-
614
- if (!opts.subject) {
615
- return reportRefusal(
616
- 'canon teach open',
617
- badInput('No subject. Pass --subject <line>.'),
618
- emitJson,
619
- process.cwd(),
620
- )
621
- }
622
-
623
- if (!opts.startingPoint) {
624
- return reportRefusal(
625
- 'canon teach open',
626
- badInput(
627
- 'No starting point. Pass --starting-point <text>, so difficulty has a floor.',
628
- ),
629
- emitJson,
630
- process.cwd(),
631
- )
632
- }
633
-
634
- const root = await rootFor(opts.root)
635
-
636
- return reportOpen(
637
- await openWorkspace(root, {
638
- topic,
639
- subject: opts.subject,
640
- startingPoint: opts.startingPoint,
641
- success: opts.success ?? [],
642
- outOfScope: opts.outOfScope ?? [],
643
- title: opts.title,
644
- date: opts.date,
645
- }),
646
- emitJson,
647
- root,
648
- )
649
- }
650
-
651
- async function runResource(
652
- topic: string,
653
- opts: ResourceCommandOptions,
654
- ): Promise<number> {
655
- const emitJson = opts.json ?? false
656
- const raw = [...(opts.read ?? []), ...(opts.lead ?? [])]
657
-
658
- if (raw.length === 0) {
659
- return reportRefusal(
660
- 'canon teach resource',
661
- badInput('No source given. Pass --read or --lead as <title>=<url>.'),
662
- emitJson,
663
- process.cwd(),
664
- )
665
- }
666
-
667
- const parsed = parsePairs(raw)
668
-
669
- if ('invalid' in parsed) {
670
- return reportRefusal(
671
- 'canon teach resource',
672
- badInput(
673
- `Not a title and url: ${parsed.invalid.join(', ')}`,
674
- parsed.invalid,
675
- ),
676
- emitJson,
677
- process.cwd(),
678
- )
679
- }
680
-
681
- const readCount = (opts.read ?? []).length
682
- const sources: Source[] = parsed.pairs.map(([title, url]) => ({ title, url }))
683
- const root = await rootFor(opts.root)
684
-
685
- return reportResource(
686
- await recordSources(
687
- root,
688
- topic,
689
- sources.slice(0, readCount),
690
- sources.slice(readCount),
691
- ),
692
- emitJson,
693
- root,
694
- )
695
- }
696
-
697
- async function runGlossary(
698
- topic: string,
699
- opts: GlossaryCommandOptions,
700
- ): Promise<number> {
701
- const emitJson = opts.json ?? false
702
- const raw = opts.term ?? []
703
-
704
- if (raw.length === 0) {
705
- return reportRefusal(
706
- 'canon teach glossary',
707
- badInput('No term given. Pass --term <term>=<definition>.'),
708
- emitJson,
709
- process.cwd(),
710
- )
711
- }
712
-
713
- const parsed = parsePairs(raw)
714
-
715
- if ('invalid' in parsed) {
716
- return reportRefusal(
717
- 'canon teach glossary',
718
- badInput(
719
- `Not a term and definition: ${parsed.invalid.join(', ')}`,
720
- parsed.invalid,
721
- ),
722
- emitJson,
723
- process.cwd(),
724
- )
725
- }
726
-
727
- const terms: Term[] = parsed.pairs.map(([term, definition]) => ({
728
- term,
729
- definition,
730
- }))
731
-
732
- const duplicated = terms
733
- .map((term) => term.term.toLowerCase())
734
- .filter((term, index, all) => all.indexOf(term) !== index)
735
-
736
- if (duplicated.length > 0) {
737
- return reportRefusal(
738
- 'canon teach glossary',
739
- badInput(`Two definitions for ${[...new Set(duplicated)].join(', ')}.`),
740
- emitJson,
741
- process.cwd(),
742
- )
743
- }
744
-
745
- const root = await rootFor(opts.root)
746
-
747
- return reportGlossary(
748
- await defineTerms(root, topic, terms, opts.firstSeen),
749
- emitJson,
750
- root,
751
- )
752
- }
753
-
754
- async function runLesson(
755
- topic: string,
756
- opts: LessonCommandOptions,
757
- ): Promise<number> {
758
- const emitJson = opts.json ?? false
759
-
760
- if (!opts.slug) {
761
- return reportRefusal(
762
- 'canon teach lesson',
763
- badInput('No lesson slug. Pass --slug <kebab>.'),
764
- emitJson,
765
- process.cwd(),
766
- )
767
- }
768
-
769
- if (!opts.questions) {
770
- return reportRefusal(
771
- 'canon teach lesson',
772
- badInput('No question count. Pass --questions <n>.'),
773
- emitJson,
774
- process.cwd(),
775
- )
776
- }
777
-
778
- const root = await rootFor(opts.root)
779
-
780
- return reportLesson(
781
- await planLesson(root, topic, {
782
- slug: opts.slug,
783
- questions: Number(opts.questions),
784
- options: Number(opts.options ?? '4'),
785
- }),
786
- emitJson,
787
- root,
788
- )
789
- }
790
-
791
- async function runNav(
792
- topic: string | undefined,
793
- opts: NavCommandOptions,
794
- ): Promise<number> {
795
- const emitJson = opts.json ?? false
796
- const root = await rootFor(opts.root)
797
-
798
- return reportNav(await generateNav(root, topic), emitJson, root)
799
- }
800
-
801
- async function runRender(opts: RenderCommandOptions): Promise<number> {
802
- const emitJson = opts.json ?? false
803
-
804
- if (process.stdin.isTTY) {
805
- return reportRenderRefusal(
806
- badInput(
807
- "No blocks on stdin. Pipe a JSON array: echo '[...]' | canon teach render",
808
- ),
809
- emitJson,
810
- )
811
- }
812
-
813
- const body = (await readStdin()).trim()
814
-
815
- if (!body) {
816
- return reportRenderRefusal(
817
- badInput('Empty stdin. Pipe a JSON array of blocks.'),
818
- emitJson,
819
- )
820
- }
821
-
822
- let parsed: unknown
823
- try {
824
- parsed = JSON.parse(body)
825
- } catch {
826
- return reportRenderRefusal(badInput('Malformed JSON on stdin.'), emitJson)
827
- }
828
-
829
- if (!Array.isArray(parsed)) {
830
- return reportRenderRefusal(
831
- badInput('Stdin must be a JSON array of blocks.'),
832
- emitJson,
833
- )
834
- }
835
-
836
- // Loaded here rather than at the top, since the renderer pulls in the
837
- // highlighter and every other verb would pay for its grammars at startup.
838
- const { renderLessonBody } = await import('@/teach/render')
839
- return reportRender(renderLessonBody(parsed), emitJson)
840
- }
841
-
842
- function reportRenderRefusal(refused: TeachRefused, emitJson: boolean): number {
843
- if (emitJson) {
844
- process.stderr.write(`${refused.message}\n`)
845
- process.stdout.write(
846
- `${JSON.stringify({
847
- ok: false,
848
- reason: refused.reason,
849
- message: refused.message,
850
- })}\n`,
851
- )
852
- return 1
853
- }
854
-
855
- intro('canon teach render')
856
- logStep('Refused')
857
- logError(refused.message)
858
- outro()
859
- return 1
860
- }
861
-
862
- function reportRender(outcome: RenderOutcome, emitJson: boolean): number {
863
- if (!outcome.ok) return reportRenderRefusal(outcome, emitJson)
864
-
865
- if (emitJson) {
866
- process.stdout.write(
867
- `${JSON.stringify({ ok: true, html: outcome.html })}\n`,
868
- )
869
- return 0
870
- }
871
-
872
- process.stdout.write(`${outcome.html}\n`)
873
- return 0
874
- }
875
-
876
- function reportNav(
877
- outcome: NavOutcome,
878
- emitJson: boolean,
879
- root: string,
880
- ): number {
881
- if (!outcome.ok)
882
- return reportRefusal('canon teach nav', outcome, emitJson, root)
883
-
884
- if (emitJson) {
885
- process.stdout.write(`${JSON.stringify(navFields(outcome))}\n`)
886
- return 0
887
- }
888
-
889
- intro('canon teach nav')
890
- logNav(outcome)
891
- outro()
892
- return 0
893
- }
894
-
895
- function navFields(outcome: NavGenerated): Record<string, unknown> {
896
- return {
897
- ok: true,
898
- root: outcome.root,
899
- contents: outcome.contents,
900
- lessons: outcome.lessons,
901
- reference: outcome.reference,
902
- skipped: outcome.skipped,
903
- unresolved: outcome.unresolved,
904
- }
905
- }
906
-
907
- function logNav(outcome: NavGenerated): void {
908
- logStep('Root')
909
- logInfo(outcome.root)
910
- logStep('Contents')
911
- for (const path of outcome.contents) logInfo(path)
912
- logStep('Lessons rewritten')
913
- logInfo(String(outcome.lessons))
914
- logStep('Reference pages rendered')
915
- logInfo(String(outcome.reference))
916
-
917
- if (outcome.skipped.length > 0) {
918
- logStep('Refused, missing a chrome marker or the markdown renderer')
919
- for (const skip of outcome.skipped) {
920
- logWarn(`${skip.file}: no ${skip.missing}`)
921
- }
922
- }
923
-
924
- if (outcome.unresolved.length > 0) {
925
- logStep('Lesson references naming no lesson')
926
- for (const reference of outcome.unresolved) {
927
- logWarn(`${reference.file}: lesson ${reference.lesson}`)
928
- }
929
- }
930
- }
931
-
932
- async function runUp(
933
- topic: string | undefined,
934
- opts: UpCommandOptions,
935
- ): Promise<number> {
936
- const emitJson = opts.json ?? false
937
- const root = await rootFor(opts.root)
938
-
939
- const port = parsePort(opts.port)
940
- if (port === undefined) {
941
- return reportRefusal(
942
- 'canon teach up',
943
- badInput(`${opts.port} is not a port`),
944
- emitJson,
945
- root,
946
- )
947
- }
948
-
949
- const nav = await generateNav(root, topic)
950
- if (!nav.ok) return reportRefusal('canon teach up', nav, emitJson, root)
951
-
952
- // The server resolves its folder against the cwd, and the teach folder sits
953
- // at the main worktree root, so it takes the absolute path a linked
954
- // worktree can still reach.
955
- const dir = resolve(teachDir(root))
956
- const contents = nav.contents[0]
957
- const entry =
958
- topic === undefined || contents === undefined
959
- ? 'index.html'
960
- : relative(dir, resolve(root, contents)).split(sep).join('/')
961
-
962
- const stream = refreshOnChange(root, dir, topic, emitJson)
963
- const served = startServer(dir, {
964
- port,
965
- entry,
966
- live: {
967
- stream,
968
- script: compileScript(reload, [LIVE_EVENTS_PATH]),
969
- },
970
- })
971
- if (!served.ok) {
972
- stream.close()
973
- // A serve refusal carries a reason the teach refusals do not list, so it
974
- // is written here in the teach record's shape rather than through one.
975
- if (emitJson) {
976
- process.stderr.write(`${served.detail}\n`)
977
- process.stdout.write(
978
- `${JSON.stringify({ ok: false, root, reason: served.reason, message: served.detail, detail: [] })}\n`,
979
- )
980
- return 1
981
- }
982
- intro('canon teach up')
983
- logStep('Refused')
984
- logError(served.detail)
985
- outro()
986
- return 1
987
- }
988
-
989
- if (emitJson) {
990
- process.stdout.write(
991
- `${JSON.stringify({ ...navFields(nav), served: served.root, ...serveFields(served) })}\n`,
992
- )
993
- } else {
994
- intro('canon teach up')
995
- logNav(nav)
996
- logServing(served)
997
- }
998
-
999
- await waitForInterrupt(async () => {
1000
- stream.close()
1001
- await served.stop()
1002
- })
1003
- return 0
1004
- }
1005
-
1006
- /**
1007
- * An editor's swap or backup file, or anything under a dot folder. It changes
1008
- * nothing a page shows, so it neither starts a pass nor counts as served.
1009
- */
1010
- function isScratch(path: string): boolean {
1011
- return path
1012
- .split(/[\\/]/)
1013
- .some((segment) => segment.startsWith('.') || segment.endsWith('~'))
1014
- }
1015
-
1016
- /**
1017
- * What the served folder holds, as each file's path, size, and mtime. A pass
1018
- * whose `nav` wrote nothing leaves it unchanged, which is how the echo of a
1019
- * pass's own writes settles without a second reload.
1020
- */
1021
- function servedState(dir: string): string {
1022
- return readdirSync(dir, { recursive: true, withFileTypes: true })
1023
- .filter((entry) => entry.isFile())
1024
- .map((entry) => relative(dir, join(entry.parentPath, entry.name)))
1025
- .filter((path) => !isScratch(path))
1026
- .sort()
1027
- .map((path) => {
1028
- const stat = statSync(join(dir, path), { throwIfNoEntry: false })
1029
- return `${path}:${stat?.size}:${stat?.mtimeMs}`
1030
- })
1031
- .join('\n')
1032
- }
1033
-
1034
- /**
1035
- * Re-runs `nav` on each settled change under the teach folder and tells every
1036
- * open page to reload once a pass changed what is served. Passes run one at a
1037
- * time, since `nav` is not reentrant over the files it rewrites, and a change
1038
- * arriving mid-pass is coalesced into exactly one more. A refused or failed
1039
- * pass sends no reload, so a page never reloads onto a half-written state.
1040
- */
1041
- function refreshOnChange(
1042
- root: string,
1043
- dir: string,
1044
- topic: string | undefined,
1045
- emitJson: boolean,
1046
- ): LiveStream<string> {
1047
- let isRunning = false
1048
- let isDirty = false
1049
- let state = servedState(dir)
1050
-
1051
- const report = (message: string, isWarning: boolean) => {
1052
- if (emitJson) process.stderr.write(`${message}\n`)
1053
- else if (isWarning) logWarn(message)
1054
- else logInfo(message)
1055
- }
1056
-
1057
- const pass = async (): Promise<void> => {
1058
- const nav = await generateNav(root, topic)
1059
- if (!nav.ok) {
1060
- report(`Refresh refused: ${nav.message}`, true)
1061
- return
1062
- }
1063
- const next = servedState(dir)
1064
- if (next === state) return
1065
- state = next
1066
- stream.send({ reload: true })
1067
- report(`Refreshed ${nav.lessons} lessons, reloading open pages`, false)
1068
- }
1069
-
1070
- const schedule = async (): Promise<void> => {
1071
- if (isRunning) {
1072
- isDirty = true
1073
- return
1074
- }
1075
- isRunning = true
1076
- do {
1077
- isDirty = false
1078
- // A throw, such as the teach folder removed mid-pass, is reported and
1079
- // the loop carries on, since a rejection left here would end the
1080
- // process and a stuck flag would end every later refresh.
1081
- try {
1082
- await pass()
1083
- } catch (error) {
1084
- const code = (error as NodeJS.ErrnoException).code ?? String(error)
1085
- report(`Refresh failed: ${code}`, true)
1086
- }
1087
- } while (isDirty)
1088
- isRunning = false
1089
- }
1090
-
1091
- const stream = new LiveStream<string>(dir, {
1092
- map: (path) => (isScratch(path) ? undefined : path),
1093
- onChange: () => void schedule(),
1094
- })
1095
- return stream
1096
- }
1097
-
1098
- function reportRefusal(
1099
- title: string,
1100
- refused: TeachRefused,
1101
- emitJson: boolean,
1102
- root: string,
1103
- ): number {
1104
- // The framed branch reaches stderr through logError, so the bare write is
1105
- // what keeps the JSON mode from reporting the reason on stdout alone.
1106
- if (emitJson) {
1107
- process.stderr.write(`${refused.message}\n`)
1108
- process.stdout.write(
1109
- `${JSON.stringify({
1110
- ok: false,
1111
- root,
1112
- reason: refused.reason,
1113
- message: refused.message,
1114
- detail: refused.detail,
1115
- })}\n`,
1116
- )
1117
- return 1
1118
- }
1119
-
1120
- intro(title)
1121
- logStep('Refused')
1122
- logError(refused.message)
1123
- if (refused.detail.length > 0) pipeOutput(refused.detail.join('\n'))
1124
- outro()
1125
-
1126
- return 1
1127
- }
1128
-
1129
- /**
1130
- * One scheduled revisit, carrying both ladder dates rather than the rung alone.
1131
- * A session copies the date the outcome names instead of computing one from a
1132
- * ladder stated in prose.
1133
- */
1134
- function describeRevisit(item: RevisitItem): string {
1135
- const when = item.overdue ? `due ${item.date}` : item.date
1136
-
1137
- return `${item.item}: ${when}, rung ${item.rung} (hit ${item.hit}, miss ${item.miss}), from ${item.record}`
1138
- }
1139
-
1140
- function describe(workspace: WorkspaceSummary): string {
1141
- return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
1142
- }
1143
-
1144
- /**
1145
- * `canon serve` resolves its folder against the caller's cwd, while the list
1146
- * verb reads the teach folder at the main worktree root, so the line carries
1147
- * the folder actually read. A literal `.canon/teach` serves an absent folder
1148
- * from a linked worktree.
1149
- */
1150
- function serveLine(root: string, entry: string): string {
1151
- const dir = resolve(teachDir(root))
1152
- const fromCwd = relative(process.cwd(), dir)
1153
- const isOutsideCwd =
1154
- fromCwd === '..' || fromCwd.startsWith(`..${sep}`) || isAbsolute(fromCwd)
1155
- const printed = fromCwd === '' ? '.' : isOutsideCwd ? dir : fromCwd
1156
-
1157
- return `canon serve ${printed} --entry ${entry}`
1158
- }
1159
-
1160
- function reportList(
1161
- outcome: ListOutcome,
1162
- emitJson: boolean,
1163
- root: string,
1164
- ): number {
1165
- if (!outcome.ok)
1166
- return reportRefusal('canon teach list', outcome, emitJson, root)
1167
-
1168
- if (emitJson) {
1169
- process.stdout.write(
1170
- `${JSON.stringify({
1171
- ok: true,
1172
- root,
1173
- workspaces: outcome.workspaces,
1174
- next: outcome.next,
1175
- })}\n`,
1176
- )
1177
- return 0
1178
- }
1179
-
1180
- intro('canon teach list')
1181
- logStep(outcome.workspaces.length > 0 ? 'Workspaces' : 'No workspaces')
1182
-
1183
- for (const workspace of outcome.workspaces) logInfo(describe(workspace))
1184
-
1185
- const incomplete = outcome.workspaces.filter(
1186
- (workspace) => workspace.missing.length > 0,
1187
- )
1188
-
1189
- if (incomplete.length > 0) {
1190
- logStep('Missing a required file')
1191
- for (const workspace of incomplete) {
1192
- logWarn(`${workspace.slug}: no ${workspace.missing.join(' and no ')}`)
1193
- }
1194
- }
1195
-
1196
- logStep('Next ordinal')
1197
- logInfo(outcome.next)
1198
-
1199
- if (outcome.workspaces.length > 0) {
1200
- logStep('Open')
1201
- logInfo(serveLine(root, '<nn>-<topic>/index.html'))
1202
- }
1203
-
1204
- outro()
1205
-
1206
- return 0
1207
- }
1208
-
1209
- function reportWorkspace(
1210
- outcome: ReadOutcome,
1211
- emitJson: boolean,
1212
- root: string,
1213
- ): number {
1214
- if (!outcome.ok)
1215
- return reportRefusal('canon teach list', outcome, emitJson, root)
1216
-
1217
- const workspace = outcome.workspace
1218
-
1219
- if (emitJson) {
1220
- process.stdout.write(`${JSON.stringify({ ok: true, root, workspace })}\n`)
1221
- return 0
1222
- }
1223
-
1224
- intro('canon teach list')
1225
- logStep(workspace.slug)
1226
- logInfo(`${workspace.title ?? 'no title'}, opened ${workspace.opened ?? '?'}`)
1227
- logInfo(workspace.path)
1228
-
1229
- for (const [label, files] of [
1230
- ['Lessons', workspace.lessonFiles],
1231
- ['Learning records', workspace.recordFiles],
1232
- ['Reference', workspace.referenceFiles],
1233
- ] as const) {
1234
- logStep(files.length > 0 ? label : `${label} (none)`)
1235
- for (const file of files) logInfo(file)
1236
- }
1237
-
1238
- logStep(workspace.glossary.length > 0 ? 'Glossary' : 'Glossary (empty)')
1239
- for (const entry of workspace.glossary) logInfo(entry)
1240
-
1241
- logStep(
1242
- workspace.success.length > 0 ? 'Exit criteria' : 'Exit criteria (none)',
1243
- )
1244
- for (const line of workspace.success) logInfo(line)
1245
-
1246
- logStep(workspace.due.length > 0 ? 'Revisit' : 'Revisit (nothing scheduled)')
1247
- for (const item of workspace.due) logInfo(describeRevisit(item))
1248
-
1249
- if (workspace.missing.length > 0) {
1250
- logStep('Missing a required file')
1251
- logWarn(`no ${workspace.missing.join(' and no ')}`)
1252
- }
1253
-
1254
- logStep('Open')
1255
- logInfo(serveLine(root, `${workspace.slug}/index.html`))
1256
- outro()
1257
-
1258
- return 0
1259
- }
1260
-
1261
- function reportOpen(
1262
- outcome: OpenOutcome,
1263
- emitJson: boolean,
1264
- root: string,
1265
- ): number {
1266
- if (!outcome.ok)
1267
- return reportRefusal('canon teach open', outcome, emitJson, root)
1268
-
1269
- if (emitJson) {
1270
- process.stdout.write(
1271
- `${JSON.stringify({
1272
- ok: true,
1273
- root,
1274
- slug: outcome.slug,
1275
- path: outcome.path,
1276
- created: outcome.created,
1277
- })}\n`,
1278
- )
1279
- return 0
1280
- }
1281
-
1282
- intro('canon teach open')
1283
- logStep('Opened')
1284
- logInfo(outcome.slug)
1285
- for (const file of outcome.created) logAdd(file)
1286
- outro()
1287
-
1288
- return 0
1289
- }
1290
-
1291
- function reportResource(
1292
- outcome: SourceOutcome,
1293
- emitJson: boolean,
1294
- root: string,
1295
- ): number {
1296
- if (!outcome.ok) {
1297
- return reportRefusal('canon teach resource', outcome, emitJson, root)
1298
- }
1299
-
1300
- if (emitJson) {
1301
- process.stdout.write(
1302
- `${JSON.stringify({
1303
- ok: true,
1304
- root,
1305
- slug: outcome.slug,
1306
- path: relative(root, outcome.path),
1307
- read: outcome.read,
1308
- leads: outcome.leads,
1309
- })}\n`,
1310
- )
1311
- return 0
1312
- }
1313
-
1314
- intro('canon teach resource')
1315
- logStep('Recorded')
1316
- for (const source of outcome.read) logInfo(`read: ${source.title}`)
1317
- for (const source of outcome.leads) logInfo(`lead: ${source.title}`)
1318
- logAdd(relative(root, outcome.path))
1319
- outro()
1320
-
1321
- return 0
1322
- }
1323
-
1324
- function reportGlossary(
1325
- outcome: TermOutcome,
1326
- emitJson: boolean,
1327
- root: string,
1328
- ): number {
1329
- if (!outcome.ok) {
1330
- return reportRefusal('canon teach glossary', outcome, emitJson, root)
1331
- }
1332
-
1333
- if (emitJson) {
1334
- process.stdout.write(
1335
- `${JSON.stringify({
1336
- ok: true,
1337
- root,
1338
- slug: outcome.slug,
1339
- path: relative(root, outcome.path),
1340
- defined: outcome.defined,
1341
- })}\n`,
1342
- )
1343
- return 0
1344
- }
1345
-
1346
- intro('canon teach glossary')
1347
- logStep('Defined')
1348
- for (const term of outcome.defined) logInfo(term.term)
1349
- logAdd(relative(root, outcome.path))
1350
- outro()
1351
-
1352
- return 0
1353
- }
1354
-
1355
- function reportLesson(
1356
- outcome: LessonOutcome,
1357
- emitJson: boolean,
1358
- root: string,
1359
- ): number {
1360
- if (!outcome.ok) {
1361
- return reportRefusal('canon teach lesson', outcome, emitJson, root)
1362
- }
1363
-
1364
- if (emitJson) {
1365
- process.stdout.write(
1366
- `${JSON.stringify({
1367
- ok: true,
1368
- root,
1369
- slug: outcome.slug,
1370
- path: outcome.path,
1371
- lesson: outcome.lesson,
1372
- stylesheet: outcome.stylesheet,
1373
- stylesheetExists: outcome.stylesheetExists,
1374
- success: outcome.success,
1375
- quiz: outcome.quiz,
1376
- })}\n`,
1377
- )
1378
- return 0
1379
- }
1380
-
1381
- intro('canon teach lesson')
1382
- logStep('Lesson')
1383
- logInfo(outcome.lesson)
1384
-
1385
- logStep(outcome.stylesheetExists ? 'Stylesheet' : 'Stylesheet (to write)')
1386
- logInfo(`${outcome.stylesheet} resolved for embedding`)
1387
-
1388
- logStep(outcome.success.length > 0 ? 'Exit criteria' : 'Exit criteria (none)')
1389
- for (const line of outcome.success) logInfo(line)
1390
-
1391
- logStep('Option order')
1392
- for (const question of outcome.quiz) {
1393
- logInfo(
1394
- `question ${question.question}: present ${question.order.join(', ')}, correct answer in position ${question.answer}`,
1395
- )
1396
- }
1397
-
1398
- outro()
1399
-
1400
- return 0
1401
- }