explico 0.2.0 → 0.4.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 (83) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +67 -7
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.d.ts +16 -0
  6. package/dist/compiler/cli.js +43 -9
  7. package/dist/compiler/collections.js +4 -1
  8. package/dist/compiler/course-config.d.ts +80 -4
  9. package/dist/compiler/course-config.js +12 -0
  10. package/dist/compiler/knowledge-version.d.ts +58 -0
  11. package/dist/compiler/knowledge-version.js +80 -0
  12. package/dist/compiler/lesson-derivation.d.ts +1 -1
  13. package/dist/compiler/lesson-derivation.js +2 -2
  14. package/dist/compiler/manifest.d.ts +98 -2
  15. package/dist/compiler/manifest.js +153 -24
  16. package/dist/compiler/resolution-report.d.ts +4 -0
  17. package/dist/reference/base-operators.d.mts +38 -0
  18. package/dist/reference/base-operators.mjs +103 -0
  19. package/dist/reference/consistency.d.ts +7 -2
  20. package/dist/reference/consistency.js +51 -2
  21. package/dist/reference/diagram-layout.d.ts +63 -0
  22. package/dist/reference/diagram-layout.js +122 -0
  23. package/dist/reference/equations-validate.d.ts +10 -0
  24. package/dist/reference/equations-validate.js +1 -1
  25. package/dist/reference/equations.d.mts +80 -13
  26. package/dist/reference/equations.mjs +109 -19
  27. package/dist/reference/figures-validate.d.ts +21 -0
  28. package/dist/reference/figures-validate.js +13 -0
  29. package/dist/reference/keyed-float-validate.d.ts +73 -0
  30. package/dist/reference/keyed-float-validate.js +110 -0
  31. package/dist/reference/keyed-jsx-floats.d.mts +64 -0
  32. package/dist/reference/keyed-jsx-floats.mjs +191 -0
  33. package/dist/reference/math-glyphs.d.mts +18 -0
  34. package/dist/reference/math-glyphs.mjs +92 -0
  35. package/dist/reference/remark-notation.d.mts +61 -0
  36. package/dist/reference/remark-notation.mjs +344 -10
  37. package/dist/reference/tables-validate.d.ts +21 -0
  38. package/dist/reference/tables-validate.js +13 -0
  39. package/dist/reference/units-vocab.js +19 -1
  40. package/docs/VERSIONING.md +66 -0
  41. package/docs/authoring/README.md +100 -0
  42. package/docs/authoring/agents.md +63 -0
  43. package/docs/authoring/migrating.md +169 -0
  44. package/docs/authoring/notation.md +297 -0
  45. package/docs/authoring/structure.md +139 -0
  46. package/docs/authoring/versioning.md +116 -0
  47. package/docs/authoring/visualizations.md +195 -0
  48. package/package.json +20 -5
  49. package/src/compiler/astro.ts +127 -0
  50. package/src/compiler/cli.ts +52 -7
  51. package/src/compiler/collections.ts +12 -3
  52. package/src/compiler/course-config.ts +93 -3
  53. package/src/compiler/knowledge-version.ts +157 -0
  54. package/src/compiler/lesson-derivation.ts +2 -2
  55. package/src/compiler/manifest.ts +320 -26
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/diagram/Diagram.astro +317 -0
  58. package/src/components/examples/CompactExamples.astro +1 -1
  59. package/src/components/figure/Figure.astro +104 -0
  60. package/src/components/notation/NotationLayer.astro +11 -0
  61. package/src/components/notation/render-notation-math.ts +13 -2
  62. package/src/components/reference/FloatRefLayer.astro +671 -0
  63. package/src/components/starlight/AboutPanel.astro +255 -0
  64. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  65. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  66. package/src/components/starlight/LayoutSidebar.astro +62 -1
  67. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  68. package/src/components/starlight/LessonFooter.astro +15 -2
  69. package/src/components/table/Table.astro +105 -0
  70. package/src/reference/base-operators.mjs +103 -0
  71. package/src/reference/consistency.ts +83 -2
  72. package/src/reference/diagram-layout.ts +246 -0
  73. package/src/reference/equations-validate.ts +8 -1
  74. package/src/reference/equations.mjs +109 -19
  75. package/src/reference/figures-validate.ts +40 -0
  76. package/src/reference/keyed-float-validate.ts +210 -0
  77. package/src/reference/keyed-jsx-floats.mjs +191 -0
  78. package/src/reference/math-glyphs.mjs +92 -0
  79. package/src/reference/remark-notation.mjs +344 -10
  80. package/src/reference/tables-validate.ts +40 -0
  81. package/src/reference/units-vocab.ts +18 -0
  82. package/src/styles/global.css +244 -7
  83. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -6,6 +6,7 @@ import {
6
6
  readdirSync,
7
7
  writeFileSync
8
8
  } from "node:fs";
