explico 0.8.0 → 0.11.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 (230) hide show
  1. package/CHANGELOG.md +1239 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +46 -2
  5. package/dist/compiler/cli.js +70 -4
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +68 -5
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +4 -1
  15. package/dist/compiler/logging.js +4 -1
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +3 -2
  27. package/dist/reference/diagrams-validate.d.ts +7 -2
  28. package/dist/reference/diagrams-validate.js +9 -1
  29. package/dist/reference/equations-validate.d.ts +8 -5
  30. package/dist/reference/equations-validate.js +1 -0
  31. package/dist/reference/equations.d.mts +60 -31
  32. package/dist/reference/equations.mjs +83 -43
  33. package/dist/reference/examples-validate.d.ts +31 -0
  34. package/dist/reference/examples-validate.js +48 -0
  35. package/dist/reference/figures-validate.d.ts +1 -1
  36. package/dist/reference/figures-validate.js +4 -1
  37. package/dist/reference/folds-validate.d.ts +33 -0
  38. package/dist/reference/folds-validate.js +32 -0
  39. package/dist/reference/gate-abbreviations.d.ts +3 -1
  40. package/dist/reference/gate-abbreviations.js +1 -0
  41. package/dist/reference/gate-course-rules.d.ts +32 -0
  42. package/dist/reference/gate-course-rules.js +85 -0
  43. package/dist/reference/gate-heading-length.d.ts +5 -2
  44. package/dist/reference/gate-heading-length.js +45 -21
  45. package/dist/reference/gate-heading-structure.d.ts +41 -0
  46. package/dist/reference/gate-heading-structure.js +113 -0
  47. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  48. package/dist/reference/gate-math-punctuation.js +251 -0
  49. package/dist/reference/gate-math.d.ts +22 -6
  50. package/dist/reference/gate-math.js +42 -8
  51. package/dist/reference/gate-part-pages.d.ts +50 -0
  52. package/dist/reference/gate-part-pages.js +118 -0
  53. package/dist/reference/gate-prose-notation.d.ts +21 -11
  54. package/dist/reference/gate-prose-notation.js +25 -7
  55. package/dist/reference/katex-delimiters.mjs +20 -7
  56. package/dist/reference/keyed-float-validate.d.ts +27 -10
  57. package/dist/reference/keyed-float-validate.js +15 -1
  58. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  59. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  60. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  61. package/dist/reference/knowledge-checks.d.mts +3 -1
  62. package/dist/reference/knowledge-checks.mjs +9 -4
  63. package/dist/reference/lab-math-scope.d.ts +1 -0
  64. package/dist/reference/lab-math-slot.d.ts +34 -0
  65. package/dist/reference/lab-math-slot.js +6 -0
  66. package/dist/reference/math-glyphs.d.mts +80 -4
  67. package/dist/reference/math-glyphs.mjs +572 -37
  68. package/dist/reference/page-notation.d.ts +6 -1
  69. package/dist/reference/raw-floats-validate.js +1 -1
  70. package/dist/reference/raw-floats.mjs +1 -1
  71. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  72. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  73. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  74. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  75. package/dist/reference/remark-asides.d.mts +3 -0
  76. package/dist/reference/remark-asides.mjs +180 -0
  77. package/dist/reference/remark-memo.d.mts +3 -1
  78. package/dist/reference/remark-memo.mjs +42 -11
  79. package/dist/reference/remark-notation.d.mts +60 -10
  80. package/dist/reference/remark-notation.mjs +387 -36
  81. package/dist/reference/render-lab-math.d.ts +4 -26
  82. package/dist/reference/render-lab-math.js +4 -4
  83. package/dist/reference/sections-validate.d.ts +27 -0
  84. package/dist/reference/sections-validate.js +41 -0
  85. package/dist/reference/sections.d.mts +97 -0
  86. package/dist/reference/sections.mjs +306 -0
  87. package/dist/reference/tables-validate.d.ts +1 -1
  88. package/dist/reference/types.d.ts +17 -1
  89. package/dist/reference/unit-words.d.ts +37 -0
  90. package/dist/reference/unit-words.js +170 -0
  91. package/dist/rules/catalog.d.ts +176 -0
  92. package/dist/rules/catalog.js +983 -0
  93. package/dist/rules/course-rules.d.ts +115 -0
  94. package/dist/rules/course-rules.js +292 -0
  95. package/dist/rules/course-rules.schema.json +138 -0
  96. package/dist/rules/in-effect.d.ts +53 -0
  97. package/dist/rules/in-effect.js +142 -0
  98. package/dist/rules/rules.schema.json +1072 -0
  99. package/dist/rules/settings.d.ts +3 -0
  100. package/dist/rules/settings.js +290 -0
  101. package/dist/rules/waivers.d.ts +53 -0
  102. package/dist/rules/waivers.js +132 -0
  103. package/dist/session/PreferenceStore.d.ts +5 -0
  104. package/dist/session/PreferenceStore.js +9 -0
  105. package/docs/VERSIONING.md +2 -2
  106. package/docs/authoring/README.md +232 -40
  107. package/docs/authoring/agents.md +55 -9
  108. package/docs/authoring/components.md +178 -22
  109. package/docs/authoring/migrating.md +1096 -2
  110. package/docs/authoring/notation.md +576 -322
  111. package/docs/authoring/prose.md +551 -0
  112. package/docs/authoring/retired.md +35 -0
  113. package/docs/authoring/structure-rules.md +422 -40
  114. package/docs/authoring/structure.md +445 -62
  115. package/docs/authoring/versioning.md +20 -28
  116. package/docs/authoring/visualizations.md +142 -20
  117. package/package.json +2 -2
  118. package/src/compiler/cli.ts +116 -6
  119. package/src/compiler/collections.ts +111 -7
  120. package/src/compiler/course-config.ts +273 -34
  121. package/src/compiler/knowledge-version.ts +1 -1
  122. package/src/compiler/lesson-derivation.ts +54 -15
  123. package/src/compiler/logging.ts +8 -2
  124. package/src/compiler/manifest.ts +573 -104
  125. package/src/components/BlockHeading.astro +113 -0
  126. package/src/components/CourseContents.astro +221 -0
  127. package/src/components/CurriculumMap.astro +5 -6
  128. package/src/components/FloatIndex.astro +200 -0
  129. package/src/components/HoverPanel.astro +431 -79
  130. package/src/components/PartBridge.astro +27 -0
  131. package/src/components/PartOverview.astro +401 -0
  132. package/src/components/PartScope.astro +27 -0
  133. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  134. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  135. package/src/components/assessments/AssessmentSet.astro +23 -26
  136. package/src/components/block-heading.ts +146 -0
  137. package/src/components/card-arrow.ts +243 -0
  138. package/src/components/card-drag.ts +631 -0
  139. package/src/components/card-jump.ts +45 -0
  140. package/src/components/card-manager.ts +1589 -0
  141. package/src/components/card-memory.ts +177 -0
  142. package/src/components/card-placement.ts +504 -0
  143. package/src/components/card-side.ts +336 -0
  144. package/src/components/card-travel.ts +197 -0
  145. package/src/components/citation/CitationLayer.astro +77 -76
  146. package/src/components/diagram/Diagram.astro +161 -316
  147. package/src/components/disclosure/course-disclosure.ts +24 -0
  148. package/src/components/disclosure/hover-preview.ts +122 -42
  149. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  150. package/src/components/disclosure/print-as-shown.ts +252 -0
  151. package/src/components/examples/CompactExample.astro +3 -1
  152. package/src/components/examples/CompactExamples.astro +101 -94
  153. package/src/components/figure/Figure.astro +174 -274
  154. package/src/components/figure/FloatCaption.astro +87 -0
  155. package/src/components/figure/figure-state.ts +58 -0
  156. package/src/components/figure/figure-tools.ts +87 -0
  157. package/src/components/float-name.ts +101 -0
  158. package/src/components/fold/Fold.astro +125 -0
  159. package/src/components/fold/fold-id.ts +40 -0
  160. package/src/components/memo/Memo.astro +162 -231
  161. package/src/components/memo/ShortNote.astro +6 -6
  162. package/src/components/notation/NotationFormula.astro +5 -1
  163. package/src/components/notation/NotationGlossary.astro +3 -4
  164. package/src/components/notation/NotationLayer.astro +169 -162
  165. package/src/components/notation/NotationMath.astro +10 -1
  166. package/src/components/reference/FloatRefLayer.astro +485 -423
  167. package/src/components/starlight/AboutPanel.astro +5 -4
  168. package/src/components/starlight/CollapsibleSections.astro +207 -93
  169. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  170. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  171. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  172. package/src/components/starlight/LayoutHeader.astro +298 -19
  173. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  174. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  175. package/src/components/starlight/LayoutSidebar.astro +229 -11
  176. package/src/components/starlight/LessonExportActions.astro +53 -72
  177. package/src/components/starlight/LessonFooter.astro +26 -4
  178. package/src/components/starlight/section-parts.ts +15 -2
  179. package/src/components/starlight/section-summary.ts +181 -0
  180. package/src/components/table/Table.astro +95 -76
  181. package/src/components/table/TableOverflowExpander.astro +65 -28
  182. package/src/content-config.ts +40 -4
  183. package/src/reference/base-accents.mjs +31 -0
  184. package/src/reference/base-roman-labels.mjs +2 -2
  185. package/src/reference/consistency.ts +176 -5
  186. package/src/reference/delimiter-size.ts +3 -2
  187. package/src/reference/diagrams-validate.ts +17 -1
  188. package/src/reference/equations-validate.ts +10 -5
  189. package/src/reference/equations.mjs +83 -43
  190. package/src/reference/examples-validate.ts +97 -0
  191. package/src/reference/figures-validate.ts +6 -1
  192. package/src/reference/folds-validate.ts +65 -0
  193. package/src/reference/gate-abbreviations.ts +1 -0
  194. package/src/reference/gate-course-rules.ts +134 -0
  195. package/src/reference/gate-heading-length.ts +66 -35
  196. package/src/reference/gate-heading-structure.ts +193 -0
  197. package/src/reference/gate-math-punctuation.ts +381 -0
  198. package/src/reference/gate-math.ts +69 -10
  199. package/src/reference/gate-part-pages.ts +217 -0
  200. package/src/reference/gate-prose-notation.ts +59 -12
  201. package/src/reference/katex-delimiters.mjs +20 -7
  202. package/src/reference/keyed-float-validate.ts +45 -11
  203. package/src/reference/keyed-jsx-floats.mjs +73 -20
  204. package/src/reference/knowledge-checks-validate.ts +1 -1
  205. package/src/reference/knowledge-checks.mjs +9 -4
  206. package/src/reference/lab-math-slot.ts +36 -0
  207. package/src/reference/math-glyphs.mjs +572 -37
  208. package/src/reference/raw-floats-validate.ts +1 -1
  209. package/src/reference/raw-floats.mjs +1 -1
  210. package/src/reference/rehype-heading-anchors.mjs +116 -17
  211. package/src/reference/rehype-table-header-labels.mjs +21 -3
  212. package/src/reference/remark-asides.mjs +180 -0
  213. package/src/reference/remark-memo.mjs +42 -11
  214. package/src/reference/remark-notation.mjs +387 -36
  215. package/src/reference/render-lab-math.ts +16 -31
  216. package/src/reference/sections-validate.ts +81 -0
  217. package/src/reference/sections.mjs +306 -0
  218. package/src/reference/tables-validate.ts +2 -1
  219. package/src/reference/types.ts +17 -0
  220. package/src/reference/unit-words.ts +233 -0
  221. package/src/rules/catalog.ts +1298 -0
  222. package/src/rules/course-rules.ts +456 -0
  223. package/src/rules/in-effect.ts +235 -0
  224. package/src/rules/settings.ts +448 -0
  225. package/src/rules/waivers.ts +249 -0
  226. package/src/session/PreferenceStore.ts +15 -0
  227. package/src/styles/global.css +1471 -163
  228. package/src/styles/surfaces.css +182 -0
  229. package/src/components/hover-panel.ts +0 -339
  230. package/src/components/popup-stack.ts +0 -345
