explico 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +30 -4
  6. package/dist/compiler/cli.js +79 -10
  7. package/dist/compiler/collections.js +16 -3
  8. package/dist/compiler/exports/standalone-html.js +13 -0
  9. package/dist/compiler/logging.d.ts +119 -0
  10. package/dist/compiler/logging.js +108 -0
  11. package/dist/compiler/manifest.d.ts +7 -3
  12. package/dist/compiler/manifest.js +1 -1
  13. package/dist/reference/base-abbreviations.mjs +8 -2
  14. package/dist/reference/consistency.d.ts +8 -10
  15. package/dist/reference/consistency.js +3 -47
  16. package/dist/reference/delimiter-size.d.ts +48 -0
  17. package/dist/reference/delimiter-size.js +153 -0
  18. package/dist/reference/diagrams-validate.d.ts +1 -1
  19. package/dist/reference/equations-validate.d.ts +2 -2
  20. package/dist/reference/equations-validate.js +1 -1
  21. package/dist/reference/equations.d.mts +9 -3
  22. package/dist/reference/equations.mjs +7 -1
  23. package/dist/reference/figures-validate.d.ts +1 -1
  24. package/dist/reference/gate-abbreviations.js +2 -2
  25. package/dist/reference/gate-heading-length.js +2 -2
  26. package/dist/reference/gate-math.d.ts +25 -1
  27. package/dist/reference/gate-math.js +50 -9
  28. package/dist/reference/gate-prose-notation.d.ts +12 -1
  29. package/dist/reference/gate-prose-notation.js +5 -1
  30. package/dist/reference/katex-delimiters.d.mts +15 -0
  31. package/dist/reference/katex-delimiters.mjs +125 -0
  32. package/dist/reference/katex-options.mjs +4 -0
  33. package/dist/reference/keyed-float-validate.d.ts +2 -2
  34. package/dist/reference/keyed-float-validate.js +1 -1
  35. package/dist/reference/math-glyphs.mjs +16 -2
  36. package/dist/reference/references.js +2 -1
  37. package/dist/reference/registry.js +1 -1
  38. package/dist/reference/rehype-base-links.d.mts +23 -0
  39. package/dist/reference/rehype-base-links.mjs +40 -0
  40. package/dist/reference/remark-abbreviations.mjs +10 -12
  41. package/dist/reference/remark-citation.mjs +0 -0
  42. package/dist/reference/render-lab-math.d.ts +18 -1
  43. package/dist/reference/render-lab-math.js +11 -4
  44. package/dist/reference/tables-validate.d.ts +1 -1
  45. package/dist/reference/types.d.ts +8 -1
  46. package/docs/VERSIONING.md +2 -2
  47. package/docs/authoring/README.md +12 -2
  48. package/docs/authoring/agents.md +7 -3
  49. package/docs/authoring/migrating.md +514 -0
  50. package/docs/authoring/notation.md +157 -7
  51. package/docs/authoring/prose.md +64 -0
  52. package/docs/katex-bracket-scaling.md +191 -0
  53. package/package.json +2 -1
  54. package/src/compiler/astro.ts +89 -0
  55. package/src/compiler/cli.ts +167 -12
  56. package/src/compiler/collections.ts +33 -3
  57. package/src/compiler/exports/standalone-html.ts +15 -0
  58. package/src/compiler/logging.ts +259 -0
  59. package/src/compiler/manifest.ts +7 -3
  60. package/src/components/memo/Memo.astro +2 -5
  61. package/src/components/memo/ShortNote.astro +2 -5
  62. package/src/components/notation/NotationFormula.astro +199 -10
  63. package/src/components/notation/NotationGlossary.astro +100 -58
  64. package/src/components/notation/NotationLayer.astro +83 -81
  65. package/src/components/notation/render-notation-math.ts +111 -10
  66. package/src/components/scroll-animation.ts +160 -0
  67. package/src/components/starlight/AboutPanel.astro +17 -2
  68. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  69. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  70. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  71. package/src/components/starlight/LessonFooter.astro +2 -0
  72. package/src/reference/base-abbreviations.mjs +8 -2
  73. package/src/reference/consistency.ts +10 -66
  74. package/src/reference/delimiter-size.ts +279 -0
  75. package/src/reference/diagrams-validate.ts +1 -1
  76. package/src/reference/equations-validate.ts +3 -3
  77. package/src/reference/equations.mjs +7 -1
  78. package/src/reference/figures-validate.ts +1 -1
  79. package/src/reference/gate-abbreviations.ts +2 -2
  80. package/src/reference/gate-heading-length.ts +2 -2
  81. package/src/reference/gate-math.ts +91 -8
  82. package/src/reference/gate-prose-notation.ts +15 -1
  83. package/src/reference/katex-delimiters.mjs +125 -0
  84. package/src/reference/katex-options.mjs +4 -0
  85. package/src/reference/keyed-float-validate.ts +3 -3
  86. package/src/reference/math-glyphs.mjs +16 -2
  87. package/src/reference/references.ts +2 -1
  88. package/src/reference/registry.ts +1 -1
  89. package/src/reference/rehype-base-links.mjs +40 -0
  90. package/src/reference/remark-abbreviations.mjs +10 -12
  91. package/src/reference/remark-citation.mjs +0 -0
  92. package/src/reference/render-lab-math.ts +25 -2
  93. package/src/reference/tables-validate.ts +1 -1
  94. package/src/reference/types.ts +8 -1
  95. package/src/styles/global.css +119 -14
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Rule N17's engine half: make `\left`/`\right` grow strictly outward.
3
+ *
4
+ * Importing this module for its side effect installs a KaTeX whose
5
+ * `\delimitershortfall` is `-1pt` instead of the stock `5pt`, so a scalable
6
+ * delimiter pair is always *strictly* taller than what it wraps and nesting
7
+ * depth shows up on its own. See `docs/katex-bracket-scaling.md` for the
8
+ * derivation and `docs/authoring/notation.md#n17` for the authoring rule.
9
+ *
10
+ * ## Why this is not a `patches/` file
11
+ *
12
+ * `delimiterExtend` is a function-local `const` inside `makeLeftRightDelim`.
13
+ * Nothing KaTeX exports reaches it — `__defineMacro`, `__defineFunction`,
14
+ * `__defineSymbol`, `__setFontMetrics` and the settings schema all sit above
15
+ * the layout engine — so the constant can only be changed in the module's own
16
+ * source. A `pnpm patch` does that, but a patch is project-local: it cannot
17
+ * travel inside the published `explico` artifact, so every downstream course
18
+ * would have to reapply it or silently render flat nests.
19
+ *
20
+ * So the engine does it in code instead. It reads KaTeX's own ESM bundle,
21
+ * rewrites that one constant, imports the result as a module, and moves the
22
+ * rendering entry points onto the shared `katex` object every consumer in the
23
+ * process already imported. KaTeX's default export is a plain mutable object
24
+ * and `rehype-katex` reads `katex.renderToString` at call time, so the swap
25
+ * reaches lesson-body math without forking the plugin.
26
+ *
27
+ * ## Why this is safe to rely on
28
+ *
29
+ * The assumptions above (a matching constant, a mutable export, a bundle with
30
+ * no imports of its own) are all checked here, and `assertDelimiterGrowth()`
31
+ * renders a known nest afterwards and throws when the sizes do not actually
32
+ * increase. A course therefore either renders with engine-sized delimiters or
33
+ * fails its build saying so — it cannot quietly get the stock behaviour.
34
+ */
35
+ import { readFileSync } from 'node:fs';
36
+ import { createRequire } from 'node:module';
37
+ import katex from 'katex';
38
+
39
+ /** TeX's `\delimitershortfall`, in points, as KaTeX hard-codes it. */
40
+ const STOCK_SHORTFALL = '5.0';
41
+ /** The negative shortfall that forces strict outward growth. */
42
+ const GROWN_SHORTFALL = '-1.0';
43
+
44
+ const needle = (points) =>
45
+ `var delimiterExtend = ${points} / options.fontMetrics().ptPerEm;`;
46
+
47
+ /** The entry points that build a delimiter and must come from the rewrite. */
48
+ const RENDER_EXPORTS = [
49
+ 'render',
50
+ 'renderToString',
51
+ '__renderToDomTree',
52
+ '__renderToHTMLTree',
53
+ ];
54
+
55
+ /**
56
+ * Exported for the test that pins both failure messages — a KaTeX upgrade that
57
+ * moves the constant, and a leftover `patches/` file, are the two ways this
58
+ * stops being able to install itself.
59
+ */
60
+ export function rewriteBundle(source) {
61
+ const stock = needle(STOCK_SHORTFALL);
62
+ if (!source.includes(stock)) {
63
+ // Either the constant moved or someone already rewrote it. Both mean the
64
+ // assumption this module rests on no longer holds for this KaTeX build.
65
+ const alreadyGrown = source.includes(needle(GROWN_SHORTFALL));
66
+ throw new Error(
67
+ alreadyGrown
68
+ ? `explico: katex ${katex.version} already carries the -1pt delimiter ` +
69
+ 'shortfall (a patches/ file?). Remove that patch — the engine ' +
70
+ 'installs it in code (src/reference/katex-delimiters.mjs).'
71
+ : `explico: cannot install the N17 delimiter sizing into katex ` +
72
+ `${katex.version}: the \\delimitershortfall constant ` +
73
+ `(${stock}) is not in its ESM bundle. Re-derive the one-line ` +
74
+ 'change against the new bundle — see docs/katex-bracket-scaling.md.',
75
+ );
76
+ }
77
+ return source.replace(stock, needle(GROWN_SHORTFALL));
78
+ }
79
+
80
+ async function loadGrownKatex() {
81
+ const require = createRequire(import.meta.url);
82
+ const source = readFileSync(require.resolve('katex/dist/katex.mjs'), 'utf8');
83
+ // The bundle is self-contained, so it can be re-imported from a data URL
84
+ // with no base to resolve relative specifiers against.
85
+ const rewritten = rewriteBundle(source);
86
+ const module = await import(
87
+ `data:text/javascript;base64,${Buffer.from(rewritten).toString('base64')}`
88
+ );
89
+ return module.default;
90
+ }
91
+
92
+ /**
93
+ * Render a known nest and confirm the outer pair really is larger than the
94
+ * inner one. Cheap, and it turns every way this could go wrong — an upstream
95
+ * refactor, a frozen export, an import that beat us to it — into one loud
96
+ * failure at the point of installation.
97
+ */
98
+ export function assertDelimiterGrowth(instance = katex) {
99
+ const html = instance.renderToString(
100
+ String.raw`\left[1+\left(\frac{a}{b}\right)\right]`,
101
+ { displayMode: true, output: 'html' },
102
+ );
103
+ const sizes = [...html.matchAll(/delimsizing size(\d)/g)].map((m) =>
104
+ Number(m[1]),
105
+ );
106
+ // Two pairs, so four glyphs: outer, inner, inner, outer.
107
+ const grew = sizes.length === 4 && sizes[0] > sizes[1];
108
+ if (!grew) {
109
+ throw new Error(
110
+ 'explico: the N17 delimiter sizing did not take effect — a nested ' +
111
+ `\\left…\\right pair rendered as [${sizes.join(', ')}] instead of ` +
112
+ 'growing outward. Something rendered math before ' +
113
+ "'explico/reference/katex-delimiters.mjs' was imported, or katex " +
114
+ 'changed shape. See docs/katex-bracket-scaling.md.',
115
+ );
116
+ }
117
+ }
118
+
119
+ const grown = await loadGrownKatex();
120
+ for (const name of RENDER_EXPORTS) {
121
+ if (typeof grown[name] === 'function') katex[name] = grown[name];
122
+ }
123
+ assertDelimiterGrowth();
124
+
125
+ export default katex;
@@ -1,3 +1,7 @@
1
+ // Side-effect import: installs rule N17's delimiter sizing onto the shared
2
+ // `katex` module before anything renders. A course wires this file into its
3
+ // `astro.config.mjs`, so `rehype-katex` inherits it without a patch.
4
+ import './katex-delimiters.mjs';
1
5
  import { NOTATION_KEY_PATTERN } from './remark-notation.mjs';
