explico 0.7.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 (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  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 +14 -1
  15. package/dist/compiler/logging.js +6 -2
  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 +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. package/src/components/popup-stack.ts +0 -345
@@ -1,71 +1,95 @@
1
- import { stripFencedCode } from "./equations.mjs";
2
- import { lineOf } from "./gate-prose-notation.js";
1
+ import { EQ_CAPTION_PATTERN, stripFencedCode } from "./equations.mjs";
2
+ import { stripHeadingMarker } from "./rehype-heading-anchors.mjs";
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
- line: lineOf(lesson.body, finding.index),
92
+ line: lessonLine(lesson, finding.index),
69
93
  token: finding.token,
70
94
  message: finding.message
71
95
  });
@@ -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 };
@@ -0,0 +1,251 @@
1
+ import { equationLinkFor, mathSpans } from "./gate-math.js";
2
+ import { lessonLine } from "./gate-prose-notation.js";
3
+ const MATH_END_ABBREVIATIONS = [
4
+ "a.e.",
5
+ "a.s.",
6
+ "c.c.",
7
+ "const.",
8
+ "h.c.",
9
+ "i.i.d."
10
+ ];
11
+ const LETTERS = /[A-Za-z]+/y;
12
+ function tokenize(latex) {
13
+ const tokens = [];
14
+ const stack = [];
15
+ let i = 0;
16
+ while (i < latex.length) {
17
+ const ch = latex[i];
18
+ const parent = stack.length === 0 ? -1 : stack[stack.length - 1];
19
+ if (ch === "%") {
20
+ const newline = latex.indexOf("\n", i);
21
+ i = newline === -1 ? latex.length : newline;
22
+ continue;
23
+ }
24
+ if (ch === "\\") {
25
+ LETTERS.lastIndex = i + 1;
26
+ const letters = LETTERS.exec(latex);
27
+ const name = letters ? letters[0] : latex.charAt(i + 1);
28
+ tokens.push({ kind: "command", text: name, start: i, match: -1, parent });
29
+ i += 1 + Math.max(name.length, 1);
30
+ continue;
31
+ }
32
+ if (ch === "{") {
33
+ tokens.push({ kind: "open", text: ch, start: i, match: -1, parent });
34
+ stack.push(tokens.length - 1);
35
+ } else if (ch === "}") {
36
+ const open = stack.pop();
37
+ const index = tokens.length;
38
+ tokens.push({
39
+ kind: "close",
40
+ text: ch,
41
+ start: i,
42
+ match: open ?? -1,
43
+ parent: open === void 0 ? -1 : tokens[open].parent
44
+ });
45
+ if (open !== void 0) tokens[open].match = index;
46
+ } else {
47
+ tokens.push({ kind: /\s/.test(ch) ? "space" : "char", text: ch, start: i, match: -1, parent });
48
+ }
49
+ i += 1;
50
+ }
51
+ return tokens;
52
+ }
53
+ const MARKS = /* @__PURE__ */ new Set([".", ",", ";", ":"]);
54
+ const INVISIBLE = /* @__PURE__ */ new Set([
55
+ ",",
56
+ ";",
57
+ ":",
58
+ "!",
59
+ " ",
60
+ "\\",
61
+ "quad",
62
+ "qquad",
63
+ "enspace",
64
+ "thinspace",
65
+ "medspace",
66
+ "thickspace",
67
+ "negthinspace",
68
+ "negmedspace",
69
+ "negthickspace",
70
+ "nonumber",
71
+ "notag",
72
+ "displaystyle",
73
+ "textstyle"
74
+ ]);
75
+ const SKIPPED_GROUP = /* @__PURE__ */ new Set(["label", "caption", "tag", "begin", "end", "hspace", "vspace", "color"]);
76
+ const UPRIGHT = /* @__PURE__ */ new Set(["text", "textrm", "textup", "textnormal", "mathrm", "mbox", "operatorname"]);
77
+ const SIZER = /^(?:left|right|middle|[bB]igg?[lrm]?)$/;
78
+ function groupOwner(tokens, open, from) {
79
+ let k = open - 1;
80
+ while (k >= from && tokens[k].kind === "space") k -= 1;
81
+ if (k >= from && tokens[k].kind === "char" && tokens[k].text === "*") k -= 1;
82
+ return k >= from && tokens[k].kind === "command" ? k : void 0;
83
+ }
84
+ function tail(tokens, from, to) {
85
+ let j = to - 1;
86
+ while (j >= from) {
87
+ const token = tokens[j];
88
+ if (token.kind === "space") {
89
+ j -= 1;
90
+ continue;
91
+ }
92
+ if (token.kind === "command" && INVISIBLE.has(token.text)) {
93
+ j -= 1;
94
+ continue;
95
+ }
96
+ if (token.kind === "char" && token.text === ".") {
97
+ const before = tokens[j - 1];
98
+ if (j - 1 >= from && before?.kind === "command" && SIZER.test(before.text)) {
99
+ j -= 2;
100
+ continue;
101
+ }
102
+ }
103
+ if (token.kind === "char" && token.text === "]") {
104
+ let open = j - 1;
105
+ while (open >= from && !(tokens[open].kind === "char" && tokens[open].text === "[")) open -= 1;
106
+ const breakToken = tokens[open - 1];
107
+ if (open - 1 >= from && breakToken?.kind === "command" && breakToken.text === "\\") {
108
+ j = open - 2;
109
+ continue;
110
+ }
111
+ return "clean";
112
+ }
113
+ if (token.kind === "close") {
114
+ if (token.match < from) return "clean";
115
+ const owner = groupOwner(tokens, token.match, from);
116
+ if (owner !== void 0 && SKIPPED_GROUP.has(tokens[owner].text)) {
117
+ j = owner - 1;
118
+ continue;
119
+ }
120
+ const inner = tail(tokens, token.match + 1, j);
121
+ if (inner !== "empty") return inner;
122
+ j = token.match - 1;
123
+ continue;
124
+ }
125
+ if (token.kind === "char" && MARKS.has(token.text)) return { mark: j };
126
+ return "clean";
127
+ }
128
+ return "empty";
129
+ }
130
+ function classify(tokens, j, rowEnd, allowed) {
131
+ const token = tokens[j];
132
+ const base = { index: token.start, mark: token.text, rowEnd };
133
+ if (token.text !== ".") return { ...base, kind: "sentence" };
134
+ const isChar = (k, pattern) => tokens[k]?.kind === "char" && pattern.test(tokens[k].text);
135
+ if (isChar(j - 1, /^\.$/)) return { ...base, kind: "ellipsis" };
136
+ if (isChar(j - 1, /^\d$/)) {
137
+ let k = j - 1;
138
+ while (isChar(k - 1, /^\d$/)) k -= 1;
139
+ if (!isChar(k - 1, /^[_^]$/)) return { ...base, kind: "decimal" };
140
+ }
141
+ const owner = token.parent >= 0 ? groupOwner(tokens, token.parent, 0) : void 0;
142
+ if (owner !== void 0 && UPRIGHT.has(tokens[owner].text)) {
143
+ let k = j;
144
+ while (isChar(k - 1, /^[A-Za-z.]$/)) k -= 1;
145
+ const word = tokens.slice(k, j + 1).map((t) => t.text).join("");
146
+ if (/[A-Za-z]/.test(word)) {
147
+ if (allowed.has(word)) return void 0;
148
+ return { ...base, kind: "abbreviation", word };
149
+ }
150
+ }
151
+ return { ...base, kind: "sentence" };
152
+ }
153
+ function allowedAbbreviations(options) {
154
+ const deny = new Set(options.deny ?? []);
155
+ return /* @__PURE__ */ new Set([
156
+ ...MATH_END_ABBREVIATIONS.filter((word) => !deny.has(word)),
157
+ ...options.allow ?? []
158
+ ]);
159
+ }
160
+ function findings(latex, allowed) {
161
+ const tokens = tokenize(latex);
162
+ const byMark = /* @__PURE__ */ new Map();
163
+ const record = (result, rowEnd) => {
164
+ if (typeof result !== "object" || byMark.has(result.mark)) return;
165
+ const finding = classify(tokens, result.mark, rowEnd, allowed);
166
+ if (finding) byMark.set(result.mark, finding);
167
+ };
168
+ tokens.forEach((token, k) => {
169
+ if (token.kind === "command" && token.text === "\\") record(tail(tokens, token.parent + 1, k), true);
170
+ });
171
+ record(tail(tokens, 0, tokens.length), false);
172
+ return [...byMark.values()].sort((left, right) => left.index - right.index);
173
+ }
174
+ function trailingPunctuation(latex, options = {}) {
175
+ return findings(latex, allowedAbbreviations(options));
176
+ }
177
+ const SEE = "See docs/authoring/notation.md#n12.";
178
+ function messageFor(finding, snippet) {
179
+ const where = finding.rowEnd ? "ends a row of the math (before `\\\\`)" : "ends the math";
180
+ switch (finding.kind) {
181
+ case "decimal":
182
+ return `"${snippet}" ${where} with a trailing decimal point, which cannot be told apart from the sentence's full stop. Drop the point, or write the digits that state the precision (\`5.0\`); keep a full stop outside the math. ${SEE}`;
183
+ case "ellipsis":
184
+ return `"${snippet}" ${where} with a typed "...". Write \`\\ldots\`, and keep a full stop outside the math. ${SEE}`;
185
+ case "abbreviation":
186
+ return `"${snippet}" ${where} with "${finding.word}". If that abbreviation is notation (like \`\\text{h.c.}\`), add it to \`rules.N12.allow\`; if the "." is the sentence's full stop, move it after the closing \`$\`. ${SEE}`;
187
+ case "sentence":
188
+ return finding.rowEnd ? `"${snippet}" ${where} with "${finding.mark}" \u2014 the sentence's own punctuation. Drop it; the row break already separates the lines. ${SEE}` : `"${snippet}" ${where} with "${finding.mark}" \u2014 the sentence's own punctuation. Move it after the closing \`$\` (inline math) or drop it (display math). ${SEE}`;
189
+ }
190
+ }
191
+ function snippetOf(latex, index) {
192
+ const from = Math.max(0, index - 24);
193
+ const text = latex.slice(from, index + 1).replace(/\s+/g, " ").trimStart();
194
+ return from > 0 ? `\u2026${text}` : text;
195
+ }
196
+ function gateMathPunctuation(lessons, options = {}) {
197
+ const allowed = allowedAbbreviations(options);
198
+ const diagnostics = [];
199
+ for (const lesson of lessons) {
200
+ for (const span of mathSpans(lesson.body)) {
201
+ const equation = equationLinkFor(lesson.lessonId, span.latex);
202
+ for (const finding of findings(span.latex, allowed)) {
203
+ const at = span.offset + finding.index;
204
+ const snippet = snippetOf(span.latex, finding.index);
205
+ diagnostics.push({
206
+ context: "body",
207
+ code: "math-trailing-punctuation",
208
+ severity: "error",
209
+ file: lesson.source.file,
210
+ line: lessonLine(lesson, at),
211
+ column: at - lesson.body.lastIndexOf("\n", at - 1),
212
+ token: snippet,
213
+ ...equation === void 0 ? {} : { equation },
214
+ message: messageFor(finding, snippet)
215
+ });
216
+ }
217
+ }
218
+ }
219
+ return diagnostics;
220
+ }
221
+ function gateMathPunctuationInAssessments(assessments, options = {}) {
222
+ const allowed = allowedAbbreviations(options);
223
+ const diagnostics = [];
224
+ for (const assessment of assessments) {
225
+ for (const item of assessment.items ?? []) {
226
+ for (const field of [item.prompt, item.explanation]) {
227
+ if (typeof field !== "string") continue;
228
+ for (const span of mathSpans(field)) {
229
+ for (const finding of findings(span.latex, allowed)) {
230
+ const snippet = snippetOf(span.latex, finding.index);
231
+ diagnostics.push({
232
+ context: "assessment",
233
+ code: "math-trailing-punctuation",
234
+ severity: "error",
235
+ file: `${assessment.id} (${item.id ?? "?"})`,
236
+ token: snippet,
237
+ message: messageFor(finding, snippet)
238
+ });
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
244
+ return diagnostics;
245
+ }
246
+ export {
247
+ MATH_END_ABBREVIATIONS,
248
+ gateMathPunctuation,
249
+ gateMathPunctuationInAssessments,
250
+ trailingPunctuation
251
+ };