@@ -0,0 +1,31 @@
1
+ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } from './keyed-float-validate.js';
2
+
3
+ /**
4
+ * `validateKeyedFloats` for `<CompactExamples>` — see
5
+ * `keyed-float-validate.ts`. A worked-example set is a numbered kind of its
6
+ * own, like a knowledge check (docs/plans/titles-and-containers.md Part 6,
7
+ * Q11): `Example ‹prefix›.‹n›`, anchored `#ex-‹id›`, cited with `[[ex-‹id›]]`.
8
+ *
9
+ * On top of the shared checks, both of the set's names are required
10
+ * (`ex-missing-prop`): the `id` its anchor and references need, and the
11
+ * `short` title its heading shows after the engine's "Example" badge. There is
12
+ * no default title to fall back on.
13
+ */
14
+
15
+ type ExampleDiagnosticCode = 'ex-invalid-key' | 'ex-duplicate-key' | 'ex-ref-resolves' | 'ex-key-unused' | 'ex-removed-prop' | 'ex-missing-prop';
16
+ interface ExampleDiagnostic {
17
+ readonly code: ExampleDiagnosticCode;
18
+ readonly severity: 'error' | 'warning' | 'info';
19
+ readonly message: string;
20
+ readonly file?: string;
21
+ readonly lessonId?: string;
22
+ readonly key?: string;
23
+ }
24
+ type ExampleLabelRecord = KeyedFloatLabelRecord;
25
+ type ExampleLessonInput = KeyedFloatLessonInput;
26
+ interface ExampleValidation extends KeyedFloatValidation {
27
+ readonly diagnostics: readonly ExampleDiagnostic[];
28
+ }
29
+ declare function validateExamples(lessons: readonly ExampleLessonInput[]): ExampleValidation;
30
+
31
+ export { type ExampleDiagnostic, type ExampleDiagnosticCode, type ExampleLabelRecord, type ExampleLessonInput, type ExampleValidation, validateExamples };
@@ -0,0 +1,48 @@
1
+ import {
2
+ validateKeyedFloats
3
+ } from "./keyed-float-validate.js";
4
+ import { scanKeyedJsxFloats } from "./keyed-jsx-floats.mjs";
5
+ const REQUIRED_PROPS = {
6
+ id: "give it an `id` (lowercase letters, digits and hyphens): the set is anchored `#ex-\u2039id\u203A` and cited with `[[ex-\u2039id\u203A]]`.",
7
+ short: 'give it a `short` title naming the topic, e.g. "Converting between coordinate systems"; the engine draws "Example" and the number before it.'
8
+ };
9
+ function validateExamples(lessons) {
10
+ const shared = validateKeyedFloats(lessons, {
11
+ tagName: "CompactExamples",
12
+ prefix: "ex",
13
+ noun: "worked-example set",
14
+ removedProps: {
15
+ summary: 'rename it `short`: the set\u2019s topical title, shown after the engine\u2019s "Example" badge.'
16
+ }
17
+ });
18
+ const missing = [];
19
+ for (const lesson of lessons) {
20
+ const sets = scanKeyedJsxFloats(lesson.body, {
21
+ tagName: "CompactExamples",
22
+ numbering: lesson.numbering
23
+ });
24
+ for (const set of sets) {
25
+ for (const [prop, fix] of Object.entries(REQUIRED_PROPS)) {
26
+ if (set.props.includes(prop)) continue;
27
+ missing.push({
28
+ code: "ex-missing-prop",
29
+ severity: "error",
30
+ message: `${lesson.lessonId} has a <CompactExamples>${set.key ? ` (id="${set.key}")` : ""} with no \`${prop}\`: ${fix}`,
31
+ file: lesson.file,
32
+ lessonId: lesson.lessonId,
33
+ ...set.key ? { key: set.key } : {}
34
+ });
35
+ }
36
+ }
37
+ }
38
+ const diagnostics = [
39
+ ...shared.diagnostics,
40
+ ...missing
41
+ ].sort(
42
+ (a, b) => a.code.localeCompare(b.code) || (a.lessonId ?? "").localeCompare(b.lessonId ?? "") || (a.key ?? "").localeCompare(b.key ?? "") || a.message.localeCompare(b.message)
43
+ );
44
+ return { ...shared, diagnostics };
45
+ }
46
+ export {
47
+ validateExamples
48
+ };
@@ -2,7 +2,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
2
2
 
