explico 0.13.1 → 0.15.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 (256) hide show
  1. package/CHANGELOG.md +990 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +100 -0
  6. package/dist/compiler/cli-main.js +433 -0
  7. package/dist/compiler/cli-reference.d.ts +50 -0
  8. package/dist/compiler/cli-reference.js +213 -0
  9. package/dist/compiler/cli-verbs.d.ts +45 -0
  10. package/dist/compiler/cli-verbs.js +224 -0
  11. package/dist/compiler/cli-verify.d.ts +51 -0
  12. package/dist/compiler/cli-verify.js +151 -0
  13. package/dist/compiler/cli.d.ts +408 -24
  14. package/dist/compiler/cli.js +1328 -8
  15. package/dist/compiler/collections.d.ts +88 -1
  16. package/dist/compiler/collections.js +170 -25
  17. package/dist/compiler/content-release.d.ts +49 -0
  18. package/dist/compiler/content-release.js +63 -0
  19. package/dist/compiler/content-schemas.d.ts +2 -2
  20. package/dist/compiler/course-config.d.ts +1 -1
  21. package/dist/compiler/course-config.js +105 -1
  22. package/dist/compiler/decks.d.ts +161 -0
  23. package/dist/compiler/decks.js +235 -0
  24. package/dist/compiler/exports/combined-html.d.ts +5 -1
  25. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  26. package/dist/compiler/exports/deck-fit.js +90 -0
  27. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  28. package/dist/compiler/exports/export-chapters.js +64 -5
  29. package/dist/compiler/exports/export-pages.d.ts +30 -3
  30. package/dist/compiler/exports/export-pages.js +24 -1
  31. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  32. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  33. package/dist/compiler/exports/latex/cache.js +129 -0
  34. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  35. package/dist/compiler/exports/latex/capture.js +3 -1
  36. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  37. package/dist/compiler/exports/latex/compile.js +12 -1
  38. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  39. package/dist/compiler/exports/latex/deck.js +349 -0
  40. package/dist/compiler/exports/latex/document.d.ts +51 -6
  41. package/dist/compiler/exports/latex/document.js +71 -12
  42. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  43. package/dist/compiler/exports/latex/explico.cls +13 -183
  44. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  45. package/dist/compiler/exports/latex/serialize.js +4 -2
  46. package/dist/compiler/exports/latex-export.d.ts +24 -2
  47. package/dist/compiler/exports/latex-export.js +107 -26
  48. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  49. package/dist/compiler/exports/markdown/serialize.js +85 -8
  50. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  51. package/dist/compiler/exports/markdown-export.js +50 -11
  52. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  53. package/dist/compiler/exports/standalone-html.js +3 -3
  54. package/dist/compiler/exports/static-tree.d.ts +38 -1
  55. package/dist/compiler/exports/static-tree.js +120 -4
  56. package/dist/compiler/knowledge-version.d.ts +26 -2
  57. package/dist/compiler/knowledge-version.js +27 -17
  58. package/dist/compiler/lesson-derivation.d.ts +143 -6
  59. package/dist/compiler/lesson-derivation.js +145 -13
  60. package/dist/compiler/manifest.d.ts +133 -4
  61. package/dist/compiler/manifest.js +364 -58
  62. package/dist/compiler/page-versions.d.ts +184 -0
  63. package/dist/compiler/page-versions.js +217 -0
  64. package/dist/compiler/render-diagnostics.d.ts +5 -1
  65. package/dist/compiler/resolution-report.d.ts +24 -6
  66. package/dist/compiler/resolution-report.js +22 -4
  67. package/dist/compiler/validate-content.d.ts +7 -0
  68. package/dist/compiler/validate-content.js +160 -0
  69. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  70. package/dist/curriculum/competency-graph.d.ts +54 -0
  71. package/dist/curriculum/competency-graph.js +41 -0
  72. package/dist/reference/base-library.d.mts +8 -0
  73. package/dist/reference/base-library.mjs +19 -0
  74. package/dist/reference/consistency.d.ts +8 -4
  75. package/dist/reference/consistency.js +82 -26
  76. package/dist/reference/deck-remark.d.mts +123 -0
  77. package/dist/reference/deck-remark.mjs +600 -0
  78. package/dist/reference/deck-source.d.ts +107 -0
  79. package/dist/reference/deck-source.js +140 -0
  80. package/dist/reference/decks-validate.d.ts +133 -0
  81. package/dist/reference/decks-validate.js +359 -0
  82. package/dist/reference/derived-entries.d.ts +5 -1
  83. package/dist/reference/diagram-fit.d.ts +6 -2
  84. package/dist/reference/diagram-fit.js +5 -1
  85. package/dist/reference/figure-symbols.d.ts +23 -6
  86. package/dist/reference/figure-symbols.js +51 -15
  87. package/dist/reference/float-name.d.ts +43 -0
  88. package/dist/reference/float-name.js +58 -0
  89. package/dist/reference/folds-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-indices.js +5 -4
  92. package/dist/reference/gate-math.d.ts +29 -2
  93. package/dist/reference/gate-math.js +99 -18
  94. package/dist/reference/gate-part-pages.d.ts +1 -1
  95. package/dist/reference/lab-katex-validate.d.ts +10 -6
  96. package/dist/reference/lab-katex-validate.js +17 -9
  97. package/dist/reference/lab-math-scope.js +29 -4
  98. package/dist/reference/math-glyphs.d.mts +25 -0
  99. package/dist/reference/math-glyphs.mjs +189 -25
  100. package/dist/reference/page-notation.d.ts +22 -3
  101. package/dist/reference/page-notation.js +32 -10
  102. package/dist/reference/references.d.ts +14 -1
  103. package/dist/reference/references.js +13 -0
  104. package/dist/reference/registry-scope.d.mts +33 -0
  105. package/dist/reference/registry-scope.mjs +34 -0
  106. package/dist/reference/registry.js +29 -10
  107. package/dist/reference/rehype-deck.d.mts +6 -0
  108. package/dist/reference/rehype-deck.mjs +286 -0
  109. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  110. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  111. package/dist/reference/remark-notation.mjs +249 -40
  112. package/dist/reference/source-lines.d.mts +12 -0
  113. package/dist/reference/source-lines.mjs +65 -0
  114. package/dist/reference/types.d.ts +28 -0
  115. package/dist/rules/catalog.d.ts +21 -4
  116. package/dist/rules/catalog.js +237 -2
  117. package/dist/rules/course-rules.d.ts +5 -1
  118. package/dist/rules/course-rules.js +17 -3
  119. package/dist/rules/course-rules.schema.json +12 -0
  120. package/dist/rules/in-effect.d.ts +13 -3
  121. package/dist/rules/in-effect.js +34 -10
  122. package/dist/rules/rules.schema.json +280 -1
  123. package/dist/rules/settings.d.ts +1 -1
  124. package/dist/session/PreferenceStore.d.ts +6 -0
  125. package/dist/session/PreferenceStore.js +18 -1
  126. package/dist/session/deck-memory.d.ts +116 -0
  127. package/dist/session/deck-memory.js +143 -0
  128. package/dist/session/reading-memory.d.ts +96 -0
  129. package/dist/session/reading-memory.js +133 -0
  130. package/docs/VERSIONING.md +21 -18
  131. package/docs/authoring/README.md +31 -9
  132. package/docs/authoring/agents.md +72 -18
  133. package/docs/authoring/cli-reference.json +2172 -0
  134. package/docs/authoring/cli.md +887 -0
  135. package/docs/authoring/components.md +15 -0
  136. package/docs/authoring/deck.md +612 -0
  137. package/docs/authoring/migrating.md +1195 -16
  138. package/docs/authoring/notation.md +49 -6
  139. package/docs/authoring/structure.md +80 -1
  140. package/docs/authoring/versioning.md +56 -3
  141. package/docs/authoring/visualizations.md +7 -2
  142. package/docs/course-agent/AGENTS.md +40 -0
  143. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  144. package/docs/course-agent/skills/draft-lesson/SKILL.md +38 -0
  145. package/docs/course-agent/skills/migrate-course/SKILL.md +32 -0
  146. package/docs/course-agent/skills/release-content/SKILL.md +26 -0
  147. package/docs/diagram-scaling.md +148 -0
  148. package/docs/privacy.md +59 -0
  149. package/package.json +8 -2
  150. package/src/compiler/astro.ts +60 -21
  151. package/src/compiler/cli-main.ts +557 -0
  152. package/src/compiler/cli-reference.ts +285 -0
  153. package/src/compiler/cli-verbs.ts +270 -0
  154. package/src/compiler/cli-verify.ts +241 -0
  155. package/src/compiler/cli.ts +2119 -170
  156. package/src/compiler/collections.ts +284 -25
  157. package/src/compiler/content-release.ts +91 -0
  158. package/src/compiler/course-config.ts +267 -0
  159. package/src/compiler/decks.ts +455 -0
  160. package/src/compiler/exports/deck-fit.ts +127 -0
  161. package/src/compiler/exports/export-chapters.ts +105 -6
  162. package/src/compiler/exports/export-pages.ts +46 -2
  163. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  164. package/src/compiler/exports/latex/cache.ts +183 -0
  165. package/src/compiler/exports/latex/capture.ts +5 -2
  166. package/src/compiler/exports/latex/compile.ts +18 -0
  167. package/src/compiler/exports/latex/deck.ts +419 -0
  168. package/src/compiler/exports/latex/document.ts +117 -21
  169. package/src/compiler/exports/latex/explico-common.sty +198 -0
  170. package/src/compiler/exports/latex/explico.cls +13 -183
  171. package/src/compiler/exports/latex/serialize.ts +25 -21
  172. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  173. package/src/compiler/exports/latex-export.ts +187 -45
  174. package/src/compiler/exports/markdown/serialize.ts +118 -9
  175. package/src/compiler/exports/markdown-export.ts +56 -11
  176. package/src/compiler/exports/standalone-html.ts +8 -7
  177. package/src/compiler/exports/static-tree.ts +175 -3
  178. package/src/compiler/knowledge-version.ts +56 -21
  179. package/src/compiler/lesson-derivation.ts +282 -16
  180. package/src/compiler/manifest.ts +568 -63
  181. package/src/compiler/page-versions.ts +401 -0
  182. package/src/compiler/resolution-report.ts +38 -7
  183. package/src/compiler/validate-content.ts +215 -0
  184. package/src/components/CourseContents.astro +124 -6
  185. package/src/components/CurriculumMap.astro +49 -1
  186. package/src/components/FloatIndex.astro +1 -1
  187. package/src/components/PartOverview.astro +51 -5
  188. package/src/components/assessments/AssessmentSet.astro +41 -15
  189. package/src/components/block-heading.ts +15 -6
  190. package/src/components/card-carry.ts +7 -4
  191. package/src/components/card-manager.ts +14 -2
  192. package/src/components/card-memory.ts +13 -9
  193. package/src/components/deck/DeckEnhancer.astro +13 -0
  194. package/src/components/deck/deck-context.ts +104 -0
  195. package/src/components/deck/deck-controller.ts +1287 -0
  196. package/src/components/deck/deck-map.ts +91 -0
  197. package/src/components/deck-lane.ts +163 -0
  198. package/src/components/diagram/Diagram.astro +131 -86
  199. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  200. package/src/components/figure/Figure.astro +7 -2
  201. package/src/components/figure/FloatCaption.astro +5 -3
  202. package/src/components/fold/Fold.astro +2 -2
  203. package/src/components/notation/NotationFormula.astro +81 -11
  204. package/src/components/notation/NotationGlossary.astro +132 -1
  205. package/src/components/notation/NotationLayer.astro +15 -7
  206. package/src/components/starlight/AboutPanel.astro +186 -3
  207. package/src/components/starlight/CollapsibleSections.astro +24 -3
  208. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  209. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  210. package/src/components/starlight/LayoutHeader.astro +17 -0
  211. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  212. package/src/components/starlight/LayoutSidebar.astro +807 -21
  213. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  214. package/src/components/starlight/LessonExportActions.astro +27 -9
  215. package/src/components/starlight/LessonFooter.astro +59 -0
  216. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  217. package/src/components/starlight/TextSizeControl.astro +24 -13
  218. package/src/components/starlight/page-marks.ts +139 -0
  219. package/src/components/starlight/section-summary.ts +1 -1
  220. package/src/components/starlight/sidebar-editions.ts +245 -0
  221. package/src/components/table/Table.astro +15 -7
  222. package/src/content-config.ts +87 -1
  223. package/src/curriculum/competency-graph.ts +95 -0
  224. package/src/reference/base-library.mjs +19 -0
  225. package/src/reference/consistency.ts +137 -39
  226. package/src/reference/deck-remark.mjs +600 -0
  227. package/src/reference/deck-source.ts +273 -0
  228. package/src/reference/decks-validate.ts +604 -0
  229. package/src/reference/diagram-fit.ts +18 -4
  230. package/src/reference/diagram-layout.ts +3 -1
  231. package/src/reference/figure-symbols.ts +108 -31
  232. package/src/reference/folds-validate.ts +1 -1
  233. package/src/reference/gate-indices.ts +5 -3
  234. package/src/reference/gate-math.ts +169 -25
  235. package/src/reference/lab-katex-validate.ts +30 -15
  236. package/src/reference/lab-math-scope.ts +34 -3
  237. package/src/reference/math-glyphs.mjs +189 -25
  238. package/src/reference/page-notation.ts +69 -15
  239. package/src/reference/references.ts +28 -0
  240. package/src/reference/registry-scope.mjs +34 -0
  241. package/src/reference/registry.ts +41 -11
  242. package/src/reference/rehype-deck.mjs +286 -0
  243. package/src/reference/rehype-heading-anchors.mjs +48 -6
  244. package/src/reference/remark-notation.mjs +249 -40
  245. package/src/reference/source-lines.mjs +65 -0
  246. package/src/reference/types.ts +28 -0
  247. package/src/rules/catalog.ts +274 -4
  248. package/src/rules/course-rules.ts +21 -1
  249. package/src/rules/in-effect.ts +58 -11
  250. package/src/session/PreferenceStore.ts +23 -0
  251. package/src/session/deck-memory.ts +263 -0
  252. package/src/session/reading-memory.ts +243 -0
  253. package/src/styles/deck.css +1150 -0
  254. package/src/styles/global.css +958 -27
  255. package/src/styles/zones.css +277 -0
  256. /package/src/{components → reference}/float-name.ts +0 -0
