@erclx/canon 4.113.0 → 4.115.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/codebase-layout/SKILL.md +1 -1
- package/claude/skills/document-health/references/axes.md +1 -1
- package/claude/skills/target-setup/SKILL.md +3 -1
- package/claude/skills/target-setup/references/detect.md +43 -9
- package/claude/skills/target-setup/references/verify.md +26 -10
- package/claude/skills/teach-workspace/SKILL.md +5 -3
- package/claude/skills/test-craft/SKILL.md +1 -1
- package/docs/agents/audits.md +4 -2
- package/docs/agents/commands.md +2 -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/counts.md +3 -3
- package/docs/agents/install-and-sync.md +2 -0
- package/docs/agents/records.md +22 -15
- package/docs/agents/teach.md +12 -4
- package/docs/target-projects.md +11 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/rules/lang/130-go.md +40 -0
- package/governance/rules/lang/140-php.md +31 -0
- package/governance/rules/lib/335-testing-go.md +35 -0
- package/governance/rules/lib/336-testing-php.md +30 -0
- package/governance/rules/lib/360-security-server.md +2 -0
- package/governance/rules/lib/370-database.md +2 -0
- package/governance/rules/ui/410-a11y.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/governance/stacks/nextjs.toml +2 -0
- package/governance/stacks/react.toml +1 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +52 -0
- package/src/commands/context.ts +30 -10
- package/src/commands/records.ts +18 -1
- package/src/commands/teach.ts +4 -1
- package/src/commands/tooling.ts +25 -2
- package/src/context/audit.ts +54 -18
- package/src/design/components.ts +17 -0
- package/src/design/tokens.ts +1 -1
- package/src/gate/measures.ts +13 -2
- package/src/records/backup.ts +197 -27
- package/src/records/push-guard.ts +71 -0
- package/src/teach/nav.ts +278 -34
- package/src/tooling/inject.ts +43 -4
- package/src/tooling/scan.ts +35 -0
- package/src/tooling/subfolder.ts +110 -0
- package/standards/teach.md +13 -12
- package/tooling/base/configs/.husky/post-merge +5 -1
- 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
- package/tooling/python/configs/scripts/verify.sh +32 -15
- package/tooling/web/configs/scripts/verify.sh +32 -15
package/src/teach/nav.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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'
|
|
12
13
|
import { TEACH_FONT_FACES } from '@/teach/fonts'
|
|
@@ -14,10 +15,12 @@ import {
|
|
|
14
15
|
listWorkspaces,
|
|
15
16
|
readWorkspace,
|
|
16
17
|
TEACH_ASSETS,
|
|
18
|
+
TEACH_GLOSSARY,
|
|
17
19
|
TEACH_LESSONS,
|
|
18
20
|
TEACH_MISSION,
|
|
19
21
|
TEACH_REFERENCE,
|
|
20
22
|
TEACH_STYLESHEET,
|
|
23
|
+
TEACH_STYLESHEET_BASE,
|
|
21
24
|
teachDir,
|
|
22
25
|
type TeachRefused,
|
|
23
26
|
type WorkspaceDetail,
|
|
@@ -35,12 +38,23 @@ const BRAND_MARK = 'assets/brand/mark.svg'
|
|
|
35
38
|
*/
|
|
36
39
|
const HAND_WRITTEN_ICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='10 10 80 80'%3E%3Cpath d='M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z' fill='rgb(224,114,75)' /%3E%3Crect x='44' y='15' width='12' height='70' rx='2' fill='rgb(224,114,75)' /%3E%3C/svg%3E" />`
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The stylesheet link lessons used to carry by hand outside every region. The
|
|
43
|
+
* `style` region now links the base sheet itself, so keeping this one would
|
|
44
|
+
* load the base twice.
|
|
45
|
+
*/
|
|
46
|
+
const HAND_WRITTEN_STYLESHEET = `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET}" />`
|
|
47
|
+
|
|
48
|
+
const HAND_WRITTEN_HEAD = [HAND_WRITTEN_ICON, HAND_WRITTEN_STYLESHEET]
|
|
49
|
+
|
|
50
|
+
function dropHandWrittenHead(html: string): string {
|
|
51
|
+
return HAND_WRITTEN_HEAD.reduce(
|
|
52
|
+
(text, link) => text.replaceAll(link, ''),
|
|
53
|
+
html
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter((line) => !HAND_WRITTEN_HEAD.includes(line.trim()))
|
|
56
|
+
.join('\n'),
|
|
57
|
+
)
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
/**
|
|
@@ -504,6 +518,11 @@ function titleCase(slug: string): string {
|
|
|
504
518
|
].join(' ')
|
|
505
519
|
}
|
|
506
520
|
|
|
521
|
+
/** The mission's own title, falling back to the slug where it names none. */
|
|
522
|
+
function workspaceLabel(workspace: WorkspaceSummary): string {
|
|
523
|
+
return workspace.title ?? titleCase(workspace.topic)
|
|
524
|
+
}
|
|
525
|
+
|
|
507
526
|
interface JumpEntry {
|
|
508
527
|
readonly ordinal: string
|
|
509
528
|
readonly label: string
|
|
@@ -629,7 +648,7 @@ function renderSidebar(sidebar: Sidebar): string {
|
|
|
629
648
|
|
|
630
649
|
return `<aside class="sb">
|
|
631
650
|
<div class="sb-top">
|
|
632
|
-
<details class="sb-ws" name="mast"><summary><span class="ws-name">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
|
|
651
|
+
<details class="sb-ws" name="mast"><summary><span class="ws-name" title="${escapeHtml(sidebar.heading)}">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
|
|
633
652
|
</div>
|
|
634
653
|
<div class="sb-scroll">
|
|
635
654
|
<div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
|
|
@@ -678,6 +697,8 @@ function pageHead(
|
|
|
678
697
|
title: string,
|
|
679
698
|
cssHref: string | undefined,
|
|
680
699
|
embeddedCss: string | undefined,
|
|
700
|
+
page: 'index' | 'lesson' = 'index',
|
|
701
|
+
extraHead = '',
|
|
681
702
|
): string {
|
|
682
703
|
const style =
|
|
683
704
|
embeddedCss === undefined
|
|
@@ -689,10 +710,10 @@ function pageHead(
|
|
|
689
710
|
<head>
|
|
690
711
|
<meta charset="utf-8">
|
|
691
712
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
692
|
-
<title>${escapeHtml(title)}</title>
|
|
713
|
+
${extraHead}<title>${escapeHtml(title)}</title>
|
|
693
714
|
${teachFavicon()}
|
|
694
715
|
${style}
|
|
695
|
-
${headScript(
|
|
716
|
+
${headScript(page)}
|
|
696
717
|
</head>
|
|
697
718
|
`
|
|
698
719
|
}
|
|
@@ -724,7 +745,7 @@ function workspaceJumpEntries(
|
|
|
724
745
|
): JumpEntry[] {
|
|
725
746
|
return workspaces.map((workspace) => ({
|
|
726
747
|
ordinal: ordinalOf(workspace),
|
|
727
|
-
label:
|
|
748
|
+
label: workspaceLabel(workspace),
|
|
728
749
|
href: hrefForWorkspace(workspace, prefix),
|
|
729
750
|
at: workspace.slug === currentSlug,
|
|
730
751
|
trailing: String(workspace.lessons),
|
|
@@ -935,7 +956,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
|
|
|
935
956
|
meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
|
|
936
957
|
items: workspaces.map((workspace) => ({
|
|
937
958
|
ordinal: ordinalOf(workspace),
|
|
938
|
-
label:
|
|
959
|
+
label: workspaceLabel(workspace),
|
|
939
960
|
href: hrefForWorkspace(workspace, '') ?? '#',
|
|
940
961
|
at: false,
|
|
941
962
|
})),
|
|
@@ -951,7 +972,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
|
|
|
951
972
|
? 'Live'
|
|
952
973
|
: 'Open'
|
|
953
974
|
const blurb = `${workspace.lessons} lesson(s) · ${workspace.reference} reference page(s) · ${workspace.terms} term(s)`
|
|
954
|
-
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(
|
|
975
|
+
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(workspaceLabel(workspace))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
|
|
955
976
|
|
|
956
977
|
return href === undefined
|
|
957
978
|
? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
|
|
@@ -998,13 +1019,15 @@ async function readLessonMetas(
|
|
|
998
1019
|
* on disk: the mission, the lessons already written, the reference pages, and
|
|
999
1020
|
* the glossary. A workspace with no `index.html` yet gets one the same way a
|
|
1000
1021
|
* workspace that already had one gets its rewrite, since both read the same
|
|
1001
|
-
* sources.
|
|
1022
|
+
* sources. A reference row links the rendered sibling, or the markdown itself
|
|
1023
|
+
* on a Bun that cannot render one, so no row points at a file never written.
|
|
1002
1024
|
*/
|
|
1003
1025
|
async function renderContentsPage(
|
|
1004
1026
|
root: string,
|
|
1005
1027
|
workspaces: readonly WorkspaceSummary[],
|
|
1006
1028
|
detail: WorkspaceDetail,
|
|
1007
1029
|
metas: readonly LessonMeta[],
|
|
1030
|
+
canRenderMarkdown: boolean,
|
|
1008
1031
|
): Promise<string> {
|
|
1009
1032
|
const missionPath = join(root, detail.path, TEACH_MISSION)
|
|
1010
1033
|
const description = existsSync(missionPath)
|
|
@@ -1014,7 +1037,7 @@ async function renderContentsPage(
|
|
|
1014
1037
|
)
|
|
1015
1038
|
: undefined
|
|
1016
1039
|
|
|
1017
|
-
const title =
|
|
1040
|
+
const title = workspaceLabel(detail)
|
|
1018
1041
|
|
|
1019
1042
|
const segments: CrumbSegment[] = [
|
|
1020
1043
|
{
|
|
@@ -1026,7 +1049,7 @@ async function renderContentsPage(
|
|
|
1026
1049
|
},
|
|
1027
1050
|
},
|
|
1028
1051
|
{
|
|
1029
|
-
label:
|
|
1052
|
+
label: workspaceLabel(detail),
|
|
1030
1053
|
jump: {
|
|
1031
1054
|
ariaLabel: 'Jump to a lesson',
|
|
1032
1055
|
entries: lessonJumpEntries(metas, '', undefined),
|
|
@@ -1035,7 +1058,7 @@ async function renderContentsPage(
|
|
|
1035
1058
|
]
|
|
1036
1059
|
|
|
1037
1060
|
const sidebar: Sidebar = {
|
|
1038
|
-
heading:
|
|
1061
|
+
heading: workspaceLabel(detail),
|
|
1039
1062
|
switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
|
|
1040
1063
|
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1041
1064
|
items: metas.map((meta, index) => ({
|
|
@@ -1059,9 +1082,10 @@ async function renderContentsPage(
|
|
|
1059
1082
|
detail.referenceFiles.map(async (file, index) => {
|
|
1060
1083
|
const title = await readTitle(
|
|
1061
1084
|
join(root, detail.path, TEACH_REFERENCE, file),
|
|
1062
|
-
|
|
1085
|
+
referenceFallbackTitle(file),
|
|
1063
1086
|
)
|
|
1064
|
-
|
|
1087
|
+
const target = canRenderMarkdown ? renderedReferenceName(file) : file
|
|
1088
|
+
return `<li><a href="${TEACH_REFERENCE}/${target}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
|
|
1065
1089
|
}),
|
|
1066
1090
|
)
|
|
1067
1091
|
).join('')
|
|
@@ -1100,6 +1124,157 @@ ${renderScripts(false, true)}
|
|
|
1100
1124
|
`
|
|
1101
1125
|
}
|
|
1102
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* Marks a reference page as nav's own output, which is the one test nav applies
|
|
1129
|
+
* before deleting a page whose markdown source is gone. A hand-written HTML
|
|
1130
|
+
* file beside the reference pages carries none and is never touched.
|
|
1131
|
+
*/
|
|
1132
|
+
const REFERENCE_GENERATOR = '<meta name="generator" content="canon teach nav">'
|
|
1133
|
+
|
|
1134
|
+
/** A formatter closes the tag as `/>`, which still marks the page as generated. */
|
|
1135
|
+
const REFERENCE_GENERATOR_PATTERN =
|
|
1136
|
+
/<meta name="generator" content="canon teach nav"\s*\/?>/
|
|
1137
|
+
|
|
1138
|
+
function renderedReferenceName(file: string): string {
|
|
1139
|
+
return file.replace(/\.md$/, '.html')
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function referenceFallbackTitle(file: string): string {
|
|
1143
|
+
return titleCase(file.replace(/\.md$/, ''))
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* A relative href landing on another reference page's markdown is retargeted
|
|
1148
|
+
* to its rendered sibling, and one landing on the workspace glossary goes to
|
|
1149
|
+
* the glossary the contents page renders under `#gloss`, dropping any fragment
|
|
1150
|
+
* since the entries there carry no per-term anchor. The resources page has no
|
|
1151
|
+
* rendered form, so a blanket suffix swap would break a link to it.
|
|
1152
|
+
*/
|
|
1153
|
+
function retargetReferenceLinks(
|
|
1154
|
+
html: string,
|
|
1155
|
+
referenceFiles: ReadonlySet<string>,
|
|
1156
|
+
): string {
|
|
1157
|
+
return html.replace(
|
|
1158
|
+
/href="([^"#?:]+)\.md([#?][^"]*)?"/g,
|
|
1159
|
+
(match, stem: string, suffix: string | undefined) => {
|
|
1160
|
+
const target = posix.normalize(posix.join(TEACH_REFERENCE, `${stem}.md`))
|
|
1161
|
+
if (target === TEACH_GLOSSARY) return 'href="../index.html#gloss"'
|
|
1162
|
+
|
|
1163
|
+
const [folder, file, ...rest] = target.split('/')
|
|
1164
|
+
if (folder !== TEACH_REFERENCE || rest.length > 0) return match
|
|
1165
|
+
if (!referenceFiles.has(file)) return match
|
|
1166
|
+
return `href="${stem}.html${suffix ?? ''}"`
|
|
1167
|
+
},
|
|
1168
|
+
)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* One reference page as a styled page carrying the workspace chrome. The
|
|
1173
|
+
* markdown beside it stays the durable, promotable half, and this file is
|
|
1174
|
+
* regenerated from it on every run. Raw HTML in the markdown is escaped rather
|
|
1175
|
+
* than passed through, since a reference page is plain markdown by contract.
|
|
1176
|
+
*/
|
|
1177
|
+
async function renderReferencePage(
|
|
1178
|
+
root: string,
|
|
1179
|
+
workspaces: readonly WorkspaceSummary[],
|
|
1180
|
+
detail: WorkspaceDetail,
|
|
1181
|
+
metas: readonly LessonMeta[],
|
|
1182
|
+
file: string,
|
|
1183
|
+
): Promise<string> {
|
|
1184
|
+
const source = await readFile(
|
|
1185
|
+
join(root, detail.path, TEACH_REFERENCE, file),
|
|
1186
|
+
'utf8',
|
|
1187
|
+
)
|
|
1188
|
+
const title =
|
|
1189
|
+
readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
|
|
1190
|
+
|
|
1191
|
+
const rendered = retargetReferenceLinks(
|
|
1192
|
+
Bun.markdown.html(stripFrontmatter(source), {
|
|
1193
|
+
noHtmlBlocks: true,
|
|
1194
|
+
noHtmlSpans: true,
|
|
1195
|
+
}),
|
|
1196
|
+
new Set(detail.referenceFiles),
|
|
1197
|
+
)
|
|
1198
|
+
const body = /<h1[\s>]/.test(rendered)
|
|
1199
|
+
? rendered
|
|
1200
|
+
: `<h1>${escapeHtml(title)}</h1>\n${rendered}`
|
|
1201
|
+
|
|
1202
|
+
const teachPrefix = '../../'
|
|
1203
|
+
const workspacePrefix = '../'
|
|
1204
|
+
|
|
1205
|
+
const header = renderHeader(
|
|
1206
|
+
[
|
|
1207
|
+
{
|
|
1208
|
+
label: 'Workspaces',
|
|
1209
|
+
href: `${teachPrefix}index.html`,
|
|
1210
|
+
jump: {
|
|
1211
|
+
ariaLabel: 'Switch workspace',
|
|
1212
|
+
entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1213
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
label: workspaceLabel(detail),
|
|
1217
|
+
href: `${workspacePrefix}index.html`,
|
|
1218
|
+
jump: {
|
|
1219
|
+
ariaLabel: 'Jump to a lesson',
|
|
1220
|
+
entries: lessonJumpEntries(metas, workspacePrefix, undefined),
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
{ label: title },
|
|
1224
|
+
],
|
|
1225
|
+
{
|
|
1226
|
+
heading: workspaceLabel(detail),
|
|
1227
|
+
switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1228
|
+
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1229
|
+
items: metas.map((meta, index) => ({
|
|
1230
|
+
ordinal: String(index + 1).padStart(2, '0'),
|
|
1231
|
+
label: meta.title,
|
|
1232
|
+
href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
|
|
1233
|
+
at: false,
|
|
1234
|
+
})),
|
|
1235
|
+
foot: workspaceFoot(detail),
|
|
1236
|
+
},
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
// The sidebar opens as on a lesson, since this page lists no lessons itself.
|
|
1240
|
+
return `${pageHead(title, `${workspacePrefix}${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined, 'lesson', `${REFERENCE_GENERATOR}\n`)}<body>
|
|
1241
|
+
${header}
|
|
1242
|
+
<main class="ref">
|
|
1243
|
+
${body.trim()}
|
|
1244
|
+
</main>
|
|
1245
|
+
${PANE_CLOSE}
|
|
1246
|
+
${renderScripts(false, false)}
|
|
1247
|
+
</body>
|
|
1248
|
+
</html>
|
|
1249
|
+
`
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Deletes a generated reference page whose markdown source is gone, so a
|
|
1254
|
+
* renamed or removed page leaves nothing no contents row reaches.
|
|
1255
|
+
*/
|
|
1256
|
+
async function removeOrphanReferencePages(
|
|
1257
|
+
root: string,
|
|
1258
|
+
detail: WorkspaceDetail,
|
|
1259
|
+
): Promise<void> {
|
|
1260
|
+
const dir = join(root, detail.path, TEACH_REFERENCE)
|
|
1261
|
+
if (!existsSync(dir)) return
|
|
1262
|
+
|
|
1263
|
+
const sources = new Set(detail.referenceFiles.map(renderedReferenceName))
|
|
1264
|
+
const orphans = (await readdir(dir)).filter(
|
|
1265
|
+
(file) => file.endsWith('.html') && !sources.has(file),
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
await Promise.all(
|
|
1269
|
+
orphans.map(async (file) => {
|
|
1270
|
+
const path = join(dir, file)
|
|
1271
|
+
if (REFERENCE_GENERATOR_PATTERN.test(await readFile(path, 'utf8'))) {
|
|
1272
|
+
await rm(path)
|
|
1273
|
+
}
|
|
1274
|
+
}),
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1103
1278
|
const REGIONS = ['style', 'header', 'footnav', 'scripts'] as const
|
|
1104
1279
|
type Region = (typeof REGIONS)[number]
|
|
1105
1280
|
|
|
@@ -1139,21 +1314,44 @@ interface LessonRefused {
|
|
|
1139
1314
|
readonly missing: Region
|
|
1140
1315
|
}
|
|
1141
1316
|
|
|
1142
|
-
/**
|
|
1143
|
-
* Splices the four chrome regions into one lesson file, in a fixed order so a
|
|
1144
|
-
* missing marker is always reported against the same region a session can
|
|
1145
|
-
* check first. Nothing is written when any region is missing, which is what
|
|
1146
|
-
* keeps a partially-spliced file off disk.
|
|
1147
|
-
*/
|
|
1148
1317
|
/**
|
|
1149
1318
|
* A lesson embeds the workspace stylesheet, and an `@import` resolves against
|
|
1150
1319
|
* the lesson's own folder there rather than the assets folder, so it never
|
|
1151
|
-
* loaded anything. The base sheet reaches the page through the `<link
|
|
1320
|
+
* loaded anything. The base sheet reaches the page through the `<link>` the
|
|
1321
|
+
* `style` region carries ahead of the embedded rules.
|
|
1152
1322
|
*/
|
|
1153
1323
|
function stripImports(css: string): string {
|
|
1154
1324
|
return css.replace(/^@import[^;]*;[ \t]*\n?/gm, '')
|
|
1155
1325
|
}
|
|
1156
1326
|
|
|
1327
|
+
const HEADER_CLOSE = '<!-- /canon:teach:header -->'
|
|
1328
|
+
const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* The authored body between the header and footnav regions, wrapped in
|
|
1332
|
+
* `<main>` when it holds none. The skill has a session write the body bare,
|
|
1333
|
+
* and the column width hangs off `main`. A body already holding one is left
|
|
1334
|
+
* alone, which keeps a second run byte-identical.
|
|
1335
|
+
*/
|
|
1336
|
+
function wrapMain(html: string): string {
|
|
1337
|
+
const start = html.indexOf(HEADER_CLOSE)
|
|
1338
|
+
const end = html.indexOf(FOOTNAV_OPEN, start)
|
|
1339
|
+
if (start === -1 || end === -1) return html
|
|
1340
|
+
|
|
1341
|
+
const bodyStart = start + HEADER_CLOSE.length
|
|
1342
|
+
const body = html.slice(bodyStart, end)
|
|
1343
|
+
if (/<main[\s>]/.test(body)) return html
|
|
1344
|
+
|
|
1345
|
+
return `${html.slice(0, bodyStart)}\n<main>\n${body.trim()}\n</main>\n${html.slice(end)}`
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Splices the four chrome regions into one lesson file, in a fixed order so a
|
|
1350
|
+
* missing marker is always reported against the same region a session can
|
|
1351
|
+
* check first, then wraps the authored body in `<main>` where it holds none.
|
|
1352
|
+
* Nothing is written when any region is missing, which is what keeps a
|
|
1353
|
+
* partially-spliced file off disk.
|
|
1354
|
+
*/
|
|
1157
1355
|
async function rewriteLesson(
|
|
1158
1356
|
root: string,
|
|
1159
1357
|
detail: WorkspaceDetail,
|
|
@@ -1164,7 +1362,7 @@ async function rewriteLesson(
|
|
|
1164
1362
|
): Promise<LessonRewritten | LessonRefused> {
|
|
1165
1363
|
const file = metas[index].file
|
|
1166
1364
|
const path = join(root, detail.path, TEACH_LESSONS, file)
|
|
1167
|
-
let html =
|
|
1365
|
+
let html = dropHandWrittenHead(await readFile(path, 'utf8'))
|
|
1168
1366
|
|
|
1169
1367
|
const teachPrefix = '../../'
|
|
1170
1368
|
const workspacePrefix = '../'
|
|
@@ -1180,7 +1378,7 @@ async function rewriteLesson(
|
|
|
1180
1378
|
},
|
|
1181
1379
|
},
|
|
1182
1380
|
{
|
|
1183
|
-
label:
|
|
1381
|
+
label: workspaceLabel(detail),
|
|
1184
1382
|
href: `${workspacePrefix}index.html`,
|
|
1185
1383
|
jump: {
|
|
1186
1384
|
ariaLabel: 'Jump to a lesson',
|
|
@@ -1190,7 +1388,7 @@ async function rewriteLesson(
|
|
|
1190
1388
|
{ label: `Lesson ${index + 1} of ${metas.length}` },
|
|
1191
1389
|
],
|
|
1192
1390
|
{
|
|
1193
|
-
heading:
|
|
1391
|
+
heading: workspaceLabel(detail),
|
|
1194
1392
|
switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1195
1393
|
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1196
1394
|
items: metas.map((meta, i) => ({
|
|
@@ -1210,6 +1408,16 @@ async function rewriteLesson(
|
|
|
1210
1408
|
// the class the script reveals feedback with.
|
|
1211
1409
|
const legacy = html.includes(LEGACY_OPTION)
|
|
1212
1410
|
|
|
1411
|
+
// Linked ahead of the embedded rules so those still win the cascade.
|
|
1412
|
+
// Embedding it instead repeats a sheet carrying its fonts inline in every
|
|
1413
|
+
// lesson. A workspace seeded before the pair has no base sheet to link.
|
|
1414
|
+
const hasBase = existsSync(
|
|
1415
|
+
join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET_BASE),
|
|
1416
|
+
)
|
|
1417
|
+
const baseLink = hasBase
|
|
1418
|
+
? `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET_BASE}">\n`
|
|
1419
|
+
: ''
|
|
1420
|
+
|
|
1213
1421
|
const regions: ReadonlyArray<readonly [Region, string]> = [
|
|
1214
1422
|
// The stepper follows the workspace stylesheet so it wins the cascade at
|
|
1215
1423
|
// equal specificity, which is what reaches a workspace seeded before it.
|
|
@@ -1218,7 +1426,7 @@ async function rewriteLesson(
|
|
|
1218
1426
|
// lesson would lack.
|
|
1219
1427
|
[
|
|
1220
1428
|
'style',
|
|
1221
|
-
`${teachFavicon()}\n<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
|
|
1429
|
+
`${teachFavicon()}\n${baseLink}<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
|
|
1222
1430
|
],
|
|
1223
1431
|
['header', header],
|
|
1224
1432
|
['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
|
|
@@ -1231,7 +1439,7 @@ async function rewriteLesson(
|
|
|
1231
1439
|
html = spliced
|
|
1232
1440
|
}
|
|
1233
1441
|
|
|
1234
|
-
await writeFile(path, html)
|
|
1442
|
+
await writeFile(path, wrapMain(html))
|
|
1235
1443
|
return { ok: true, file }
|
|
1236
1444
|
}
|
|
1237
1445
|
|
|
@@ -1246,10 +1454,22 @@ export interface NavGenerated {
|
|
|
1246
1454
|
readonly root: string
|
|
1247
1455
|
readonly contents: readonly string[]
|
|
1248
1456
|
readonly lessons: number
|
|
1249
|
-
/**
|
|
1457
|
+
/** Reference pages rendered to their `.html` sibling. */
|
|
1458
|
+
readonly reference: number
|
|
1459
|
+
/**
|
|
1460
|
+
* A lesson file missing a chrome marker, refused rather than rewritten, or a
|
|
1461
|
+
* reference page left unrendered on a Bun carrying no markdown renderer.
|
|
1462
|
+
*/
|
|
1250
1463
|
readonly skipped: readonly LessonSkipped[]
|
|
1251
1464
|
}
|
|
1252
1465
|
|
|
1466
|
+
const MARKDOWN_RENDERER = 'Bun.markdown'
|
|
1467
|
+
|
|
1468
|
+
/** An older Bun carries no `Bun.markdown`, and calling it there throws. */
|
|
1469
|
+
function hasMarkdownRenderer(): boolean {
|
|
1470
|
+
return typeof Bun.markdown?.html === 'function'
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1253
1473
|
export type NavOutcome = NavGenerated | TeachRefused
|
|
1254
1474
|
|
|
1255
1475
|
/**
|
|
@@ -1293,6 +1513,8 @@ export async function generateNav(
|
|
|
1293
1513
|
const contents: string[] = []
|
|
1294
1514
|
const skipped: LessonSkipped[] = []
|
|
1295
1515
|
let lessons = 0
|
|
1516
|
+
let reference = 0
|
|
1517
|
+
const canRenderMarkdown = hasMarkdownRenderer()
|
|
1296
1518
|
|
|
1297
1519
|
for (const slug of targetSlugs) {
|
|
1298
1520
|
const found = await readWorkspace(root, slug)
|
|
@@ -1307,10 +1529,31 @@ export async function generateNav(
|
|
|
1307
1529
|
const contentsPath = join(root, detail.path, 'index.html')
|
|
1308
1530
|
await writeFile(
|
|
1309
1531
|
contentsPath,
|
|
1310
|
-
await renderContentsPage(
|
|
1532
|
+
await renderContentsPage(
|
|
1533
|
+
root,
|
|
1534
|
+
listed.workspaces,
|
|
1535
|
+
detail,
|
|
1536
|
+
metas,
|
|
1537
|
+
canRenderMarkdown,
|
|
1538
|
+
),
|
|
1311
1539
|
)
|
|
1312
1540
|
contents.push(relative(root, contentsPath))
|
|
1313
1541
|
|
|
1542
|
+
for (const file of detail.referenceFiles) {
|
|
1543
|
+
const source = join(detail.path, TEACH_REFERENCE, file)
|
|
1544
|
+
if (!canRenderMarkdown) {
|
|
1545
|
+
skipped.push({ file: source, missing: MARKDOWN_RENDERER })
|
|
1546
|
+
continue
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
await writeFile(
|
|
1550
|
+
join(root, detail.path, TEACH_REFERENCE, renderedReferenceName(file)),
|
|
1551
|
+
await renderReferencePage(root, listed.workspaces, detail, metas, file),
|
|
1552
|
+
)
|
|
1553
|
+
reference += 1
|
|
1554
|
+
}
|
|
1555
|
+
await removeOrphanReferencePages(root, detail)
|
|
1556
|
+
|
|
1314
1557
|
const css = stripImports(await readFile(cssPath, 'utf8'))
|
|
1315
1558
|
|
|
1316
1559
|
for (let index = 0; index < metas.length; index += 1) {
|
|
@@ -1339,6 +1582,7 @@ export async function generateNav(
|
|
|
1339
1582
|
root: relative(root, rootPath),
|
|
1340
1583
|
contents,
|
|
1341
1584
|
lessons,
|
|
1585
|
+
reference,
|
|
1342
1586
|
skipped,
|
|
1343
1587
|
}
|
|
1344
1588
|
}
|
package/src/tooling/inject.ts
CHANGED
|
@@ -10,19 +10,30 @@ import { ancestorsFirst, listFiles, type Manifest } from '@/tooling/manifest'
|
|
|
10
10
|
import {
|
|
11
11
|
applyScripts,
|
|
12
12
|
collectDeps,
|
|
13
|
+
collectScripts,
|
|
13
14
|
readPackage,
|
|
14
15
|
serializePackage,
|
|
15
16
|
} from '@/tooling/package'
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
hasSpellConfig,
|
|
19
|
+
isSubfolderTarget,
|
|
20
|
+
isWithheldInSubfolder,
|
|
21
|
+
SUBFOLDER_SPELL_CONFIG,
|
|
22
|
+
subfolderSpellConfig,
|
|
23
|
+
} from '@/tooling/subfolder'
|
|
24
|
+
import { logAdd, logRemove, logStep, logWarn } from '@/ui'
|
|
17
25
|
|
|
18
26
|
export async function injectConfigs(
|
|
19
27
|
chain: readonly Manifest[],
|
|
20
28
|
target: string,
|
|
21
29
|
): Promise<string[]> {
|
|
22
30
|
const applied: string[] = []
|
|
31
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
23
32
|
|
|
24
33
|
for (const manifest of ancestorsFirst(chain)) {
|
|
25
|
-
const files = listFiles(manifest.configsDir)
|
|
34
|
+
const files = listFiles(manifest.configsDir).filter(
|
|
35
|
+
(rel) => !(isSubfolder && isWithheldInSubfolder(rel)),
|
|
36
|
+
)
|
|
26
37
|
if (files.length === 0) continue
|
|
27
38
|
|
|
28
39
|
logStep(`Applying ${manifest.name} configs`)
|
|
@@ -100,6 +111,16 @@ export async function injectSeeds(
|
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
const spellConfig = isSubfolderTarget(target)
|
|
115
|
+
? subfolderSpellConfig(chain)
|
|
116
|
+
: undefined
|
|
117
|
+
if (spellConfig !== undefined && !hasSpellConfig(target)) {
|
|
118
|
+
logStep('Applying subfolder spell config')
|
|
119
|
+
await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
|
|
120
|
+
logAdd(SUBFOLDER_SPELL_CONFIG)
|
|
121
|
+
applied.push(SUBFOLDER_SPELL_CONFIG)
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
return applied
|
|
104
125
|
}
|
|
105
126
|
|
|
@@ -158,8 +179,9 @@ export async function pruneGitignore(
|
|
|
158
179
|
|
|
159
180
|
/**
|
|
160
181
|
* Installs missing dev dependencies, fills package scripts, and merges
|
|
161
|
-
* gitignore entries.
|
|
162
|
-
*
|
|
182
|
+
* gitignore entries. A target with no package.json gets the gitignore alone,
|
|
183
|
+
* plus a warning naming every install it skipped, since seeding a package.json
|
|
184
|
+
* would pick a package manager and a project name on the target's behalf.
|
|
163
185
|
*/
|
|
164
186
|
export async function injectManifest(
|
|
165
187
|
chain: readonly Manifest[],
|
|
@@ -192,4 +214,21 @@ export async function injectManifest(
|
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
await injectGitignore(chain, target)
|
|
217
|
+
|
|
218
|
+
if (!pkg) warnSkippedInstalls(chain, target)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
|
|
222
|
+
const deps = collectDeps(chain, {}).map((dep) => dep.spec)
|
|
223
|
+
const scripts = collectScripts(chain, {}).map((script) => script.key)
|
|
224
|
+
if (deps.length === 0 && scripts.length === 0) return
|
|
225
|
+
|
|
226
|
+
logStep('Skipped installs')
|
|
227
|
+
if (deps.length > 0) {
|
|
228
|
+
logWarn(`Dev dependencies not installed: ${deps.join(', ')}`)
|
|
229
|
+
}
|
|
230
|
+
if (scripts.length > 0) {
|
|
231
|
+
logWarn(`Scripts not added: ${scripts.join(', ')}`)
|
|
232
|
+
}
|
|
233
|
+
logWarn(`No package.json: run 'bun init' in ${target}, then sync again`)
|
|
195
234
|
}
|
package/src/tooling/scan.ts
CHANGED
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
readPackage,
|
|
11
11
|
type ScriptState,
|
|
12
12
|
} from '@/tooling/package'
|
|
13
|
+
import {
|
|
14
|
+
hasSpellConfig,
|
|
15
|
+
isSubfolderTarget,
|
|
16
|
+
isWithheldInSubfolder,
|
|
17
|
+
SUBFOLDER_SPELL_CONFIG,
|
|
18
|
+
subfolderSpellConfig,
|
|
19
|
+
} from '@/tooling/subfolder'
|
|
13
20
|
|
|
14
21
|
export interface ConfigState {
|
|
15
22
|
readonly rel: string
|
|
@@ -27,8 +34,15 @@ export interface EntryState {
|
|
|
27
34
|
readonly state: 'missing' | 'present'
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
export interface WithheldState {
|
|
38
|
+
readonly rel: string
|
|
39
|
+
readonly stack: string
|
|
40
|
+
readonly present: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
export interface ScanResult {
|
|
31
44
|
readonly configs: readonly ConfigState[]
|
|
45
|
+
readonly withheld: readonly WithheldState[]
|
|
32
46
|
readonly seeds: readonly SeedState[]
|
|
33
47
|
readonly scripts: readonly ScriptState[]
|
|
34
48
|
readonly deps: readonly DepState[]
|
|
@@ -48,9 +62,16 @@ function isIdentical(a: string, b: string): boolean {
|
|
|
48
62
|
* category and mirrors the bash: configs, seeds, and scripts resolve nearest
|
|
49
63
|
* stack first, while dependencies and gitignore entries resolve from the
|
|
50
64
|
* furthest ancestor inward.
|
|
65
|
+
*
|
|
66
|
+
* A target below its git toplevel is scanned as a subfolder: a path GitHub
|
|
67
|
+
* reads only at the root moves to `withheld` and counts as no change, and the
|
|
68
|
+
* nested spell config joins the seeds. The scope is read here so every caller
|
|
69
|
+
* of `scan` agrees without passing a flag.
|
|
51
70
|
*/
|
|
52
71
|
export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
72
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
53
73
|
const configs: ConfigState[] = []
|
|
74
|
+
const withheld: WithheldState[] = []
|
|
54
75
|
const seenConfigs = new Set<string>()
|
|
55
76
|
|
|
56
77
|
for (const manifest of chain) {
|
|
@@ -60,6 +81,12 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
60
81
|
|
|
61
82
|
const source = join(manifest.configsDir, rel)
|
|
62
83
|
const dest = join(target, rel)
|
|
84
|
+
|
|
85
|
+
if (isSubfolder && isWithheldInSubfolder(rel)) {
|
|
86
|
+
withheld.push({ rel, stack: manifest.name, present: existsSync(dest) })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
const state = !existsSync(dest)
|
|
64
91
|
? 'new'
|
|
65
92
|
: isIdentical(source, dest)
|
|
@@ -87,6 +114,13 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
87
114
|
}
|
|
88
115
|
}
|
|
89
116
|
|
|
117
|
+
if (isSubfolder && subfolderSpellConfig(chain) !== undefined) {
|
|
118
|
+
seeds.push({
|
|
119
|
+
rel: SUBFOLDER_SPELL_CONFIG,
|
|
120
|
+
state: hasSpellConfig(target) ? 'present' : 'missing',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
90
124
|
const packagePath = join(target, 'package.json')
|
|
91
125
|
const pkg = readPackage(packagePath)
|
|
92
126
|
const scripts = pkg ? collectScripts(chain, pkg) : []
|
|
@@ -107,6 +141,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
107
141
|
|
|
108
142
|
return {
|
|
109
143
|
configs,
|
|
144
|
+
withheld,
|
|
110
145
|
seeds,
|
|
111
146
|
scripts,
|
|
112
147
|
deps,
|