@erclx/canon 5.1.0 → 5.3.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/teach-workspace/REQUIREMENT.md +4 -2
- package/claude/skills/teach-workspace/SKILL.md +2 -2
- package/claude/skills/teach-workspace/references/lesson-craft.md +4 -1
- package/claude/skills/teach-workspace/references/pedagogy.md +1 -0
- package/docs/agents/commands.md +6 -0
- package/docs/agents/teach-authoring.md +9 -3
- package/docs/agents/teach.md +1 -1
- package/docs/workflow/operating-model.md +1 -1
- package/package.json +2 -1
- package/src/claude/plugin-update.ts +2 -2
- package/src/commands/teach.ts +12 -5
- package/src/commands/upgrade.ts +62 -6
- package/src/design/components.ts +52 -0
- package/src/teach/components/code.tsx +24 -0
- package/src/teach/highlight.ts +71 -0
- package/src/teach/nav.ts +35 -4
- package/src/teach/render.ts +27 -1
- package/standards/teach.md +15 -0
|
@@ -7,7 +7,9 @@ description: Scope boundary for learning a subject across sessions, and the spli
|
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, a session asked to teach a subject answers in the conversation, so everything it produced dies with the session and the next one starts from nothing. It teaches from recall rather than from a source, so the learner is handed claims nothing was read for and cannot tell which ones.
|
|
10
|
+
Without this skill, a session asked to teach a subject answers in the conversation, so everything it produced dies with the session and the next one starts from nothing. It teaches from recall rather than from a source, so the learner is handed claims nothing was read for and cannot tell which ones. When it does read, it takes whatever a search returned first, so a course rests on a few thin sources, and it cites the learner's own projects or an employer's material as proof of a general concept.
|
|
11
|
+
|
|
12
|
+
It picks the next topic from the subject's own order rather than from what the learner got wrong, so it teaches past a gap it never measured. It asks nothing about what the learner already knows, so difficulty has no floor and the session lands either below or far above them.
|
|
11
13
|
|
|
12
14
|
It also produces one output where two are needed. A page written to be worked through once and a page written to be looked up later have different lifetimes and different readers, and merging them yields material that is disposable and gets kept, or durable and carries a quiz nobody can promote. Writing the durable half in a format the authoring gates do not read costs a conversion at the moment it matters most, which is the moment someone tries to promote it.
|
|
13
15
|
|
|
@@ -25,7 +27,7 @@ Two failures land specifically on where the folder sits. A workspace resolved ag
|
|
|
25
27
|
- Name the write route for a main-root path from a linked worktree, since a body naming only the destination reports a success that did not happen
|
|
26
28
|
- Route every edit inside a workspace file that already exists through a verb resolving the root in-process, since a heredoc reaches a whole-file create alone and the stream editors that would reach the rest are banned
|
|
27
29
|
- Settle the learner's starting point by asking, so difficulty sits above a measured floor
|
|
28
|
-
- Research the subject from sources before teaching it, and record what was read and what was found and not opened
|
|
30
|
+
- Research the subject from sources before teaching it, choosing them by the teach standard's ladder, and record what was read and what was found and not opened
|
|
29
31
|
- Place each lesson from the learning records rather than from the subject's order, and open with retrieval on the last wrong answer
|
|
30
32
|
- Split the output by lifetime, sending the worked-through half to lessons and the looked-up half to reference pages in the format the authoring gates read
|
|
31
33
|
- Record the wrong answer itself rather than the count, since that is what the next session places the learner from
|
|
@@ -61,7 +61,7 @@ Repeat `--success` and `--out-of-scope` per line. The verb refuses a topic anoth
|
|
|
61
61
|
|
|
62
62
|
## Step 2: research before teaching
|
|
63
63
|
|
|
64
|
-
Read the subject from sources rather than from recall. Record what was read and what was only found through the verb that owns the file:
|
|
64
|
+
Read the subject from sources rather than from recall, chosen by the standard's `### Choosing sources` ladder, with a third-rung source's reason in its title. Record what was read and what was only found through the verb that owns the file:
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
canon teach resource <topic> --json \
|
|
@@ -83,7 +83,7 @@ Open on what `due` reports as overdue, oldest date first, before anything new. W
|
|
|
83
83
|
|
|
84
84
|
Two outputs with two lifetimes, and the split decides the format.
|
|
85
85
|
|
|
86
|
-
- A lesson is a self-contained page carrying its own quiz, a teach-back block, and the feedback for each question. It embeds one shared stylesheet rather than restating styles, and it is disposable and never promoted.
|
|
86
|
+
- A lesson is a self-contained page carrying its own quiz, a teach-back block, and the feedback for each question. It embeds one shared stylesheet rather than restating styles, and it is disposable and never promoted. Its `refs` pass the source ladder, and a learner's or organization's file goes in the body, labeled.
|
|
87
87
|
- A reference page goes to `reference/<slug>.md`, written for a reader with no learner in it. This is the half that survives the workspace, so it is written in markdown to pass the authoring gates a promotion would put it through. `canon teach nav` renders it to a `reference/<slug>.html` sibling, and a lesson links that sibling, since a served `.md` opens as plain text.
|
|
88
88
|
|
|
89
89
|
Resolve the lesson before writing it, rather than composing its name or its quiz order by hand:
|
|
@@ -74,14 +74,17 @@ The stepper hides and shows and sets nothing else, so how a selected option look
|
|
|
74
74
|
|
|
75
75
|
## The block list
|
|
76
76
|
|
|
77
|
-
`canon teach render` takes a JSON array of blocks and renders it through the same components a lesson body composes.
|
|
77
|
+
`canon teach render` takes a JSON array of blocks and renders it through the same components a lesson body composes. Six types, and every structural body composes from them:
|
|
78
78
|
|
|
79
79
|
- `{"type":"heading","level":1|2,"text":"<text>"}`: an `<h1>` or `<h2>`.
|
|
80
80
|
- `{"type":"paragraph","text":"<text>","lede":true,"cites":[1]}`: a `<p>`, marked `lede` for the dek that opens the lesson. `cites` adds a footnote marker per reference number after the text, and a lede takes none.
|
|
81
81
|
- `{"type":"list","items":["<text>", ...],"ordered":true}`: a `<ul>` or, with `ordered`, an `<ol>`.
|
|
82
82
|
- `{"type":"refs","items":[{"title":"<name>","url":"<https URL>","note":"<text>"}, ...]}`: the lesson's one reference list, numbered from 1 in item order. `url` and `note` are optional, and a `url` must be http or https.
|
|
83
|
+
- `{"type":"code","lang":"ts","text":"<source>"}`: a code sample, highlighted at render time with no script. `lang` is optional and takes a highlight.js name or alias. One the renderer does not register renders as plain escaped code.
|
|
83
84
|
- `{"type":"raw","html":"<markup>"}`: the escape hatch, passed through unescaped.
|
|
84
85
|
|
|
86
|
+
Code never travels through `raw`. A hand-written `<pre><code>` renders with no color, and `nav` never highlights markup already in a lesson body, so a sample written that way stays plain for good. A fenced block in a reference page's markdown is highlighted by `nav` on its own.
|
|
87
|
+
|
|
85
88
|
The quiz and the teach-back block above are the one case `raw` is always needed for, since their fixed contract is not a components concern. Give them the array's last `raw` entry, followed only by `refs`, rather than reaching for `raw` anywhere the other types could carry the content instead.
|
|
86
89
|
|
|
87
90
|
A claim resting on a source carries a `cites` number, and the source goes in the lesson's one `refs` block rather than into the sentence. Draw every reference from what `canon teach resource --read` recorded, never from a lead, since a lead is a source nobody opened. Render refuses a cite no reference resolves, so number the list before citing into it.
|
|
@@ -75,3 +75,4 @@ Parametric recall is confident where it is wrong, and a learner cannot audit it.
|
|
|
75
75
|
- Read the subject from sources before teaching, and record each one with its link
|
|
76
76
|
- Mark any claim resting on recall as resting on recall, in the lesson itself
|
|
77
77
|
- List a source found and not opened as a lead, and never cite it
|
|
78
|
+
- Choose which sources to read by the ladder under `### Choosing sources` in the teach standard, which also rules out the learner's own work as proof of a general claim
|
package/docs/agents/commands.md
CHANGED
|
@@ -141,3 +141,9 @@ The report carries three states and never changes an exit code. `behind` names
|
|
|
141
141
|
reason the registry could not be reached. Branch on the `skew.state` field in
|
|
142
142
|
the JSON record rather than on the exit, since an offline machine has to read as
|
|
143
143
|
unmeasured rather than as a failing check.
|
|
144
|
+
|
|
145
|
+
`canon upgrade --json` carries one further state, `pending`, that the skew report
|
|
146
|
+
does not. It means the checkout at the working directory holds a release npm does
|
|
147
|
+
not serve yet, so there is nothing to install. The record names that version in
|
|
148
|
+
`checkout` beside `latest`, exits 0, and its `message` starts `CLI stays at`. A
|
|
149
|
+
directory outside any canon checkout never reads `pending`.
|
|
@@ -38,7 +38,7 @@ The order is drawn here rather than instructed, and that is the point of the ver
|
|
|
38
38
|
|
|
39
39
|
## Render
|
|
40
40
|
|
|
41
|
-
`canon teach render` renders a lesson body's structural blocks to HTML, through the
|
|
41
|
+
`canon teach render` renders a lesson body's structural blocks to HTML, through the five lesson components. It takes no topic and no `--root`, since the verb is a stateless transform reading nothing off a workspace on disk.
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach render --json
|
|
@@ -48,7 +48,13 @@ echo '[{"type":"heading","level":1,"text":"Compass bearings"}]' | canon teach re
|
|
|
48
48
|
| -------- | ---------------------------------------- |
|
|
49
49
|
| `--json` | Emit a machine-readable record on stdout |
|
|
50
50
|
|
|
51
|
-
It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, `refs`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the components can express takes a `raw` block, carrying its own HTML verbatim and unescaped, which is the shape the quiz and the teach-back block travel in.
|
|
51
|
+
It reads a JSON array of blocks from stdin, each a `heading`, `paragraph`, `list`, `refs`, `code`, or `raw` block, and reports `{ ok: true, html }` on `--json` or the bare rendered HTML on stdout otherwise. Content none of the components can express takes a `raw` block, carrying its own HTML verbatim and unescaped, which is the shape the quiz and the teach-back block travel in.
|
|
52
|
+
|
|
53
|
+
A `code` block carries `text` and an optional `lang`, a highlight.js name or alias. The source is highlighted at render time into `hljs-*` class spans the workspace stylesheet colors in both themes, so the page carries no script. A `lang` the renderer does not register renders as plain escaped code, and one that does not match `^[a-z0-9+#-]+$` is refused. A workspace seeded before the syntax colors existed shows the spans uncolored until `canon teach stylesheet <topic>` rewrites its base stylesheet.
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
[{ "type": "code", "lang": "ts", "text": "const a: number = 1" }]
|
|
57
|
+
```
|
|
52
58
|
|
|
53
59
|
A citation takes two shapes. A `paragraph` carries an optional `cites` array of reference numbers, rendered as `sup.cite` markers after its text. The one `refs` block carries `items` of `{ title, url?, note? }`, rendered as `ol.refs` and numbered from 1, so cite `1` links to the first item.
|
|
54
60
|
|
|
@@ -107,6 +113,6 @@ Splicing the stepper into a lesson of either shape was measured against a browse
|
|
|
107
113
|
|
|
108
114
|
### Reference pages
|
|
109
115
|
|
|
110
|
-
Each reference page is rendered to a `reference/<slug>.html` sibling carrying the same chrome, and the contents page links that sibling rather than the markdown, which `canon serve` hands over as plain text. The render drops the frontmatter, turns tables into `<table>` markup, and escapes raw HTML written in the markdown rather than passing it through. A body with no H1 gets one from the frontmatter `title`. A relative link to another reference page's markdown points at that page's `.html` sibling, and one to the workspace `GLOSSARY.md` points at the glossary on the contents page, `../index.html#gloss`. Every other link, such as `../RESOURCES.md`, stays as written. The markdown file is never touched.
|
|
116
|
+
Each reference page is rendered to a `reference/<slug>.html` sibling carrying the same chrome, and the contents page links that sibling rather than the markdown, which `canon serve` hands over as plain text. The render drops the frontmatter, turns tables into `<table>` markup, and escapes raw HTML written in the markdown rather than passing it through. A fenced code block in a language the highlighter registers is highlighted the same way a `code` block is, and one in any other language stays plain. A hand-written code block in a lesson body is never highlighted. A body with no H1 gets one from the frontmatter `title`. A relative link to another reference page's markdown points at that page's `.html` sibling, and one to the workspace `GLOSSARY.md` points at the glossary on the contents page, `../index.html#gloss`. Every other link, such as `../RESOURCES.md`, stays as written. The markdown file is never touched.
|
|
111
117
|
|
|
112
118
|
Every rendered page carries a `<meta name="generator" content="canon teach nav">` tag. A `.html` file under `reference/` whose markdown is gone is deleted when it carries that tag, and a hand-written HTML file there, carrying none, is left alone. The record's `reference` field counts the pages rendered. On a Bun with no `Bun.markdown`, each reference page lands in `skipped` with `Bun.markdown` as what is missing, and the rest of the run goes on.
|
package/docs/agents/teach.md
CHANGED
|
@@ -81,7 +81,7 @@ canon teach resource regular-expressions \
|
|
|
81
81
|
| `--json` | Emit a machine-readable record on stdout |
|
|
82
82
|
| `--root <path>` | Teach root, defaulting to the main worktree |
|
|
83
83
|
|
|
84
|
-
The pair splits on the first `=`, so a URL carrying its own separator survives intact. Say in the title which claims rest on the source, since the entry is the only place a later session reads that from.
|
|
84
|
+
The pair splits on the first `=`, so a URL carrying its own separator survives intact. Say in the title which claims rest on the source, since the entry is the only place a later session reads that from. Which sources qualify is the source ladder in `canon standards teach`, and a source below its first two rungs also names why no higher one covers the claim.
|
|
85
85
|
|
|
86
86
|
A URL either heading already lists is refused rather than written twice. Two entries for one source split what rests on it across two lines, and a reader checking a claim then finds half of the answer.
|
|
87
87
|
|
|
@@ -232,5 +232,5 @@ port-collision detail.
|
|
|
232
232
|
## Related
|
|
233
233
|
|
|
234
234
|
- [Claude Code and git worktrees](../../wiki/claude/worktrees.md) for the isolation and fan-out mechanics
|
|
235
|
-
- [Claude Code subagents](../../wiki/claude/subagents.md) for in-session parallelism without worktrees
|
|
235
|
+
- [Claude Code subagents](../../wiki/claude/subagents.md) for in-session parallelism without worktrees, and [blocking subagents](../../wiki/claude/permissions.md#blocking-subagents) for the `permissions.deny` rule that stops every spawn
|
|
236
236
|
- `canon/context/claude-plugin/skill-strategy/overview.md` for how the skills in the loop are categorized
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.3.0",
|
|
5
5
|
"description": "Infrastructure and quality tooling for developer workflows",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"commander": "^13.1.0",
|
|
58
58
|
"execa": "^9.5.2",
|
|
59
|
+
"highlight.js": "^11.12.0",
|
|
59
60
|
"playwright-core": "1.59.1",
|
|
60
61
|
"pptxgenjs": "^4.0.1"
|
|
61
62
|
},
|
|
@@ -43,6 +43,6 @@ export function matchInstall(
|
|
|
43
43
|
|
|
44
44
|
export function updatedMessage(before: string, after: string): string {
|
|
45
45
|
return after === before
|
|
46
|
-
? `
|
|
47
|
-
: `
|
|
46
|
+
? `Plugin reinstalled ${after}, unchanged.`
|
|
47
|
+
: `Plugin updated ${before} to ${after}.`
|
|
48
48
|
}
|
package/src/commands/teach.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
4
4
|
import { type NavOutcome, generateNav } from '@/teach/nav'
|
|
5
|
-
import {
|
|
5
|
+
import type { RenderOutcome } from '@/teach/render'
|
|
6
6
|
import {
|
|
7
7
|
defineTerms,
|
|
8
8
|
type ListOutcome,
|
|
@@ -393,10 +393,14 @@ export function register(program: Command): void {
|
|
|
393
393
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
394
394
|
'',
|
|
395
395
|
'Reads a JSON array of blocks from stdin, each a heading, paragraph,',
|
|
396
|
-
'list, refs, or raw block, and renders it through the same
|
|
397
|
-
'the fixture lesson is generated from. Content the
|
|
398
|
-
'cannot express takes type raw, carrying its own
|
|
399
|
-
'unescaped.',
|
|
396
|
+
'list, refs, code, or raw block, and renders it through the same',
|
|
397
|
+
'components the fixture lesson is generated from. Content the',
|
|
398
|
+
'structured blocks cannot express takes type raw, carrying its own',
|
|
399
|
+
'html verbatim, unescaped.',
|
|
400
|
+
'',
|
|
401
|
+
'A code block takes text and an optional lang, a highlight.js name or',
|
|
402
|
+
'alias matching ^[a-z0-9+#-]+$, and renders highlighted at build time',
|
|
403
|
+
'with no script. An unregistered lang renders as plain escaped code.',
|
|
400
404
|
'',
|
|
401
405
|
'A paragraph takes an optional cites array of reference numbers,',
|
|
402
406
|
'rendered as footnote markers after its text. The one refs block',
|
|
@@ -752,6 +756,9 @@ async function runRender(opts: RenderCommandOptions): Promise<number> {
|
|
|
752
756
|
)
|
|
753
757
|
}
|
|
754
758
|
|
|
759
|
+
// Loaded here rather than at the top, since the renderer pulls in the
|
|
760
|
+
// highlighter and every other verb would pay for its grammars at startup.
|
|
761
|
+
const { renderLessonBody } = await import('@/teach/render')
|
|
755
762
|
return reportRender(renderLessonBody(parsed), emitJson)
|
|
756
763
|
}
|
|
757
764
|
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
-
import { PROJECT_ROOT } from '@/project-root'
|
|
3
|
+
import { findCheckoutMismatch, PROJECT_ROOT } from '@/project-root'
|
|
4
4
|
import {
|
|
5
5
|
frameError,
|
|
6
6
|
intro,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
outro,
|
|
11
11
|
select,
|
|
12
12
|
} from '@/ui'
|
|
13
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
13
14
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
14
15
|
import { detectManager, installCommand, type Manager } from '@/version/manager'
|
|
15
16
|
import {
|
|
@@ -30,7 +31,9 @@ interface UpgradeRecord {
|
|
|
30
31
|
readonly before: string
|
|
31
32
|
readonly after?: string
|
|
32
33
|
readonly latest?: string
|
|
33
|
-
|
|
34
|
+
/** The version of the checkout at cwd, present only on `pending`. */
|
|
35
|
+
readonly checkout?: string
|
|
36
|
+
readonly state: 'upgraded' | 'current' | 'pending' | 'cancelled' | 'refused'
|
|
34
37
|
readonly reason?: string
|
|
35
38
|
/**
|
|
36
39
|
* One rendered line for a caller that reports the outcome without parsing
|
|
@@ -117,7 +120,24 @@ async function runUpgrade(opts: UpgradeOptions): Promise<number> {
|
|
|
117
120
|
logStep('Published')
|
|
118
121
|
logInfo(describeSkew(skew))
|
|
119
122
|
|
|
123
|
+
const checkout = readCheckoutVersion()
|
|
124
|
+
const latest = latestOf(skew)
|
|
125
|
+
|
|
120
126
|
if (skew.state === 'current') {
|
|
127
|
+
const note = pendingNote(latest, checkout)
|
|
128
|
+
if (note !== undefined && checkout !== undefined) {
|
|
129
|
+
logWarn(note)
|
|
130
|
+
outro()
|
|
131
|
+
emit(opts, {
|
|
132
|
+
...base(before, manager, command, skew),
|
|
133
|
+
after: before,
|
|
134
|
+
checkout,
|
|
135
|
+
state: 'pending',
|
|
136
|
+
message: `CLI stays at ${before}. ${note}`,
|
|
137
|
+
})
|
|
138
|
+
return 0
|
|
139
|
+
}
|
|
140
|
+
|
|
121
141
|
outro()
|
|
122
142
|
emit(opts, {
|
|
123
143
|
...base(before, manager, command, skew),
|
|
@@ -128,7 +148,7 @@ async function runUpgrade(opts: UpgradeOptions): Promise<number> {
|
|
|
128
148
|
return 0
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
return await applyUpgrade(opts, before, skew, manager, command)
|
|
151
|
+
return await applyUpgrade(opts, before, skew, manager, command, checkout)
|
|
132
152
|
}
|
|
133
153
|
|
|
134
154
|
/**
|
|
@@ -147,6 +167,7 @@ async function applyUpgrade(
|
|
|
147
167
|
skew: SkewReport,
|
|
148
168
|
manager: Manager,
|
|
149
169
|
command: readonly string[],
|
|
170
|
+
checkout: string | undefined,
|
|
150
171
|
): Promise<number> {
|
|
151
172
|
const proceed = await select({
|
|
152
173
|
message: `Run \`${command.join(' ')}\`?`,
|
|
@@ -199,15 +220,50 @@ async function applyUpgrade(
|
|
|
199
220
|
...base(before, manager, command, skew),
|
|
200
221
|
after,
|
|
201
222
|
state: 'upgraded',
|
|
202
|
-
message: upgradedMessage(before, after),
|
|
223
|
+
message: [upgradedMessage(before, after), pendingNote(after, checkout)]
|
|
224
|
+
.filter((part) => part !== undefined)
|
|
225
|
+
.join(' '),
|
|
203
226
|
})
|
|
204
227
|
return 0
|
|
205
228
|
}
|
|
206
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The version of the checkout the caller stands in, read off cwd rather than
|
|
232
|
+
* off `PROJECT_ROOT`, which is the installed package a bare `canon` resolves
|
|
233
|
+
* to. A target project sits in no canon checkout, so this reads nothing there
|
|
234
|
+
* and the verb behaves as it did.
|
|
235
|
+
*/
|
|
236
|
+
function readCheckoutVersion(): string | undefined {
|
|
237
|
+
const dir = findCheckoutMismatch(process.cwd())
|
|
238
|
+
return dir === undefined ? undefined : readInstalled(dir).version
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The clause a caller appends when the checkout carries a release npm does not
|
|
243
|
+
* serve yet. A pull lands the release commit before the publish job finishes,
|
|
244
|
+
* so for a few minutes the checkout leads the registry, and a release that
|
|
245
|
+
* never publishes keeps it leading. The wording names the served version
|
|
246
|
+
* rather than promising a publish for that reason. `undefined` covers every
|
|
247
|
+
* case with nothing to say, including a version that does not parse.
|
|
248
|
+
*/
|
|
249
|
+
export function pendingNote(
|
|
250
|
+
served: string | undefined,
|
|
251
|
+
checkout: string | undefined,
|
|
252
|
+
): string | undefined {
|
|
253
|
+
if (served === undefined || checkout === undefined) return undefined
|
|
254
|
+
|
|
255
|
+
const local = parseVersion(checkout)
|
|
256
|
+
const published = parseVersion(served)
|
|
257
|
+
if (local === undefined || published === undefined) return undefined
|
|
258
|
+
if (compareVersions(local, published) <= 0) return undefined
|
|
259
|
+
|
|
260
|
+
return `The checkout is at ${checkout} and npm serves ${served}.`
|
|
261
|
+
}
|
|
262
|
+
|
|
207
263
|
export function upgradedMessage(before: string, after: string): string {
|
|
208
264
|
return after === before
|
|
209
|
-
? `
|
|
210
|
-
: `
|
|
265
|
+
? `CLI reinstalled ${after}, unchanged.`
|
|
266
|
+
: `CLI upgraded ${before} to ${after}.`
|
|
211
267
|
}
|
|
212
268
|
|
|
213
269
|
function base(
|
package/src/design/components.ts
CHANGED
|
@@ -210,6 +210,39 @@ export const COMPONENTS: readonly Component[] = [
|
|
|
210
210
|
*/
|
|
211
211
|
export const TEACH_SIDEBAR_BREAKPOINT = 1100
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* The colors highlighted code renders in, one per token class the teach
|
|
215
|
+
* article maps highlight.js spans onto. Hex rather than `color-mix` so the
|
|
216
|
+
* contrast test can measure each against the code surface, and dark by default
|
|
217
|
+
* with a light override, following the same `data-theme` switch as the ground
|
|
218
|
+
* they sit on. Teach-local until a second surface renders highlighted code.
|
|
219
|
+
* `keyword`, `comment` and `meta` repeat the accent, muted and secondary
|
|
220
|
+
* values, so code reads in the page's own palette.
|
|
221
|
+
*/
|
|
222
|
+
export const TEACH_SYNTAX: readonly {
|
|
223
|
+
readonly name: string
|
|
224
|
+
readonly dark: string
|
|
225
|
+
readonly light: string
|
|
226
|
+
}[] = [
|
|
227
|
+
{ name: 'keyword', dark: '#c76b5f', light: '#ad4a4b' },
|
|
228
|
+
{ name: 'string', dark: '#8dbb6c', light: '#2d6b22' },
|
|
229
|
+
{ name: 'number', dark: '#d4a15a', light: '#8a5300' },
|
|
230
|
+
{ name: 'title', dark: '#b99ad8', light: '#72489a' },
|
|
231
|
+
{ name: 'attr', dark: '#6fb3c4', light: '#1e6478' },
|
|
232
|
+
{ name: 'comment', dark: '#7f7f7c', light: '#6e6d6c' },
|
|
233
|
+
{ name: 'meta', dark: '#8c8b86', light: '#666561' },
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
const syntaxProperty = (name: string): string => `--teach-syntax-${name}`
|
|
237
|
+
|
|
238
|
+
const TEACH_SYNTAX_DARK = TEACH_SYNTAX.map(
|
|
239
|
+
({ name, dark }) => ` ${syntaxProperty(name)}: ${dark};`,
|
|
240
|
+
).join('\n')
|
|
241
|
+
|
|
242
|
+
const TEACH_SYNTAX_LIGHT = TEACH_SYNTAX.map(
|
|
243
|
+
({ name, light }) => ` ${syntaxProperty(name)}: ${light};`,
|
|
244
|
+
).join('\n')
|
|
245
|
+
|
|
213
246
|
const TEACH_CHROME: Component = {
|
|
214
247
|
name: 'teach-chrome',
|
|
215
248
|
note: [
|
|
@@ -263,6 +296,12 @@ const TEACH_CHROME: Component = {
|
|
|
263
296
|
--ink-faint: var(--color-muted);
|
|
264
297
|
--accent: var(--color-accent);
|
|
265
298
|
--accent-bg: var(--color-teach-accent-bg);
|
|
299
|
+
|
|
300
|
+
${TEACH_SYNTAX_DARK}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
[data-theme='light'] {
|
|
304
|
+
${TEACH_SYNTAX_LIGHT}
|
|
266
305
|
}
|
|
267
306
|
|
|
268
307
|
@media (prefers-color-scheme: dark) {
|
|
@@ -1007,6 +1046,7 @@ const TEACH_ARTICLE: Component = {
|
|
|
1007
1046
|
'--color-accent',
|
|
1008
1047
|
'--teach-hand',
|
|
1009
1048
|
'--teach-mono',
|
|
1049
|
+
...TEACH_SYNTAX.map(({ name }) => syntaxProperty(name)),
|
|
1010
1050
|
'--t1',
|
|
1011
1051
|
'--t2',
|
|
1012
1052
|
'--t3',
|
|
@@ -1092,6 +1132,18 @@ pre {
|
|
|
1092
1132
|
|
|
1093
1133
|
pre code { background: none; padding: 0; font-size: 1em; }
|
|
1094
1134
|
|
|
1135
|
+
/* Build-time highlight.js spans. Every class reads a --teach-syntax-* token,
|
|
1136
|
+
so the dark and light values switch with the ground rather than per rule. */
|
|
1137
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-built_in, .hljs-doctag { color: var(--teach-syntax-keyword); }
|
|
1138
|
+
.hljs-string, .hljs-regexp, .hljs-addition, .hljs-template-tag, .hljs-symbol { color: var(--teach-syntax-string); }
|
|
1139
|
+
.hljs-number, .hljs-type, .hljs-selector-class, .hljs-selector-id { color: var(--teach-syntax-number); }
|
|
1140
|
+
.hljs-title, .hljs-section, .hljs-name, .hljs-bullet { color: var(--teach-syntax-title); }
|
|
1141
|
+
.hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-property { color: var(--teach-syntax-attr); }
|
|
1142
|
+
.hljs-comment, .hljs-quote { color: var(--teach-syntax-comment); font-style: italic; }
|
|
1143
|
+
.hljs-meta, .hljs-deletion, .hljs-subst { color: var(--teach-syntax-meta); }
|
|
1144
|
+
.hljs-emphasis { font-style: italic; }
|
|
1145
|
+
.hljs-strong { font-weight: 700; }
|
|
1146
|
+
|
|
1095
1147
|
/* ---- Callout ---- */
|
|
1096
1148
|
|
|
1097
1149
|
.hard {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import { highlight } from '@/teach/highlight'
|
|
3
|
+
import type { Element } from '@/teach/html/jsx-runtime'
|
|
4
|
+
import { raw } from '@/teach/html/raw'
|
|
5
|
+
|
|
6
|
+
export interface CodeProps {
|
|
7
|
+
/** A highlight.js language name or alias. Unregistered ones render plain. */
|
|
8
|
+
readonly lang?: string
|
|
9
|
+
readonly text: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one way code reaches a lesson body. The highlighter escapes every source
|
|
14
|
+
* character before wrapping tokens in spans, which is what makes passing its
|
|
15
|
+
* output through `raw` safe.
|
|
16
|
+
*/
|
|
17
|
+
export function Code({ lang, text }: CodeProps): Element {
|
|
18
|
+
const className = lang === undefined ? 'hljs' : `hljs language-${lang}`
|
|
19
|
+
return (
|
|
20
|
+
<pre>
|
|
21
|
+
<code class={className}>{raw(highlight(text, lang).html)}</code>
|
|
22
|
+
</pre>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import hljs from 'highlight.js/lib/core'
|
|
2
|
+
import bash from 'highlight.js/lib/languages/bash'
|
|
3
|
+
import css from 'highlight.js/lib/languages/css'
|
|
4
|
+
import diff from 'highlight.js/lib/languages/diff'
|
|
5
|
+
import dockerfile from 'highlight.js/lib/languages/dockerfile'
|
|
6
|
+
import go from 'highlight.js/lib/languages/go'
|
|
7
|
+
import javascript from 'highlight.js/lib/languages/javascript'
|
|
8
|
+
import json from 'highlight.js/lib/languages/json'
|
|
9
|
+
import markdown from 'highlight.js/lib/languages/markdown'
|
|
10
|
+
import python from 'highlight.js/lib/languages/python'
|
|
11
|
+
import shell from 'highlight.js/lib/languages/shell'
|
|
12
|
+
import sql from 'highlight.js/lib/languages/sql'
|
|
13
|
+
import typescript from 'highlight.js/lib/languages/typescript'
|
|
14
|
+
import xml from 'highlight.js/lib/languages/xml'
|
|
15
|
+
import yaml from 'highlight.js/lib/languages/yaml'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A fixed set rather than all of highlight.js, since each grammar costs load
|
|
19
|
+
* time and a workspace rarely reaches past these. An unlisted language renders
|
|
20
|
+
* plain rather than refusing, so adding one here is the whole change.
|
|
21
|
+
*/
|
|
22
|
+
const LANGUAGES = {
|
|
23
|
+
bash,
|
|
24
|
+
css,
|
|
25
|
+
diff,
|
|
26
|
+
dockerfile,
|
|
27
|
+
go,
|
|
28
|
+
javascript,
|
|
29
|
+
json,
|
|
30
|
+
markdown,
|
|
31
|
+
python,
|
|
32
|
+
shell,
|
|
33
|
+
sql,
|
|
34
|
+
typescript,
|
|
35
|
+
xml,
|
|
36
|
+
yaml,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const [name, grammar] of Object.entries(LANGUAGES)) {
|
|
40
|
+
hljs.registerLanguage(name, grammar)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Highlighted {
|
|
44
|
+
/** Markup safe to embed inside `<code>`: every source character escaped. */
|
|
45
|
+
readonly html: string
|
|
46
|
+
/** The language as asked for, or `undefined` when it rendered plain. */
|
|
47
|
+
readonly language: string | undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeCode(text: string): string {
|
|
51
|
+
return text
|
|
52
|
+
.replaceAll('&', '&')
|
|
53
|
+
.replaceAll('<', '<')
|
|
54
|
+
.replaceAll('>', '>')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Highlights code at build time into `hljs-*` class spans the teach
|
|
59
|
+
* stylesheet colors, so an emitted page needs no script. An absent or
|
|
60
|
+
* unregistered language falls back to escaped plain text rather than
|
|
61
|
+
* throwing or guessing.
|
|
62
|
+
*/
|
|
63
|
+
export function highlight(text: string, lang?: string): Highlighted {
|
|
64
|
+
if (lang === undefined || hljs.getLanguage(lang) === undefined) {
|
|
65
|
+
return { html: escapeCode(text), language: undefined }
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
html: hljs.highlight(text, { language: lang, ignoreIllegals: true }).value,
|
|
69
|
+
language: lang,
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/teach/nav.ts
CHANGED
|
@@ -1170,6 +1170,35 @@ function retargetReferenceLinks(
|
|
|
1170
1170
|
)
|
|
1171
1171
|
}
|
|
1172
1172
|
|
|
1173
|
+
const FENCED_CODE =
|
|
1174
|
+
/<pre><code class="language-([^"]+)">([\s\S]*?)<\/code><\/pre>/g
|
|
1175
|
+
|
|
1176
|
+
function decodeHtml(html: string): string {
|
|
1177
|
+
return html
|
|
1178
|
+
.replaceAll('<', '<')
|
|
1179
|
+
.replaceAll('>', '>')
|
|
1180
|
+
.replaceAll('"', '"')
|
|
1181
|
+
.replaceAll(''', "'")
|
|
1182
|
+
.replaceAll('&', '&')
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Highlights the fenced blocks `Bun.markdown` emitted, decoding each once
|
|
1187
|
+
* since the renderer already escaped it. A fence in a language the highlighter
|
|
1188
|
+
* does not register is left exactly as the markdown rendered it. The module is
|
|
1189
|
+
* imported here rather than at the top so a verb that never reaches a
|
|
1190
|
+
* reference page does not load its grammars.
|
|
1191
|
+
*/
|
|
1192
|
+
async function highlightFences(html: string): Promise<string> {
|
|
1193
|
+
if (!html.includes('<pre><code class="language-')) return html
|
|
1194
|
+
const { highlight } = await import('@/teach/highlight')
|
|
1195
|
+
return html.replace(FENCED_CODE, (match, lang: string, escaped: string) => {
|
|
1196
|
+
const result = highlight(decodeHtml(escaped), lang)
|
|
1197
|
+
if (result.language === undefined) return match
|
|
1198
|
+
return `<pre><code class="hljs language-${lang}">${result.html}</code></pre>`
|
|
1199
|
+
})
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1173
1202
|
/**
|
|
1174
1203
|
* One reference page as a styled page carrying the workspace chrome. The
|
|
1175
1204
|
* markdown beside it stays the durable, promotable half, and this file is
|
|
@@ -1191,10 +1220,12 @@ async function renderReferencePage(
|
|
|
1191
1220
|
readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
|
|
1192
1221
|
|
|
1193
1222
|
const rendered = retargetReferenceLinks(
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1223
|
+
await highlightFences(
|
|
1224
|
+
Bun.markdown.html(stripFrontmatter(source), {
|
|
1225
|
+
noHtmlBlocks: true,
|
|
1226
|
+
noHtmlSpans: true,
|
|
1227
|
+
}),
|
|
1228
|
+
),
|
|
1198
1229
|
new Set(detail.referenceFiles),
|
|
1199
1230
|
)
|
|
1200
1231
|
const body = /<h1[\s>]/.test(rendered)
|
package/src/teach/render.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Code } from '@/teach/components/code'
|
|
1
2
|
import { Heading } from '@/teach/components/heading'
|
|
2
3
|
import { List } from '@/teach/components/list'
|
|
3
4
|
import { Paragraph } from '@/teach/components/paragraph'
|
|
@@ -19,6 +20,7 @@ export type LessonBlock =
|
|
|
19
20
|
readonly ordered?: boolean
|
|
20
21
|
}
|
|
21
22
|
| { readonly type: 'refs'; readonly items: readonly Reference[] }
|
|
23
|
+
| { readonly type: 'code'; readonly text: string; readonly lang?: string }
|
|
22
24
|
| { readonly type: 'raw'; readonly html: string }
|
|
23
25
|
|
|
24
26
|
export interface RenderRendered {
|
|
@@ -28,7 +30,17 @@ export interface RenderRendered {
|
|
|
28
30
|
|
|
29
31
|
export type RenderOutcome = RenderRendered | TeachRefused
|
|
30
32
|
|
|
31
|
-
const BLOCK_TYPES = [
|
|
33
|
+
const BLOCK_TYPES = [
|
|
34
|
+
'heading',
|
|
35
|
+
'paragraph',
|
|
36
|
+
'list',
|
|
37
|
+
'refs',
|
|
38
|
+
'code',
|
|
39
|
+
'raw',
|
|
40
|
+
] as const
|
|
41
|
+
|
|
42
|
+
/** Bounds a lang to what a class name can carry, so it never injects. */
|
|
43
|
+
const CODE_LANG = /^[a-z0-9+#-]+$/
|
|
32
44
|
|
|
33
45
|
const LINK_PROTOCOLS = new Set(['http:', 'https:'])
|
|
34
46
|
|
|
@@ -170,6 +182,20 @@ function renderBlock(
|
|
|
170
182
|
referenceCount: items.length,
|
|
171
183
|
}
|
|
172
184
|
}
|
|
185
|
+
case 'code': {
|
|
186
|
+
if (!isString(fields.text)) {
|
|
187
|
+
return badBlock(index, 'code needs a string text')
|
|
188
|
+
}
|
|
189
|
+
if (
|
|
190
|
+
fields.lang !== undefined &&
|
|
191
|
+
!(isString(fields.lang) && CODE_LANG.test(fields.lang))
|
|
192
|
+
) {
|
|
193
|
+
return badBlock(index, `code lang must match ${CODE_LANG.source}`)
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
html: render(Code({ lang: fields.lang, text: fields.text })),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
173
199
|
case 'raw': {
|
|
174
200
|
if (!isString(fields.html)) {
|
|
175
201
|
return badBlock(index, 'raw needs a string html')
|
package/standards/teach.md
CHANGED
|
@@ -128,6 +128,20 @@ Splits what was read from what was found.
|
|
|
128
128
|
|
|
129
129
|
A source found and not read is listed as a lead because a link attached to a page nobody opened is worse than no link. Listing it still pays, since it stops a later session searching for what this one already surfaced.
|
|
130
130
|
|
|
131
|
+
### Choosing sources
|
|
132
|
+
|
|
133
|
+
Rest each general claim on the highest rung that covers it:
|
|
134
|
+
|
|
135
|
+
1. Documentation or a specification published by whoever owns the subject
|
|
136
|
+
2. A canonical work on the subject, such as the standard text, a reference book, or a primary paper
|
|
137
|
+
3. Other material, such as a tutorial, a course, or a post, and only for a claim the first two rungs do not cover
|
|
138
|
+
|
|
139
|
+
A subject nobody owns, such as a mathematical topic or a craft, has no first rung, so its list starts at the second and is not missing anything. Record a third-rung source with the claim it covers and why no higher rung does, since that reason is what a later session checks the choice against.
|
|
140
|
+
|
|
141
|
+
The learner's own projects and material specific to one employer or organization are never a source for a general claim. Either may appear in the workspace as a worked application labeled with whose it is, and neither stands in `RESOURCES.md` as proof of what the subject says.
|
|
142
|
+
|
|
143
|
+
A source reachable only through a paywall or a summary stays a lead until it is opened. A review or summary of a book is a different source from the book, so list the summary as read on its own rung and the book as a lead.
|
|
144
|
+
|
|
131
145
|
## Conventions
|
|
132
146
|
|
|
133
147
|
- Write the markdown half in the format the authoring gates read, so a page promoted later needs no conversion.
|
|
@@ -141,6 +155,7 @@ A source found and not read is listed as a lead because a link attached to a pag
|
|
|
141
155
|
- **The record that counts errors.** A tally of how many were missed carries none of the misconception, which is the only part a later session can act on.
|
|
142
156
|
- **The revisit note with no date.** A line naming what to come back to and not when is read by nothing and schedules nothing, so the next session picks from the last record alone, which is the state the section exists to end.
|
|
143
157
|
- **The date left in the body.** A frontmatter field and a sentence both claiming the opening date resolve to whichever a reader happens to hit, and only one of them is readable by a walker.
|
|
158
|
+
- **The first search result as the source list.** A workspace resting on whatever a search returned first teaches the weakest account of the subject, and nothing in the folder says a stronger one was never looked for.
|
|
144
159
|
- **The renumbered folder.** Closing a gap in the ordinals moves every name a reader or a record already cited.
|
|
145
160
|
|
|
146
161
|
## Template
|