@@ -9,7 +9,10 @@
9
9
  * snapshot. Nothing here changes content. A finding is silenced only by a
10
10
  * reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
11
11
  *
12
- * (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
12
+ * (a) glyph-unique-in-corpus one rendered glyph → one shared meaning; a
13
+ * local card or a gloss may reuse a letter
14
+ * (a') definition-repeated one local key declared in two lessons
15
+ * (a'') meaning-repeated one symbol and name defined at two sites, any tier
13
16
  * (b) notation-units / units-vocab every entry has units or `dimensionless`,
14
17
  * unit strings drawn from a controlled vocab
15
18
  * (f) weak-local a `notation.local` symbol used only in its
@@ -35,6 +38,7 @@
35
38
  * `src/content/resolution-report.ts`.
36
39
  */
37
40
 
41
+ import type { LocalNotationDefinitionInput } from './types';
38
42
  import type {
39
43
  CourseAccents,
40
44
  CourseOperators,
@@ -70,7 +74,8 @@ export type ConsistencyCode =
70
74
  | 'units-vocab'
71
75
  | 'weak-local'
72
76
  | 'notation-source-locator'
73
- | 'gloss-wants-promoting'
77
+ | 'definition-repeated'
78
+ | 'meaning-repeated'
74
79
  | 'card-wants-demoting'
75
80
  | 'gloss-name-shape'
76
81
  | 'operator-explained'
@@ -142,7 +147,7 @@ function mathSpans(body: string): MathSpan[] {
142
147
 
143
148
  // --- glyph regex for weak-local --------------------------------------
144
149
 
145
- function glyphMatcher(latex: string): RegExp | undefined {
150
+ export function glyphMatcher(latex: string): RegExp | undefined {
146
151
  const trimmed = latex.trim();
147
152
  if (trimmed === '') return undefined;
148
153
  if (/^[A-Za-z]$/.test(trimmed)) {
@@ -175,15 +180,19 @@ function checkGlyphUniqueness(
175
180
  glyphs.set(trimmed, keys);
176
181
  };
177
182
 
183
+ // Shared cards only: a vocabulary glyph has one meaning across the course.
184
+ // A `notation.local` entry or a gloss that reuses a letter is the declared
185
+ // exception — the tier exists for that — so it is never recorded here;
186
+ // `definition-repeated` and `meaning-repeated` watch those for copies.
178
187
  for (const definition of input.registry.definitions) {
179
- if (definition.kind === 'local') {
180
- record(definition.notation, definition.key, definition.lessonId);
188
+ if (definition.kind === 'shared') {
189
+ record(definition.notation, definition.key, '');
181
190
  }
182
191
  }
183
192
  for (const bundle of input.registry.bundles) {
184
193
  for (const binding of bundle.bindings) {
185
194
  const definition = byId.get(binding.definitionId);
186
- if (definition) record(definition.notation, binding.key, bundle.lessonId);
195
+ if (definition?.kind === 'shared') record(definition.notation, binding.key, bundle.lessonId);
187
196
  }
188
197
  }
189
198
 
@@ -194,13 +203,16 @@ function checkGlyphUniqueness(
194
203
  if (input.lintIgnore.matches('glyph-unique-in-corpus', glyph)) continue;
195
204
  const detail = [...keys]
196
205
  .sort(([a], [b]) => a.localeCompare(b))
197
- .map(([key, lessons]) => `${key} (${[...lessons].sort().join(', ')})`)
206
+ .map(([key, lessons]) => {
207
+ const bound = [...lessons].filter((lesson) => lesson !== '').sort();
208
+ return `${key} (${bound.length > 0 ? bound.join(', ') : 'no lesson binds it'})`;
209
+ })
198
210
  .join('; ');
199
211
  out.push({
200
212
  code: 'glyph-unique-in-corpus',
201
213
  severity: 'warning',
202
214
  glyph,
203
- message: `glyph ${JSON.stringify(glyph)} resolves to ${keys.size} meanings across the corpus: ${detail}`,
215
+ message: `glyph ${JSON.stringify(glyph)} is ${keys.size} shared cards: ${detail}; course vocabulary gives a glyph one meaning — keep one card, or make the page-bound one a notation.local entry`,
204
216
  });
205
217
  }
206
218
  }
@@ -286,50 +298,134 @@ function checkNotationSourceLocator(
286
298
  }
287
299
 
288
300
  /**
289
- * The two-tier vocabulary needs both directions kept honest (D15).
301
+ * The tier model needs both directions kept honest (D15). A meaning is
302
+ * declared once: a shared card unless the letter already means something
303
+ * else in the course, and only then `notation.local` or a gloss. The sign
304
+ * that a meaning is not page-bound is that it got defined a second time —
305
+ * so the lints watch for copies, never for how often a symbol is used.
290
306
  *
291
- * A gloss is cheap on purpose, and cheapness invites copying: the same symbol
292
- * and name glossed on three cards is a shared meaning that should be a card,
293
- * reviewed once, with sources and a curriculum home. `GLOSS_PROMOTION_LIMIT`
294
- * is where "a letter this formula needs" stops being a plausible reading.
307
+ * `definition-repeated`: one local key declared in two or more lessons. Either
308
+ * it is one meaning, so it belongs in `content/notation/<key>.md` (every
309
+ * `[[key]]` keeps working), or it is two, so one needs another key. A deck
310
+ * inherits its chapters' entries rather than redeclaring them, so decks never
311
+ * count. When a shared card already owns the glyph, the entry is a reused
312
+ * letter (N4) and promoting it would put two shared cards on one glyph, so
313
+ * the message says that instead of "move one copy".
295
314
  */
296
- const GLOSS_PROMOTION_LIMIT = 3;
315
+ function checkDefinitionRepeated(
316
+ input: ConsistencyInput,
317
+ out: ConsistencyDiagnostic[],
318
+ ): void {
319
+ const byKey = new Map<string, { lessonId: string; file: string; definition: LocalNotationDefinitionInput }[]>();
320
+ // A deck resolves in its chapters' scopes (`scopeLessonIds`) and is no
321
+ // declaration site of its own, whatever its frontmatter repeats.
322
+ const chapters = input.notationInput.lessons.filter((lesson) => lesson.scopeLessonIds === undefined);
323
+ for (const lesson of [...chapters].sort((a, b) => a.lessonId.localeCompare(b.lessonId))) {
324
+ for (const definition of lesson.localDefinitions) {
325
+ const list = byKey.get(definition.key) ?? [];
326
+ if (!list.some((entry) => entry.lessonId === lesson.lessonId)) {
327
+ list.push({ lessonId: lesson.lessonId, file: lesson.source.file, definition });
328
+ }
329
+ byKey.set(definition.key, list);
330
+ }
331
+ }
332
+ const sharedByGlyph = new Map<string, string>();
333
+ for (const definition of input.notationInput.sharedDefinitions) {
334
+ if (!sharedByGlyph.has(definition.notation.trim())) sharedByGlyph.set(definition.notation.trim(), definition.key);
335
+ }
336
+ for (const [key, copies] of [...byKey].sort(([a], [b]) => a.localeCompare(b))) {
337
+ if (copies.length < 2) continue;
338
+ if (input.lintIgnore.matches('definition-repeated', key)) continue;
339
+ const first = copies[0]!;
340
+ const differing = (['notation', 'label', 'summary', 'units', 'sources'] as const).filter((field) =>
341
+ copies.some((copy) => JSON.stringify(copy.definition[field] ?? null) !== JSON.stringify(first.definition[field] ?? null)),
342
+ );
343
+ const lessons = copies.map((copy) => copy.lessonId).sort();
344
+ const owner = sharedByGlyph.get(first.definition.notation.trim());
345
+ const advice =
346
+ owner !== undefined && owner !== key
347
+ ? ` — its glyph is already shared card ${owner}'s, so it is a reused letter (rule N4) and promoting it would put two shared cards on one glyph (glyph-unique-in-corpus): keep the copies local and acknowledge them in lint-ignore.yml ("definition-repeated:${key} — <why>"), or, if they are two meanings, give one another key`
348
+ : ` — move one copy to content/notation/${key}.md and delete the others: every [[${key}]] and \\explain{${key}} keeps working; if they are two meanings, give one another key`;
349
+ out.push({
350
+ code: 'definition-repeated',
351
+ severity: 'warning',
352
+ key,
353
+ lessonId: first.lessonId,
354
+ file: first.file,
355
+ count: copies.length,
356
+ message:
357
+ `local entry ${key} (${first.definition.notation}) is declared in ${copies.length} lessons (${lessons.join(', ')}); ` +
358
+ (differing.length === 0
359
+ ? 'the copies agree'
360
+ : `the copies differ in ${differing.join(', ')}`) +
361
+ advice,
362
+ });
363
+ }
364
+ }
297
365
 
298
- function checkGlossWantsPromoting(
366
+ /**
367
+ * `meaning-repeated`: one symbol with one name defined at two or more sites,
368
+ * in any tier — a local card and a gloss, two glosses, a local card beside a
369
+ * shared card that already says it. A second definition of the same meaning
370
+ * is the signal that it is vocabulary; a shared card serves every site, and
371
+ * where one exists already the page should bind it instead.
372
+ */
373
+ function checkMeaningRepeated(
299
374
  input: ConsistencyInput,
300
375
  out: ConsistencyDiagnostic[],
301
376
  ): void {
302
- const owners = new Map<string, { label: string; keys: Set<string> }>();
303
- const record = (
304
- ownerKey: string,
305
- gloss: { notation: string; label: string },
306
- ) => {
307
- const id = `${gloss.notation}\u0000${gloss.label.toLowerCase()}`;
308
- const current = owners.get(id) ?? { label: gloss.label, keys: new Set() };
309
- current.keys.add(ownerKey);
310
- owners.set(id, current);
377
+ interface Site {
378
+ readonly tier: 'shared' | 'local' | 'gloss';
379
+ readonly key: string;
380
+ readonly where: string;
381
+ }
382
+ const normalize = (label: string) => label.toLowerCase().replace(/\s+/g, ' ').trim();
383
+ const groups = new Map<string, { notation: string; label: string; sites: Site[] }>();
384
+ const record = (notation: string, label: string, site: Site) => {
385
+ const id = `${notation.trim()}\u0000${normalize(label)}`;
386
+ const group = groups.get(id) ?? { notation: notation.trim(), label, sites: [] };
387
+ if (!group.sites.some((known) => known.tier === site.tier && known.key === site.key && known.where === site.where)) {
388
+ group.sites.push(site);
389
+ }
390
+ groups.set(id, group);
311
391
  };
312
-
313
392
  for (const definition of input.notationInput.sharedDefinitions) {
314
- for (const gloss of definition.glosses) record(definition.key, gloss);
393
+ record(definition.notation, definition.label, { tier: 'shared', key: definition.key, where: `card ${definition.key}` });
394
+ for (const gloss of definition.glosses) {
395
+ record(gloss.notation, gloss.label, { tier: 'gloss', key: gloss.key, where: `gloss on ${definition.key}` });
396
+ }
315
397
  }
316
398
  for (const lesson of input.notationInput.lessons) {
399
+ if (lesson.scopeLessonIds !== undefined) continue; // a deck declares nothing of its own
317
400
  for (const definition of lesson.localDefinitions) {
318
- for (const gloss of definition.glosses) record(definition.key, gloss);
401
+ record(definition.notation, definition.label, {
402
+ tier: 'local',
403
+ key: definition.key,
404
+ where: `local ${definition.key} on ${lesson.lessonId}`,
405
+ });
406
+ for (const gloss of definition.glosses) {
407
+ record(gloss.notation, gloss.label, { tier: 'gloss', key: gloss.key, where: `gloss on ${definition.key} (${lesson.lessonId})` });
408
+ }
319
409
  }
320
410
  }
321
-
322
- for (const [id, { label, keys }] of [...owners].sort(([a], [b]) =>
323
- a.localeCompare(b),
324
- )) {
325
- if (keys.size < GLOSS_PROMOTION_LIMIT) continue;
326
- const glyph = id.split('\u0000')[0] ?? '';
327
- if (input.lintIgnore.matches('gloss-wants-promoting', glyph)) continue;
411
+ for (const [, group] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
412
+ if (group.sites.length < 2) continue;
413
+ // One local key copied across lessons is `definition-repeated`'s finding.
414
+ if (group.sites.every((site) => site.tier === 'local' && site.key === group.sites[0]!.key)) continue;
415
+ if (input.lintIgnore.matches('meaning-repeated', group.notation)) continue;
416
+ const shared = group.sites.find((site) => site.tier === 'shared');
328
417
  out.push({
329
- code: 'gloss-wants-promoting',
418
+ code: 'meaning-repeated',
330
419
  severity: 'info',
331
- glyph,
332
- message: `gloss ${JSON.stringify(glyph)} (${label}) is declared on ${keys.size} entries (${[...keys].sort().join(', ')}); a meaning reused this widely wants promoting to a card`,
420
+ glyph: group.notation,
421
+ count: group.sites.length,
422
+ message:
423
+ `${group.notation} as ${JSON.stringify(group.label)} is defined ${group.sites.length} times: ${group.sites
424
+ .map((site) => site.where)
425
+ .join('; ')}` +
426
+ (shared
427
+ ? ` — ${shared.key} is the shared card; bind it with [[${shared.key}]] (or \\explain{${shared.key}}{${group.notation}} in a formula) instead of declaring it again`
428
+ : ' — one shared card would serve every site; promote it and reference it'),
333
429
  });
334
430
  }
335
431
  }
@@ -726,7 +822,8 @@ const CHECKS: ((
726
822
  checkNotationUnits,
727
823
  checkWeakLocal,
728
824
  checkNotationSourceLocator,
729
- checkGlossWantsPromoting,
825
+ checkDefinitionRepeated,
826
+ checkMeaningRepeated,
730
827
  checkCardWantsDemoting,
731
828
  checkGlossNameShape,
732
829
  checkOperatorExplained,
@@ -765,7 +862,8 @@ export function consistencyCounts(
765
862
  'units-vocab': 0,
766
863
  'weak-local': 0,
767
864
  'notation-source-locator': 0,
768
- 'gloss-wants-promoting': 0,
865
+ 'definition-repeated': 0,
866
+ 'meaning-repeated': 0,
769
867
  'card-wants-demoting': 0,
770
868
  'gloss-name-shape': 0,
771
869
  'operator-explained': 0,