@erclx/canon 4.115.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.
@@ -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
- return lede ? <p class="lede">{children}</p> : <p>{children}</p>
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
+ }
@@ -13,7 +13,7 @@ import {
13
13
  const LESSON_WIDTH = 4
14
14
 
15
15
  /** The ordinal a lesson filename opens with, which fixes its read order. */
16
- const LESSON_NUMBER = /^(\d{4})-/
16
+ export const LESSON_NUMBER = /^(\d{4})-/
17
17
 
18
18
  const LESSON_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
19
19
 
package/src/teach/nav.ts CHANGED
@@ -10,7 +10,9 @@ import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
10
10
  import { stripFrontmatter } from '@/frontmatter'
11
11
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
12
12
  import { PROJECT_ROOT } from '@/project-root'
13
+ import { linkLessonReferences } from '@/teach/cross-references'
13
14
  import { TEACH_FONT_FACES } from '@/teach/fonts'
15
+ import { LESSON_NUMBER } from '@/teach/lesson'
14
16
  import {
15
17
  listWorkspaces,
16
18
  readWorkspace,
@@ -1306,6 +1308,7 @@ function spliceRegion(
1306
1308
  interface LessonRewritten {
1307
1309
  readonly ok: true
1308
1310
  readonly file: string
1311
+ readonly unresolved: readonly string[]
1309
1312
  }
1310
1313
 
1311
1314
  interface LessonRefused {
@@ -1334,15 +1337,57 @@ const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
1334
1337
  * alone, which keeps a second run byte-identical.
1335
1338
  */
1336
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 {
1337
1352
  const start = html.indexOf(HEADER_CLOSE)
1338
1353
  const end = html.indexOf(FOOTNAV_OPEN, start)
1339
- if (start === -1 || end === -1) return html
1354
+ if (start === -1 || end === -1) return undefined
1355
+ return { start: start + HEADER_CLOSE.length, end }
1356
+ }
1340
1357
 
1341
- const bodyStart = start + HEADER_CLOSE.length
1342
- const body = html.slice(bodyStart, end)
1343
- if (/<main[\s>]/.test(body)) return html
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: [] }
1344
1381
 
1345
- return `${html.slice(0, bodyStart)}\n<main>\n${body.trim()}\n</main>\n${html.slice(end)}`
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
+ }
1346
1391
  }
1347
1392
 
1348
1393
  /**
@@ -1359,6 +1404,7 @@ async function rewriteLesson(
1359
1404
  metas: readonly LessonMeta[],
1360
1405
  workspaces: readonly WorkspaceSummary[],
1361
1406
  css: string,
1407
+ targets: ReadonlyMap<string, string>,
1362
1408
  ): Promise<LessonRewritten | LessonRefused> {
1363
1409
  const file = metas[index].file
1364
1410
  const path = join(root, detail.path, TEACH_LESSONS, file)
@@ -1439,8 +1485,9 @@ async function rewriteLesson(
1439
1485
  html = spliced
1440
1486
  }
1441
1487
 
1442
- await writeFile(path, wrapMain(html))
1443
- return { ok: true, file }
1488
+ const linked = linkAuthoredRegion(wrapMain(html), targets, file)
1489
+ await writeFile(path, linked.html)
1490
+ return { ok: true, file, unresolved: linked.unresolved }
1444
1491
  }
1445
1492
 
1446
1493
  export interface LessonSkipped {
@@ -1461,6 +1508,17 @@ export interface NavGenerated {
1461
1508
  * reference page left unrendered on a Bun carrying no markdown renderer.
1462
1509
  */
1463
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
1464
1522
  }
1465
1523
 
1466
1524
  const MARKDOWN_RENDERER = 'Bun.markdown'