9
+ import { createRequire } from "node:module";
9
10
  import { dirname, join, relative } from "node:path";
10
11
  import { parseFrontmatter } from "@astrojs/markdown-remark";
11
12
  import {
@@ -15,9 +16,17 @@ import {
15
16
  runConsistencyChecks
16
17
  } from "../reference/consistency.js";
17
18
  import { validateNotationAlignment } from "../reference/curriculum-alignment.js";
19
+ import { countDisplayEquations } from "../reference/equations.mjs";
20
+ import { countKeyedJsxFloats } from "../reference/keyed-jsx-floats.mjs";
18
21
  import {
19
22
  validateEquations
20
23
  } from "../reference/equations-validate.js";
24
+ import {
25
+ validateTables
26
+ } from "../reference/tables-validate.js";
27
+ import {
28
+ validateFigures
29
+ } from "../reference/figures-validate.js";
21
30
  import {
22
31
  gateContentMath
23
32
  } from "../reference/gate-math.js";
@@ -25,6 +34,10 @@ import { loadLintIgnore } from "../reference/lint-ignore.js";
25
34
  import { buildNotationRegistry } from "../reference/registry.js";
26
35
  import { formulaGlyphScope } from "../reference/gloss.js";
27
36
  import { resolveReferenceId } from "../reference/references.js";
37
+ import { coursePartsOf } from "./course-config.js";
38
+ import {
39
+ resolveKnowledgeVersion
40
+ } from "./knowledge-version.js";
28
41
  import {
29
42
  loadCurriculumCatalog,
30
43
  loadNotationDefinitions,
@@ -35,12 +48,22 @@ import {
35
48
  import {
36
49
  buildSidebar,
37
50
  deriveSources,
38
- isLessonSlug
51
+ isLessonSlug,
52
+ lessonIdFromSlug
39
53
  } from "./lesson-derivation.js";
40
- const MANIFEST_SCHEMA_VERSION = 3;
54
+ const MANIFEST_SCHEMA_VERSION = 7;
41
55
  const REPOSITORY_ROOT = process.cwd();
42
56
  const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
43
57
  const MANIFEST_PATH = join(REPOSITORY_ROOT, "build", "manifest.json");
58
+ let enginePackage;
59
+ function readEnginePackage() {
60
+ if (!enginePackage) {
61
+ enginePackage = createRequire(import.meta.url)(
62
+ "explico/package.json"
63
+ );
64
+ }
65
+ return enginePackage;
66
+ }
44
67
  const CITE_PATTERN = /\[@([a-z0-9][a-z0-9.-]*)(?:;[ \t\r\n]*([^\]]*))?\]/g;
45
68
  function lessonCitations(body) {
46
69
  const withoutCode = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
@@ -156,6 +179,11 @@ function lessonFrontmatterById() {
156
179
  return result;
157
180
  }
158
181
  async function compileManifest(courseConfig) {
182
+ if (courseConfig.about === void 0 || courseConfig.about.version === void 0 || courseConfig.about.authors === void 0 || courseConfig.about.disclaimer === void 0) {
183
+ throw new Error(
184
+ "course.config.ts is missing a complete `about` block (required since explico 0.3.0): `about: { version, authors: [{ name }], disclaimer, materialName?, summary?, changelogHref? }`. See docs/authoring/migrating.md (\xA7 0.2.x \u2192 0.3.0) in the installed package."
185
+ );
186
+ }
159
187
  const [catalog, notationInput] = await Promise.all([
160
188
  loadCurriculumCatalog(),
161
189
  loadNotationRegistryInput()
@@ -202,20 +230,71 @@ async function compileManifest(courseConfig) {
202
230
  catalog.lessons.map((lesson) => [lesson.id, lesson])
203
231
  );
204
232
  const frontmatterById = lessonFrontmatterById();
205
- const equationValidation = validateEquations(
206
- notationInput.lessons.map((lesson) => ({
233
+ const lessonSlugs = [...notationInput.lessons].map((lesson) => lesson.lessonId.replace(/\./g, "/")).sort();
234
+ const sidebar = buildSidebar(
235
+ catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
236
+ lessonSlugs,
237
+ coursePartsOf(courseConfig)
238
+ );
239
+ const bodyByLessonId = new Map(
240
+ notationInput.lessons.map((lesson) => [lesson.lessonId, lesson.body])
241
+ );
242
+ const placementByLessonId = /* @__PURE__ */ new Map();
243
+ let partCursor = 0;
244
+ for (const group of sidebar) {
245
+ const lessonIds = group.items.filter((item) => typeof item === "string").map(lessonIdFromSlug);
246
+ if (lessonIds.length === 0) continue;
247
+ partCursor += 1;
248
+ const used = { equations: 0, tables: 0, figures: 0 };
249
+ let chapterCursor = 0;
250
+ for (const lessonId of lessonIds) {
251
+ const body = bodyByLessonId.get(lessonId) ?? "";
252
+ placementByLessonId.set(lessonId, {
253
+ part: partCursor,
254
+ chapter: ++chapterCursor,
255
+ numbering: { ...used }
256
+ });
257
+ used.equations += countDisplayEquations(body);
258
+ used.tables += countKeyedJsxFloats(body, { tagName: "Table" });
259
+ used.figures += countKeyedJsxFloats(body, { tagName: "Figure" });
260
+ }
261
+ }
262
+ const floatInputs = (kind) => notationInput.lessons.map((lesson) => {
263
+ const placement = placementByLessonId.get(lesson.lessonId);
264
+ return {
207
265
  lessonId: lesson.lessonId,
208
266
  slug: lesson.lessonId.replace(/\./g, "/"),
209
267
  file: lesson.source.file,
210
- body: lesson.body
211
- }))
212
- );
268
+ body: lesson.body,
269
+ ...placement ? {
270
+ numbering: {
271
+ prefix: placement.part,
272
+ offset: placement.numbering[kind]
273
+ }
274
+ } : {}
275
+ };
276
+ });
277
+ const equationValidation = validateEquations(floatInputs("equations"));
213
278
  const equationsBySlug = new Map(
214
279
  Object.entries(equationValidation.numbersBySlug).map(([slug, table]) => [
215
280
  slug,
216
281
  Object.entries(table).map(([key, number]) => ({ key, number }))
217
282
  ])
218
283
  );
284
+ const tableValidation = validateTables(floatInputs("tables"));
285
+ const tablesBySlug = new Map(
286
+ Object.entries(tableValidation.numbersBySlug).map(([slug, table]) => [
287
+ slug,
288
+ Object.entries(table).map(([key, number]) => ({ key, number }))
289
+ ])
290
+ );
291
+ const figureValidation = validateFigures(floatInputs("figures"));
292
+ const figuresBySlug = new Map(
293
+ Object.entries(figureValidation.numbersBySlug).map(([slug, table]) => [
294
+ slug,
295
+ Object.entries(table).map(([key, number]) => ({ key, number }))
296
+ ])
297
+ );
219
298
  const lessons = [];
220
299
  for (const notationLesson of notationInput.lessons) {
221
300
  const id = notationLesson.lessonId;
@@ -229,6 +308,16 @@ async function compileManifest(courseConfig) {
229
308
  lessons.push({
230
309
  id,
231
310
  slug: id.replace(/\./g, "/"),
311
+ // 0 = the lesson is not placed in any configured part; a curriculum
312
+ // diagnostic covers that separately, and both numbering paths then fall
313
+ // back to the legacy `##`-section scheme.
314
+ part: placementByLessonId.get(id)?.part ?? 0,
315
+ chapter: placementByLessonId.get(id)?.chapter ?? 0,
316
+ numbering: placementByLessonId.get(id)?.numbering ?? {
317
+ equations: 0,
318
+ tables: 0,
319
+ figures: 0
320
+ },
232
321
  file: notationLesson.source.file,
233
322
  title: frontmatter.title,
234
323
  description: frontmatter.description,
@@ -240,6 +329,8 @@ async function compileManifest(courseConfig) {
240
329
  sources: deriveSources(notationLesson.body),
241
330
  citations: lessonCitations(notationLesson.body),
242
331
  equations: equationsBySlug.get(id.replace(/\./g, "/")) ?? [],
332
+ tables: tablesBySlug.get(id.replace(/\./g, "/")) ?? [],
333
+ figures: figuresBySlug.get(id.replace(/\./g, "/")) ?? [],
243
334
  prereqEdges: sortEdges(
244
335
  teaches.flatMap(
245
336
  (to) => (prerequisitesById.get(to) ?? []).filter((from) => !teaches.includes(from)).map((from) => ({ from, to }))
@@ -264,7 +355,8 @@ async function compileManifest(courseConfig) {
264
355
  specs: loadNotationSpecs(),
265
356
  catalog,
266
357
  lintIgnore: loadLintIgnore(),
267
- conventions: courseConfig.conventions
358
+ conventions: courseConfig.conventions,
359
+ ...courseConfig.operators === void 0 ? {} : { operators: courseConfig.operators }
268
360
  });
269
361
  const rawNotation = [...loadNotationDefinitions()].sort(
270
362
  (a, b) => String(a.key).localeCompare(String(b.key))
@@ -272,14 +364,40 @@ async function compileManifest(courseConfig) {
272
364
  const competencies = catalog.competencies.map((entry) => ({ ...entry })).sort(byId);
273
365
  const sources = [...loadSourceRecords()].sort(byId);
274
366
  const tracks = catalog.tracks.map((entry) => ({ ...entry })).sort(byId);
275
- const lessonSlugs = [...notationInput.lessons].map((lesson) => lesson.lessonId.replace(/\./g, "/")).sort();
276
- const sidebar = buildSidebar(
277
- catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
278
- lessonSlugs,
279
- courseConfig.sections
280
- );
367
+ const hashes = contentHashes();
368
+ const knowledgeChangelogPath = join(CONTENT_ROOT, "CHANGELOG.md");
369
+ const knowledge = resolveKnowledgeVersion({
370
+ changelogText: existsSync(knowledgeChangelogPath) ? readFileSync(knowledgeChangelogPath, "utf8") : void 0,
371
+ declaredVersion: courseConfig.about.version,
372
+ file: relative(REPOSITORY_ROOT, knowledgeChangelogPath).replace(/\\/g, "/")
373
+ });
374
+ const enginePkg = readEnginePackage();
375
+ const about = {
376
+ engine: {
377
+ name: enginePkg.name,
378
+ version: enginePkg.version,
379
+ ...enginePkg.homepage === void 0 ? {} : { homepage: enginePkg.homepage }
380
+ },
381
+ material: {
382
+ name: courseConfig.about.materialName ?? courseConfig.title,
383
+ summary: courseConfig.about.summary ?? courseConfig.description
384
+ },
385
+ knowledge: {
386
+ version: knowledge.version,
387
+ ...knowledge.latest?.date === void 0 ? {} : { date: knowledge.latest.date },
388
+ notes: knowledge.latest?.notes ?? [],
389
+ contentTree: hashes.contentTree
390
+ },
391
+ authors: courseConfig.about.authors.map((author) => ({
392
+ name: author.name,
393
+ ...author.contact === void 0 ? {} : { contact: author.contact }
394
+ })),
395
+ disclaimer: courseConfig.about.disclaimer,
396
+ ...courseConfig.about.changelogHref === void 0 ? {} : { changelogHref: courseConfig.about.changelogHref }
397
+ };
281
398
  return {
282
399
  schemaVersion: MANIFEST_SCHEMA_VERSION,
400
+ about,
283
401
  notation: {
284
402
  definitions: registry.definitions,
285
403
  bundles: registry.bundles,
@@ -297,15 +415,26 @@ async function compileManifest(courseConfig) {
297
415
  labels: equationValidation.labels,
298
416
  numbersBySlug: equationValidation.numbersBySlug
299
417
  },
418
+ tables: {
419
+ labels: tableValidation.labels,
420
+ numbersBySlug: tableValidation.numbersBySlug
421
+ },
422
+ figures: {
423
+ labels: figureValidation.labels,
424
+ numbersBySlug: figureValidation.numbersBySlug
425
+ },
300
426
  diagnostics: {
301
427
  curriculum: curriculumIssues,
302
428
  notation: registry.diagnostics,
303
429
  alignment: alignmentIssues,
304
430
  math: mathIssues,
305
431
  consistency: consistencyIssues,
306
- equations: equationValidation.diagnostics
432
+ equations: equationValidation.diagnostics,
433
+ tables: tableValidation.diagnostics,
434
+ figures: figureValidation.diagnostics,
435
+ knowledge: knowledge.diagnostics
307
436
  },
308
- hashes: contentHashes()
437
+ hashes
309
438
  };
310
439
  }
311
440
  function byId(a, b) {
@@ -340,15 +469,15 @@ function readManifestSync() {
340
469
  return JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
341
470
  }
342
471
  async function loadManifest(courseConfig) {
343
- if (existsSync(MANIFEST_PATH)) return readManifestSync();
344
- if (!courseConfig) {
345
- throw new Error(
346
- `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`
347
- );
472
+ if (courseConfig) {
473
+ const manifest = await compileManifest(courseConfig);
474
+ writeManifest(manifest);
475
+ return manifest;
348
476
  }
349
- const manifest = await compileManifest(courseConfig);
350
- writeManifest(manifest);
351
- return manifest;
477
+ if (existsSync(MANIFEST_PATH)) return readManifestSync();
478
+ throw new Error(
479
+ `${relative(REPOSITORY_ROOT, MANIFEST_PATH)} not found. Run \`pnpm validate:content\` first.`
480
+ );
352
481
  }
353
482
  export {
354
483
  MANIFEST_PATH,
@@ -6,8 +6,12 @@ import './collections.js';
6
6
  import '../reference/types.js';
7
7
  import '../reference/lint-ignore.js';
8
8
  import '../reference/equations-validate.js';
9
+ import '../reference/tables-validate.js';
10
+ import '../reference/keyed-float-validate.js';
11
+ import '../reference/figures-validate.js';
9
12
  import '../reference/gate-math.js';
10
13
  import '../reference/gloss.js';
14
+ import './knowledge-version.js';
11
15
  import './lesson-derivation.js';
12
16
 
13
17
  /**
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The operators a course treats as common, given its `course.config.ts`
3
+ * `operators` block: the shipped baseline, plus `allow`, minus `deny`.
4
+ *
5
+ * @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} operators
6
+ * @returns {Set<string>}
7
+ */
8
+ export function effectiveOperatorAllowlist(operators: {
9
+ allow?: readonly string[];
10
+ deny?: readonly string[];
11
+ } | undefined): Set<string>;
12
+ /** True when `name` needs no notation entry for this course. */
13
+ export function isBaseOperator(name: any, operators: any): boolean;
14
+ /**
15
+ * The base operator library: the named mathematical operators every reader is
16
+ * assumed to know, so a lesson never has to introduce them with a notation
17
+ * entry.
18
+ *
19
+ * Rationale (parallel to `base-library.mjs`): the completeness gate has no
20
+ * opinion about operators — KaTeX parses `\exp`, `\int`, `\sin`, … as operator
21
+ * nodes, and `\operatorname{Var}` as an `operatorname` node, none of which the
22
+ * glyph resolver treats as a bindable symbol. The `operator-explained`
23
+ * consistency check (rule N8) fills that gap: an operator a lesson uses is
24
+ * either on this list (or the course's additions to it) or it carries a
25
+ * notation entry the reader can hover. This set is the "everyone knows these"
26
+ * baseline — trig, the exponential and logarithms, limits, extrema, and the
27
+ * big operators — kept greppable and small like the base library.
28
+ *
29
+ * A domain operator (`Var`, `Cov`, `Corr`, `sgn`, `erf`, `Tr`, `argmax`, …) is
30
+ * written `\operatorname{…}` and is NOT here: it earns a card, a
31
+ * `notation.local` entry, or an explicit line in `course.config.ts`
32
+ * (`operators.allow`).
33
+ *
34
+ * Entries are the KaTeX command without its backslash (`exp`, not `\exp`) for
35
+ * `op` nodes, and the verbatim body for `\operatorname{…}` nodes (`Var`).
36
+ * Matching is case-sensitive.
37
+ */
38
+ export const BASE_OPERATORS: Readonly<Set<string>>;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The base operator library: the named mathematical operators every reader is
3
+ * assumed to know, so a lesson never has to introduce them with a notation
4
+ * entry.
5
+ *
6
+ * Rationale (parallel to `base-library.mjs`): the completeness gate has no
7
+ * opinion about operators — KaTeX parses `\exp`, `\int`, `\sin`, … as operator
8
+ * nodes, and `\operatorname{Var}` as an `operatorname` node, none of which the
9
+ * glyph resolver treats as a bindable symbol. The `operator-explained`
10
+ * consistency check (rule N8) fills that gap: an operator a lesson uses is
11
+ * either on this list (or the course's additions to it) or it carries a
12
+ * notation entry the reader can hover. This set is the "everyone knows these"
13
+ * baseline — trig, the exponential and logarithms, limits, extrema, and the
14
+ * big operators — kept greppable and small like the base library.
15
+ *
16
+ * A domain operator (`Var`, `Cov`, `Corr`, `sgn`, `erf`, `Tr`, `argmax`, …) is
17
+ * written `\operatorname{…}` and is NOT here: it earns a card, a
18
+ * `notation.local` entry, or an explicit line in `course.config.ts`
19
+ * (`operators.allow`).
20
+ *
21
+ * Entries are the KaTeX command without its backslash (`exp`, not `\exp`) for
22
+ * `op` nodes, and the verbatim body for `\operatorname{…}` nodes (`Var`).
23
+ * Matching is case-sensitive.
24
+ */
25
+ export const BASE_OPERATORS = Object.freeze(
26
+ new Set([
27
+ // exponential / logarithms
28
+ 'exp',
29
+ 'log',
30
+ 'ln',
31
+ 'lg',
32
+ // trigonometric
33
+ 'sin',
34
+ 'cos',
35
+ 'tan',
36
+ 'cot',
37
+ 'sec',
38
+ 'csc',
39
+ // inverse trigonometric
40
+ 'arcsin',
41
+ 'arccos',
42
+ 'arctan',
43
+ // hyperbolic
44
+ 'sinh',
45
+ 'cosh',
46
+ 'tanh',
47
+ 'coth',
48
+ // limits
49
+ 'lim',
50
+ 'limsup',
51
+ 'liminf',
52
+ 'injlim',
53
+ 'projlim',
54
+ 'varlimsup',
55
+ 'varliminf',
56
+ 'varinjlim',
57
+ 'varprojlim',
58
+ // extrema
59
+ 'max',
60
+ 'min',
61
+ 'sup',
62
+ 'inf',
63
+ // linear algebra / number theory / misc
64
+ 'arg',
65
+ 'deg',
66
+ 'det',
67
+ 'dim',
68
+ 'gcd',
69
+ 'hom',
70
+ 'ker',
71
+ 'Pr',
72
+ 'mod',
73
+ // big operators
74
+ 'sum',
75
+ 'prod',
76
+ 'coprod',
77
+ 'int',
78
+ 'iint',
79
+ 'iiint',
80
+ 'oint',
81
+ ]),
82
+ );
83
+
84
+ /**
85
+ * The operators a course treats as common, given its `course.config.ts`
86
+ * `operators` block: the shipped baseline, plus `allow`, minus `deny`.
87
+ *
88
+ * @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} operators
89
+ * @returns {Set<string>}
90
+ */
91
+ export function effectiveOperatorAllowlist(operators) {
92
+ const allow = operators?.allow ?? [];
93
+ const deny = new Set(operators?.deny ?? []);
94
+ const effective = new Set(BASE_OPERATORS);
95
+ for (const name of allow) effective.add(name);
96
+ for (const name of deny) effective.delete(name);
97
+ return effective;
98
+ }
99
+
100
+ /** True when `name` needs no notation entry for this course. */
101
+ export function isBaseOperator(name, operators) {
102
+ return effectiveOperatorAllowlist(operators).has(name);
103
+ }
@@ -1,4 +1,4 @@
1
- import { CourseConventions } from '../compiler/course-config.js';
1
+ import { CourseConventions, CourseOperators } from '../compiler/course-config.js';
2
2
  import { CurriculumCatalog } from '../curriculum/validation.js';
3
3
  import { NotationSpec } from '../compiler/collections.js';
4
4
  import { LintIgnore } from './lint-ignore.js';
@@ -25,12 +25,15 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
25
25
  * (f) weak-local a `notation.local` symbol used only in its
26
26
  * own defining equation and prose
27
27
  * (g) notation-source-locator every notation `sources` entry has a locator
28
+ * (h) operator-explained a named operator a lesson uses is on the
29
+ * shipped/course operator allowlist or
30
+ * carries a notation entry (rule N8)
28
31
  *
29
32
  * Check (d) — the committed per-lesson resolution report — lives in
30
33
  * `src/content/resolution-report.ts`.
31
34
  */
32
35
 
33
- type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape';
36
+ type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape' | 'operator-explained';
34
37
  interface ConsistencyDiagnostic {
35
38
  readonly code: ConsistencyCode;
36
39
  /** Tier 3 — every finding here is a warning until content is brought clean. */
@@ -51,6 +54,8 @@ interface ConsistencyInput {
51
54
  readonly lintIgnore: LintIgnore;
52
55
  /** Convention keys this course anchors on (from `content/course.config.ts`). */
53
56
  readonly conventions: CourseConventions;
57
+ /** This course's edits to the named-operator baseline (rule N8). */
58
+ readonly operators?: CourseOperators;
54
59
  }
55
60
  /**
56
61
  * Run every consistency check. The result is sorted deterministically (code,
@@ -1,3 +1,5 @@
1
+ import { effectiveOperatorAllowlist } from "./base-operators.mjs";
2
+ import { collectOperators } from "./math-glyphs.mjs";
1
3
  import { offVocabularyTokens } from "./units-vocab.js";
2
4
  const CONVENTION_RE = /\bsign convention\b|\bcash-flow perspective\b|\bholder(?:'s)?(?:\s+\w+){0,2}\s+perspective\b|\bfrom (?:a|one|the|its)(?:\s+\w+){0,3}\s+perspective\b|\breverses? every sign\b|\bpositive (?:means|amounts?)\b/i;
3
5
  function stripCode(text) {
@@ -285,6 +287,51 @@ function checkGlossNameShape(input, out) {
285
287
  }
286
288
  }
287
289
  }
290
+ function checkOperatorExplained(input, out) {
291
+ const allowlist = effectiveOperatorAllowlist(input.operators);
292
+ const byId = new Map(
293
+ input.registry.definitions.map((definition) => [definition.id, definition])
294
+ );
295
+ const bindingsByLesson = new Map(
296
+ input.registry.bundles.map((bundle) => [bundle.lessonId, bundle.bindings])
297
+ );
298
+ for (const lesson of input.notationInput.lessons) {
299
+ const explained = /* @__PURE__ */ new Set();
300
+ const noteEntryOperators = (latex) => {
301
+ for (const operator of collectOperators(latex)) {
302
+ explained.add(operator.name);
303
+ }
304
+ };
305
+ for (const definition of lesson.localDefinitions) {
306
+ noteEntryOperators(definition.notation);
307
+ }
308
+ for (const binding of bindingsByLesson.get(lesson.lessonId) ?? []) {
309
+ const definition = byId.get(binding.definitionId);
310
+ if (definition) noteEntryOperators(definition.notation);
311
+ }
312
+ const reported = /* @__PURE__ */ new Set();
313
+ for (const span of mathSpans(lesson.body)) {
314
+ for (const operator of collectOperators(span.latex)) {
315
+ if (allowlist.has(operator.name)) continue;
316
+ if (explained.has(operator.name)) continue;
317
+ if (reported.has(operator.name)) continue;
318
+ reported.add(operator.name);
319
+ if (input.lintIgnore.matches("operator-explained", operator.name)) {
320
+ continue;
321
+ }
322
+ const display = operator.source === "operatorname" ? `\\operatorname{${operator.name}}` : `\\${operator.name}`;
323
+ out.push({
324
+ code: "operator-explained",
325
+ severity: "warning",
326
+ lessonId: lesson.lessonId,
327
+ file: lesson.source.file,
328
+ glyph: operator.name,
329
+ message: `${lesson.lessonId}: operator ${JSON.stringify(display)} is neither on the course operator allowlist nor explained by a notation entry; give it a card or notation.local entry (latex ${JSON.stringify(display)}) introduced with [[key]], or add ${JSON.stringify(operator.name)} to operators.allow in course.config.ts`
330
+ });
331
+ }
332
+ }
333
+ }
334
+ }
288
335
  const CHECKS = [
289
336
  checkGlyphUniqueness,
290
337
  checkNotationUnits,
@@ -294,7 +341,8 @@ const CHECKS = [
294
341
  checkNotationSourceLocator,
295
342
  checkGlossWantsPromoting,
296
343
  checkCardWantsDemoting,
297
- checkGlossNameShape
344
+ checkGlossNameShape,
345
+ checkOperatorExplained
298
346
  ];
299
347
  function runConsistencyChecks(input) {
300
348
  const diagnostics = [];
@@ -314,7 +362,8 @@ function consistencyCounts(diagnostics) {
314
362
  "notation-source-locator": 0,
315
363
  "gloss-wants-promoting": 0,
316
364
  "card-wants-demoting": 0,
317
- "gloss-name-shape": 0
365
+ "gloss-name-shape": 0,
366
+ "operator-explained": 0
318
367
  };
319
368
  for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
320
369
  return counts;
@@ -0,0 +1,63 @@
1
+ interface DiagramNodeInput {
2
+ readonly id: string;
3
+ readonly width: number;
4
+ readonly height: number;
5
+ }
6
+ interface DiagramEdgeInput {
7
+ readonly from: string;
8
+ readonly to: string;
9
+ }
10
+ interface DiagramLayoutOptions {
11
+ /** Top-to-bottom (default) or left-to-right. */
12
+ readonly direction?: 'TB' | 'LR';
13
+ readonly nodeSeparation?: number;
14
+ readonly rankSeparation?: number;
15
+ /** Uniform padding added around the computed bounding box. */
16
+ readonly padding?: number;
17
+ }
18
+ interface Point {
19
+ readonly x: number;
20
+ readonly y: number;
21
+ }
22
+ interface LaidOutNode {
23
+ readonly id: string;
24
+ /** Top-left corner, not the center dagre itself works in. */
25
+ readonly x: number;
26
+ readonly y: number;
27
+ readonly width: number;
28
+ readonly height: number;
29
+ }
30
+ interface LaidOutEdge {
31
+ readonly from: string;
32
+ readonly to: string;
33
+ readonly points: readonly Point[];
34
+ }
35
+ interface DiagramLayout {
36
+ readonly nodes: readonly LaidOutNode[];
37
+ readonly edges: readonly LaidOutEdge[];
38
+ /** The SVG canvas size a caller should use as the `viewBox`. */
39
+ readonly width: number;
40
+ readonly height: number;
41
+ }
42
+ /**
43
+ * Lay out a small directed graph. Node ids must be unique; an edge naming an
44
+ * undeclared node fails loudly rather than silently dropping it (dagre itself
45
+ * would otherwise create a phantom zero-size node).
46
+ */
47
+ declare function layoutDiagram(nodes: readonly DiagramNodeInput[], edges: readonly DiagramEdgeInput[], options?: DiagramLayoutOptions): DiagramLayout;
48
+ interface BoxSizeEstimate {
49
+ readonly width: number;
50
+ readonly height: number;
51
+ }
52
+ /**
53
+ * Estimate a diagram node's box from its label text (or raw, pre-resolution
54
+ * LaTeX for a formula node). Deliberately generous: it is meant to avoid
55
+ * clipping, not to match a real render pixel-for-pixel. Pass an explicit
56
+ * `width`/`height` on the node to override for a label this estimate gets
57
+ * badly wrong.
58
+ */
59
+ declare function estimateBoxSize(content: string, kind: 'text' | 'formula', options?: {
60
+ readonly maxWidth?: number;
61
+ }): BoxSizeEstimate;
62
+
63
+ export { type BoxSizeEstimate, type DiagramEdgeInput, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodeInput, type LaidOutEdge, type LaidOutNode, type Point, estimateBoxSize, layoutDiagram };