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
@@ -1,16 +1,16 @@
1
1
  /**
2
- * The heading/title-length gate (rule S1): no Markdown heading (`##`
3
- * through `######`) and no `<Figure title>`, `<Diagram label>`, `<Memo
4
- * title>`, or `<ShortNote title>` may run past `HEADING_LENGTH_LIMIT`
5
- * characters. A long heading or float title is usually a full sentence that
6
- * belongs in the body paragraph (for a heading) or the `caption` (for a
7
- * float) instead — see docs/authoring/structure-rules.md#s1.
2
+ * The heading/short-caption-length gate (rule S1): no Markdown heading (`##`
3
+ * through `######`), no `short` caption (`<Figure>`, `<Diagram>`, `<Table>`,
4
+ * `<Memo>`, `<CompactExamples>`), no display equation's `\caption{…}` and no
5
+ * `<ShortNote title>` may run past `HEADING_LENGTH_LIMIT` characters. A long
6
+ * heading or short caption is usually a full sentence that belongs in the
7
+ * body paragraph (for a heading) or the long `caption` (for a float) instead
8
+ * — see docs/authoring/structure-rules.md#s1.
8
9
  *
9
- * `<Table>` is deliberately not covered: it has only a `caption`, no
10
- * separate `title`, and that caption is the LaTeX-convention full sentence
11
- * shown above the table — capping it would fight the rule that puts it
12
- * there. `<Figure>`'s `caption` is the same full sentence; only its short
13
- * `title` (the text on the collapsible title bar) is in scope.
10
+ * The long `caption` is deliberately never measured: it is the LaTeX
11
+ * convention's full sentence, shown with the float's number, and capping it
12
+ * would fight the rule that puts it there. Only the short name a float goes
13
+ * by away from itself is in scope — the `\caption[short]{…}` half.
14
14
  *
15
15
  * A lesson's own `#` (its `frontmatter.title`, rendered as the page H1 by
16
16
  * Starlight) is Review-only — it never appears in `lesson.body`, so this
@@ -19,26 +19,33 @@
19
19
  * Ships straight as an error, same as N9/N14: no partial corpus to absorb,
20
20
  * no `lint-ignore.yml` entry.
21
21
  */
22
- import { stripFencedCode } from './equations.mjs';
22
+ import { EQ_CAPTION_PATTERN, stripFencedCode } from './equations.mjs';
23
+ import { stripHeadingMarker } from './rehype-heading-anchors.mjs';
23
24
  import type { MathGateDiagnostic } from './gate-math';
24
25
  import { lessonLine } from './gate-prose-notation';
25
26
  import type { NotationLessonInput } from './types';
26
27
 
28
+ /** Rule S1's default; a course sets its own with `rules.S1.maxLength`. */
27
29
  export const HEADING_LENGTH_LIMIT = 65;
28
30
 
29
31
  // ATX heading: 1-6 `#`, then the text, with any trailing closing `#`s
30
32
  // trimmed off (the `# text #` form CommonMark also allows).
31
33
  const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
32
34
 