@@ -1512,6 +1570,7 @@ export async function generateNav(
1512
1570
 
1513
1571
  const contents: string[] = []
1514
1572
  const skipped: LessonSkipped[] = []
1573
+ const unresolved: LessonReferenceUnresolved[] = []
1515
1574
  let lessons = 0
1516
1575
  let reference = 0
1517
1576
  const canRenderMarkdown = hasMarkdownRenderer()
@@ -1555,6 +1614,7 @@ export async function generateNav(
1555
1614
  await removeOrphanReferencePages(root, detail)
1556
1615
 
1557
1616
  const css = stripImports(await readFile(cssPath, 'utf8'))
1617
+ const targets = lessonTargets(detail.lessonFiles)
1558
1618
 
1559
1619
  for (let index = 0; index < metas.length; index += 1) {
1560
1620
  const outcome = await rewriteLesson(
@@ -1564,10 +1624,17 @@ export async function generateNav(
1564
1624
  metas,
1565
1625
  listed.workspaces,
1566
1626
  css,
1627
+ targets,
1567
1628
  )
1568
1629
 
1569
1630
  if (outcome.ok) {
1570
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
+ }
1571
1638
  } else {
1572
1639
  skipped.push({
1573
1640
  file: join(detail.path, TEACH_LESSONS, outcome.file),
@@ -1584,5 +1651,6 @@ export async function generateNav(
1584
1651
  lessons,
1585
1652
  reference,
1586
1653
  skipped,
1654
+ unresolved,
1587
1655
  }
1588
1656
  }
@@ -1,6 +1,7 @@
1
1
  import { Heading } from '@/teach/components/heading'
2
2
  import { List } from '@/teach/components/list'
3
3
  import { Paragraph } from '@/teach/components/paragraph'
4
+ import { type Reference, Refs } from '@/teach/components/refs'
4
5
  import { render } from '@/teach/html/jsx-runtime'
5
6
  import type { TeachRefused } from '@/teach/workspace'
6
7
 
@@ -10,12 +11,14 @@ export type LessonBlock =
10
11
  readonly type: 'paragraph'
11
12
  readonly text: string
12
13
  readonly lede?: boolean
14
+ readonly cites?: readonly number[]
13
15
  }
14
16
  | {
15
17
  readonly type: 'list'
16
18
  readonly items: readonly string[]
17
19
  readonly ordered?: boolean
18
20
  }
21
+ | { readonly type: 'refs'; readonly items: readonly Reference[] }
19
22
  | { readonly type: 'raw'; readonly html: string }
20
23
 
21
24
  export interface RenderRendered {
@@ -25,7 +28,9 @@ export interface RenderRendered {
25
28
 
26
29
  export type RenderOutcome = RenderRendered | TeachRefused
27
30
 
28
- const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'raw'] as const
31
+ const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'refs', 'raw'] as const
32
+
33
+ const LINK_PROTOCOLS = new Set(['http:', 'https:'])
29
34
 
30
35
  function isBlockType(value: unknown): value is LessonBlock['type'] {
31
36
  return (BLOCK_TYPES as readonly string[]).includes(value as string)
@@ -48,10 +53,46 @@ function isStringArray(value: unknown): value is readonly string[] {
48
53
  return Array.isArray(value) && value.every(isString)
49
54
  }
50
55
 
56
+ function isCiteArray(value: unknown): value is readonly number[] {
57
+ return (
58
+ Array.isArray(value) &&
59
+ value.length > 0 &&
60
+ value.every((cite) => Number.isInteger(cite) && cite > 0)
61
+ )
62
+ }
63
+
64
+ function isLinkUrl(value: string): boolean {
65
+ if (!URL.canParse(value)) return false
66
+ return LINK_PROTOCOLS.has(new URL(value).protocol)
67
+ }
68
+
69
+ function readReference(item: unknown, position: number): Reference | string {
70
+ if (typeof item !== 'object' || item === null) {
71
+ return `reference ${position} is not an object`
72
+ }
73
+ const { title, url, note } = item as Record<string, unknown>
74
+ if (!isString(title) || title === '') {
75
+ return `reference ${position} needs a non-empty string title`
76
+ }
77
+ if (note !== undefined && !isString(note)) {
78
+ return `reference ${position} note must be a string`
79
+ }
80
+ if (url !== undefined && !(isString(url) && isLinkUrl(url))) {
81
+ return `reference ${position} url must be an http or https URL`
82
+ }
83
+ return { title, url, note }
84
+ }
85
+
86
+ interface RenderedBlock {
87
+ readonly html: string
88
+ readonly cites?: readonly number[]
89
+ readonly referenceCount?: number
90
+ }
91
+
51
92
  function renderBlock(
52
93
  block: unknown,
53
94
  index: number,
54
- ): { readonly html: string } | TeachRefused {
95
+ ): RenderedBlock | TeachRefused {
55
96
  if (typeof block !== 'object' || block === null || !('type' in block)) {
56
97
  return badBlock(index, 'not an object carrying a type')
57
98
  }
@@ -83,8 +124,24 @@ function renderBlock(
83
124
  if (fields.lede !== undefined && typeof fields.lede !== 'boolean') {
84
125
  return badBlock(index, 'paragraph lede must be a boolean')
85
126
  }
127
+ if (fields.cites !== undefined && !isCiteArray(fields.cites)) {
128
+ return badBlock(
129
+ index,
130
+ 'paragraph cites must be a non-empty array of positive integers',
131
+ )
132
+ }
133
+ if (fields.lede === true && fields.cites !== undefined) {
134
+ return badBlock(index, 'a lede paragraph cannot carry cites')
135
+ }
86
136
  return {
87
- html: render(Paragraph({ lede: fields.lede, children: fields.text })),
137
+ html: render(
138
+ Paragraph({
139
+ lede: fields.lede,
140
+ cites: fields.cites,
141
+ children: fields.text,
142
+ }),
143
+ ),
144
+ cites: fields.cites,
88
145
  }
89
146
  }
90
147
  case 'list': {
@@ -98,6 +155,21 @@ function renderBlock(
98
155
  html: render(List({ ordered: fields.ordered, items: fields.items })),
99
156
  }
100
157
  }
158
+ case 'refs': {
159
+ if (!Array.isArray(fields.items) || fields.items.length === 0) {
160
+ return badBlock(index, 'refs needs a non-empty items array')
161
+ }
162
+ const items: Reference[] = []
163
+ for (const [position, item] of fields.items.entries()) {
164
+ const reference = readReference(item, position + 1)
165
+ if (isString(reference)) return badBlock(index, reference)
166
+ items.push(reference)
167
+ }
168
+ return {
169
+ html: render(Refs({ items })),
170
+ referenceCount: items.length,
171
+ }
172
+ }
101
173
  case 'raw': {
102
174
  if (!isString(fields.html)) {
103
175
  return badBlock(index, 'raw needs a string html')
@@ -112,15 +184,31 @@ function renderBlock(
112
184
  * `lesson.test.tsx` composes as JSX, so a real lesson and the component test
113
185
  * share one rendering mechanism. Every block past the first refusal
114
186
  * goes unread, matching how a malformed JSON parse refuses the whole call.
187
+ * Citations resolve across blocks once every block has rendered, so a marker
188
+ * with no reference refuses rather than leaving a dead anchor.
115
189
  */
116
190
  export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
117
- const rendered: string[] = []
191
+ const rendered: RenderedBlock[] = []
192
+ let referenceCount = 0
118
193
 
119
194
  for (const [index, block] of blocks.entries()) {
120
195
  const result = renderBlock(block, index)
121
196
  if ('ok' in result) return result
122
- rendered.push(result.html)
197
+ if (result.referenceCount !== undefined) {
198
+ if (referenceCount > 0) {
199
+ return badBlock(index, 'a lesson carries one refs block')
200
+ }
201
+ referenceCount = result.referenceCount
202
+ }
203
+ rendered.push(result)
204
+ }
205
+
206
+ for (const [index, block] of rendered.entries()) {
207
+ const dangling = block.cites?.find((cite) => cite > referenceCount)
208
+ if (dangling !== undefined) {
209
+ return badBlock(index, `cite ${dangling} resolves to no reference`)
210
+ }
123
211
  }
124
212
 
125
- return { ok: true, html: rendered.join('\n') }
213
+ return { ok: true, html: rendered.map((block) => block.html).join('\n') }
126
214
  }