explico 0.8.0 → 0.11.1

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 +1257 -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 +5 -3
  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 +1152 -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 +1467 -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,456 @@
1
+ /**
2
+ * A course's own rules — `courseRules` in `course.settings.json`.
3
+ *
4
+ * The engine ships the `N` / `V` / `C` / `S` / `P` areas and a course tunes or
5
+ * turns them off through `rules` (`src/rules/settings.ts`). This is the other
6
+ * half: a classroom's house style, written and edited by the people who teach
7
+ * the course, with no engine change and no fork. `X` is permanently reserved
8
+ * for them, so an `X` rule reads like any other rule everywhere it appears —
9
+ * a diagnostic, a waiver, the rules-in-effect page, a review comment.
10
+ *
11
+ * ```json
12
+ * "courseRules": [
13
+ * {
14
+ * "id": "X1",
15
+ * "title": "A paragraph never ends in an exclamation.",
16
+ * "summary": "End a paragraph on a statement…",
17
+ * "severity": "error",
18
+ * "forbid": [{ "scope": "paragraph", "pattern": "Ni!$", "message": "…" }]
19
+ * },
20
+ * { "id": "X2", "title": "Every fifth paragraph is four sentences.", "enabled": false }
21
+ * ]
22
+ * ```
23
+ *
24
+ * A rule with a `forbid` pattern is **enforced**: the pattern is matched
25
+ * against prose the same way rule N9's gate sees it (`gate-course-rules.ts`),
26
+ * and each match is a diagnostic at the rule's `severity`, carrying the rule's
27
+ * ID as its code — so `enforcementOf` calls it `script`, and it can be waived
28
+ * at one place with `{/* explico-allow X1: reason *\/}` like any located rule.
29
+ * A rule with no pattern is a **guide**: it appears on the rules-in-effect page
30
+ * for an author or an agent to follow, and nothing runs. `enabled: false`
31
+ * keeps a rule in the file and moves it to the page's "Off" list — the way to
32
+ * park a house rule that is drafted but not yet in force.
33
+ *
34
+ * Everything here is validated strictly and throws: the settings file is the
35
+ * course's own, a mistake in it is a mistake the author wants to hear about
36
+ * before a build runs with a rule that silently does nothing.
37
+ */
38
+ import {
39
+ ELEMENTS,
40
+ isCourseRuleId,
41
+ type CourseRuleId,
42
+ type Element,
43
+ type RuleCheck,
44
+ type RuleEntry,
45
+ } from './catalog';
46
+
47
+ /** Where a `forbid` pattern is matched. */
48
+ export type CourseRuleScope = 'paragraph' | 'prose' | 'heading';
49
+
50
+ export const COURSE_RULE_SCOPES: readonly CourseRuleScope[] = ['paragraph', 'prose', 'heading'];
51
+
52
+ /** Regular-expression flags a course may set. `g` is always added. */
53
+ const ALLOWED_FLAGS = 'imsu';
54
+
55
+ /**
56
+ * One forbidden pattern, as the settings file gives it. `scope` is a plain
57
+ * `string` on purpose: this is the shape of untrusted JSON, and a
58
+ * `course.settings.json` imported by `course.config.ts` widens every literal to
59
+ * `string`, so a union here would make the course's own file fail to typecheck.
60
+ * The value is checked at resolve time, with the setting's path in the message.
61
+ */
62
+ export interface CourseRulePatternInput {
63
+ readonly pattern: string;
64
+ readonly flags?: string;
65
+ readonly scope?: string;
66
+ readonly message?: string;
67
+ }
68
+
69
+ /** One course rule, as the settings file gives it. See {@link CourseRulePatternInput}. */
70
+ export interface CourseRuleInput {
71
+ readonly id: string;
72
+ readonly title: string;
73
+ readonly summary?: string;
74
+ readonly elements?: readonly string[];
75
+ readonly enabled?: boolean;
76
+ readonly severity?: string;
77
+ readonly forbid?: readonly CourseRulePatternInput[];
78
+ readonly unchecked?: string;
79
+ readonly doc?: string;
80
+ }
81
+
82
+ /** `CourseConfig.courseRules` — an array, so the settings form can add and remove one. */
83
+ export type CourseRuleDefinitions = readonly CourseRuleInput[];
84
+
85
+ export interface CourseRulePattern {
86
+ readonly pattern: string;
87
+ readonly flags: string;
88
+ readonly scope: CourseRuleScope;
89
+ readonly message: string;
90
+ }
91
+
92
+ /** A course rule with every default applied and every value checked. */
93
+ export interface CourseRule {
94
+ readonly id: CourseRuleId;
95
+ readonly title: string;
96
+ readonly summary: string;
97
+ readonly elements: readonly [Element, ...Element[]];
98
+ readonly enabled: boolean;
99
+ readonly severity: 'error' | 'warning';
100
+ readonly forbid: readonly CourseRulePattern[];
101
+ readonly unchecked?: string;
102
+ readonly doc?: string;
103
+ }
104
+
105
+ /**
106
+ * Where a course rule's text lives when the course names no page of its own.
107
+ * Relative to `build/`, like every other link on the rules-in-effect page.
108
+ */
109
+ const SETTINGS_DOC = '../content/course.settings.json';
110
+
111
+ function describe(value: unknown): string {
112
+ return JSON.stringify(value) ?? String(value);
113
+ }
114
+
115
+ function at(index: number, id?: string): string {
116
+ return `CourseConfig courseRules[${index}]${id === undefined ? '' : ` (${id})`}`;
117
+ }
118
+
119
+ function requireString(value: unknown, where: string): string {
120
+ if (typeof value !== 'string' || value.trim() === '') {
121
+ throw new Error(`${where} must be a non-empty string; received ${describe(value)}.`);
122
+ }
123
+ return value;
124
+ }
125
+
126
+ function resolveElements(value: unknown, where: string): readonly [Element, ...Element[]] {
127
+ if (value === undefined) return ['prose'];
128
+ if (!Array.isArray(value) || value.length === 0) {
129
+ throw new Error(
130
+ `${where}.elements must be a non-empty array of elements; received ${describe(value)}.`,
131
+ );
132
+ }
133
+ for (const element of value) {
134
+ if (!(ELEMENTS as readonly unknown[]).includes(element)) {
135
+ throw new Error(
136
+ `${where}.elements: ${describe(element)} is not an element. One of: ${ELEMENTS.join(', ')}.`,
137
+ );
138
+ }
139
+ }
140
+ return value as unknown as readonly [Element, ...Element[]];
141
+ }
142
+
143
+ function resolvePattern(
144
+ input: unknown,
145
+ where: string,
146
+ index: number,
147
+ fallbackMessage: string,
148
+ ): CourseRulePattern {
149
+ const spot = `${where}.forbid[${index}]`;
150
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
151
+ throw new Error(
152
+ `${spot} must be an object such as { "pattern": "…" }; received ${describe(input)}.`,
153
+ );
154
+ }
155
+ const raw = input as Record<string, unknown>;
156
+ for (const key of Object.keys(raw)) {
157
+ if (!['pattern', 'flags', 'scope', 'message'].includes(key)) {
158
+ throw new Error(`${spot}: unknown key \`${key}\`. Takes: pattern, flags, scope, message.`);
159
+ }
160
+ }
161
+ const pattern = requireString(raw.pattern, `${spot}.pattern`);
162
+ const flags = raw.flags === undefined ? '' : requireString(raw.flags, `${spot}.flags`);
163
+ for (const flag of flags) {
164
+ if (!ALLOWED_FLAGS.includes(flag)) {
165
+ throw new Error(
166
+ `${spot}.flags: \`${flag}\` is not allowed. Use any of \`${ALLOWED_FLAGS}\` — \`g\` is always applied.`,
167
+ );
168
+ }
169
+ }
170
+ if (new Set(flags).size !== flags.length) {
171
+ throw new Error(`${spot}.flags: ${describe(flags)} repeats a flag.`);
172
+ }
173
+ const scope = raw.scope === undefined ? 'paragraph' : raw.scope;
174
+ if (!COURSE_RULE_SCOPES.includes(scope as CourseRuleScope)) {
175
+ throw new Error(
176
+ `${spot}.scope: ${describe(scope)} is not a scope. One of: ${COURSE_RULE_SCOPES.join(', ')}.`,
177
+ );
178
+ }
179
+ let compiled: RegExp;
180
+ try {
181
+ compiled = new RegExp(pattern, `${flags}g`);
182
+ } catch (error) {
183
+ throw new Error(
184
+ `${spot}.pattern: ${pattern} is not a valid regular expression — ${
185
+ error instanceof Error ? error.message : String(error)
186
+ }`,
187
+ );
188
+ }
189
+ // A pattern that matches nothing at all would flag every block of the corpus.
190
+ if (compiled.test('')) {
191
+ throw new Error(
192
+ `${spot}.pattern: ${pattern} matches the empty string, so it would flag every ${scope}. Make it match the text you mean to forbid.`,
193
+ );
194
+ }
195
+ const message =
196
+ raw.message === undefined ? fallbackMessage : requireString(raw.message, `${spot}.message`);
197
+ return { pattern, flags, scope: scope as CourseRuleScope, message };
198
+ }
199
+
200
+ /**
201
+ * Validate `config.courseRules` and apply the defaults. Throws on anything that
202
+ * would leave a rule doing something the author did not write: an ID outside
203
+ * the `X` namespace, a duplicate ID, an unknown key, a pattern that does not
204
+ * compile.
205
+ */
206
+ export function resolveCourseRules(config: {
207
+ readonly courseRules?: CourseRuleDefinitions;
208
+ }): readonly CourseRule[] {
209
+ const given = config.courseRules;
210
+ if (given === undefined) return [];
211
+ if (!Array.isArray(given)) {
212
+ throw new Error(
213
+ `CourseConfig courseRules must be an array of rules; received ${describe(given)}. ` +
214
+ 'It is a list, not a map, so the settings form can add and remove one.',
215
+ );
216
+ }
217
+
218
+ const seen = new Map<string, number>();
219
+ const out: CourseRule[] = [];
220
+
221
+ given.forEach((input, index) => {
222
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
223
+ throw new Error(
224
+ `${at(index)} must be an object such as { "id": "X1", "title": "…" }; received ${describe(input)}.`,
225
+ );
226
+ }
227
+ const raw = input as Record<string, unknown>;
228
+ const known = ['id', 'title', 'summary', 'elements', 'enabled', 'severity', 'forbid', 'unchecked', 'doc'];
229
+ for (const key of Object.keys(raw)) {
230
+ if (!known.includes(key)) {
231
+ throw new Error(`${at(index)}: unknown key \`${key}\`. Takes: ${known.join(', ')}.`);
232
+ }
233
+ }
234
+ const id = requireString(raw.id, `${at(index)}.id`);
235
+ if (!isCourseRuleId(id)) {
236
+ throw new Error(
237
+ `${at(index)}.id: ${describe(id)} is not a course-rule ID. A course's own rules are \`X1\`…\`X99\` — ` +
238
+ 'the `X` area is reserved for them, and the engine never ships one, so no release can collide with yours. ' +
239
+ 'To change an engine rule, use `rules` instead.',
240
+ );
241
+ }
242
+ const first = seen.get(id);
243
+ if (first !== undefined) {
244
+ throw new Error(
245
+ `${at(index, id)}.id: ${id} is already used by courseRules[${first}]. An ID names one rule.`,
246
+ );
247
+ }
248
+ seen.set(id, index);
249
+
250
+ const where = at(index, id);
251
+ const title = requireString(raw.title, `${where}.title`);
252
+ const summary = raw.summary === undefined ? title : requireString(raw.summary, `${where}.summary`);
253
+ const elements = resolveElements(raw.elements, where);
254
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
255
+ throw new Error(`${where}.enabled must be true or false; received ${describe(raw.enabled)}.`);
256
+ }
257
+ if (raw.severity !== undefined && raw.severity !== 'error' && raw.severity !== 'warning') {
258
+ throw new Error(
259
+ `${where}.severity must be "error" or "warning"; received ${describe(raw.severity)}.`,
260
+ );
261
+ }
262
+ const forbidInput = raw.forbid;
263
+ if (forbidInput !== undefined && !Array.isArray(forbidInput)) {
264
+ throw new Error(`${where}.forbid must be an array of patterns; received ${describe(forbidInput)}.`);
265
+ }
266
+ const forbid = (forbidInput ?? []).map((pattern, i) =>
267
+ resolvePattern(pattern, where, i, title),
268
+ );
269
+ if (raw.severity !== undefined && forbid.length === 0) {
270
+ throw new Error(
271
+ `${where}.severity is set but the rule has no \`forbid\` pattern, so nothing is reported. ` +
272
+ 'Add a pattern, or drop `severity` and keep it a guide rule.',
273
+ );
274
+ }
275
+
276
+ out.push({
277
+ id: id as CourseRuleId,
278
+ title,
279
+ summary,
280
+ elements,
281
+ enabled: raw.enabled !== false,
282
+ severity: (raw.severity as 'error' | 'warning' | undefined) ?? 'error',
283
+ forbid,
284
+ ...(raw.unchecked === undefined ? {} : { unchecked: requireString(raw.unchecked, `${where}.unchecked`) }),
285
+ ...(raw.doc === undefined ? {} : { doc: requireString(raw.doc, `${where}.doc`) }),
286
+ });
287
+ });
288
+
289
+ return out;
290
+ }
291
+
292
+ /**
293
+ * A course rule as a catalog entry, so the rules-in-effect page, the waiver
294
+ * validator and `enforcementOf` treat it like any other rule. Its one check
295
+ * carries the rule's ID as its code, and is `located` — every finding names a
296
+ * lesson file and line, which is what makes an inline waiver possible.
297
+ */
298
+ export function courseRuleEntry(rule: CourseRule): RuleEntry {
299
+ const checks: RuleCheck[] =
300
+ rule.forbid.length === 0
301
+ ? []
302
+ : [
303
+ {
304
+ code: rule.id,
305
+ severity: rule.severity,
306
+ located: true,
307
+ emitters: [
308
+ { module: 'src/reference/gate-course-rules.ts', fn: 'gateCourseRules' },
309
+ ],
310
+ },
311
+ ];
312
+ return {
313
+ kind: 'rule',
314
+ id: rule.id,
315
+ title: rule.title,
316
+ elements: rule.elements,
317
+ doc: rule.doc ?? SETTINGS_DOC,
318
+ summary: rule.summary,
319
+ checks,
320
+ ...(rule.unchecked === undefined ? {} : { unchecked: rule.unchecked }),
321
+ source: 'course',
322
+ };
323
+ }
324
+
325
+ // --- JSON Schema -------------------------------------------------------------
326
+
327
+ type JsonSchema = Record<string, unknown>;
328
+
329
+ /**
330
+ * The `courseRules` property for a course's `course.settings.schema.json`,
331
+ * written to `dist/rules/course-rules.schema.json` by
332
+ * `scripts/gen-rules-schema.mjs`. Self-contained (no `$ref`), so the settings
333
+ * form editor can inline it: an array of objects, which is what gives the form
334
+ * its add / remove / reorder controls.
335
+ */
336
+ export function courseRulesJsonSchema(): JsonSchema {
337
+ return {
338
+ title: 'Course rules',
339
+ description:
340
+ "This course's own authoring rules, on top of the engine's. IDs are `X1`…`X99` — the `X` area is reserved for a course, so no engine release collides with one. A rule with a `forbid` pattern is enforced by the build; a rule without one is a guide an author or an agent reads. Every rule here appears in `build/rules-in-effect.md` beside the engine's, and can be waived at one place with `{/* explico-allow X1: reason */}`.",
341
+ type: 'array',
342
+ default: [],
343
+ items: {
344
+ title: 'Course rule',
345
+ type: 'object',
346
+ additionalProperties: false,
347
+ required: ['id', 'title'],
348
+ properties: {
349
+ id: {
350
+ title: 'ID',
351
+ description:
352
+ 'Permanent name of this rule, `X1`…`X99`. It is what a diagnostic, a waiver and a review comment say. Never renumber a rule that is in use.',
353
+ type: 'string',
354
+ pattern: '^X[1-9][0-9]?$',
355
+ 'x-patternMessage': 'An `X` and a number, 1–99 — for example `X1`.',
356
+ },
357
+ title: {
358
+ title: 'Rule',
359
+ description:
360
+ 'One imperative sentence, as a rule page would open: "A paragraph never ends in an exclamation."',
361
+ type: 'string',
362
+ minLength: 1,
363
+ 'x-widget': 'textarea',
364
+ },
365
+ summary: {
366
+ title: 'What to do',
367
+ description:
368
+ 'One to three sentences telling an author or an agent how to comply — no rationale. This is what `build/rules-in-effect.md` prints. Defaults to the rule sentence.',
369
+ type: 'string',
370
+ 'x-widget': 'textarea',
371
+ },
372
+ elements: {
373
+ title: 'Elements',
374
+ description:
375
+ 'The parts of a page this rule is about; the first is primary and decides which section of the rules-in-effect page lists it.',
376
+ type: 'array',
377
+ default: ['prose'],
378
+ items: { title: 'Element', type: 'string', enum: [...ELEMENTS] },
379
+ },
380
+ enabled: {
381
+ title: 'Enabled',
382
+ description:
383
+ 'Off: the rule stays in this file and moves to the "Off" list on the rules-in-effect page — nothing runs, and an agent neither applies it nor flags it in review. Use it to park a rule that is drafted but not in force.',
384
+ type: 'boolean',
385
+ default: true,
386
+ },
387
+ severity: {
388
+ title: 'Severity',
389
+ description:
390
+ 'What a match of a `forbid` pattern is: an error fails `pnpm validate:content`, a warning is reported and does not. Only for a rule that has a pattern.',
391
+ type: 'string',
392
+ enum: ['error', 'warning'],
393
+ default: 'error',
394
+ },
395
+ forbid: {
396
+ title: 'Forbidden patterns',
397
+ description:
398
+ 'What makes this rule enforced. Each entry is a regular expression matched against the lesson text — with code, math, MDX comments and JSX tags blanked out first, exactly as rule N9 sees prose. Leave empty for a guide rule.',
399
+ type: 'array',
400
+ default: [],
401
+ items: {
402
+ title: 'Pattern',
403
+ type: 'object',
404
+ additionalProperties: false,
405
+ required: ['pattern'],
406
+ properties: {
407
+ pattern: {
408
+ title: 'Regular expression',
409
+ description:
410
+ 'JavaScript regular-expression source, e.g. `Ni!\\s*$`. It must not match the empty string.',
411
+ type: 'string',
412
+ minLength: 1,
413
+ },
414
+ scope: {
415
+ title: 'Matched against',
416
+ description:
417
+ 'paragraph — each block of prose separated by a blank line, so `^` and `$` are its start and end. prose — the lesson\'s whole prose, so a pattern can span paragraphs. heading — the text of each Markdown heading.',
418
+ type: 'string',
419
+ enum: [...COURSE_RULE_SCOPES],
420
+ default: 'paragraph',
421
+ },
422
+ flags: {
423
+ title: 'Flags',
424
+ description:
425
+ 'Any of `i` (ignore case), `m` (`^`/`$` match each line), `s` (`.` matches a newline), `u` (unicode). `g` is always applied.',
426
+ type: 'string',
427
+ pattern: '^[imsu]*$',
428
+ 'x-patternMessage': 'Only the letters i, m, s and u.',
429
+ },
430
+ message: {
431
+ title: 'Message',
432
+ description:
433
+ 'What the build says at each match. Defaults to the rule sentence. Say what to do, not only what is wrong.',
434
+ type: 'string',
435
+ 'x-widget': 'textarea',
436
+ },
437
+ },
438
+ },
439
+ },
440
+ unchecked: {
441
+ title: 'Not checked',
442
+ description:
443
+ 'What the patterns cannot see, in one clause ("that the four sentences are in OSV order"). On a rule that has patterns it marks the rule `partial`, so a reader knows a green build is not the whole rule; on a guide rule it says why no pattern could do the job.',
444
+ type: 'string',
445
+ 'x-widget': 'textarea',
446
+ },
447
+ doc: {
448
+ title: 'Full text',
449
+ description:
450
+ 'Link to the course page that states this rule in full, as it should appear on the rules-in-effect page (a path relative to `build/`, or a URL). Defaults to the settings file itself.',
451
+ type: 'string',
452
+ },
453
+ },
454
+ },
455
+ };
456
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The "rules in effect" page — the one short document an agent reads before
3
+ * drafting, instead of the 4,000-line rule set.
4
+ *
5
+ * Rendered from the catalog and the course's resolved settings
6
+ * (`manifest.rules`): every rule the course has *on* — the engine's, plus the
7
+ * course's own `X` rules from `courseRules` — grouped by the element it is
8
+ * about, with its enforcement, its diagnostic codes, its effective settings and
9
+ * a one-line summary; the invariants that are always on; and the rules the
10
+ * course turned off, which an agent neither applies nor flags.
11
+ * `pnpm validate:content` writes it to `build/rules-in-effect.md` (git-ignored,
12
+ * like the manifest — regenerated every run); `pnpm content rules` prints it,
13
+ * optionally filtered to an element or an enforcement kind.
14
+ */
15
+ import { mkdirSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import {
18
+ ELEMENTS,
19
+ enforcementOf,
20
+ invariants,
21
+ rules as catalogRules,
22
+ type CatalogEntry,
23
+ type Element,
24
+ type Enforcement,
25
+ type RuleEntry,
26
+ } from './catalog';
27
+ import { courseRuleEntry } from './course-rules';
28
+ import type { ManifestRules } from './settings';
29
+ import { isWaivable } from './waivers';
30
+
31
+ /** The slice of a manifest the page needs. */
32
+ export interface RulesInEffectInput {
33
+ readonly rules: ManifestRules;
34
+ readonly about: {
35
+ readonly engine: { readonly name: string; readonly version: string };
36
+ readonly material: { readonly name: string };
37
+ readonly knowledge: { readonly version: string };
38
+ };
39
+ }
40
+
41
+ export interface RulesFilter {
42
+ /** Keep a rule whose elements (any, not just primary) include one of these. */
43
+ readonly elements?: readonly Element[];
44
+ readonly enforcement?: Enforcement | 'locked';
45
+ }
46
+
47
+ /** One rule as `content rules --json` reports it. */
48
+ export interface RuleInEffect {
49
+ readonly id: string;
50
+ readonly kind: 'rule' | 'invariant';
51
+ /** `course` for one of this course's own `X` rules; `engine` for the rest. */
52
+ readonly source: 'engine' | 'course';
53
+ readonly title: string;
54
+ readonly elements: readonly Element[];
55
+ readonly enforcement: Enforcement | 'locked';
56
+ readonly enabled: boolean;
57
+ /** May be waived at one place with `{/* explico-allow <ID>: reason *\/}`. */
58
+ readonly waivable: boolean;
59
+ readonly codes: readonly string[];
60
+ readonly settings: Readonly<Record<string, unknown>>;
61
+ readonly summary: string;
62
+ readonly doc: string;
63
+ }
64
+
65
+ /** Where a course's copy of the rule text lives, relative to `build/`. */
66
+ const DOC_ROOT = '../node_modules/explico/docs/authoring';
67
+
68
+ /** An engine rule's page, or a course rule's own link, as written. */
69
+ function docLink(entry: CatalogEntry): string {
70
+ return entry.kind === 'rule' && entry.source === 'course' ? entry.doc : `${DOC_ROOT}/${entry.doc}`;
71
+ }
72
+
73
+ /**
74
+ * Every rule in force for this course: the engine's, then the course's own,
75
+ * which the manifest carries as written in `courseRules`. A retired rule is not
76
+ * in the catalog (`RetiredRule`), so there is nothing to filter out here.
77
+ */
78
+ function liveRules(input: RulesInEffectInput): RuleEntry[] {
79
+ return [...catalogRules(), ...input.rules.course.map(courseRuleEntry)];
80
+ }
81
+
82
+ function enforcementLabel(entry: CatalogEntry): Enforcement | 'locked' {
83
+ return entry.kind === 'rule' && entry.locked !== undefined ? 'locked' : enforcementOf(entry);
84
+ }
85
+
86
+ function matches(entry: CatalogEntry, filter: RulesFilter): boolean {
87
+ if (filter.elements && !entry.elements.some((e) => filter.elements!.includes(e))) return false;
88
+ if (filter.enforcement && enforcementLabel(entry) !== filter.enforcement) return false;
89
+ return true;
90
+ }
91
+
92
+ export function rulesInEffect(input: RulesInEffectInput, filter: RulesFilter = {}): RuleInEffect[] {
93
+ const off = new Set<string>(input.rules.disabled);
94
+ const entries: CatalogEntry[] = [...liveRules(input), ...invariants()];
95
+ return entries.filter((entry) => matches(entry, filter)).map((entry) => ({
96
+ id: entry.id,
97
+ kind: entry.kind,
98
+ source: entry.kind === 'rule' && entry.source === 'course' ? 'course' : 'engine',
99
+ title: entry.title,
100
+ elements: entry.elements,
101
+ enforcement: enforcementLabel(entry),
102
+ enabled: !off.has(entry.id),
103
+ waivable: entry.kind === 'rule' && isWaivable(entry),
104
+ codes: entry.checks.map((c) => c.code),
105
+ settings: entry.kind === 'rule' ? (input.rules.settings[entry.id] ?? {}) : {},
106
+ summary: entry.summary,
107
+ doc: docLink(entry),
108
+ }));
109
+ }
110
+
111
+ /**
112
+ * A setting's value as the page shows it. A string array is printed item by
113
+ * item in backticks rather than as JSON, because a LaTeX value
114
+ * (`\mathbb{N} — the naturals`) comes out of `JSON.stringify` with its
115
+ * backslash doubled — not what the author writes in the settings file.
116
+ */
117
+ function settingValue(value: unknown): string {
118
+ if (!Array.isArray(value)) return JSON.stringify(value) ?? String(value);
119
+ if (value.length === 0) return '[]';
120
+ const items = value.map((item) =>
121
+ typeof item === 'string' ? `\`${item}\`` : JSON.stringify(item),
122
+ );
123
+ return `[${items.join(', ')}]`;
124
+ }
125
+
126
+ function settingsText(rule: RuleEntry, settings: Readonly<Record<string, unknown>>): string {
127
+ const specs = rule.settings ?? [];
128
+ if (specs.length === 0) return '';
129
+ return ` (${specs.map((s) => `${s.key} = ${settingValue(settings[s.key])}`).join(', ')})`;
130
+ }
131
+
132
+ function ruleLine(rule: RuleEntry, settings: Readonly<Record<string, unknown>>): string {
133
+ const label = enforcementLabel(rule);
134
+ const codes = rule.checks.map((c) => `\`${c.code}\``).join(', ');
135
+ const head = [
136
+ `**${rule.id}**`,
137
+ rule.source === 'course' ? 'course' : '',
138
+ label,
139
+ codes,
140
+ isWaivable(rule) ? 'waivable' : '',
141
+ ]
142
+ .filter(Boolean)
143
+ .join(' · ');
144
+ const also = rule.elements.length > 1 ? ` (also: ${rule.elements.slice(1).join(', ')})` : '';
145
+ const unchecked = rule.unchecked ? ` Not checked by the build: ${rule.unchecked}` : '';
146
+ return `- ${head}${also} — ${rule.summary}${settingsText(rule, settings)}${unchecked} → [${rule.doc}](${docLink(rule)})`;
147
+ }
148
+
149
+ /**
150
+ * The page. With a filter, only the matching rules are listed and the
151
+ * header says so; the Off / Locked / Always-on sections still list IDs so
152
+ * the reader knows what was left out on purpose.
153
+ */
154
+ export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilter = {}): string {
155
+ const off = new Set<string>(input.rules.disabled);
156
+ const on = liveRules(input).filter((r) => !off.has(r.id));
157
+ const ours = on.filter((r) => r.source === 'course').length;
158
+ const locked = on.filter((r) => r.locked !== undefined).map((r) => r.id);
159
+ const filtered = filter.elements || filter.enforcement;
160
+ const visible = on.filter((r) => matches(r, filter));
161
+
162
+ const lines: string[] = [];
163
+ lines.push(
164
+ `# Rules in effect — ${input.about.material.name} (knowledge ${input.about.knowledge.version}, ${input.about.engine.name} ${input.about.engine.version})`,
165
+ '',
166
+ 'Generated by `pnpm validate:content` (and `pnpm content rules`) from the rule',
167
+ 'catalog and `rules` / `courseRules` in `course.settings.json`. Do not edit. Read this before',
168
+ 'drafting, and open a rule page only when it is listed here and you need the',
169
+ 'example: a rule absent from this page is either off for this course or does',
170
+ 'not exist, and is neither applied nor flagged in review.',
171
+ '',
172
+ `${on.length} rules on (${locked.length} locked${ours === 0 ? '' : `, ${ours} this course's own`}), ` +
173
+ `${off.size} off, ${invariants().length} invariants always on.` +
174
+ (filtered
175
+ ? ` Filtered: ${[
176
+ filter.elements ? `element ${filter.elements.join(', ')}` : '',
177
+ filter.enforcement ? `enforcement ${filter.enforcement}` : '',
178
+ ]
179
+ .filter(Boolean)
180
+ .join('; ')} — ${visible.length} shown.`
181
+ : ''),
182
+ '',
183
+ 'A line is `ID · enforcement · codes — what to do (settings in effect) → full',
184
+ 'text`. **course**: this course wrote the rule in `courseRules` in',
185
+ '`course.settings.json`, where it can be edited; the `X` area is reserved for',
186
+ 'them. **script**: the build reports every violation under those codes.',
187
+ '**partial**: the build reports the codes; the rest is on you. **guide**: nothing',
188
+ 'runs; it is on you. **locked**: script, and the course cannot turn it off.',
189
+ '**waivable**: break it at one place with `{/* explico-allow <ID>[, <ID>]: <reason> */}`',
190
+ 'on its own line above the block (a paragraph, heading, or tag; a blank line',
191
+ 'ends it) — the reason is mandatory, and a waiver that silences nothing is a',
192
+ 'warning. A finding without a line (a notation entry, a per-lesson count) is',
193
+ 'silenced per key in `lint-ignore.yml` (`- <code>:<key> — <reason>`) instead.',
194
+ );
195
+
196
+ for (const element of ELEMENTS) {
197
+ const here = visible.filter((r) => r.elements[0] === element);
198
+ if (here.length === 0) continue;
199
+ lines.push('', `## ${element}`, '');
200
+ for (const rule of here) lines.push(ruleLine(rule, input.rules.settings[rule.id] ?? {}));
201
+ }
202
+
203
+ const visibleInvariants = invariants().filter((i) => matches(i, filter));
204
+ lines.push('', '## Always on (invariants)', '');
205
+ if (visibleInvariants.length === 0) {
206
+ lines.push('- (none match the filter)');
207
+ }
208
+ for (const inv of visibleInvariants) {
209
+ const codes = inv.checks.map((c) => `\`${c.code}\``).join(', ');
210
+ lines.push(
211
+ `- **${inv.id}** (${inv.elements.join(', ')}) — ${inv.summary} Codes: ${codes}. → [${inv.doc}](${DOC_ROOT}/${inv.doc})`,
212
+ );
213
+ }
214
+
215
+ lines.push('', '## Off', '');
216
+ lines.push(
217
+ off.size === 0
218
+ ? '- Nothing — every rule is on.'
219
+ : `- ${[...off].join(', ')} — turned off in \`course.settings.json\` (\`rules\`, or \`courseRules\` for an \`X\` rule). Do not apply them; do not flag them in review.`,
220
+ );
221
+
222
+ lines.push('', '## Locked', '');
223
+ lines.push(`- ${locked.join(', ')} — cannot be turned off; listed above under their element.`);
224
+
225
+ return `${lines.join('\n')}\n`;
226
+ }
227
+
228
+ /** Write `build/rules-in-effect.md` under `root` (the course package). Returns the path. */
229
+ export function writeRulesInEffect(input: RulesInEffectInput, root = process.cwd()): string {
230
+ const dir = join(root, 'build');
231
+ mkdirSync(dir, { recursive: true });
232
+ const path = join(dir, 'rules-in-effect.md');
233
+ writeFileSync(path, renderRulesInEffect(input));
234
+ return path;
235
+ }