2
6
 
3
7
  export const EXPLAIN_MACRO = String.raw`\htmlData{notation-key=#1}{#2}`;
@@ -7,7 +7,7 @@
7
7
  * - `<prefix>-duplicate-key` (error): an `id` is unique per lesson.
8
8
  * - `<prefix>-ref-resolves` (error): every `[[<prefix>-key]]` /
9
9
  * `[[slug#<prefix>-key]]` points at the matching keyed float with that `id`.
10
- * - `<prefix>-key-unused` (warning): a keyed float never referenced.
10
+ * - `<prefix>-key-unused` (info): a keyed float is never referenced.
11
11
  *
12
12
  * `tables-validate.ts`, `figures-validate.ts`, and `diagrams-validate.ts` are
13
13
  * thin wrappers over this so callers import a kind-specific validator with
@@ -38,7 +38,7 @@ interface KeyedFloatRefRecord {
38
38
  }
39
39
  interface KeyedFloatDiagnostic {
40
40
  readonly code: string;
41
- readonly severity: 'error' | 'warning';
41
+ readonly severity: 'error' | 'warning' | 'info';
42
42
  readonly message: string;
43
43
  readonly file?: string;
44
44
  readonly lessonId?: string;
@@ -93,7 +93,7 @@ function validateKeyedFloats(lessons, kind) {
93
93
  if (referenced.has(`${label.slug}#${label.key}`)) continue;
94
94
  diagnostics.push({
95
95
  code: `${prefix}-key-unused`,
96
- severity: "warning",
96
+ severity: "info",
97
97
  message: `${label.lessonId} has a ${noun} id="${label.key}" (${label.number}) but nothing references it with [[${prefix}-${label.key}]]`,
98
98
  file: label.slug,
99
99
  lessonId: label.lessonId,
@@ -1,4 +1,4 @@
1
- import katex from 'katex';
1
+ import katex from './katex-delimiters.mjs';
2
2
 
3
3
  import { BASE_LIBRARY } from './base-library.mjs';
4
4
  import { PARENTHESIZED_FUNCTIONS } from './base-operators.mjs';
@@ -386,6 +386,19 @@ function trimTrailingWhitespace(latex, start, end) {
386
386
  return trimmed;
387
387
  }
388
388
 
389
+ /**
390
+ * KaTeX gives a node it produced by expanding a macro a `loc` from the lexer
391
+ * over the *macro body*, not over the source being scanned: `\vdots` expands
392
+ * to `{\varvdots\rule{0pt}{15pt}}`, and its `\varvdots` atom arrives with
393
+ * `loc.start = 1`, `loc.end = 10` — offsets into that 27-character body.
394
+ * Those numbers are valid indices into any longer source, so they read as a
395
+ * real span pointing at unrelated text. Every `loc` therefore has to prove it
396
+ * belongs to the string this pass parsed before its offsets are believed.
397
+ */
398
+ function isAuthoredLocation(loc, latex) {
399
+ return loc?.lexer?.input === latex;
400
+ }
401
+
389
402
  function nodeSpan(node, latex) {
390
403
  let start = Number.POSITIVE_INFINITY;
391
404
  let end = Number.NEGATIVE_INFINITY;
@@ -397,7 +410,8 @@ function nodeSpan(node, latex) {
397
410
  if (
398
411
  value.loc &&
399
412
  Number.isInteger(value.loc.start) &&
400
- Number.isInteger(value.loc.end)
413
+ Number.isInteger(value.loc.end) &&
414
+ isAuthoredLocation(value.loc, latex)
401
415
  ) {
402
416
  start = Math.min(start, value.loc.start);
403
417
  end = Math.max(end, value.loc.end);
@@ -28,7 +28,8 @@ function compareDiagnostics(left, right) {
28
28
  return severityRank(left.severity) - severityRank(right.severity) || left.code.localeCompare(right.code) || (left.lessonId ?? "").localeCompare(right.lessonId ?? "") || (left.key ?? "").localeCompare(right.key ?? "") || (left.definitionId ?? "").localeCompare(right.definitionId ?? "") || compareSourceSpans(left.source, right.source) || left.message.localeCompare(right.message);
29
29
  }
30
30
  function severityRank(severity) {
31
- return severity === "error" ? 0 : 1;
31
+ if (severity === "error") return 0;
32
+ return severity === "warning" ? 1 : 2;
32
33
  }
33
34
  export {
34
35
  NOTATION_KEY_PATTERN,
@@ -144,7 +144,7 @@ function buildNotationRegistry(rawInput) {
144
144
  if (!reachableDefinitions.has(candidate.id)) {
145
145
  diagnostics.push({
146
146
  code: "unused-definition",
147
- severity: "warning",
147
+ severity: "info",
148
148
  message: `${candidate.id} is not reachable from any lesson reference`,
149
149
  key: candidate.input.key,
150
150
  lessonId: candidate.kind === "local" ? candidate.lesson.lessonId : void 0,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Prefixes root-relative `<a href="/...">` links in rendered lesson content
3
+ * with the course's Astro `base` path.
4
+ *
5
+ * A course deployed under a subpath (any GitHub Pages *project* site, e.g.
6
+ * `base: '/my-course'`) gets that prefix applied automatically to Starlight's
7
+ * own generated navigation — sidebar, pagination, breadcrumbs — because those
8
+ * are rendered by Starlight's own `.astro` components, which call its
9
+ * internal `pathWithBase()` helper. They are **not** applied to anything
10
+ * produced by the Markdown/MDX → rehype pipeline: a lesson's own prose links
11
+ * (`[text](/other-lesson/)`), `remark-notation.mjs`'s equation cross-
12
+ * references, and `remark-citation.mjs`'s citation links are all root-
13
+ * relative absolute paths written or generated without the base in mind, so
14
+ * without this plugin every one of them 404s on a subpath deployment. A
15
+ * lesson's Starlight frontmatter (e.g. `hero.actions[].link`) is untouched
16
+ * either way — that YAML never enters this pipeline — so write those as
17
+ * paths relative to the current page instead.
18
+ *
19
+ * A no-op when the course has no base (`base` omitted or `'/'`, e.g. local
20
+ * dev or a GitHub *user/org* Pages site), so a course can wire this in
21
+ * unconditionally and it only does anything on the deployments that need it.
22
+ */
23
+ export default function rehypeBaseLinks(base: any): (tree: any) => void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Prefixes root-relative `<a href="/...">` links in rendered lesson content
3
+ * with the course's Astro `base` path.
4
+ *
5
+ * A course deployed under a subpath (any GitHub Pages *project* site, e.g.
6
+ * `base: '/my-course'`) gets that prefix applied automatically to Starlight's
7
+ * own generated navigation — sidebar, pagination, breadcrumbs — because those
8
+ * are rendered by Starlight's own `.astro` components, which call its
9
+ * internal `pathWithBase()` helper. They are **not** applied to anything
10
+ * produced by the Markdown/MDX → rehype pipeline: a lesson's own prose links
11
+ * (`[text](/other-lesson/)`), `remark-notation.mjs`'s equation cross-
12
+ * references, and `remark-citation.mjs`'s citation links are all root-
13
+ * relative absolute paths written or generated without the base in mind, so
14
+ * without this plugin every one of them 404s on a subpath deployment. A
15
+ * lesson's Starlight frontmatter (e.g. `hero.actions[].link`) is untouched
16
+ * either way — that YAML never enters this pipeline — so write those as
17
+ * paths relative to the current page instead.
18
+ *
19
+ * A no-op when the course has no base (`base` omitted or `'/'`, e.g. local
20
+ * dev or a GitHub *user/org* Pages site), so a course can wire this in
21
+ * unconditionally and it only does anything on the deployments that need it.
22
+ */
23
+ export default function rehypeBaseLinks(base) {
24
+ const prefix = base && base !== '/' ? base.replace(/\/+$/, '') : '';
25
+
26
+ return function transform(tree) {
27
+ if (!prefix) return;
28
+ visit(tree);
29
+ };
30
+
31
+ function visit(node) {
32
+ if (node.type === 'element' && node.tagName === 'a') {
33
+ const href = node.properties?.href;
34
+ if (typeof href === 'string' && href.startsWith('/') && !href.startsWith('//')) {
35
+ node.properties.href = prefix + href;
36
+ }
37
+ }
38
+ if (Array.isArray(node.children)) node.children.forEach(visit);
39
+ }
40
+ }
@@ -5,12 +5,15 @@
5
5
  * requires every abbreviation-shaped word in prose to either be universal or
6
6
  * carry a `content/abbreviations/*.md` entry, so the literal written form
7
7
  * (`abbr`, or one of its `aliases`) is itself the trigger. This plugin scans
8
- * ordinary prose for that literal text and wraps the **first** occurrence per
9
- * document in a real link (`/glossary/#abbr-<key>`, so it still navigates
10
- * with no JS) carrying the `data-abbr-key` an island script reads to render a
11
- * hover/click card, exactly the way `remarkCitation` wraps `[@id]` markers.
12
- * Later occurrences of the same term on the same page stay plain text, so a
13
- * common short word isn't turned into a link everywhere it appears.
8
+ * ordinary prose for that literal text and wraps **every** occurrence in a
9
+ * real link (`/glossary/#abbr-<key>`, so it still navigates with no JS)
10
+ * carrying the `data-abbr-key` an island script reads to render a hover/click
11
+ * card, exactly the way `remarkCitation` wraps `[@id]` markers. A reader who
12
+ * deep-links into a mid-page heading — the sidebar TOC, a heading anchor, an
13
+ * external link — sees the same hoverable cue no matter which occurrence they
14
+ * land on first, rather than only the earliest one in document order.
15
+ * `AbbreviationLayer.astro` already resolves every marker independently by
16
+ * `data-abbr-key`, so wrapping every occurrence needs no island-side change.
14
17
  *
15
18
  * Matches only ever come from the known table by construction, so unlike
16
19
  * citation's `[@id]` there is no "unknown id" failure path here.
@@ -118,10 +121,6 @@ export default function remarkAbbreviations(options = {}) {
118
121
  const { regex, keyByMatch } = buildMatcher(suppliedAbbreviations);
119
122
  if (!regex) return;
120
123
 
121
- // Fresh per file, same lifetime as citation's own `pairs` Map: only the
122
- // first occurrence of a given key on this page gets wrapped.
123
- const usedKeys = new Set();
124
-
125
124
  const replaceText = (value) => {
126
125
  regex.lastIndex = 0;
127
126
  const output = [];
@@ -131,13 +130,12 @@ export default function remarkAbbreviations(options = {}) {
131
130
 
132
131
  while ((match = regex.exec(value))) {
133
132
  const key = keyByMatch.get(match[0]);
134
- if (!key || usedKeys.has(key)) continue;
133
+ if (!key) continue;
135
134
 
136
135
  if (match.index > cursor) {
137
136
  output.push({ type: 'text', value: value.slice(cursor, match.index) });
138
137
  }
139
138
  output.push(termNode(key, match[0], base));
140
- usedKeys.add(key);
141
139
  changed = true;
142
140
  cursor = match.index + match[0].length;
143
141
  }
Binary file
@@ -9,7 +9,24 @@ interface RenderedLabMath {
9
9
  readonly slots: readonly string[];
10
10
  /** Notation keys the template resolved to, for diagnostics. */
11
11
  readonly keys: readonly string[];
12
+ /**
13
+ * Plain, copyable LaTeX — every explained glyph resolved to its bare
14
+ * symbol, every `\slot{name}{…}` replaced by `slotPlaceholder(name)` — so
15
+ * the island can substitute each slot's *current* live value in and keep
16
+ * `[data-notation-formula]`'s `data-latex` accurate as the reader drags a
17
+ * control, the same "click the equation to copy it" gesture a static
18
+ * lesson or glossary equation gets (`NotationLayer.astro`).
19
+ */
20
+ readonly plainLatexTemplate: string;
12
21
  }
22
+ /**
23
+ * A slot's placeholder inside `plainLatexTemplate` — distinct from the
24
+ * numeric sentinel `sentinelFor` below uses internally (that one only has to
25
+ * survive a single notation-binding pass at build time; this one is the
26
+ * stable, name-keyed token an island substitutes with a live value at
27
+ * runtime, so it's exported for that purpose).
28
+ */
29
+ declare function slotPlaceholder(name: string): string;
13
30
  /**
14
31
  * Compile a developer-authored lab math template the same way lesson `$$`
15
32
  * math is compiled: every identifier must resolve to a notation key in the
@@ -18,4 +35,4 @@ interface RenderedLabMath {
18
35
  */
19
36
  declare function renderLabMath(template: string, scope: readonly LabMathScopeDefinition[]): RenderedLabMath;
20
37
 
21
- export { type LabMathScopeDefinition, type RenderedLabMath, renderLabMath };
38
+ export { type LabMathScopeDefinition, type RenderedLabMath, renderLabMath, slotPlaceholder };
@@ -1,6 +1,9 @@
1
- import katex from "katex";
2
- import { resolveMathGlyphs } from "./math-glyphs.mjs";
1
+ import katex from "./katex-delimiters.mjs";
2
+ import { resolveMathGlyphs, stripNotationMarkers } from "./math-glyphs.mjs";
3
3
  import { createNotationKatexOptions } from "./katex-options.mjs";
4
+ function slotPlaceholder(name) {
5
+ return `\u27E6${name}\u27E7`;
6
+ }
4
7
  const SLOT_PATTERN = /\\slot\{([a-z][a-z0-9]*)\}\{((?:[^{}]|\{[^{}]*\})*)\}/g;
5
8
  const sentinelFor = (index) => `909909${index}.909909`;
6
9
  function renderLabMath(template, scope) {
@@ -30,6 +33,7 @@ function renderLabMath(template, scope) {
30
33
  );
31
34
  }
32
35
  let bound = result.latex;
36
+ let copyBound = result.latex;
33
37
  slots.forEach(({ name, fallback }, index) => {
34
38
  const sentinel = sentinelFor(index);
35
39
  if (!bound.includes(sentinel)) {
@@ -42,6 +46,7 @@ function renderLabMath(template, scope) {
42
46
  sentinel,
43
47
  `\\htmlData{lab-slot=${name}}{${fallback}}`
44
48
  );
49
+ copyBound = copyBound.replace(sentinel, slotPlaceholder(name));
45
50
  });
46
51
  const html = katex.renderToString(
47
52
  bound,
@@ -50,9 +55,11 @@ function renderLabMath(template, scope) {
50
55
  return {
51
56
  html,
52
57
  slots: slots.map(({ name }) => name),
53
- keys: [...new Set(result.bindings.map(({ key }) => key))]
58
+ keys: [...new Set(result.bindings.map(({ key }) => key))],
59
+ plainLatexTemplate: stripNotationMarkers(copyBound)
54
60
  };
55
61
  }
56
62
  export {
57
- renderLabMath
63
+ renderLabMath,
64
+ slotPlaceholder
58
65
  };
@@ -5,7 +5,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
5
5
  type TableDiagnosticCode = 'tab-invalid-key' | 'tab-duplicate-key' | 'tab-ref-resolves' | 'tab-key-unused';
6
6
  interface TableDiagnostic {
7
7
  readonly code: TableDiagnosticCode;
8
- readonly severity: 'error' | 'warning';
8
+ readonly severity: 'error' | 'warning' | 'info';
9
9
  readonly message: string;
10
10
  readonly file?: string;
11
11
  readonly lessonId?: string;
@@ -78,6 +78,13 @@ interface NotationLessonInput {
78
78
  readonly references: readonly NotationReferenceInput[];
79
79
  /** Original lesson body, retained for lossless loader adapters. */
80
80
  readonly body: string;
81
+ /**
82
+ * 1-based line in `source.file` where `body` starts. Gates scan the body, so
83
+ * every finding's index is body-relative; this is what turns one into a real
84
+ * location in the file. Optional so a hand-built input (a test, an adapter)
85
+ * stays valid — it then behaves as 1, the pre-0.7.1 behavior.
86
+ */
87
+ readonly bodyLine?: number;
81
88
  readonly source: SourceSpan;
82
89
  }
83
90
  interface NotationRegistryInput {
@@ -100,7 +107,7 @@ interface AbbreviationDefinitionInput {
100
107
  readonly source: SourceSpan;
101
108
  }
102
109
  type NotationDiagnosticCode = 'invalid-key' | 'duplicate-page' | 'duplicate-definition' | 'conflicting-definition' | 'undefined-reference' | 'reference-cycle' | 'unused-definition' | 'alignment-unknown-competency' | 'alignment-unknown-lesson' | 'alignment-introduction' | 'alignment-unavailable' | 'alignment-review-state' | 'gloss-collides-with-card';
103
- type DiagnosticSeverity = 'error' | 'warning';
110
+ type DiagnosticSeverity = 'error' | 'warning' | 'info';
104
111
  interface NotationDiagnostic {
105
112
  readonly code: NotationDiagnosticCode;
106
113
  readonly severity: DiagnosticSeverity;
@@ -38,8 +38,8 @@ passed now fails.
38
38
  `CHANGELOG.md`).
39
39
 
40
40
  **PATCH** — a bug fix, a docs change, an internal refactor with no surface
41
- change, or a new **Tier-3 warning** (warnings never fail a build, so surfacing
42
- one does not break a consumer).
41
+ change, or a new **Tier-3 warning/info diagnostic** (neither fails a build, so
42
+ surfacing one does not break a consumer).
43
43
 
44
44
  ### Pre-1.0
45
45
 
@@ -22,6 +22,12 @@ Two kinds of rule live here:
22
22
  - **Review-only** — nothing checks it automatically; a reviewer does. Breaking
23
23
  one is a review comment, not a broken build.
24
24
 
25
+ Automated diagnostics use three severities. An **error** means the content is
26
+ incorrect, ambiguous, unexplainable, or cannot render reliably and therefore
27
+ blocks validation. A **warning** is non-blocking but marks a rule, readability,
28
+ or maintenance problem that should be repaired. **Info** is optional advice;
29
+ leaving it in place can be intentional and creates no repair debt.
30
+
25
31
  Every rule is written to be falsifiable: you can look at a lesson and say
26
32
  whether it complies.
27
33
 
@@ -43,7 +49,7 @@ speak in: what a part, a chapter, a section, and a subsection are, and which of
43
49
  them an equation number counts.
44
50
 
45
51
  Each rule has a stable ID — a letter for its area (`N` notation,
46
- `V` visualizations, `C` versioning) plus a number — and this shape:
52
+ `V` visualizations, `C` versioning, `S` structure, `P` prose) plus a number — and this shape:
47
53
 
48
54
  > **N1 — One-line imperative.**
49
55
  > _Rationale._ Why the rule exists; the failure it prevents.
@@ -75,6 +81,7 @@ file table.
75
81
  | [visualizations.md](visualizations.md) | `V` | diagrams, charts, interactive figures, randomness |
76
82
  | [versioning.md](versioning.md) | `C` | the knowledge version and `content/CHANGELOG.md` |
77
83
  | [structure-rules.md](structure-rules.md) | `S` | heading and figure/diagram/memo/short-note title length |
84
+ | [prose.md](prose.md) | `P` | how a lesson's sentences are worded |
78
85
  | [components.md](components.md) | — | _catalog_: inline/block features available in a lesson body — tooltips, tables, figures, diagrams, memos, abbreviations, citations |
79
86
  | [agents.md](agents.md) | — | _guide_: working on a course repo (with an AI agent) |
80
87
  | [migrating.md](migrating.md) | — | _guide_: per-version steps when bumping the `explico` dep |
@@ -97,7 +104,9 @@ file table.
97
104
  | [N12](notation.md#n12) | Math syntax never carries the sentence's own trailing punctuation | Review-only |
98
105
  | [N13](notation.md#n13) | A nonlinear function always takes a parenthesized argument | Build-enforced (`resolveMathGlyphs`) |
99
106
  | [N14](notation.md#n14) | An abbreviation-shaped word in prose is universal, or explained | Build-enforced (`gate-abbreviations.ts`) |
100
- | [N15](notation.md#n15) | A nested `(`, `[`, `{`, or `\|` must use `\left`/`\right` sizing | Build-enforced (`resolveMathGlyphs`) |
107
+ | [N15](notation.md#n15) | A nested `(`, `[`, `{`, or `\|` must use scalable sizing | Build-enforced (`resolveMathGlyphs`) |
108
+ | [N16](notation.md#n16) | AI-generated stylistic grouping alternates delimiter types | Review-only |
109
+ | [N17](notation.md#n17) | A nested delimiter is sized by the engine, never by hand | Tier-3 warning (`delimiter-manual-size`) |
101
110
  | [V1](visualizations.md#v1) | Graphs and flowcharts use the `Diagram` component | Review-only |
102
111
  | [V2](visualizations.md#v2) | Static charts: SVG → D3 → Observable Plot, chosen per figure | Review-only |
103
112
  | [V3](visualizations.md#v3) | Interactive figures: island + `renderLabMath` seam + text alt | Review-only |
@@ -108,3 +117,4 @@ file table.
108
117
  | [C3](versioning.md#c3) | `about.version` equals the newest `content/CHANGELOG.md` heading | Build-enforced (Tier-3 `knowledge-version-mismatch`) |
109
118
  | [C4](versioning.md#c4) | On release, move `[Unreleased]` and bump `about.version` together | Review-only |
110
119
  | [S1](structure-rules.md#s1) | No heading, or figure/diagram/memo/short-note title, over 65 characters | Build-enforced (`gate-heading-length.ts`) for body/floats + review for `frontmatter.title` |
120
+ | [P1](prose.md#p1) | "or" never introduces a second name for the same thing | Review-only |
@@ -7,7 +7,9 @@ particular to your layout.
7
7
 
8
8
  This is a guide, not a rule area. The enforceable rules are in
9
9
  [`notation.md`](notation.md) (`N`), [`visualizations.md`](visualizations.md)
10
- (`V`), and [`versioning.md`](versioning.md) (`C`). When the engine's behaviour
10
+ (`V`), [`versioning.md`](versioning.md) (`C`),
11
+ [`structure-rules.md`](structure-rules.md) (`S`), and [`prose.md`](prose.md)
12
+ (`P`). When the engine's behaviour
11
13
  and a rule disagree, the engine wins — see [`README.md`](README.md).
12
14
 
13
15
  ## The loop
@@ -15,8 +17,10 @@ and a rule disagree, the engine wins — see [`README.md`](README.md).
15
17
  - **Start from the CLI, not a file hunt.** `pnpm --filter <course> content
16
18
  context <lesson>` prints a lesson plus exactly its notation, references, and
17
19
  assumptions. `pnpm content status` is the draft-debt overview. `pnpm content
18
- check <lesson> | --changed` is schema + refs + MDX→KaTeX, exits non-zero on an
19
- error. Read those before opening files.
20
+ diagnostics info` lists everything; `warning` and `error` raise the minimum
21
+ severity. `pnpm content warnings` lists warnings only.
22
+ `pnpm content check <lesson> | --changed` is schema + refs + MDX→KaTeX, exits
23
+ non-zero on an error. Read those before opening files.
20
24
  - **Validate, twice.** `pnpm --filter <course> validate:content` regenerates
21
25
  `build/resolution/*` and then fails on the same run that changed them. Run it
22
26
  again; the second run is clean. Commit the regenerated `build/` and