@erclx/canon 4.114.0 → 4.116.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/document-health/references/axes.md +1 -1
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-setup/references/detect.md +43 -9
- package/claude/skills/teach-workspace/SKILL.md +10 -6
- package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
- package/docs/agents/audits.md +4 -2
- package/docs/agents/commands.md +4 -2
- package/docs/agents/context-audit-checks.md +6 -6
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/sessions.md +84 -1
- package/docs/agents/teach.md +26 -4
- package/docs/target-projects.md +4 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +52 -0
- package/src/commands/context.ts +30 -10
- package/src/commands/sessions.ts +222 -0
- package/src/commands/teach.ts +22 -4
- package/src/context/audit.ts +54 -18
- package/src/records/backup.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sessions/registry.ts +11 -7
- package/src/sessions/transfer/bundle.ts +546 -0
- package/src/sessions/transfer/transcript.ts +148 -0
- package/src/teach/components/paragraph.tsx +19 -2
- package/src/teach/components/refs.tsx +28 -0
- package/src/teach/cross-references.ts +78 -0
- package/src/teach/lesson.ts +1 -1
- package/src/teach/nav.ts +278 -16
- package/src/teach/render.ts +94 -6
- package/standards/teach.md +13 -12
- package/tooling/go/configs/.golangci.yml +25 -0
- package/tooling/go/configs/scripts/verify.sh +95 -0
- package/tooling/go/manifest.toml +13 -0
- package/tooling/go/reference.md +60 -0
- package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
- package/tooling/go/seeds/smoke_test.go +9 -0
- package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
- package/tooling/php/configs/phpstan.neon +5 -0
- package/tooling/php/configs/phpunit.xml +18 -0
- package/tooling/php/configs/scripts/verify.sh +100 -0
- package/tooling/php/manifest.toml +15 -0
- package/tooling/php/reference.md +74 -0
- package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
- package/tooling/php/seeds/tests/SmokeTest.php +13 -0
|
@@ -4,9 +4,26 @@ import type { Child, Element } from '@/teach/html/jsx-runtime'
|
|
|
4
4
|
export interface ParagraphProps {
|
|
5
5
|
/** The lesson's dek, read back by `extractLessonMeta` in `@/teach/nav`. */
|
|
6
6
|
readonly lede?: boolean
|
|
7
|
+
/** Reference numbers, each linking to the `Refs` item carrying that id. */
|
|
8
|
+
readonly cites?: readonly number[]
|
|
7
9
|
readonly children: Child
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
export function Paragraph({ lede, children }: ParagraphProps): Element {
|
|
11
|
-
|
|
12
|
+
export function Paragraph({ lede, cites, children }: ParagraphProps): Element {
|
|
13
|
+
const markers = (cites ?? []).map((cite) => (
|
|
14
|
+
<sup class="cite">
|
|
15
|
+
<a href={`#r${cite}`}>{cite}</a>
|
|
16
|
+
</sup>
|
|
17
|
+
))
|
|
18
|
+
return lede ? (
|
|
19
|
+
<p class="lede">
|
|
20
|
+
{children}
|
|
21
|
+
{markers}
|
|
22
|
+
</p>
|
|
23
|
+
) : (
|
|
24
|
+
<p>
|
|
25
|
+
{children}
|
|
26
|
+
{markers}
|
|
27
|
+
</p>
|
|
28
|
+
)
|
|
12
29
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @jsxImportSource ../html */
|
|
2
|
+
import type { Element } from '@/teach/html/jsx-runtime'
|
|
3
|
+
|
|
4
|
+
export interface Reference {
|
|
5
|
+
readonly title: string
|
|
6
|
+
readonly url?: string
|
|
7
|
+
readonly note?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RefsProps {
|
|
11
|
+
readonly items: readonly Reference[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Numbers each item from 1 so its `id` matches the `#r<n>` a cited
|
|
16
|
+
* `Paragraph` links to. The caller has already checked every `url` parses as
|
|
17
|
+
* http or https, since attribute escaping does not stop a `javascript:` href.
|
|
18
|
+
*/
|
|
19
|
+
export function Refs({ items }: RefsProps): Element {
|
|
20
|
+
const rendered = items.map(({ title, url, note }, index) => (
|
|
21
|
+
<li id={`r${index + 1}`}>
|
|
22
|
+
<cite>{title}</cite>
|
|
23
|
+
{note === undefined ? null : `. ${note}`}
|
|
24
|
+
{url === undefined ? null : [' ', <a href={url}>{new URL(url).host}</a>]}
|
|
25
|
+
</li>
|
|
26
|
+
))
|
|
27
|
+
return <ol class="refs">{rendered}</ol>
|
|
28
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface LessonReferencesLinked {
|
|
2
|
+
readonly html: string
|
|
3
|
+
/** Four-digit numbers naming no lesson, each once, in first-seen order. */
|
|
4
|
+
readonly unresolved: readonly string[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A mention a learner would read as pointing at another lesson. The whitespace
|
|
9
|
+
* is captured rather than normalized, since a prettier-wrapped lesson can break
|
|
10
|
+
* a mention across a line.
|
|
11
|
+
*/
|
|
12
|
+
const MENTION = /\b(lesson|Lesson)(\s+)(\d{4})\b/g
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A link nav wrote on an earlier run, and the only kind it may rewrite. The
|
|
16
|
+
* close tag allows whitespace before its `>`, which is how prettier wraps it.
|
|
17
|
+
*/
|
|
18
|
+
const OWNED_LINK = /<a\s[^>]*\bdata-lesson-ref\b[^>]*>([\s\S]*?)<\/a\s*>/g
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A comment, a raw-text element taken whole so a `<` inside a script never
|
|
22
|
+
* reads as a tag, any other tag, or the text between tags.
|
|
23
|
+
*/
|
|
24
|
+
const TOKEN =
|
|
25
|
+
/<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>|<\/?[a-zA-Z][^>]*>|[^<]+|</g
|
|
26
|
+
|
|
27
|
+
const TAG_NAME = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Elements whose text stays plain. A link inside a quiz `label` navigates on
|
|
31
|
+
* the click meant to pick an option, and one inside `summary` fights the
|
|
32
|
+
* toggle.
|
|
33
|
+
*/
|
|
34
|
+
const SKIPPED = new Set(['a', 'code', 'pre', 'label', 'summary'])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Links every "lesson NNNN" mention in a lesson's authored region to the
|
|
38
|
+
* sibling file carrying that number, and reports the numbers naming no lesson.
|
|
39
|
+
* A link carrying `data-lesson-ref` is nav's own, so it is unwrapped and
|
|
40
|
+
* resolved again from its text, which re-points it after a slug rename and
|
|
41
|
+
* drops it once its target is gone. An author's own `<a>` is never touched.
|
|
42
|
+
*/
|
|
43
|
+
export function linkLessonReferences(
|
|
44
|
+
html: string,
|
|
45
|
+
targets: ReadonlyMap<string, string>,
|
|
46
|
+
current: string,
|
|
47
|
+
): LessonReferencesLinked {
|
|
48
|
+
const unresolved: string[] = []
|
|
49
|
+
let depth = 0
|
|
50
|
+
|
|
51
|
+
const linkText = (text: string): string =>
|
|
52
|
+
text.replace(MENTION, (mention, word, space, number: string) => {
|
|
53
|
+
const target = targets.get(number)
|
|
54
|
+
if (target === current) return mention
|
|
55
|
+
if (target === undefined) {
|
|
56
|
+
if (!unresolved.includes(number)) unresolved.push(number)
|
|
57
|
+
return mention
|
|
58
|
+
}
|
|
59
|
+
return `<a href="${target}" data-lesson-ref>${word}${space}${number}</a>`
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const unwrapped = html.replace(OWNED_LINK, (_link, text: string) => text)
|
|
63
|
+
|
|
64
|
+
const rewritten = unwrapped.replace(TOKEN, (token) => {
|
|
65
|
+
if (!token.startsWith('<') || token === '<') {
|
|
66
|
+
return depth === 0 ? linkText(token) : token
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const tag = TAG_NAME.exec(token)
|
|
70
|
+
if (tag === null || !SKIPPED.has(tag[2].toLowerCase())) return token
|
|
71
|
+
|
|
72
|
+
if (tag[1] === '/') depth = Math.max(0, depth - 1)
|
|
73
|
+
else if (!token.endsWith('/>')) depth += 1
|
|
74
|
+
return token
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return { html: rewritten, unresolved }
|
|
78
|
+
}
|
package/src/teach/lesson.ts
CHANGED
package/src/teach/nav.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative } from 'node:path'
|
|
2
|
+
import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join, posix, relative } from 'node:path'
|
|
4
4
|
import {
|
|
5
5
|
TEACH_SIDEBAR_BREAKPOINT,
|
|
6
6
|
TEACH_STYLESHEET_COMPONENTS,
|
|
7
7
|
} from '@/design/components'
|
|
8
8
|
import { buildDesignCss } from '@/design/css'
|
|
9
9
|
import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
|
|
10
|
+
import { stripFrontmatter } from '@/frontmatter'
|
|
10
11
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
11
12
|
import { PROJECT_ROOT } from '@/project-root'
|
|
13
|
+
import { linkLessonReferences } from '@/teach/cross-references'
|
|
12
14
|
import { TEACH_FONT_FACES } from '@/teach/fonts'
|
|
15
|
+
import { LESSON_NUMBER } from '@/teach/lesson'
|
|
13
16
|
import {
|
|
14
17
|
listWorkspaces,
|
|
15
18
|
readWorkspace,
|
|
16
19
|
TEACH_ASSETS,
|
|
20
|
+
TEACH_GLOSSARY,
|
|
17
21
|
TEACH_LESSONS,
|
|
18
22
|
TEACH_MISSION,
|
|
19
23
|
TEACH_REFERENCE,
|
|
@@ -695,6 +699,8 @@ function pageHead(
|
|
|
695
699
|
title: string,
|
|
696
700
|
cssHref: string | undefined,
|
|
697
701
|
embeddedCss: string | undefined,
|
|
702
|
+
page: 'index' | 'lesson' = 'index',
|
|
703
|
+
extraHead = '',
|
|
698
704
|
): string {
|
|
699
705
|
const style =
|
|
700
706
|
embeddedCss === undefined
|
|
@@ -706,10 +712,10 @@ function pageHead(
|
|
|
706
712
|
<head>
|
|
707
713
|
<meta charset="utf-8">
|
|
708
714
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
709
|
-
<title>${escapeHtml(title)}</title>
|
|
715
|
+
${extraHead}<title>${escapeHtml(title)}</title>
|
|
710
716
|
${teachFavicon()}
|
|
711
717
|
${style}
|
|
712
|
-
${headScript(
|
|
718
|
+
${headScript(page)}
|
|
713
719
|
</head>
|
|
714
720
|
`
|
|
715
721
|
}
|
|
@@ -1015,13 +1021,15 @@ async function readLessonMetas(
|
|
|
1015
1021
|
* on disk: the mission, the lessons already written, the reference pages, and
|
|
1016
1022
|
* the glossary. A workspace with no `index.html` yet gets one the same way a
|
|
1017
1023
|
* workspace that already had one gets its rewrite, since both read the same
|
|
1018
|
-
* sources.
|
|
1024
|
+
* sources. A reference row links the rendered sibling, or the markdown itself
|
|
1025
|
+
* on a Bun that cannot render one, so no row points at a file never written.
|
|
1019
1026
|
*/
|
|
1020
1027
|
async function renderContentsPage(
|
|
1021
1028
|
root: string,
|
|
1022
1029
|
workspaces: readonly WorkspaceSummary[],
|
|
1023
1030
|
detail: WorkspaceDetail,
|
|
1024
1031
|
metas: readonly LessonMeta[],
|
|
1032
|
+
canRenderMarkdown: boolean,
|
|
1025
1033
|
): Promise<string> {
|
|
1026
1034
|
const missionPath = join(root, detail.path, TEACH_MISSION)
|
|
1027
1035
|
const description = existsSync(missionPath)
|
|
@@ -1076,9 +1084,10 @@ async function renderContentsPage(
|
|
|
1076
1084
|
detail.referenceFiles.map(async (file, index) => {
|
|
1077
1085
|
const title = await readTitle(
|
|
1078
1086
|
join(root, detail.path, TEACH_REFERENCE, file),
|
|
1079
|
-
|
|
1087
|
+
referenceFallbackTitle(file),
|
|
1080
1088
|
)
|
|
1081
|
-
|
|
1089
|
+
const target = canRenderMarkdown ? renderedReferenceName(file) : file
|
|
1090
|
+
return `<li><a href="${TEACH_REFERENCE}/${target}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
|
|
1082
1091
|
}),
|
|
1083
1092
|
)
|
|
1084
1093
|
).join('')
|
|
@@ -1117,6 +1126,157 @@ ${renderScripts(false, true)}
|
|
|
1117
1126
|
`
|
|
1118
1127
|
}
|
|
1119
1128
|
|
|
1129
|
+
/**
|
|
1130
|
+
* Marks a reference page as nav's own output, which is the one test nav applies
|
|
1131
|
+
* before deleting a page whose markdown source is gone. A hand-written HTML
|
|
1132
|
+
* file beside the reference pages carries none and is never touched.
|
|
1133
|
+
*/
|
|
1134
|
+
const REFERENCE_GENERATOR = '<meta name="generator" content="canon teach nav">'
|
|
1135
|
+
|
|
1136
|
+
/** A formatter closes the tag as `/>`, which still marks the page as generated. */
|
|
1137
|
+
const REFERENCE_GENERATOR_PATTERN =
|
|
1138
|
+
/<meta name="generator" content="canon teach nav"\s*\/?>/
|
|
1139
|
+
|
|
1140
|
+
function renderedReferenceName(file: string): string {
|
|
1141
|
+
return file.replace(/\.md$/, '.html')
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function referenceFallbackTitle(file: string): string {
|
|
1145
|
+
return titleCase(file.replace(/\.md$/, ''))
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* A relative href landing on another reference page's markdown is retargeted
|
|
1150
|
+
* to its rendered sibling, and one landing on the workspace glossary goes to
|
|
1151
|
+
* the glossary the contents page renders under `#gloss`, dropping any fragment
|
|
1152
|
+
* since the entries there carry no per-term anchor. The resources page has no
|
|
1153
|
+
* rendered form, so a blanket suffix swap would break a link to it.
|
|
1154
|
+
*/
|
|
1155
|
+
function retargetReferenceLinks(
|
|
1156
|
+
html: string,
|
|
1157
|
+
referenceFiles: ReadonlySet<string>,
|
|
1158
|
+
): string {
|
|
1159
|
+
return html.replace(
|
|
1160
|
+
/href="([^"#?:]+)\.md([#?][^"]*)?"/g,
|
|
1161
|
+
(match, stem: string, suffix: string | undefined) => {
|
|
1162
|
+
const target = posix.normalize(posix.join(TEACH_REFERENCE, `${stem}.md`))
|
|
1163
|
+
if (target === TEACH_GLOSSARY) return 'href="../index.html#gloss"'
|
|
1164
|
+
|
|
1165
|
+
const [folder, file, ...rest] = target.split('/')
|
|
1166
|
+
if (folder !== TEACH_REFERENCE || rest.length > 0) return match
|
|
1167
|
+
if (!referenceFiles.has(file)) return match
|
|
1168
|
+
return `href="${stem}.html${suffix ?? ''}"`
|
|
1169
|
+
},
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* One reference page as a styled page carrying the workspace chrome. The
|
|
1175
|
+
* markdown beside it stays the durable, promotable half, and this file is
|
|
1176
|
+
* regenerated from it on every run. Raw HTML in the markdown is escaped rather
|
|
1177
|
+
* than passed through, since a reference page is plain markdown by contract.
|
|
1178
|
+
*/
|
|
1179
|
+
async function renderReferencePage(
|
|
1180
|
+
root: string,
|
|
1181
|
+
workspaces: readonly WorkspaceSummary[],
|
|
1182
|
+
detail: WorkspaceDetail,
|
|
1183
|
+
metas: readonly LessonMeta[],
|
|
1184
|
+
file: string,
|
|
1185
|
+
): Promise<string> {
|
|
1186
|
+
const source = await readFile(
|
|
1187
|
+
join(root, detail.path, TEACH_REFERENCE, file),
|
|
1188
|
+
'utf8',
|
|
1189
|
+
)
|
|
1190
|
+
const title =
|
|
1191
|
+
readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
|
|
1192
|
+
|
|
1193
|
+
const rendered = retargetReferenceLinks(
|
|
1194
|
+
Bun.markdown.html(stripFrontmatter(source), {
|
|
1195
|
+
noHtmlBlocks: true,
|
|
1196
|
+
noHtmlSpans: true,
|
|
1197
|
+
}),
|
|
1198
|
+
new Set(detail.referenceFiles),
|
|
1199
|
+
)
|
|
1200
|
+
const body = /<h1[\s>]/.test(rendered)
|
|
1201
|
+
? rendered
|
|
1202
|
+
: `<h1>${escapeHtml(title)}</h1>\n${rendered}`
|
|
1203
|
+
|
|
1204
|
+
const teachPrefix = '../../'
|
|
1205
|
+
const workspacePrefix = '../'
|
|
1206
|
+
|
|
1207
|
+
const header = renderHeader(
|
|
1208
|
+
[
|
|
1209
|
+
{
|
|
1210
|
+
label: 'Workspaces',
|
|
1211
|
+
href: `${teachPrefix}index.html`,
|
|
1212
|
+
jump: {
|
|
1213
|
+
ariaLabel: 'Switch workspace',
|
|
1214
|
+
entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
label: workspaceLabel(detail),
|
|
1219
|
+
href: `${workspacePrefix}index.html`,
|
|
1220
|
+
jump: {
|
|
1221
|
+
ariaLabel: 'Jump to a lesson',
|
|
1222
|
+
entries: lessonJumpEntries(metas, workspacePrefix, undefined),
|
|
1223
|
+
},
|
|
1224
|
+
},
|
|
1225
|
+
{ label: title },
|
|
1226
|
+
],
|
|
1227
|
+
{
|
|
1228
|
+
heading: workspaceLabel(detail),
|
|
1229
|
+
switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1230
|
+
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1231
|
+
items: metas.map((meta, index) => ({
|
|
1232
|
+
ordinal: String(index + 1).padStart(2, '0'),
|
|
1233
|
+
label: meta.title,
|
|
1234
|
+
href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
|
|
1235
|
+
at: false,
|
|
1236
|
+
})),
|
|
1237
|
+
foot: workspaceFoot(detail),
|
|
1238
|
+
},
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
// The sidebar opens as on a lesson, since this page lists no lessons itself.
|
|
1242
|
+
return `${pageHead(title, `${workspacePrefix}${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined, 'lesson', `${REFERENCE_GENERATOR}\n`)}<body>
|
|
1243
|
+
${header}
|
|
1244
|
+
<main class="ref">
|
|
1245
|
+
${body.trim()}
|
|
1246
|
+
</main>
|
|
1247
|
+
${PANE_CLOSE}
|
|
1248
|
+
${renderScripts(false, false)}
|
|
1249
|
+
</body>
|
|
1250
|
+
</html>
|
|
1251
|
+
`
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Deletes a generated reference page whose markdown source is gone, so a
|
|
1256
|
+
* renamed or removed page leaves nothing no contents row reaches.
|
|
1257
|
+
*/
|
|
1258
|
+
async function removeOrphanReferencePages(
|
|
1259
|
+
root: string,
|
|
1260
|
+
detail: WorkspaceDetail,
|
|
1261
|
+
): Promise<void> {
|
|
1262
|
+
const dir = join(root, detail.path, TEACH_REFERENCE)
|
|
1263
|
+
if (!existsSync(dir)) return
|
|
1264
|
+
|
|
1265
|
+
const sources = new Set(detail.referenceFiles.map(renderedReferenceName))
|
|
1266
|
+
const orphans = (await readdir(dir)).filter(
|
|
1267
|
+
(file) => file.endsWith('.html') && !sources.has(file),
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
await Promise.all(
|
|
1271
|
+
orphans.map(async (file) => {
|
|
1272
|
+
const path = join(dir, file)
|
|
1273
|
+
if (REFERENCE_GENERATOR_PATTERN.test(await readFile(path, 'utf8'))) {
|
|
1274
|
+
await rm(path)
|
|
1275
|
+
}
|
|
1276
|
+
}),
|
|
1277
|
+
)
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1120
1280
|
const REGIONS = ['style', 'header', 'footnav', 'scripts'] as const
|
|
1121
1281
|
type Region = (typeof REGIONS)[number]
|
|
1122
1282
|
|
|
@@ -1148,6 +1308,7 @@ function spliceRegion(
|
|
|
1148
1308
|
interface LessonRewritten {
|
|
1149
1309
|
readonly ok: true
|
|
1150
1310
|
readonly file: string
|
|
1311
|
+
readonly unresolved: readonly string[]
|
|
1151
1312
|
}
|
|
1152
1313
|
|
|
1153
1314
|
interface LessonRefused {
|
|
@@ -1176,15 +1337,57 @@ const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
|
|
|
1176
1337
|
* alone, which keeps a second run byte-identical.
|
|
1177
1338
|
*/
|
|
1178
1339
|
function wrapMain(html: string): string {
|
|
1340
|
+
const bounds = authoredBounds(html)
|
|
1341
|
+
if (bounds === undefined) return html
|
|
1342
|
+
|
|
1343
|
+
const body = html.slice(bounds.start, bounds.end)
|
|
1344
|
+
if (/<main[\s>]/.test(body)) return html
|
|
1345
|
+
|
|
1346
|
+
return `${html.slice(0, bounds.start)}\n<main>\n${body.trim()}\n</main>\n${html.slice(bounds.end)}`
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function authoredBounds(
|
|
1350
|
+
html: string,
|
|
1351
|
+
): { readonly start: number; readonly end: number } | undefined {
|
|
1179
1352
|
const start = html.indexOf(HEADER_CLOSE)
|
|
1180
1353
|
const end = html.indexOf(FOOTNAV_OPEN, start)
|
|
1181
|
-
if (start === -1 || end === -1) return
|
|
1354
|
+
if (start === -1 || end === -1) return undefined
|
|
1355
|
+
return { start: start + HEADER_CLOSE.length, end }
|
|
1356
|
+
}
|
|
1182
1357
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1358
|
+
/** Each lesson number to the file carrying it, the first in filename order. */
|
|
1359
|
+
function lessonTargets(
|
|
1360
|
+
lessonFiles: readonly string[],
|
|
1361
|
+
): ReadonlyMap<string, string> {
|
|
1362
|
+
const targets = new Map<string, string>()
|
|
1363
|
+
for (const file of [...lessonFiles].sort()) {
|
|
1364
|
+
const number = LESSON_NUMBER.exec(file)?.[1]
|
|
1365
|
+
if (number !== undefined && !targets.has(number)) targets.set(number, file)
|
|
1366
|
+
}
|
|
1367
|
+
return targets
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Links the "lesson NNNN" mentions in the authored region, the one rewrite nav
|
|
1372
|
+
* makes between the header and footnav markers besides the `<main>` wrap.
|
|
1373
|
+
*/
|
|
1374
|
+
function linkAuthoredRegion(
|
|
1375
|
+
html: string,
|
|
1376
|
+
targets: ReadonlyMap<string, string>,
|
|
1377
|
+
file: string,
|
|
1378
|
+
): { readonly html: string; readonly unresolved: readonly string[] } {
|
|
1379
|
+
const bounds = authoredBounds(html)
|
|
1380
|
+
if (bounds === undefined) return { html, unresolved: [] }
|
|
1186
1381
|
|
|
1187
|
-
|
|
1382
|
+
const linked = linkLessonReferences(
|
|
1383
|
+
html.slice(bounds.start, bounds.end),
|
|
1384
|
+
targets,
|
|
1385
|
+
file,
|
|
1386
|
+
)
|
|
1387
|
+
return {
|
|
1388
|
+
html: `${html.slice(0, bounds.start)}${linked.html}${html.slice(bounds.end)}`,
|
|
1389
|
+
unresolved: linked.unresolved,
|
|
1390
|
+
}
|
|
1188
1391
|
}
|
|
1189
1392
|
|
|
1190
1393
|
/**
|
|
@@ -1201,6 +1404,7 @@ async function rewriteLesson(
|
|
|
1201
1404
|
metas: readonly LessonMeta[],
|
|
1202
1405
|
workspaces: readonly WorkspaceSummary[],
|
|
1203
1406
|
css: string,
|
|
1407
|
+
targets: ReadonlyMap<string, string>,
|
|
1204
1408
|
): Promise<LessonRewritten | LessonRefused> {
|
|
1205
1409
|
const file = metas[index].file
|
|
1206
1410
|
const path = join(root, detail.path, TEACH_LESSONS, file)
|
|
@@ -1281,8 +1485,9 @@ async function rewriteLesson(
|
|
|
1281
1485
|
html = spliced
|
|
1282
1486
|
}
|
|
1283
1487
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1488
|
+
const linked = linkAuthoredRegion(wrapMain(html), targets, file)
|
|
1489
|
+
await writeFile(path, linked.html)
|
|
1490
|
+
return { ok: true, file, unresolved: linked.unresolved }
|
|
1286
1491
|
}
|
|
1287
1492
|
|
|
1288
1493
|
export interface LessonSkipped {
|
|
@@ -1296,8 +1501,31 @@ export interface NavGenerated {
|
|
|
1296
1501
|
readonly root: string
|
|
1297
1502
|
readonly contents: readonly string[]
|
|
1298
1503
|
readonly lessons: number
|
|
1299
|
-
/**
|
|
1504
|
+
/** Reference pages rendered to their `.html` sibling. */
|
|
1505
|
+
readonly reference: number
|
|
1506
|
+
/**
|
|
1507
|
+
* A lesson file missing a chrome marker, refused rather than rewritten, or a
|
|
1508
|
+
* reference page left unrendered on a Bun carrying no markdown renderer.
|
|
1509
|
+
*/
|
|
1300
1510
|
readonly skipped: readonly LessonSkipped[]
|
|
1511
|
+
/**
|
|
1512
|
+
* A "lesson NNNN" mention naming no lesson in its workspace, one per distinct
|
|
1513
|
+
* number per lesson. The lesson still rewrote, so it counts in `lessons`.
|
|
1514
|
+
*/
|
|
1515
|
+
readonly unresolved: readonly LessonReferenceUnresolved[]
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
export interface LessonReferenceUnresolved {
|
|
1519
|
+
/** Relative to the root, the way a `skipped` entry is. */
|
|
1520
|
+
readonly file: string
|
|
1521
|
+
readonly lesson: string
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const MARKDOWN_RENDERER = 'Bun.markdown'
|
|
1525
|
+
|
|
1526
|
+
/** An older Bun carries no `Bun.markdown`, and calling it there throws. */
|
|
1527
|
+
function hasMarkdownRenderer(): boolean {
|
|
1528
|
+
return typeof Bun.markdown?.html === 'function'
|
|
1301
1529
|
}
|
|
1302
1530
|
|
|
1303
1531
|
export type NavOutcome = NavGenerated | TeachRefused
|
|
@@ -1342,7 +1570,10 @@ export async function generateNav(
|
|
|
1342
1570
|
|
|
1343
1571
|
const contents: string[] = []
|
|
1344
1572
|
const skipped: LessonSkipped[] = []
|
|
1573
|
+
const unresolved: LessonReferenceUnresolved[] = []
|
|
1345
1574
|
let lessons = 0
|
|
1575
|
+
let reference = 0
|
|
1576
|
+
const canRenderMarkdown = hasMarkdownRenderer()
|
|
1346
1577
|
|
|
1347
1578
|
for (const slug of targetSlugs) {
|
|
1348
1579
|
const found = await readWorkspace(root, slug)
|
|
@@ -1357,11 +1588,33 @@ export async function generateNav(
|
|
|
1357
1588
|
const contentsPath = join(root, detail.path, 'index.html')
|
|
1358
1589
|
await writeFile(
|
|
1359
1590
|
contentsPath,
|
|
1360
|
-
await renderContentsPage(
|
|
1591
|
+
await renderContentsPage(
|
|
1592
|
+
root,
|
|
1593
|
+
listed.workspaces,
|
|
1594
|
+
detail,
|
|
1595
|
+
metas,
|
|
1596
|
+
canRenderMarkdown,
|
|
1597
|
+
),
|
|
1361
1598
|
)
|
|
1362
1599
|
contents.push(relative(root, contentsPath))
|
|
1363
1600
|
|
|
1601
|
+
for (const file of detail.referenceFiles) {
|
|
1602
|
+
const source = join(detail.path, TEACH_REFERENCE, file)
|
|
1603
|
+
if (!canRenderMarkdown) {
|
|
1604
|
+
skipped.push({ file: source, missing: MARKDOWN_RENDERER })
|
|
1605
|
+
continue
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
await writeFile(
|
|
1609
|
+
join(root, detail.path, TEACH_REFERENCE, renderedReferenceName(file)),
|
|
1610
|
+
await renderReferencePage(root, listed.workspaces, detail, metas, file),
|
|
1611
|
+
)
|
|
1612
|
+
reference += 1
|
|
1613
|
+
}
|
|
1614
|
+
await removeOrphanReferencePages(root, detail)
|
|
1615
|
+
|
|
1364
1616
|
const css = stripImports(await readFile(cssPath, 'utf8'))
|
|
1617
|
+
const targets = lessonTargets(detail.lessonFiles)
|
|
1365
1618
|
|
|
1366
1619
|
for (let index = 0; index < metas.length; index += 1) {
|
|
1367
1620
|
const outcome = await rewriteLesson(
|
|
@@ -1371,10 +1624,17 @@ export async function generateNav(
|
|
|
1371
1624
|
metas,
|
|
1372
1625
|
listed.workspaces,
|
|
1373
1626
|
css,
|
|
1627
|
+
targets,
|
|
1374
1628
|
)
|
|
1375
1629
|
|
|
1376
1630
|
if (outcome.ok) {
|
|
1377
1631
|
lessons += 1
|
|
1632
|
+
for (const lesson of outcome.unresolved) {
|
|
1633
|
+
unresolved.push({
|
|
1634
|
+
file: join(detail.path, TEACH_LESSONS, outcome.file),
|
|
1635
|
+
lesson,
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1378
1638
|
} else {
|
|
1379
1639
|
skipped.push({
|
|
1380
1640
|
file: join(detail.path, TEACH_LESSONS, outcome.file),
|
|
@@ -1389,6 +1649,8 @@ export async function generateNav(
|
|
|
1389
1649
|
root: relative(root, rootPath),
|
|
1390
1650
|
contents,
|
|
1391
1651
|
lessons,
|
|
1652
|
+
reference,
|
|
1392
1653
|
skipped,
|
|
1654
|
+
unresolved,
|
|
1393
1655
|
}
|
|
1394
1656
|
}
|