3
3
  /** `validateKeyedFloats` for `<Figure>` — see `keyed-float-validate.ts`. */
4
4
 
5
- type FigureDiagnosticCode = 'fig-invalid-key' | 'fig-duplicate-key' | 'fig-ref-resolves' | 'fig-key-unused';
5
+ type FigureDiagnosticCode = 'fig-invalid-key' | 'fig-duplicate-key' | 'fig-ref-resolves' | 'fig-key-unused' | 'fig-removed-prop';
6
6
  interface FigureDiagnostic {
7
7
  readonly code: FigureDiagnosticCode;
8
8
  readonly severity: 'error' | 'warning' | 'info';
@@ -5,7 +5,10 @@ function validateFigures(lessons) {
5
5
  return validateKeyedFloats(lessons, {
6
6
  tagName: "Figure",
7
7
  prefix: "fig",
8
- noun: "figure"
8
+ noun: "figure",
9
+ removedProps: {
10
+ title: "a figure is named by its `caption` (the long caption, shown below it with its number) and an optional `short` caption; move a short title to `short`."
11
+ }
9
12
  });
10
13
  }
11
14
  export {
@@ -0,0 +1,33 @@
1
+ import { KeyedFloatLessonInput } from './keyed-float-validate.js';
2
+
3
+ /**
4
+ * `<Fold>` validation, run inside `compileManifest()`
5
+ * (docs/plans/figures-and-short-captions.md, decision 11). A fold's bar is
6
+ * its `label` or, without one, the numbers and short captions of the floats
7
+ * it holds (`../components/float-name.ts`), so a fold needs one or the other:
8
+ *
9
+ * - `fold-unnamed` (error): a `<Fold>` with no `label` and no `<Figure>`,
10
+ * `<Table>` or `<Diagram>` that takes a number inside it — its bar would
11
+ * have nothing to show.
12
+ *
13
+ * `Fold.astro` throws on the same fold when it renders; this reports it
14
+ * earlier, at `validate:content`, with the lesson it is in. A float that a
15
+ * wrapper component renders counts when the lesson writes it under a float's
16
+ * tag name (`<Diagram id="…" />` for a wrapper imported as `Diagram`), the
17
+ * way the numbering scan sees it.
18
+ */
19
+
20
+ type FoldDiagnosticCode = 'fold-unnamed';
21
+ interface FoldDiagnostic {
22
+ readonly code: FoldDiagnosticCode;
23
+ readonly severity: 'error';
24
+ readonly message: string;
25
+ readonly file?: string;
26
+ readonly lessonId?: string;
27
+ }
28
+ interface FoldValidation {
29
+ readonly diagnostics: readonly FoldDiagnostic[];
30
+ }
31
+ declare function validateFolds(lessons: readonly KeyedFloatLessonInput[]): FoldValidation;
32
+
33
+ export { type FoldDiagnostic, type FoldDiagnosticCode, type FoldValidation, validateFolds };
@@ -0,0 +1,32 @@
1
+ import { stripFencedCode } from "./equations.mjs";
2
+ import { scanKeyedJsxFloats } from "./keyed-jsx-floats.mjs";
3
+ const ATTRIBUTES = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'])*`;
4
+ const FOLD = new RegExp(`<Fold\\b(${ATTRIBUTES})>([\\s\\S]*?)<\\/Fold\\s*>`, "g");
5
+ const NUMBERED_FLOATS = ["Figure", "Table", "Diagram"];
6
+ function validateFolds(lessons) {
7
+ const diagnostics = [];
8
+ for (const lesson of [...lessons].sort(
9
+ (a, b) => a.lessonId.localeCompare(b.lessonId)
10
+ )) {
11
+ for (const [, attributes = "", body = ""] of stripFencedCode(lesson.body).matchAll(
12
+ FOLD
13
+ )) {
14
+ if (/(?:^|\s)label\s*=/.test(attributes)) continue;
15
+ const named = NUMBERED_FLOATS.some(
16
+ (tagName) => scanKeyedJsxFloats(body, { tagName, numbering: void 0 }).length > 0
17
+ );
18
+ if (named) continue;
19
+ diagnostics.push({
20
+ code: "fold-unnamed",
21
+ severity: "error",
22
+ message: `${lesson.lessonId} has a <Fold> with no \`label\` and no numbered <Figure>, <Table> or <Diagram> inside it, so its bar has nothing to name it by: give it a \`label\`, or fold a numbered float with it.`,
23
+ file: lesson.file,
24
+ lessonId: lesson.lessonId
25
+ });
26
+ }
27
+ }
28
+ return { diagnostics };
29
+ }
30
+ export {
31
+ validateFolds
32
+ };
@@ -1,6 +1,8 @@
1
1
  import { MathGateDiagnostic } from './gate-math.js';
2
2
  import { NotationRegistryInput, AbbreviationDefinitionInput } from './types.js';
3
- import { CourseAbbreviations } from '../compiler/course-config.js';
3
+ import { a as CourseAbbreviations } from '../course-config-Dsnri9Tk.js';
4
+ import '../rules/catalog.js';
5
+ import '../rules/course-rules.js';
4
6
 
5
7
  /**
6
8
  * Scan every lesson body for an abbreviation-shaped token outside `$…$` that
@@ -49,6 +49,7 @@ function gateAbbreviations(input) {
49
49
  reported.add(token);
50
50
  diagnostics.push({
51
51
  context: "abbreviation",
52
+ code: "abbreviation-unexplained",
52
53
  severity: "error",
53
54
  file: lesson.source.file,
54
55
  line: lessonLine(lesson, index),
@@ -0,0 +1,32 @@
1
+ import { MathGateDiagnostic } from './gate-math.js';
2
+ import { NotationLessonInput } from './types.js';
3
+ import { CourseRule } from '../rules/course-rules.js';
4
+ import '../rules/catalog.js';
5
+
6
+ /**
7
+ * The gate for a course's own rules (`courseRules` in `course.settings.json`,
8
+ * resolved by `src/rules/course-rules.ts`).
9
+ *
10
+ * One loop over every enabled course rule's `forbid` patterns, matched against
11
+ * the same "is this really prose" surface rule N9 uses — `proseOnly` blanks
12
+ * code fences, inline code, `$…$` / `$$…$$`, `[[key]]` references, `import`
13
+ * lines, JSX tags and MDX comments to spaces, so offsets still point at the
14
+ * source. A match becomes a `MathGateDiagnostic` with the rule's own ID as its
15
+ * code and a lesson `file` + `line`, which is what lets `{/* explico-allow X1:
16
+ * reason *\/}` waive one place.
17
+ *
18
+ * Nothing here knows what a rule *means*: the patterns come from the course,
19
+ * already validated (they compile, and none matches the empty string). The
20
+ * three scopes are the three units an author can reason about — a paragraph, a
21
+ * whole lesson's prose, a heading.
22
+ */
23
+
24
+ /**
25
+ * Scan every lesson body for what the course's own rules forbid. Only enabled
26
+ * rules with at least one pattern do anything; a guide rule is not this gate's
27
+ * business. Findings carry the rule ID as `code`, so `ownerOf` / `ruleIdOf`
28
+ * name the rule and a waiver can target it.
29
+ */
30
+ declare function gateCourseRules(lessons: readonly NotationLessonInput[], rules: readonly CourseRule[]): MathGateDiagnostic[];
31
+
32
+ export { gateCourseRules };
@@ -0,0 +1,85 @@
1
+ import { lessonLine, proseOnly } from "./gate-prose-notation.js";
2
+ const HEADING = /^[ \t]{0,3}(#{1,6})[ \t]+(.*)$/;
3
+ function paragraphs(text) {
4
+ const units = [];
5
+ let index = 0;
6
+ let start = -1;
7
+ const lines = text.split("\n");
8
+ lines.forEach((line, i) => {
9
+ const blank = line.trim() === "";
10
+ if (!blank && start === -1) start = index;
11
+ if ((blank || i === lines.length - 1) && start !== -1) {
12
+ const end = blank ? index : index + line.length;
13
+ units.push({ text: text.slice(start, end).replace(/\s+$/, ""), index: start });
14
+ start = -1;
15
+ }
16
+ index += line.length + 1;
17
+ });
18
+ return units.filter((unit) => unit.text !== "");
19
+ }
20
+ function headings(text) {
21
+ const units = [];
22
+ let index = 0;
23
+ for (const line of text.split("\n")) {
24
+ const match = HEADING.exec(line);
25
+ if (match !== null) {
26
+ const body = match[2].replace(/\s+#*\s*$/, "");
27
+ if (body !== "") {
28
+ units.push({ text: body, index: index + line.indexOf(body) });
29
+ }
30
+ }
31
+ index += line.length + 1;
32
+ }
33
+ return units;
34
+ }
35
+ function unitsFor(scope, prose) {
36
+ switch (scope) {
37
+ case "prose":
38
+ return [{ text: prose, index: 0 }];
39
+ case "heading":
40
+ return headings(prose);
41
+ case "paragraph":
42
+ return paragraphs(prose);
43
+ }
44
+ }
45
+ function* matches(pattern, unit) {
46
+ const regex = new RegExp(pattern.pattern, `${pattern.flags}g`);
47
+ let match;
48
+ while ((match = regex.exec(unit.text)) !== null) {
49
+ yield [unit.index + match.index, match[0]];
50
+ if (match.index === regex.lastIndex) regex.lastIndex += 1;
51
+ }
52
+ }
53
+ function gateCourseRules(lessons, rules) {
54
+ const active = rules.filter((rule) => rule.enabled && rule.forbid.length > 0);
55
+ if (active.length === 0) return [];
56
+ const diagnostics = [];
57
+ for (const lesson of lessons) {
58
+ const prose = proseOnly(lesson.body);
59
+ const cache = /* @__PURE__ */ new Map();
60
+ for (const rule of active) {
61
+ for (const pattern of rule.forbid) {
62
+ if (!cache.has(pattern.scope)) cache.set(pattern.scope, unitsFor(pattern.scope, prose));
63
+ for (const unit of cache.get(pattern.scope)) {
64
+ for (const [index, token] of matches(pattern, unit)) {
65
+ diagnostics.push({
66
+ context: "course",
67
+ code: rule.id,
68
+ severity: rule.severity,
69
+ file: lesson.source.file,
70
+ line: lessonLine(lesson, index),
71
+ // The matched text, trimmed — unless the pattern deliberately
72
+ // matched whitespace, in which case there is nothing else to show.
73
+ token: token.trim() || token,
74
+ message: `${pattern.message} (course rule ${rule.id})`
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ return diagnostics;
82
+ }
83
+ export {
84
+ gateCourseRules
85
+ };
@@ -1,12 +1,15 @@
1
1
  import { MathGateDiagnostic } from './gate-math.js';
2
2
  import { NotationLessonInput } from './types.js';
3
3
 
4
+ /** Rule S1's default; a course sets its own with `rules.S1.maxLength`. */
4
5
  declare const HEADING_LENGTH_LIMIT = 65;
5
6
  /**
6
- * Scan every lesson body for a heading or a keyed float's title running past
7
+ * Scan every lesson body for a heading or a short caption running past
7
8
  * `HEADING_LENGTH_LIMIT` characters. Returns one `MathGateDiagnostic`
8
9
  * (context `'structure'`, always an error) per finding.
9
10
  */
10
- declare function gateHeadingLength(lessons: readonly NotationLessonInput[]): MathGateDiagnostic[];
11
+ declare function gateHeadingLength(lessons: readonly NotationLessonInput[], options?: {
12
+ readonly maxLength?: number;
13
+ }): MathGateDiagnostic[];
11
14
 
12
15
  export { HEADING_LENGTH_LIMIT, gateHeadingLength };
@@ -1,68 +1,92 @@
1
- import { stripFencedCode } from "./equations.mjs";
1
+ import { EQ_CAPTION_PATTERN, stripFencedCode } from "./equations.mjs";
2
+ import { stripHeadingMarker } from "./rehype-heading-anchors.mjs";
2
3
  import { lessonLine } from "./gate-prose-notation.js";
3
4
  const HEADING_LENGTH_LIMIT = 65;
4
5
  const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
5
- const TITLE_ATTRIBUTE_BY_TAG = {
6
- Figure: "title",
7
- Diagram: "label",
8
- Memo: "title",
6
+ const SHORT_ATTRIBUTE_BY_TAG = {
7
+ Figure: "short",
8
+ Diagram: "short",
9
+ Table: "short",
10
+ Memo: "short",
11
+ CompactExamples: "short",
9
12
  ShortNote: "title"
10
13
  };
14
+ const DISPLAY_MATH = /\$\$([\s\S]+?)\$\$/g;
11
15
  function readAttribute(attributesSource, name) {
12
16
  const pattern = new RegExp(`\\b${name}\\s*=\\s*(["'])([^"']*)\\1`);
13
17
  const match = pattern.exec(attributesSource);
14
18
  return match ? match[2] : void 0;
15
19
  }
16
- function overLimitMessage(kind, text) {
17
- return `${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ${HEADING_LENGTH_LIMIT}-character limit (rule S1). Shorten it and move the rest into the paragraph or caption that follows.`;
20
+ function overLimitMessage(kind, text, limit) {
21
+ return `${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ${limit}-character limit (rule S1, rules.S1.maxLength in the course settings). Shorten it and move the rest into the paragraph or caption that follows.`;
18
22
  }
19
- function headingFindings(text) {
23
+ function headingFindings(text, limit) {
20
24
  const out = [];
21
25
  const re = new RegExp(HEADING_LINE.source, HEADING_LINE.flags);
22
26
  let match;
23
27
  while (match = re.exec(text)) {
24
28
  const level = match[1]?.length ?? 1;
25
- const title = (match[2] ?? "").trim();
26
- if (title.length > HEADING_LENGTH_LIMIT) {
29
+ const title = stripHeadingMarker(match[2] ?? "").trim();
30
+ if (title.length > limit) {
27
31
  out.push({
28
32
  index: match.index,
29
33
  token: title,
30
- message: overLimitMessage(`${"#".repeat(level)} heading`, title)
34
+ message: overLimitMessage(`${"#".repeat(level)} heading`, title, limit)
31
35
  });
32
36
  }
33
37
  }
34
38
  return out;
35
39
  }
36
- function floatTitleFindings(text) {
40
+ function floatTitleFindings(text, limit) {
37
41
  const out = [];
38
- for (const [tagName, attribute] of Object.entries(TITLE_ATTRIBUTE_BY_TAG)) {
42
+ for (const [tagName, attribute] of Object.entries(SHORT_ATTRIBUTE_BY_TAG)) {
39
43
  const opening = new RegExp(`<${tagName}\\b([^>]*)>`, "g");
40
44
  let match;
41
45
  while (match = opening.exec(text)) {
42
46
  const value = readAttribute(match[1] ?? "", attribute)?.trim();
43
- if (value && value.length > HEADING_LENGTH_LIMIT) {
47
+ if (value && value.length > limit) {
44
48
  out.push({
45
49
  index: match.index,
46
50
  token: value,
47
- message: overLimitMessage(`<${tagName} ${attribute}>`, value)
51
+ message: overLimitMessage(`<${tagName} ${attribute}>`, value, limit)
48
52
  });
49
53
  }
50
54
  }
51
55
  }
52
56
  return out;
53
57
  }
54
- function findingsIn(text) {
55
- return [...headingFindings(text), ...floatTitleFindings(text)].sort(
56
- (a, b) => a.index - b.index
57
- );
58
+ function equationCaptionFindings(text, limit) {
59
+ const out = [];
60
+ for (const display of text.matchAll(DISPLAY_MATH)) {
61
+ for (const caption of (display[1] ?? "").matchAll(EQ_CAPTION_PATTERN)) {
62
+ const value = (caption[1] ?? "").replace(/\s+/g, " ").trim();
63
+ if (value.length > limit) {
64
+ out.push({
65
+ index: (display.index ?? 0) + 2 + (caption.index ?? 0),
66
+ token: value,
67
+ message: overLimitMessage("Equation \\caption{}", value, limit)
68
+ });
69
+ }
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+ function findingsIn(text, limit) {
75
+ return [
76
+ ...headingFindings(text, limit),
77
+ ...floatTitleFindings(text, limit),
78
+ ...equationCaptionFindings(text, limit)
79
+ ].sort((a, b) => a.index - b.index);
58
80
  }
59
- function gateHeadingLength(lessons) {
81
+ function gateHeadingLength(lessons, options = {}) {
60
82
  const diagnostics = [];
83
+ const limit = options.maxLength ?? HEADING_LENGTH_LIMIT;
61
84
  for (const lesson of lessons) {
62
85
  const text = stripFencedCode(lesson.body);
63
- for (const finding of findingsIn(text)) {
86
+ for (const finding of findingsIn(text, limit)) {
64
87
  diagnostics.push({
65
88
  context: "structure",
89
+ code: "title-length",
66
90
  severity: "error",
67
91
  file: lesson.source.file,
68
92
  line: lessonLine(lesson, finding.index),
@@ -0,0 +1,41 @@
1
+ import { MathGateDiagnostic } from './gate-math.js';
2
+ import { NotationLessonInput } from './types.js';
3
+
4
+ /**
5
+ * The heading-structure gate: rules S6 and S7, over a lesson's outline — the
6
+ * top-level headings `sections.mjs` numbers, read by the same
7
+ * `scanOutlineBlocks`, so the rules and the section numbers can never
8
+ * disagree about which headings a page has. A heading inside a component
9
+ * (`<Memo>`, `<CompactExample>`, …) belongs to that component and is in
10
+ * neither. See docs/authoring/structure-rules.md#s6 and #s7.
11
+ *
12
+ * - **S6 (`heading-skip`)** — headings descend one level at a time. The page
13
+ * title (`frontmatter.title`) is the lesson's one `#`, so the body starts at
14
+ * `##`, a `#` in the body is an error, and a heading is at most one level
15
+ * deeper than the one before it. Going back up any number of levels is fine.
16
+ * - **S7 (`heading-adjacent`)** — a heading is followed by body content,
17
+ * never directly by another heading, whatever the two levels.
18
+ *
19
+ * Both also see the headings a component draws where its tag stands
20
+ * ({@link TITLED_COMPONENTS}): a knowledge check's and a worked-example set's
21
+ * `<h2>` (`BlockHeading.astro`) read as a `##`, so a heading written directly
22
+ * above either is two headings in a row, and the next heading steps from
23
+ * `##`.
24
+ *
25
+ * Ships straight as an error, same as S1: every playground violation was fixed
26
+ * or waived in the same change.
27
+ */
28
+
29
+ /**
30
+ * Scan every lesson body's outline for rule S6 (`heading-skip`) and rule S7
31
+ * (`heading-adjacent`) findings. `levels` / `adjacency` switch each rule's
32
+ * half off, the way `rules.S6.enabled` / `rules.S7.enabled` do. Returns one
33
+ * `MathGateDiagnostic` (context `'structure'`, always an error) per finding,
34
+ * located at the heading that breaks the rule.
35
+ */
36
+ declare function gateHeadingStructure(lessons: readonly NotationLessonInput[], options?: {
37
+ readonly levels?: boolean;
38
+ readonly adjacency?: boolean;
39
+ }): MathGateDiagnostic[];
40
+
41
+ export { gateHeadingStructure };
@@ -0,0 +1,113 @@
1
+ import { lessonLine } from "./gate-prose-notation.js";
2
+ import { scanOutlineBlocks } from "./sections.mjs";
3
+ const TITLED_COMPONENTS = {
4
+ AssessmentSet: {
5
+ level: 2,
6
+ what: "which renders its own `<h2>`: the \u201CKnowledge check\u201D badge, its number and the check\u2019s title"
7
+ },
8
+ CompactExamples: {
9
+ level: 2,
10
+ what: "which renders its own `<h2>`: the \u201CExample\u201D badge, its number and the set\u2019s `short` title"
11
+ }
12
+ };
13
+ function blocksOf(body) {
14
+ return scanOutlineBlocks(body).map((block) => {
15
+ if (block.kind === "heading") {
16
+ const hashes = "#".repeat(block.level);
17
+ return {
18
+ kind: "heading",
19
+ level: block.level,
20
+ label: `\`${hashes} ${block.text}\``,
21
+ token: block.text,
22
+ index: block.index
23
+ };
24
+ }
25
+ if (block.kind === "element") {
26
+ const titled = TITLED_COMPONENTS[block.name];
27
+ if (titled) {
28
+ return {
29
+ kind: "titled",
30
+ name: block.name,
31
+ index: block.index,
32
+ ...titled.level === void 0 ? {} : { level: titled.level }
33
+ };
34
+ }
35
+ }
36
+ return { kind: "content" };
37
+ });
38
+ }
39
+ function skipFindings(blocks) {
40
+ const out = [];
41
+ let previous = { level: 1, label: "the page title (`#`)" };
42
+ for (const block of blocks) {
43
+ if (block.kind === "content") continue;
44
+ if (block.kind === "titled") {
45
+ if (block.level !== void 0) {
46
+ previous = { level: block.level, label: `the \`<h${block.level}>\` of \`<${block.name} />\`` };
47
+ }
48
+ continue;
49
+ }
50
+ if (block.level === 1) {
51
+ out.push({
52
+ code: "heading-skip",
53
+ index: block.index,
54
+ token: block.token,
55
+ message: `${block.label} is a \`#\` in the lesson body, but the page title (\`frontmatter.title\`) is the lesson\u2019s one \`#\`: the body\u2019s headings start at \`##\` (rule S6). Make it \`##\`, or move the text into the title.`
56
+ });
57
+ } else if (block.level > previous.level + 1) {
58
+ const expected = "#".repeat(previous.level + 1);
59
+ out.push({
60
+ code: "heading-skip",
61
+ index: block.index,
62
+ token: block.token,
63
+ message: `${block.label} follows ${previous.label}, skipping a level: headings descend one level at a time (rule S6). Make it \`${expected}\`, or add the \`${expected}\` section it belongs under.`
64
+ });
65
+ }
66
+ previous = { level: block.level, label: block.label };
67
+ }
68
+ return out;
69
+ }
70
+ function adjacentFindings(blocks, lesson) {
71
+ const out = [];
72
+ blocks.forEach((block, position) => {
73
+ if (block.kind !== "heading") return;
74
+ const next = blocks[position + 1];
75
+ if (next === void 0 || next.kind === "content") return;
76
+ const line = lessonLine(lesson, next.index);
77
+ const followedBy = next.kind === "heading" ? `${next.label} (line ${line})` : `\`<${next.name}>\` (line ${line}), ${TITLED_COMPONENTS[next.name].what}`;
78
+ out.push({
79
+ code: "heading-adjacent",
80
+ index: block.index,
81
+ token: block.token,
82
+ message: `${block.label} is followed directly by ${followedBy}: a heading is followed by body content, never by another heading (rule S7). Write what the section is about before it goes on, or drop the heading that says nothing of its own.`
83
+ });
84
+ });
85
+ return out;
86
+ }
87
+ function gateHeadingStructure(lessons, options = {}) {
88
+ const { levels = true, adjacency = true } = options;
89
+ const diagnostics = [];
90
+ if (!levels && !adjacency) return diagnostics;
91
+ for (const lesson of lessons) {
92
+ const blocks = blocksOf(lesson.body);
93
+ const findings = [
94
+ ...levels ? skipFindings(blocks) : [],
95
+ ...adjacency ? adjacentFindings(blocks, lesson) : []
96
+ ].sort((a, b) => a.index - b.index);
97
+ for (const finding of findings) {
98
+ diagnostics.push({
99
+ context: "structure",
100
+ code: finding.code,
101
+ severity: "error",
102
+ file: lesson.source.file,
103
+ line: lessonLine(lesson, finding.index),
104
+ token: finding.token,
105
+ message: finding.message
106
+ });
107
+ }
108
+ }
109
+ return diagnostics;
110
+ }
111
+ export {
112
+ gateHeadingStructure
113
+ };
@@ -0,0 +1,71 @@
1
+ import { MathGateDiagnostic } from './gate-math.js';
2
+ import { NotationLessonInput } from './types.js';
3
+
4
+ /**
5
+ * The trailing-punctuation gate (rule N12): math never ends in the sentence's
6
+ * own `.`, `,`, `;` or `:`. It runs from the CLI alongside the completeness
7
+ * gate (`gate-math.ts`) over the same spans (`mathSpans`: every `$…$` /
8
+ * `$$…$$` in a lesson body, including table cells and component slots, and in
9
+ * an assessment item's `prompt` / `explanation`), and shares its
10
+ * `MathGateDiagnostic` shape and output array.
11
+ *
12
+ * "Ends" is read the way a reader sees it, not the raw source: trailing
13
+ * spacing (`\,`, `\quad`), `\label{…}` / `\caption{…}` / `\tag{…}`, a row break `\\`, an
14
+ * `\end{…}`, and the invisible delimiter `\right.` are looked through, and a
15
+ * closing `\text{…}` / `\mathrm{…}` is looked into — so `5\ \text{m,}` and
16
+ * `c &= d. \end{aligned}` are both caught. Every row of a multi-line block
17
+ * (the text before each `\\`) is checked the same way.
18
+ *
19
+ * Two shapes are called out by name because they look like notation: a
20
+ * trailing decimal point (`5.`) — a precision marker that cannot be told
21
+ * apart from the full stop, so it is never allowed — and a typed `...`
22
+ * (write `\ldots`). The one exception is a dotted abbreviation set upright
23
+ * that is itself notation (`+ \text{h.c.}`, `X_n \to X\ \text{a.s.}`): the
24
+ * shipped {@link MATH_END_ABBREVIATIONS}, edited per course with
25
+ * `rules.N12.allow` / `deny`.
26
+ */
27
+
28
+ interface AssessmentItemLike {
29
+ readonly id?: string;
30
+ readonly prompt?: string;
31
+ readonly explanation?: string;
32
+ }
33
+ interface AssessmentLike {
34
+ readonly id: string;
35
+ readonly items?: readonly AssessmentItemLike[];
36
+ }
37
+ /** Dotted abbreviations that are notation, so they may end math (`\text{h.c.}`). */
38
+ declare const MATH_END_ABBREVIATIONS: readonly string[];
39
+ interface MathPunctuationOptions {
40
+ /** Abbreviations allowed in addition to {@link MATH_END_ABBREVIATIONS}. */
41
+ readonly allow?: readonly string[];
42
+ /** Shipped abbreviations this course does not accept. */
43
+ readonly deny?: readonly string[];
44
+ }
45
+ interface PunctuationFinding {
46
+ /** Index of the mark in the LaTeX. */
47
+ readonly index: number;
48
+ readonly mark: string;
49
+ readonly kind: 'sentence' | 'decimal' | 'ellipsis' | 'abbreviation';
50
+ /** True when the mark ends a row (text before a `\\`), not the whole span. */
51
+ readonly rowEnd: boolean;
52
+ /** The upright dotted word the mark closes (`etc.`), for `abbreviation`. */
53
+ readonly word?: string;
54
+ }
55
+ /** Every N12 finding in one span of LaTeX (the text between the `$` delimiters). */
56
+ declare function trailingPunctuation(latex: string, options?: MathPunctuationOptions): PunctuationFinding[];
57
+ /**
58
+ * Scan every lesson body's math for the sentence's own trailing punctuation.
59
+ * One error per mark, located at the mark itself, so a per-place waiver
60
+ * (`{/* explico-allow N12: reason *\/}`) can cover it.
61
+ */
62
+ declare function gateMathPunctuation(lessons: readonly NotationLessonInput[], options?: MathPunctuationOptions): MathGateDiagnostic[];
63
+ /**
64
+ * The same check over every assessment item's `prompt` / `explanation`, which
65
+ * render through the same KaTeX pipeline as lesson math. No line number: the
66
+ * item is one JSON string field, so `file` names the item, the same
67
+ * `"<assessmentId> (<itemId>)"` shape `gate-math.ts` uses.
68
+ */
69
+ declare function gateMathPunctuationInAssessments(assessments: readonly AssessmentLike[], options?: MathPunctuationOptions): MathGateDiagnostic[];
70
+
71
+ export { MATH_END_ABBREVIATIONS, type MathPunctuationOptions, type PunctuationFinding, gateMathPunctuation, gateMathPunctuationInAssessments, trailingPunctuation };