33
- // The JSX attribute that reads as a float's own short title, per component.
34
- // `Table` has no entry — see the module doc comment.
35
- const TITLE_ATTRIBUTE_BY_TAG: Record<string, string> = {
36
- Figure: 'title',
37
- Diagram: 'label',
38
- Memo: 'title',
35
+ // The JSX attribute that holds a component's short name, per component: the
36
+ // `short` caption of the caption pair, and a short note's inline link text.
37
+ const SHORT_ATTRIBUTE_BY_TAG: Record<string, string> = {
38
+ Figure: 'short',
39
+ Diagram: 'short',
40
+ Table: 'short',
41
+ Memo: 'short',
42
+ CompactExamples: 'short',
39
43
  ShortNote: 'title',
40
44
  };
41
45
 
46
+ // A display equation's `\caption{…}` (its short caption).
47
+ const DISPLAY_MATH = /\$\$([\s\S]+?)\$\$/g;
48
+
42
49
  interface Finding {
43
50
  readonly index: number;
44
51
  readonly token: string;
@@ -51,44 +58,63 @@ function readAttribute(attributesSource: string, name: string): string | undefin
51
58
  return match ? match[2] : undefined;
52
59
  }
53
60
 
54
- function overLimitMessage(kind: string, text: string): string {
61
+ function overLimitMessage(kind: string, text: string, limit: number): string {
55
62
  return (
56
63
  `${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ` +
57
- `${HEADING_LENGTH_LIMIT}-character limit (rule S1). Shorten it and move ` +
58
- 'the rest into the paragraph or caption that follows.'
64
+ `${limit}-character limit (rule S1, rules.S1.maxLength in the course ` +
65
+ 'settings). Shorten it and move the rest into the paragraph or caption ' +
66
+ 'that follows.'
59
67
  );
60
68
  }
61
69
 
62
- function headingFindings(text: string): Finding[] {
70
+ function headingFindings(text: string, limit: number): Finding[] {
63
71
  const out: Finding[] = [];
64
72
  const re = new RegExp(HEADING_LINE.source, HEADING_LINE.flags);
65
73
  let match: RegExpExecArray | null;
66
74
  while ((match = re.exec(text))) {
67
75
  const level = match[1]?.length ?? 1;
68
- const title = (match[2] ?? '').trim();
69
- if (title.length > HEADING_LENGTH_LIMIT) {
76
+ // What the reader sees: `[collapsed]`, `[card]` and the like never render.
77
+ const title = stripHeadingMarker(match[2] ?? '').trim();
78
+ if (title.length > limit) {
70
79
  out.push({
71
80
  index: match.index,
72
81
  token: title,
73
- message: overLimitMessage(`${'#'.repeat(level)} heading`, title),
82
+ message: overLimitMessage(`${'#'.repeat(level)} heading`, title, limit),
74
83
  });
75
84
  }
76
85
  }
77
86
  return out;
78
87
  }
79
88
 
80
- function floatTitleFindings(text: string): Finding[] {
89
+ function floatTitleFindings(text: string, limit: number): Finding[] {
81
90
  const out: Finding[] = [];
82
- for (const [tagName, attribute] of Object.entries(TITLE_ATTRIBUTE_BY_TAG)) {
91
+ for (const [tagName, attribute] of Object.entries(SHORT_ATTRIBUTE_BY_TAG)) {
83
92
  const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
84
93
  let match: RegExpExecArray | null;
85
94
  while ((match = opening.exec(text))) {
86
95
  const value = readAttribute(match[1] ?? '', attribute)?.trim();
87
- if (value && value.length > HEADING_LENGTH_LIMIT) {
96
+ if (value && value.length > limit) {
88
97
  out.push({
89
98
  index: match.index,
90
99
  token: value,
91
- message: overLimitMessage(`<${tagName} ${attribute}>`, value),
100
+ message: overLimitMessage(`<${tagName} ${attribute}>`, value, limit),
101
+ });
102
+ }
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ function equationCaptionFindings(text: string, limit: number): Finding[] {
109
+ const out: Finding[] = [];
110
+ for (const display of text.matchAll(DISPLAY_MATH)) {
111
+ for (const caption of (display[1] ?? '').matchAll(EQ_CAPTION_PATTERN)) {
112
+ const value = (caption[1] ?? '').replace(/\s+/g, ' ').trim();
113
+ if (value.length > limit) {
114
+ out.push({
115
+ index: (display.index ?? 0) + 2 + (caption.index ?? 0),
116
+ token: value,
117
+ message: overLimitMessage('Equation \\caption{}', value, limit),
92
118
  });
93
119
  }
94
120
  }
@@ -97,27 +123,32 @@ function floatTitleFindings(text: string): Finding[] {
97
123
  }
98
124
 
99
125
  /** Every S1 finding in one lesson body, in source order. */
100
- function findingsIn(text: string): Finding[] {
101
- return [...headingFindings(text), ...floatTitleFindings(text)].sort(
102
- (a, b) => a.index - b.index,
103
- );
126
+ function findingsIn(text: string, limit: number): Finding[] {
127
+ return [
128
+ ...headingFindings(text, limit),
129
+ ...floatTitleFindings(text, limit),
130
+ ...equationCaptionFindings(text, limit),
131
+ ].sort((a, b) => a.index - b.index);
104
132
  }
105
133
 
106
134
  /**
107
- * Scan every lesson body for a heading or a keyed float's title running past
135
+ * Scan every lesson body for a heading or a short caption running past
108
136
  * `HEADING_LENGTH_LIMIT` characters. Returns one `MathGateDiagnostic`
109
137
  * (context `'structure'`, always an error) per finding.
110
138
  */
111
139
  export function gateHeadingLength(
112
140
  lessons: readonly NotationLessonInput[],
141
+ options: { readonly maxLength?: number } = {},
113
142
  ): MathGateDiagnostic[] {
114
143
  const diagnostics: MathGateDiagnostic[] = [];
144
+ const limit = options.maxLength ?? HEADING_LENGTH_LIMIT;
115
145
 
116
146
  for (const lesson of lessons) {
117
147
  const text = stripFencedCode(lesson.body);
118
- for (const finding of findingsIn(text)) {
148
+ for (const finding of findingsIn(text, limit)) {
119
149
  diagnostics.push({
120
150
  context: 'structure',
151
+ code: 'title-length',
121
152
  severity: 'error',
122
153
  file: lesson.source.file,
123
154
  line: lessonLine(lesson, finding.index),
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The heading-structure gate: rules S6 and S7, over a lesson's outline — the
3
+ * top-level headings `sections.mjs` numbers, read by the same
4
+ * `scanOutlineBlocks`, so the rules and the section numbers can never
5
+ * disagree about which headings a page has. A heading inside a component
6
+ * (`<Memo>`, `<CompactExample>`, …) belongs to that component and is in
7
+ * neither. See docs/authoring/structure-rules.md#s6 and #s7.
8
+ *
9
+ * - **S6 (`heading-skip`)** — headings descend one level at a time. The page
10
+ * title (`frontmatter.title`) is the lesson's one `#`, so the body starts at
11
+ * `##`, a `#` in the body is an error, and a heading is at most one level
12
+ * deeper than the one before it. Going back up any number of levels is fine.
13
+ * - **S7 (`heading-adjacent`)** — a heading is followed by body content,
14
+ * never directly by another heading, whatever the two levels.
15
+ *
16
+ * Both also see the headings a component draws where its tag stands
17
+ * ({@link TITLED_COMPONENTS}): a knowledge check's and a worked-example set's
18
+ * `<h2>` (`BlockHeading.astro`) read as a `##`, so a heading written directly
19
+ * above either is two headings in a row, and the next heading steps from
20
+ * `##`.
21
+ *
22
+ * Ships straight as an error, same as S1: every playground violation was fixed
23
+ * or waived in the same change.
24
+ */
25
+ import type { MathGateDiagnostic } from './gate-math';
26
+ import { lessonLine } from './gate-prose-notation';
27
+ import { scanOutlineBlocks } from './sections.mjs';
28
+ import type { NotationLessonInput } from './types';
29
+
30
+ /**
31
+ * Components that open with a title of their own, drawn where the tag stands.
32
+ * `level` is the `<hN>` a component renders, when it renders a real heading
33
+ * (rule S6 steps from it); `what` finishes a rule S7 message.
34
+ */
35
+ const TITLED_COMPONENTS: Readonly<Record<string, { readonly level?: number; readonly what: string }>> = {
36
+ AssessmentSet: {
37
+ level: 2,
38
+ what: 'which renders its own `<h2>`: the “Knowledge check” badge, its number and the check’s title',
39
+ },
40
+ CompactExamples: {
41
+ level: 2,
42
+ what: 'which renders its own `<h2>`: the “Example” badge, its number and the set’s `short` title',
43
+ },
44
+ };
45
+
46
+ type Block =
47
+ | {
48
+ readonly kind: 'heading';
49
+ readonly level: number;
50
+ /** How a message names it: `` `## Title` ``. */
51
+ readonly label: string;
52
+ readonly token: string;
53
+ readonly index: number;
54
+ }
55
+ | { readonly kind: 'titled'; readonly name: string; readonly index: number; readonly level?: number }
56
+ | { readonly kind: 'content' };
57
+
58
+ function blocksOf(body: string): Block[] {
59
+ return scanOutlineBlocks(body).map((block): Block => {
60
+ if (block.kind === 'heading') {
61
+ const hashes = '#'.repeat(block.level);
62
+ return {
63
+ kind: 'heading',
64
+ level: block.level,
65
+ label: `\`${hashes} ${block.text}\``,
66
+ token: block.text,
67
+ index: block.index,
68
+ };
69
+ }
70
+ if (block.kind === 'element') {
71
+ const titled = TITLED_COMPONENTS[block.name];
72
+ if (titled) {
73
+ return {
74
+ kind: 'titled',
75
+ name: block.name,
76
+ index: block.index,
77
+ ...(titled.level === undefined ? {} : { level: titled.level }),
78
+ };
79
+ }
80
+ }
81
+ return { kind: 'content' };
82
+ });
83
+ }
84
+
85
+ interface Finding {
86
+ readonly code: 'heading-skip' | 'heading-adjacent';
87
+ readonly index: number;
88
+ readonly token: string;
89
+ readonly message: string;
90
+ }
91
+
92
+ /** Rule S6: every heading steps down at most one level from the one before. */
93
+ function skipFindings(blocks: readonly Block[]): Finding[] {
94
+ const out: Finding[] = [];
95
+ // The page title is the `<h1>` above the body.
96
+ let previous: { level: number; label: string } = { level: 1, label: 'the page title (`#`)' };
97
+ for (const block of blocks) {
98
+ if (block.kind === 'content') continue;
99
+ if (block.kind === 'titled') {
100
+ if (block.level !== undefined) {
101
+ previous = { level: block.level, label: `the \`<h${block.level}>\` of \`<${block.name} />\`` };
102
+ }
103
+ continue;
104
+ }
105
+ if (block.level === 1) {
106
+ out.push({
107
+ code: 'heading-skip',
108
+ index: block.index,
109
+ token: block.token,
110
+ message:
111
+ `${block.label} is a \`#\` in the lesson body, but the page title ` +
112
+ '(`frontmatter.title`) is the lesson’s one `#`: the body’s headings start ' +
113
+ 'at `##` (rule S6). Make it `##`, or move the text into the title.',
114
+ });
115
+ } else if (block.level > previous.level + 1) {
116
+ const expected = '#'.repeat(previous.level + 1);
117
+ out.push({
118
+ code: 'heading-skip',
119
+ index: block.index,
120
+ token: block.token,
121
+ message:
122
+ `${block.label} follows ${previous.label}, skipping a level: headings ` +
123
+ `descend one level at a time (rule S6). Make it \`${expected}\`, or add ` +
124
+ `the \`${expected}\` section it belongs under.`,
125
+ });
126
+ }
127
+ previous = { level: block.level, label: block.label };
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Rule S7: a heading is followed by body content before the next heading. */
133
+ function adjacentFindings(blocks: readonly Block[], lesson: NotationLessonInput): Finding[] {
134
+ const out: Finding[] = [];
135
+ blocks.forEach((block, position) => {
136
+ if (block.kind !== 'heading') return;
137
+ const next = blocks[position + 1];
138
+ if (next === undefined || next.kind === 'content') return;
139
+ const line = lessonLine(lesson, next.index);
140
+ const followedBy =
141
+ next.kind === 'heading'
142
+ ? `${next.label} (line ${line})`
143
+ : `\`<${next.name}>\` (line ${line}), ${TITLED_COMPONENTS[next.name]!.what}`;
144
+ out.push({
145
+ code: 'heading-adjacent',
146
+ index: block.index,
147
+ token: block.token,
148
+ message:
149
+ `${block.label} is followed directly by ${followedBy}: a heading is ` +
150
+ 'followed by body content, never by another heading (rule S7). Write ' +
151
+ 'what the section is about before it goes on, or drop the heading that ' +
152
+ 'says nothing of its own.',
153
+ });
154
+ });
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Scan every lesson body's outline for rule S6 (`heading-skip`) and rule S7
160
+ * (`heading-adjacent`) findings. `levels` / `adjacency` switch each rule's
161
+ * half off, the way `rules.S6.enabled` / `rules.S7.enabled` do. Returns one
162
+ * `MathGateDiagnostic` (context `'structure'`, always an error) per finding,
163
+ * located at the heading that breaks the rule.
164
+ */
165
+ export function gateHeadingStructure(
166
+ lessons: readonly NotationLessonInput[],
167
+ options: { readonly levels?: boolean; readonly adjacency?: boolean } = {},
168
+ ): MathGateDiagnostic[] {
169
+ const { levels = true, adjacency = true } = options;
170
+ const diagnostics: MathGateDiagnostic[] = [];
171
+ if (!levels && !adjacency) return diagnostics;
172
+
173
+ for (const lesson of lessons) {
174
+ const blocks = blocksOf(lesson.body);
175
+ const findings = [
176
+ ...(levels ? skipFindings(blocks) : []),
177
+ ...(adjacency ? adjacentFindings(blocks, lesson) : []),
178
+ ].sort((a, b) => a.index - b.index);
179
+ for (const finding of findings) {
180
+ diagnostics.push({
181
+ context: 'structure',
182
+ code: finding.code,
183
+ severity: 'error',
184
+ file: lesson.source.file,
185
+ line: lessonLine(lesson, finding.index),
186
+ token: finding.token,
187
+ message: finding.message,
188
+ });
189
+ }
190
+ }
191
+
192
+ return diagnostics;
193
+ }