lens-content-processor 0.78.0 → 0.80.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 (133) hide show
  1. package/dist/authoring-markup.d.ts +22 -0
  2. package/dist/authoring-markup.js +24 -0
  3. package/dist/authoring-markup.js.map +1 -1
  4. package/dist/cache.d.ts +10 -0
  5. package/dist/cache.js +6 -5
  6. package/dist/cache.js.map +1 -1
  7. package/dist/cli-steps.d.ts +37 -0
  8. package/dist/cli-steps.js +80 -0
  9. package/dist/cli-steps.js.map +1 -0
  10. package/dist/cli.d.ts +7 -0
  11. package/dist/cli.js +52 -70
  12. package/dist/cli.js.map +1 -1
  13. package/dist/course-graph.d.ts +2 -2
  14. package/dist/course-graph.js +7 -2
  15. package/dist/course-graph.js.map +1 -1
  16. package/dist/criticmarkup-forms.d.ts +8 -6
  17. package/dist/criticmarkup-forms.js +22 -19
  18. package/dist/criticmarkup-forms.js.map +1 -1
  19. package/dist/dedupe-errors.d.ts +16 -0
  20. package/dist/dedupe-errors.js +23 -9
  21. package/dist/dedupe-errors.js.map +1 -1
  22. package/dist/dependency-cache.d.ts +15 -5
  23. package/dist/dependency-cache.js +33 -74
  24. package/dist/dependency-cache.js.map +1 -1
  25. package/dist/dropped.d.ts +114 -0
  26. package/dist/dropped.js +253 -0
  27. package/dist/dropped.js.map +1 -0
  28. package/dist/engine/assemble.d.ts +44 -0
  29. package/dist/engine/assemble.js +132 -0
  30. package/dist/engine/assemble.js.map +1 -0
  31. package/dist/engine/build.d.ts +8 -0
  32. package/dist/engine/build.js +558 -0
  33. package/dist/engine/build.js.map +1 -0
  34. package/dist/engine/file-facts.d.ts +26 -0
  35. package/dist/engine/file-facts.js +63 -0
  36. package/dist/engine/file-facts.js.map +1 -0
  37. package/dist/engine/git-source.d.ts +35 -0
  38. package/dist/engine/git-source.js +242 -0
  39. package/dist/engine/git-source.js.map +1 -0
  40. package/dist/engine/overlay.d.ts +43 -0
  41. package/dist/engine/overlay.js +167 -0
  42. package/dist/engine/overlay.js.map +1 -0
  43. package/dist/engine/protocol.d.ts +146 -0
  44. package/dist/engine/protocol.js +40 -0
  45. package/dist/engine/protocol.js.map +1 -0
  46. package/dist/engine/reader.d.ts +133 -0
  47. package/dist/engine/reader.js +298 -0
  48. package/dist/engine/reader.js.map +1 -0
  49. package/dist/engine/sources.d.ts +39 -0
  50. package/dist/engine/sources.js +137 -0
  51. package/dist/engine/sources.js.map +1 -0
  52. package/dist/engine/sqlite-store.d.ts +99 -0
  53. package/dist/engine/sqlite-store.js +452 -0
  54. package/dist/engine/sqlite-store.js.map +1 -0
  55. package/dist/engine/store.d.ts +85 -0
  56. package/dist/engine/store.js +31 -0
  57. package/dist/engine/store.js.map +1 -0
  58. package/dist/engine/tables.d.ts +15 -0
  59. package/dist/engine/tables.js +70 -0
  60. package/dist/engine/tables.js.map +1 -0
  61. package/dist/engine/units.d.ts +58 -0
  62. package/dist/engine/units.js +186 -0
  63. package/dist/engine/units.js.map +1 -0
  64. package/dist/engine/version.d.ts +6 -0
  65. package/dist/engine/version.js +21 -0
  66. package/dist/engine/version.js.map +1 -0
  67. package/dist/engine/worker.d.ts +1 -0
  68. package/dist/engine/worker.js +220 -0
  69. package/dist/engine/worker.js.map +1 -0
  70. package/dist/flattener/index.d.ts +34 -17
  71. package/dist/flattener/index.js +247 -116
  72. package/dist/flattener/index.js.map +1 -1
  73. package/dist/flattener/resolve-text-links.d.ts +15 -14
  74. package/dist/flattener/resolve-text-links.js +24 -25
  75. package/dist/flattener/resolve-text-links.js.map +1 -1
  76. package/dist/glossary.d.ts +2 -2
  77. package/dist/glossary.js.map +1 -1
  78. package/dist/index.d.ts +168 -2
  79. package/dist/index.js +888 -616
  80. package/dist/index.js.map +1 -1
  81. package/dist/markdown-parse.d.ts +6 -0
  82. package/dist/markdown-parse.js +14 -0
  83. package/dist/markdown-parse.js.map +1 -1
  84. package/dist/parser/learning-outcome.js +11 -2
  85. package/dist/parser/learning-outcome.js.map +1 -1
  86. package/dist/parser/lens.d.ts +15 -2
  87. package/dist/parser/lens.js +38 -11
  88. package/dist/parser/lens.js.map +1 -1
  89. package/dist/parser/meeting-doc.d.ts +2 -1
  90. package/dist/parser/meeting-doc.js +2 -1
  91. package/dist/parser/meeting-doc.js.map +1 -1
  92. package/dist/parser/module.d.ts +3 -1
  93. package/dist/parser/module.js +7 -5
  94. package/dist/parser/module.js.map +1 -1
  95. package/dist/parser/question-embeds.d.ts +46 -0
  96. package/dist/parser/question-embeds.js +302 -0
  97. package/dist/parser/question-embeds.js.map +1 -0
  98. package/dist/parser/response-segments.js +12 -0
  99. package/dist/parser/response-segments.js.map +1 -1
  100. package/dist/parser/sections.d.ts +18 -0
  101. package/dist/parser/sections.js +31 -3
  102. package/dist/parser/sections.js.map +1 -1
  103. package/dist/parser/widget.d.ts +2 -1
  104. package/dist/parser/widget.js.map +1 -1
  105. package/dist/parser/wikilink.d.ts +13 -3
  106. package/dist/parser/wikilink.js +55 -34
  107. package/dist/parser/wikilink.js.map +1 -1
  108. package/dist/reference-graph.d.ts +21 -3
  109. package/dist/reference-graph.js +35 -12
  110. package/dist/reference-graph.js.map +1 -1
  111. package/dist/same-page-links.d.ts +11 -1
  112. package/dist/same-page-links.js +9 -0
  113. package/dist/same-page-links.js.map +1 -1
  114. package/dist/skill-tree.d.ts +2 -1
  115. package/dist/skill-tree.js.map +1 -1
  116. package/dist/source-location.d.ts +0 -4
  117. package/dist/source-location.js +0 -43
  118. package/dist/source-location.js.map +1 -1
  119. package/dist/validator/card-module-membership.d.ts +17 -1
  120. package/dist/validator/card-module-membership.js +60 -56
  121. package/dist/validator/card-module-membership.js.map +1 -1
  122. package/dist/validator/criticmarkup.js +1 -1
  123. package/dist/validator/criticmarkup.js.map +1 -1
  124. package/dist/validator/field-typos.d.ts +1 -1
  125. package/dist/validator/field-typos.js +21 -21
  126. package/dist/validator/field-typos.js.map +1 -1
  127. package/dist/validator/output-integrity.d.ts +9 -5
  128. package/dist/validator/output-integrity.js +43 -45
  129. package/dist/validator/output-integrity.js.map +1 -1
  130. package/dist/validator/tier.d.ts +2 -2
  131. package/dist/validator/tier.js.map +1 -1
  132. package/dist/validator/uuid.d.ts +1 -1
  133. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,16 +1,19 @@
1
- import { flattenModule, tagCourseOutcomes, suggestedLensPaths, flattenLens, flattenStandaloneArticle, expandPromptImports, toKebabCase, } from "./flattener/index.js";
2
- import { dedupeErrors } from "./dedupe-errors.js";
1
+ import { declaredSections, flattenModule, tagLensOutcomes, suggestedLensPaths, flattenLens, flattenStandaloneArticle, expandPromptImports, toKebabCase, } from "./flattener/index.js";
2
+ import { attachDroppedSegments, crossCheck, droppedSegmentsOf, failedModule, failedPage, pageEntries, placedAfter, } from "./dropped.js";
3
+ import { fileNameToSlug } from "./utils/slug.js";
4
+ import { errorKey, reportOnce } from "./dedupe-errors.js";
3
5
  import { populateCardModuleSlugs, resolveInlineLensModuleSlugs, } from "./flattener/resolve-text-links.js";
4
6
  import { parseModule } from "./parser/module.js";
5
7
  import { parseCourse } from "./parser/course.js";
6
8
  import { parseSurvey } from "./parser/survey.js";
7
9
  import { MEETING_DOC_SHARED_DIR, parseMeetingDoc, } from "./parser/meeting-doc.js";
8
10
  import { parseLearningOutcome, } from "./parser/learning-outcome.js";
9
- import { parseLens } from "./parser/lens.js";
11
+ import { convertRawSegments, parseLens, } from "./parser/lens.js";
12
+ import { isQuestionFilePath, parseQuestionFile, questionEmbedCacheKey, questionEmbedsOutsideLenses, } from "./parser/question-embeds.js";
10
13
  import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion, } from "./parser/wikilink.js";
11
14
  import { validateUuids, } from "./validator/uuid.js";
12
15
  import { detectDuplicateSlugs, } from "./validator/duplicates.js";
13
- import { validateOutputIntegrity } from "./validator/output-integrity.js";
16
+ import { outputIntegrityCandidates } from "./validator/output-integrity.js";
14
17
  import { parseArticleBody } from "./bundler/article.js";
15
18
  import { parseFrontmatter } from "./parser/frontmatter.js";
16
19
  import { parseArticle } from "./parser/article.js";
@@ -22,19 +25,21 @@ import { applyGlossary, isGlossaryPath, parseGlossaryEntry, validateGlossaryAlia
22
25
  import { validateTimestamps } from "./validator/timestamps.js";
23
26
  import { levenshtein } from "./validator/field-typos.js";
24
27
  import { buildTagMap, buildTierMap, checkTierViolation, } from "./validator/tier.js";
25
- import { validateCardModuleMembership } from "./validator/card-module-membership.js";
28
+ import { cardModuleGroups, checkCardModuleMembership, } from "./validator/card-module-membership.js";
26
29
  import { collectOptionalLensSets, } from "./validator/optional-lens-sets.js";
27
30
  import { detectPendingCriticMarkup, isCriticMarkupCheckedPath, } from "./validator/criticmarkup.js";
28
31
  import { validateMath } from "./validator/math.js";
29
32
  import { validateArticleStructure } from "./validator/article-structure.js";
30
33
  import { validateArticleReviewProvenance } from "./validator/article-review-provenance.js";
31
- import { stripAuthoringMarkup } from "./authoring-markup.js";
34
+ import { stripAuthoringMarkup, stripAuthoringMarkupForValidation, } from "./authoring-markup.js";
32
35
  export { checkTierViolation } from "./validator/tier.js";
33
36
  import { buildSkillTree, } from "./skill-tree.js";
34
37
  import { DOMAIN_METADATA_FILE, parseDomainMetadata, parseDomainPath, parseTopicFile, } from "./parser/domain.js";
35
38
  import { RunInputs } from "./dependency-cache.js";
36
39
  export { FileResultCache } from "./cache.js";
37
40
  import { clearParsedMarkdown } from "./markdown-parse.js";
41
+ import { ContentView, inPathOrder, } from "./engine/reader.js";
42
+ import { pageKey } from "./engine/units.js";
38
43
  /** Authored field name of each flattened prompt-import-capable property. */
39
44
  const PROMPT_FIELD_NAMES = {
40
45
  assessmentInstructions: "assessment-instructions",
@@ -47,58 +52,59 @@ const PROMPT_FIELD_NAMES = {
47
52
  * their sourcePath, so their own id tells them apart. The backend finds a question by (id, module), so two
48
53
  * different copies in one module would both be graded, length-checked and
49
54
  * coached by the first. This also catches a lens and the article it embeds
50
- * using one id, which flatten into a single section.
55
+ * using one id, which flatten into a single section. A lens's own
56
+ * "appears twice" error is reported once over all pages (`once`), not once
57
+ * per module that copies the lens.
51
58
  */
52
- function detectSharedQuestionsInOneModule(modules) {
53
- const errors = [];
54
- const reported = new Set(); // one error per lens, not per module
55
- for (const mod of modules) {
56
- // lowercased id → where it first appeared in this module
57
- const first = new Map();
58
- for (const section of mod.sections) {
59
- const lens = section.sourcePath ?? mod.sourcePath ?? mod.slug;
60
- // Inline lenses share the module file as their path: name them.
61
- const where = section.sourcePath === mod.sourcePath &&
62
- lens.startsWith("modules/") &&
63
- section.meta.title
64
- ? `lens '${section.meta.title}' in ${lens}`
65
- : lens;
66
- for (const seg of section.segments) {
67
- if (seg.type !== "question" || !seg.responseId)
59
+ function sharedQuestionCandidates(mod) {
60
+ const candidates = [];
61
+ // lowercased id → where it first appeared in this module
62
+ const first = new Map();
63
+ for (const section of mod.sections) {
64
+ const lens = section.sourcePath ?? mod.sourcePath ?? mod.slug;
65
+ // Inline lenses share the module file as their path: name them.
66
+ const where = section.sourcePath === mod.sourcePath &&
67
+ lens.startsWith("modules/") &&
68
+ section.meta.title
69
+ ? `lens '${section.meta.title}' in ${lens}`
70
+ : lens;
71
+ for (const seg of section.segments) {
72
+ if (seg.type !== "question" || !seg.responseId)
73
+ continue;
74
+ const from = seg.partOfArticle ? `an article in ${where}` : where;
75
+ const id = seg.responseId.toLowerCase();
76
+ const seen = first.get(id);
77
+ if (seen === undefined) {
78
+ first.set(id, { section, from, inArticle: !!seg.partOfArticle });
79
+ }
80
+ else if (seen.section === section) {
81
+ // Two in the lens's own text are collectResponseSegmentIds's error.
82
+ if (!seen.inArticle && !seg.partOfArticle)
68
83
  continue;
69
- const from = seg.partOfArticle ? `an article in ${where}` : where;
70
- const id = seg.responseId.toLowerCase();
71
- const seen = first.get(id);
72
- if (seen === undefined) {
73
- first.set(id, { section, from, inArticle: !!seg.partOfArticle });
74
- }
75
- else if (seen.section === section) {
76
- // Two in the lens's own text are collectResponseSegmentIds's error.
77
- if (!seen.inArticle && !seg.partOfArticle)
78
- continue;
79
- if (reported.has(`${lens}\n${id}`))
80
- continue;
81
- reported.add(`${lens}\n${id}`);
82
- errors.push({
84
+ candidates.push({
85
+ error: {
83
86
  file: lens,
84
87
  message: `Question id '${seg.responseId}' appears twice in this lens: in ${seen.from} and in ${from}`,
85
88
  suggestion: "Give one of the two questions its own UUID",
86
89
  severity: "error",
87
- });
88
- }
89
- else if (seen.section.sourcePath !== section.sourcePath ||
90
- seen.section.contentId !== section.contentId) {
91
- errors.push({
90
+ },
91
+ once: `${lens}\n${id}`,
92
+ });
93
+ }
94
+ else if (seen.section.sourcePath !== section.sourcePath ||
95
+ seen.section.contentId !== section.contentId) {
96
+ candidates.push({
97
+ error: {
92
98
  file: mod.sourcePath ?? lens,
93
99
  message: `Module '${mod.slug}' holds question id '${seg.responseId}' twice: in ${seen.from} and in ${from}`,
94
100
  suggestion: "A question id may appear only once per module. Remove one of the copies from this module, or give one its own UUID",
95
101
  severity: "error",
96
- });
97
- }
102
+ },
103
+ });
98
104
  }
99
105
  }
100
106
  }
101
- return errors;
107
+ return candidates;
102
108
  }
103
109
  /**
104
110
  * Collect the stable id:: of every question segment (from lenses, inline
@@ -107,19 +113,14 @@ function detectSharedQuestionsInOneModule(modules) {
107
113
  * files share an id only for the same question (validator/uuid.ts).
108
114
  * validateUuids deliberately allows same-file, same-field duplicates
109
115
  * (contentId sharing across submodule groups), so duplicates within one file
110
- * are detected here instead, via the caller's per-file seen map.
116
+ * are detected here instead, via the file's own set of ids already seen.
111
117
  */
112
- function collectResponseSegmentIds(segments, context, path, uuidEntries, seenResponseIds, errors) {
118
+ function collectResponseSegmentIds(segments, context, path, uuidEntries, seenInFile, errors) {
113
119
  for (const seg of segments) {
114
120
  if (seg.type !== "question" || !("responseId" in seg) || !seg.responseId) {
115
121
  continue;
116
122
  }
117
123
  const lower = seg.responseId.toLowerCase();
118
- let seenInFile = seenResponseIds.get(path);
119
- if (!seenInFile) {
120
- seenInFile = new Set();
121
- seenResponseIds.set(path, seenInFile);
122
- }
123
124
  if (seenInFile.has(lower)) {
124
125
  errors.push({
125
126
  file: path,
@@ -141,7 +142,7 @@ function collectResponseSegmentIds(segments, context, path, uuidEntries, seenRes
141
142
  /**
142
143
  * Recursively collect UUIDs from inline lens sections (including submodule children).
143
144
  */
144
- function collectInlineLensUUIDs(sections, path, uuidEntries, seenResponseIds, errors) {
145
+ function collectInlineLensUUIDs(sections, path, uuidEntries, seenInFile, errors) {
145
146
  for (const section of sections) {
146
147
  if (section.type === "lens") {
147
148
  if (section.inlineLens) {
@@ -151,7 +152,7 @@ function collectInlineLensUUIDs(sections, path, uuidEntries, seenResponseIds, er
151
152
  file: path,
152
153
  field: "section id",
153
154
  });
154
- collectResponseSegmentIds(section.inlineLens.segments, "lens", path, uuidEntries, seenResponseIds, errors);
155
+ collectResponseSegmentIds(section.inlineLens.segments, "lens", path, uuidEntries, seenInFile, errors);
155
156
  }
156
157
  else if (!section.fields.source && !section.fields.id) {
157
158
  // No section-level source:: and no id:: → missing required id
@@ -173,598 +174,727 @@ function collectInlineLensUUIDs(sections, path, uuidEntries, seenResponseIds, er
173
174
  }
174
175
  }
175
176
  if (section.children) {
176
- collectInlineLensUUIDs(section.children, path, uuidEntries, seenResponseIds, errors);
177
+ collectInlineLensUUIDs(section.children, path, uuidEntries, seenInFile, errors);
177
178
  }
178
179
  }
179
180
  }
180
- function collectLearningOutcomeTestUUIDs(learningOutcome, path, uuidEntries, seenResponseIds, errors) {
181
+ function collectLearningOutcomeTestUUIDs(learningOutcome, path, uuidEntries, seenInFile, errors) {
181
182
  if (learningOutcome.test) {
182
183
  uuidEntries.push({
183
184
  uuid: learningOutcome.test.id,
184
185
  file: path,
185
186
  field: "test section id",
186
187
  });
187
- collectResponseSegmentIds(learningOutcome.test.segments, "test", path, uuidEntries, seenResponseIds, errors);
188
+ collectResponseSegmentIds(learningOutcome.test.segments, "test", path, uuidEntries, seenInFile, errors);
188
189
  }
189
190
  }
190
- export function processContent(files, cache) {
191
- // Parsed markdown is memoized for the length of one run (markdown-parse.ts).
192
- // Drop the trees afterwards so a long-lived caller does not keep them.
193
- try {
194
- return processContentRun(files, cache);
195
- }
196
- finally {
197
- clearParsedMarkdown();
191
+ /**
192
+ * The LOs reachable from a module's `sections`, each with the submodule it
193
+ * sits in. A "# Submodule:" heading opens a group that runs until the next one
194
+ * (its siblings belong to it, as in splitAtBoundaries), and any children nest
195
+ * under it too. Works off the raw module parse, so an LO counts as "taught by"
196
+ * the module even when it contributes no flattened sections (e.g. it has no
197
+ * test yet).
198
+ */
199
+ function collectModuleLoRefs(sections, modulePath, files, refs = [], submodule = null) {
200
+ for (const section of sections) {
201
+ if (section.type === "submodule") {
202
+ // Same slug rule as splitAtBoundaries, so the link lands on the page.
203
+ submodule = {
204
+ slug: section.fields.slug ?? toKebabCase(section.title),
205
+ title: section.title,
206
+ };
207
+ }
208
+ if (section.type === "learning-outcome" && section.fields.source) {
209
+ const wikilink = parseWikilink(section.fields.source, modulePath);
210
+ if (wikilink && !wikilink.error) {
211
+ const resolved = resolveWikilinkPath(wikilink.path, modulePath);
212
+ const actualPath = findFileWithExtension(resolved, files);
213
+ if (actualPath)
214
+ refs.push({ lo: actualPath, submodule });
215
+ }
216
+ }
217
+ if (section.children) {
218
+ collectModuleLoRefs(section.children, modulePath, files, refs, submodule);
219
+ }
198
220
  }
221
+ return refs;
199
222
  }
200
- function processContentRun(files, cache) {
201
- // Iteration order of `files` decides the order of every output array below, so it must
202
- // not depend on how a caller happened to enumerate the filesystem. Callers do differ:
203
- // the Node vault reader and the Python backend both walk directories without sorting,
204
- // and the --stdin path takes whatever order its JSON arrived in. Sorting here once
205
- // makes the output a function of the content alone, for every caller.
206
- files = new Map([...files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
207
- // Memoize a pure function of one file's content (see cache.ts for the key's
208
- // correctness argument; its entry wrapper rejects any bytes put() did not
209
- // write). Without a cache this is just fn(): the pipeline below runs
210
- // identically either way — same passes, same order — a hit only skips
211
- // recomputing what an identical input already produced.
212
- function cachedPerFile(kind, path, content, fn) {
213
- const hit = cache?.get(kind, path, content);
214
- if (hit !== undefined)
215
- return hit;
216
- const value = fn();
217
- cache?.put(kind, path, content, value);
218
- return value;
223
+ /** One file's pass: parse it, run its own checks, and flatten its pages. */
224
+ export function runPathUnit(path, { files, tierMap, tagMap }, steps) {
225
+ const unit = {
226
+ path,
227
+ errors: [],
228
+ modules: [],
229
+ urls: [],
230
+ uuids: [],
231
+ };
232
+ const { errors, modules, uuids: uuidEntries } = unit;
233
+ // Response segment ids already seen in this file (within-file duplicate check)
234
+ const seenResponseIds = new Set();
235
+ const recordDropped = (entries) => {
236
+ if (entries.length > 0)
237
+ unit.dropped = entries;
238
+ };
239
+ // Skip ignored files entirely
240
+ if (tierMap.get(path) === "ignored") {
241
+ return unit;
219
242
  }
220
- const modules = [];
221
- const articleSegments = {};
222
- const courses = [];
223
- const surveys = [];
224
- const surveyPathToId = new Map();
225
- const meetingDocs = {};
226
- const errors = [];
227
- const urlsToValidate = [];
228
- const uuidEntries = [];
229
- // Response segment ids already seen, per file (within-file duplicate check)
230
- const seenResponseIds = new Map();
231
- const slugEntries = [];
232
- const slugToPath = new Map();
233
- const filePathToSlug = new Map(); // Reverse: file path → slug (survives duplicate slugs)
234
- const courseSlugToFile = new Map();
235
- const parentSlugToChildren = new Map();
236
- // Pre-scan: frontmatter tags of every file, and the tier they imply
237
- const tagMap = buildTagMap(files);
238
- const tierMap = buildTierMap(tagMap);
239
- // Steps that read other files are cached by what they read (see
240
- // dependency-cache.ts); each is a pure function of the file and tier maps.
241
- const inputs = new RunInputs(files, tierMap, cache);
242
- // --- Skill-tree collections (assembled after the main loop) ---
243
- // Domain folder path → folder; topic file path → topic (+ its folder).
244
- const domainFolders = new Map();
245
- const topicFiles = new Map();
246
- const learningOutcomesByPath = new Map();
247
- // LO path → module path → the submodules (local slug → title) that
248
- // reference it; empty when the module is not split or the LO is module-level.
249
- const moduleLoRefs = new Map();
250
- const glossaryEntries = new Map();
251
- const skillTreeModules = new Map();
252
- // Record that `modulePath` references each LO reachable from `sections`,
253
- // remembering which submodule it sits in. A "# Submodule:" heading opens a
254
- // group that runs until the next one (its siblings belong to it, as in
255
- // splitAtBoundaries), and any children nest under it too. Works off the raw
256
- // module parse, so an LO counts as "taught by" the module even when it
257
- // contributes no flattened sections (e.g. it has no test yet).
258
- function collectModuleLoRefs(sections, modulePath, submodule = null) {
259
- for (const section of sections) {
260
- if (section.type === "submodule") {
261
- // Same slug rule as splitAtBoundaries, so the link lands on the page.
262
- submodule = {
263
- slug: section.fields.slug ?? toKebabCase(section.title),
264
- title: section.title,
265
- };
266
- }
267
- if (section.type === "learning-outcome" && section.fields.source) {
268
- const wikilink = parseWikilink(section.fields.source, modulePath);
269
- if (wikilink && !wikilink.error) {
270
- const resolved = resolveWikilinkPath(wikilink.path, modulePath);
271
- const actualPath = findFileWithExtension(resolved, files);
272
- if (actualPath) {
273
- let byModule = moduleLoRefs.get(actualPath);
274
- if (!byModule) {
275
- byModule = new Map();
276
- moduleLoRefs.set(actualPath, byModule);
277
- }
278
- let submodules = byModule.get(modulePath);
279
- if (!submodules) {
280
- submodules = new Map();
281
- byModule.set(modulePath, submodules);
282
- }
283
- if (submodule)
284
- submodules.set(submodule.slug, submodule.title);
285
- }
286
- }
287
- }
288
- if (section.children) {
289
- collectModuleLoRefs(section.children, modulePath, submodule);
290
- }
291
- }
243
+ const content = files.get(path);
244
+ // processContent passes `steps`, its result cache: a pure function of this
245
+ // file's content is cached by that content (cachedPerFile), and a step that
246
+ // reads other files by what it read (crossFile, dependency-cache.ts). The
247
+ // engine passes none: a hit returns a result without the reads that made
248
+ // it, and the engine's recording views must see every read. So a step
249
+ // reads only the views it is given. Without `steps` the pass runs the same
250
+ // functions; a hit only skips recomputing what the same input produced.
251
+ // `extraKey` adds what else a result depends on to its cache key. It runs
252
+ // only with `steps`, so the engine's views record no reads for it.
253
+ function cachedPerFile(kind, fn, extraKey) {
254
+ if (!steps)
255
+ return fn();
256
+ return steps.perFile(kind, path, content + (extraKey?.() ?? ""), fn);
292
257
  }
293
- // Identify file types by path
294
- for (const [path, content] of files.entries()) {
295
- // Skip ignored files entirely
296
- if (tierMap.get(path) === "ignored") {
297
- continue;
298
- }
299
- if (path.endsWith(".md") || isCriticMarkupCheckedPath(path)) {
300
- const prechecks = cachedPerFile("prechecks", path, content, () => {
301
- const pre = [];
302
- // A widget body is HTML in an iframe, never rendered as math. Only
303
- // the root widgets/ folder: a nested widgets/ under Lenses/ or
304
- // articles/ is parsed as a lens or article below, and renders math.
305
- if (path.endsWith(".md") && !path.startsWith("widgets/")) {
306
- const mathErrors = validateMath(content, path);
307
- if (path.startsWith("articles/") || path.includes("/articles/")) {
308
- for (const error of mathErrors) {
309
- error.code ??= error.message.includes("unclosed $$")
310
- ? "article.math-unclosed"
311
- : "article.math-invalid";
312
- }
313
- }
314
- pre.push(...mathErrors);
315
- }
316
- // Warn about pending CriticMarkup suggestions on the raw content — the
317
- // parsers strip them (reject-all behavior) before any other processing.
318
- if (isCriticMarkupCheckedPath(path)) {
319
- pre.push(...detectPendingCriticMarkup(content, path));
320
- }
321
- return pre;
322
- });
323
- errors.push(...prechecks);
324
- }
325
- if (path.startsWith("modules/")) {
326
- const result = inputs.cached("flatten-module", path, content, (f, t) => {
327
- const { modules, errors } = flattenModule(path, f, new Set(), t);
328
- return { modules, errors };
329
- });
330
- for (const mod of result.modules) {
331
- modules.push(mod);
332
- slugToPath.set(mod.slug, path);
333
- // Track parent→children mapping for course expansion
334
- if (mod.parentSlug) {
335
- if (!parentSlugToChildren.has(mod.parentSlug)) {
336
- parentSlugToChildren.set(mod.parentSlug, []);
258
+ function crossFile(kind, step, extraKey) {
259
+ if (!steps)
260
+ return step(files, tierMap);
261
+ return steps.cached(kind, path, content + (extraKey?.() ?? ""), step);
262
+ }
263
+ if (path.endsWith(".md") || isCriticMarkupCheckedPath(path)) {
264
+ const prechecks = cachedPerFile("prechecks", () => {
265
+ const pre = [];
266
+ // A widget body is HTML in an iframe, never rendered as math. Only
267
+ // the root widgets/ folder: a nested widgets/ under Lenses/ or
268
+ // articles/ is parsed as a lens or article below, and renders math.
269
+ if (path.endsWith(".md") && !path.startsWith("widgets/")) {
270
+ const mathErrors = validateMath(content, path);
271
+ if (path.startsWith("articles/") || path.includes("/articles/")) {
272
+ for (const error of mathErrors) {
273
+ error.code ??= error.message.includes("unclosed $$")
274
+ ? "article.math-unclosed"
275
+ : "article.math-invalid";
337
276
  }
338
- parentSlugToChildren.get(mod.parentSlug).push(mod.slug);
339
- }
340
- else {
341
- filePathToSlug.set(path, mod.slug);
342
- }
343
- slugEntries.push({ slug: mod.slug, file: path });
344
- if (mod.contentId) {
345
- uuidEntries.push({
346
- uuid: mod.contentId,
347
- file: path,
348
- field: "contentId",
349
- });
350
277
  }
278
+ pre.push(...mathErrors);
351
279
  }
352
- // If no submodules, set filePathToSlug for the primary module
353
- if (result.modules.length === 1 && !result.modules[0].parentSlug) {
354
- filePathToSlug.set(path, result.modules[0].slug);
280
+ // Warn about pending CriticMarkup suggestions on the raw content — the
281
+ // parsers strip them (reject-all behavior) before any other processing.
282
+ if (isCriticMarkupCheckedPath(path)) {
283
+ pre.push(...detectPendingCriticMarkup(content, path));
355
284
  }
356
- else if (result.modules.length > 0 && result.modules[0].parentSlug) {
357
- // For split modules, map the file path to the parent slug
358
- filePathToSlug.set(path, result.modules[0].parentSlug);
285
+ // A lone ![[../Questions/...]] line is a question only in a lens file
286
+ const isLensFile = path.startsWith("Lenses/") || path.includes("/Lenses/");
287
+ if (path.endsWith(".md") && !isLensFile && !isQuestionFilePath(path)) {
288
+ pre.push(...questionEmbedsOutsideLenses(content, path));
359
289
  }
360
- // Collect section-level id:: fields from Lens sections (inline lenses).
361
- // Referenced lenses (with source::) get their id from the lens file itself.
362
- if (result.modules.length > 0) {
363
- const rawParse = parseModule(content, path);
364
- if (rawParse.module) {
365
- collectInlineLensUUIDs(rawParse.module.sections, path, uuidEntries, seenResponseIds, errors);
366
- collectModuleLoRefs(rawParse.module.sections, path);
367
- }
368
- // Skill tree points at the user-visible module page: the primary
369
- // (parent) module for split modules.
370
- const primary = result.modules[0];
371
- skillTreeModules.set(path, {
372
- slug: primary.parentSlug ?? primary.slug,
373
- title: primary.parentTitle ?? primary.title,
374
- tags: tagMap.get(path) ?? [],
290
+ return pre;
291
+ });
292
+ errors.push(...prechecks);
293
+ }
294
+ if (path.startsWith("modules/")) {
295
+ const result = crossFile("flatten-module", (f, t) => {
296
+ const { modules, errors, dropped } = flattenModule(path, f, new Set(), t);
297
+ return { modules, errors, dropped };
298
+ });
299
+ const pageItems = result.dropped;
300
+ for (const mod of result.modules) {
301
+ modules.push(mod);
302
+ if (!mod.parentSlug)
303
+ unit.moduleSlug = mod.slug;
304
+ if (mod.contentId) {
305
+ uuidEntries.push({
306
+ uuid: mod.contentId,
307
+ file: path,
308
+ field: "contentId",
375
309
  });
376
310
  }
377
- errors.push(...result.errors);
378
311
  }
379
- else if (path.startsWith("courses/")) {
380
- const result = parseCourse(content, path);
381
- if (result.course) {
382
- courses.push(result.course);
383
- courseSlugToFile.set(result.course.slug, path);
384
- }
385
- errors.push(...result.errors);
312
+ // If no submodules, the file's slug is the primary module's; a split
313
+ // module's file maps to the parent slug.
314
+ if (result.modules.length === 1 && !result.modules[0].parentSlug) {
315
+ unit.moduleSlug = result.modules[0].slug;
386
316
  }
387
- else if (path.startsWith("meetings/") &&
388
- path.endsWith(".md") &&
389
- !path.startsWith(MEETING_DOC_SHARED_DIR)) {
390
- const result = inputs.cached("meeting-doc", path, content, (f) => parseMeetingDoc(content, path, f));
391
- errors.push(...result.errors);
392
- if (result.markdown !== null)
393
- meetingDocs[path] = result.markdown;
317
+ else if (result.modules.length > 0 && result.modules[0].parentSlug) {
318
+ unit.moduleSlug = result.modules[0].parentSlug;
394
319
  }
395
- else if (path.startsWith("surveys/") || path.includes("/surveys/")) {
396
- const result = parseSurvey(content, path);
397
- errors.push(...result.errors);
398
- if (result.survey) {
399
- // Response segments and interviews carry prompt-capable instruction
400
- // fields. Surveys have no flatten pass, so their prompt imports are
401
- // expanded here; a failed import drops the survey, like a failed
402
- // import drops a lens segment in the flattener.
403
- let expansionFailed = false;
404
- for (const seg of result.survey.segments) {
405
- const promptProps = seg.type === "interview"
406
- ? ["assessmentInstructions"]
407
- : seg.type === "question" && "questionType" in seg
408
- ? ["assessmentInstructions", "feedbackInstructions"]
409
- : [];
410
- for (const prop of promptProps) {
411
- const target = seg;
412
- const value = target[prop];
413
- if (!value)
414
- continue;
415
- const resolved = expandPromptImports(value, PROMPT_FIELD_NAMES[prop], path, files, tierMap);
416
- errors.push(...resolved.errors);
417
- if (resolved.value === null) {
418
- expansionFailed = true;
419
- }
420
- else {
421
- target[prop] = resolved.value;
422
- }
423
- }
424
- }
425
- if (!expansionFailed) {
426
- surveys.push({ ...result.survey, sourcePath: path });
427
- surveyPathToId.set(path, result.survey.id);
428
- uuidEntries.push({
429
- uuid: result.survey.id,
430
- file: path,
431
- field: "id",
432
- });
433
- collectResponseSegmentIds(result.survey.segments, "survey", path, uuidEntries, seenResponseIds, errors);
434
- }
320
+ // Collect section-level id:: fields from Lens sections (inline lenses).
321
+ // Referenced lenses (with source::) get their id from the lens file itself.
322
+ if (result.modules.length > 0) {
323
+ const rawParse = parseModule(content, path);
324
+ if (rawParse.module) {
325
+ collectInlineLensUUIDs(rawParse.module.sections, path, uuidEntries, seenResponseIds, errors);
326
+ const loRefs = collectModuleLoRefs(rawParse.module.sections, path, files);
327
+ if (loRefs.length > 0)
328
+ unit.loRefs = loRefs;
329
+ crossCheck(path, result.modules, pageItems, declaredSections(rawParse.module, path, files), rawParse.refused);
435
330
  }
331
+ // Skill tree points at the user-visible module page: the primary
332
+ // (parent) module for split modules.
333
+ const primary = result.modules[0];
334
+ unit.skillTreeModule = {
335
+ slug: primary.parentSlug ?? primary.slug,
336
+ title: primary.parentTitle ?? primary.title,
337
+ tags: tagMap.get(path) ?? [],
338
+ };
436
339
  }
437
- else if (path.startsWith("Learning Outcomes/") ||
438
- path.includes("/Learning Outcomes/")) {
439
- // Fully validate Learning Outcome (structure, fields, wikilink syntax)
440
- const result = parseLearningOutcome(content, path);
441
- errors.push(...result.errors);
442
- // Check that suggested lens files exist
443
- if (result.learningOutcome) {
444
- collectLearningOutcomeTestUUIDs(result.learningOutcome, path, uuidEntries, seenResponseIds, errors);
445
- for (const lensRef of result.learningOutcome.suggestedLenses) {
446
- const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
447
- if (!lensPath) {
448
- // Find similar files to suggest
449
- const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
450
- const suggestion = formatSuggestion(similarFiles, path) ??
451
- "Check the file path in the wiki-link";
452
- errors.push({
453
- file: path,
454
- message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
455
- suggestion,
456
- severity: "error",
457
- });
458
- continue;
459
- }
460
- // Check tier violation (LO → Lens)
461
- const parentTier = tierMap.get(path) ?? "production";
462
- const childTier = tierMap.get(lensPath) ?? "production";
463
- const violation = checkTierViolation(path, parentTier, lensPath, childTier, "lens");
464
- if (violation) {
465
- errors.push(violation);
340
+ errors.push(...result.errors);
341
+ recordDropped(result.modules.length
342
+ ? pageEntries(path, result.modules, pageItems)
343
+ : failedModule(content, path, result.errors));
344
+ }
345
+ else if (path.startsWith("courses/")) {
346
+ const result = parseCourse(content, path);
347
+ if (result.course)
348
+ unit.course = result.course;
349
+ errors.push(...result.errors);
350
+ }
351
+ else if (path.startsWith("meetings/") &&
352
+ path.endsWith(".md") &&
353
+ !path.startsWith(MEETING_DOC_SHARED_DIR)) {
354
+ const result = crossFile("meeting-doc", (f) => parseMeetingDoc(content, path, f));
355
+ errors.push(...result.errors);
356
+ if (result.markdown !== null)
357
+ unit.meetingDoc = result.markdown;
358
+ }
359
+ else if (path.startsWith("surveys/") || path.includes("/surveys/")) {
360
+ const result = parseSurvey(content, path);
361
+ errors.push(...result.errors);
362
+ if (result.survey) {
363
+ // Response segments and interviews carry prompt-capable instruction
364
+ // fields. Surveys have no flatten pass, so their prompt imports are
365
+ // expanded here; a failed import drops the survey, like a failed
366
+ // import drops a lens segment in the flattener.
367
+ let expansionFailed = false;
368
+ for (const seg of result.survey.segments) {
369
+ const promptProps = seg.type === "interview"
370
+ ? ["assessmentInstructions"]
371
+ : seg.type === "question" && "questionType" in seg
372
+ ? ["assessmentInstructions", "feedbackInstructions"]
373
+ : [];
374
+ for (const prop of promptProps) {
375
+ const target = seg;
376
+ const value = target[prop];
377
+ if (!value)
466
378
  continue;
379
+ const resolved = expandPromptImports(value, PROMPT_FIELD_NAMES[prop], path, files, tierMap);
380
+ errors.push(...resolved.errors);
381
+ if (resolved.value === null) {
382
+ expansionFailed = true;
467
383
  }
468
- if (childTier === "ignored") {
469
- continue;
384
+ else {
385
+ target[prop] = resolved.value;
470
386
  }
471
387
  }
472
388
  }
473
- // Collect id for UUID validation
474
- if (result.learningOutcome?.id) {
389
+ if (!expansionFailed) {
390
+ unit.survey = { ...result.survey, sourcePath: path };
475
391
  uuidEntries.push({
476
- uuid: result.learningOutcome.id,
392
+ uuid: result.survey.id,
477
393
  file: path,
478
394
  field: "id",
479
395
  });
480
- }
481
- if (result.learningOutcome) {
482
- learningOutcomesByPath.set(path, result.learningOutcome);
396
+ collectResponseSegmentIds(result.survey.segments, "survey", path, uuidEntries, seenResponseIds, errors);
483
397
  }
484
398
  }
485
- else if (path.startsWith("Domains and Topics/") ||
486
- path.includes("/Domains and Topics/")) {
487
- // Skill-tree domain folders: `Domains and Topics/<N> <Title>/` holds the
488
- // domain's topic files and an optional _domain_metadata.md. The folder
489
- // exists as soon as any file inside it does.
490
- if (!path.endsWith(".md"))
491
- continue;
492
- const located = parseDomainPath(path);
493
- if (!located.location) {
494
- errors.push(located.error);
495
- continue;
496
- }
497
- const { dir, number, title, fileName } = located.location;
498
- let folder = domainFolders.get(dir);
499
- if (!folder) {
500
- folder = { number, title, declaredBy: path };
501
- domainFolders.set(dir, folder);
502
- }
503
- if (fileName === DOMAIN_METADATA_FILE) {
504
- const result = parseDomainMetadata(content, path);
505
- errors.push(...result.errors);
506
- if (result.metadata)
507
- folder.metadata = result.metadata;
508
- }
509
- else {
510
- const result = parseTopicFile(content, path);
511
- errors.push(...result.errors);
512
- if (result.topic) {
513
- topicFiles.set(path, { ...result.topic, domainDir: dir });
514
- uuidEntries.push({ uuid: result.topic.id, file: path, field: "id" });
399
+ }
400
+ else if (path.startsWith("Learning Outcomes/") ||
401
+ path.includes("/Learning Outcomes/")) {
402
+ // Fully validate Learning Outcome (structure, fields, wikilink syntax)
403
+ const result = parseLearningOutcome(content, path);
404
+ errors.push(...result.errors);
405
+ // Check that suggested lens files exist
406
+ if (result.learningOutcome) {
407
+ collectLearningOutcomeTestUUIDs(result.learningOutcome, path, uuidEntries, seenResponseIds, errors);
408
+ for (const lensRef of result.learningOutcome.suggestedLenses) {
409
+ const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
410
+ if (!lensPath) {
411
+ // Find similar files to suggest
412
+ const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
413
+ const suggestion = formatSuggestion(similarFiles, path) ??
414
+ "Check the file path in the wiki-link";
415
+ errors.push({
416
+ file: path,
417
+ message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
418
+ suggestion,
419
+ severity: "error",
420
+ });
421
+ continue;
422
+ }
423
+ // Check tier violation (LO → Lens)
424
+ const parentTier = tierMap.get(path) ?? "production";
425
+ const childTier = tierMap.get(lensPath) ?? "production";
426
+ const violation = checkTierViolation(path, parentTier, lensPath, childTier, "lens");
427
+ if (violation) {
428
+ errors.push(violation);
429
+ continue;
430
+ }
431
+ if (childTier === "ignored") {
432
+ continue;
515
433
  }
516
434
  }
517
435
  }
518
- else if (path.startsWith("Lenses/") || path.includes("/Lenses/")) {
519
- // Fully validate Lens (structure, segments, fields)
520
- const result = cachedPerFile("lens", path, content, () => parseLens(content, path));
436
+ // Collect id for UUID validation
437
+ if (result.learningOutcome?.id) {
438
+ uuidEntries.push({
439
+ uuid: result.learningOutcome.id,
440
+ file: path,
441
+ field: "id",
442
+ });
443
+ }
444
+ if (result.learningOutcome) {
445
+ unit.learningOutcome = result.learningOutcome;
446
+ }
447
+ }
448
+ else if (path.startsWith("Domains and Topics/") ||
449
+ path.includes("/Domains and Topics/")) {
450
+ // Skill-tree domain folders: `Domains and Topics/<N> <Title>/` holds the
451
+ // domain's topic files and an optional _domain_metadata.md. The folder
452
+ // exists as soon as any file inside it does.
453
+ if (!path.endsWith(".md"))
454
+ return unit;
455
+ const located = parseDomainPath(path);
456
+ if (!located.location) {
457
+ errors.push(located.error);
458
+ return unit;
459
+ }
460
+ const { dir, number, title, fileName } = located.location;
461
+ unit.domainFolder = { dir, number, title };
462
+ if (fileName === DOMAIN_METADATA_FILE) {
463
+ const result = parseDomainMetadata(content, path);
521
464
  errors.push(...result.errors);
522
- // Collect id for UUID validation
523
- if (result.lens?.id) {
524
- uuidEntries.push({
525
- uuid: result.lens.id,
526
- file: path,
527
- field: "id",
528
- });
529
- }
530
- if (result.lens) {
531
- collectResponseSegmentIds(result.lens.segments, "lens", path, uuidEntries, seenResponseIds, errors);
532
- }
533
- // Flatten lens as standalone module (pass pre-parsed lens to avoid re-parsing)
534
- if (result.lens) {
535
- const lens = result.lens;
536
- const lensModuleResult = inputs.cached("flatten-lens", path, content, (f, t) => {
537
- const { module, errors } = flattenLens(path, f, t, lens);
538
- return { module, errors };
539
- });
540
- if (lensModuleResult.module) {
541
- modules.push(lensModuleResult.module);
542
- slugEntries.push({ slug: lensModuleResult.module.slug, file: path });
543
- slugToPath.set(lensModuleResult.module.slug, path);
544
- }
545
- errors.push(...lensModuleResult.errors);
465
+ if (result.metadata)
466
+ unit.domainFolder.metadata = result.metadata;
467
+ }
468
+ else {
469
+ const result = parseTopicFile(content, path);
470
+ errors.push(...result.errors);
471
+ if (result.topic) {
472
+ unit.topic = { ...result.topic, domainDir: dir };
473
+ uuidEntries.push({ uuid: result.topic.id, file: path, field: "id" });
546
474
  }
547
475
  }
548
- else if (path.endsWith(".timestamps.json")) {
549
- const tsErrors = validateTimestamps(content, path);
550
- errors.push(...tsErrors);
476
+ }
477
+ else if (isQuestionFilePath(path)) {
478
+ // A question file: one question, embedded by lenses with ![[...]]
479
+ const fileResult = cachedPerFile("question", () => {
480
+ const parsed = parseQuestionFile(stripAuthoringMarkupForValidation(content), path);
481
+ const fileErrors = [...parsed.errors];
482
+ // Checked as the question it becomes in a lens
483
+ const converted = parsed.segment
484
+ ? convertRawSegments([parsed.segment], path)
485
+ : { segments: [], errors: [] };
486
+ fileErrors.push(...converted.errors);
487
+ return { errors: fileErrors, segments: converted.segments };
488
+ });
489
+ errors.push(...fileResult.errors);
490
+ // Its id is taken: anything else using it must be this same question
491
+ if (fileResult.segments.length > 0) {
492
+ collectResponseSegmentIds(fileResult.segments, "lens", path, uuidEntries, seenResponseIds, errors);
551
493
  }
552
- else if (path.startsWith("articles/") || path.includes("/articles/")) {
553
- // The article's whole per-file pass (parse + structure + provenance) is
554
- // pure in (content, path); its two effects — errors and URL entries —
555
- // are the cached value.
556
- const fileResult = cachedPerFile("article", path, content, () => {
557
- const result = parseArticle(content, path);
558
- const parsedFrontmatter = parseFrontmatter(stripAuthoringMarkup(content), path);
559
- const body = parsedFrontmatter.error
560
- ? undefined
561
- : parsedFrontmatter.body;
562
- // Standalone-page segments are emitted only when the page differs
563
- // from the raw body. Without inline imports they depend on this file
564
- // alone, so they are built (and cached) here; with imports they need
565
- // the file map and are built below.
566
- const parsedBody = body === undefined ? undefined : parseArticleBody(content, path);
567
- // Elements resolve against other files (transcripts, widgets, prompt
568
- // imports), so an article that has any is flattened with the file map
569
- // below.
570
- const hasImports = parsedBody?.markers.some((m) => m.kind === "element") ?? false;
571
- let standaloneSegments;
572
- if (parsedBody && !hasImports) {
573
- const { segments } = flattenStandaloneArticle(path, new Map([[path, content]]));
574
- // Linked author names (author_url) only exist on processor
575
- // segments; the API's plain fallback carries the name alone.
576
- const plain = segments.length === 1 &&
577
- segments[0].type === "article" &&
578
- segments[0].content === parsedBody.body &&
579
- !segments[0].authorLinks;
580
- if (!plain)
581
- standaloneSegments = segments;
582
- }
583
- const fileErrors = [
584
- ...result.errors,
585
- ...validateArticleStructure(content, path),
586
- ...validateArticleReviewProvenance(content, path),
587
- ];
588
- const urls = [];
589
- if (result.article) {
590
- // Only carry the flag when set true, so URL entries (and thus the
591
- // serialized output / Python parity) are unchanged for normal articles.
592
- const overrideFlag = result.article.allowUnreachableUrl
593
- ? { allowUnreachable: true }
594
- : {};
494
+ }
495
+ else if (path.startsWith("Lenses/") || path.includes("/Lenses/")) {
496
+ // Fully validate Lens (structure, segments, fields). The segments the
497
+ // parser refused ride on the lens as a Symbol (src/dropped.ts), which a
498
+ // cache entry drops, so the entry keeps them as a field and a hit puts
499
+ // them back. The parse also reads the question files the lens embeds,
500
+ // and the flatten step below reuses it, so both cache keys cover them.
501
+ const embeds = () => questionEmbedCacheKey(content, path, files);
502
+ const result = cachedPerFile("lens", () => {
503
+ const parsed = parseLens(content, path, files);
504
+ const refused = parsed.lens ? droppedSegmentsOf(parsed.lens) : [];
505
+ return { ...parsed, refused };
506
+ }, embeds);
507
+ if (result.lens && droppedSegmentsOf(result.lens).length === 0) {
508
+ attachDroppedSegments(result.lens, result.refused);
509
+ }
510
+ errors.push(...result.errors);
511
+ // Collect id for UUID validation
512
+ if (result.lens?.id) {
513
+ uuidEntries.push({
514
+ uuid: result.lens.id,
515
+ file: path,
516
+ field: "id",
517
+ });
518
+ }
519
+ if (result.lens) {
520
+ collectResponseSegmentIds(result.lens.segments, "lens", path, uuidEntries, seenResponseIds, errors);
521
+ }
522
+ // Flatten lens as standalone module (pass pre-parsed lens to avoid re-parsing)
523
+ if (result.lens) {
524
+ const lens = result.lens;
525
+ const lensModuleResult = crossFile("flatten-lens", (f, t) => {
526
+ const { modules, errors, dropped } = flattenLens(path, f, t, lens);
527
+ return { modules, errors, dropped };
528
+ }, embeds);
529
+ modules.push(...lensModuleResult.modules);
530
+ errors.push(...lensModuleResult.errors);
531
+ recordDropped(pageEntries(path, lensModuleResult.modules, lensModuleResult.dropped));
532
+ }
533
+ else {
534
+ const page = "lens/" + fileNameToSlug(path);
535
+ recordDropped([failedPage(page, path, result.errors)]);
536
+ }
537
+ }
538
+ else if (path.endsWith(".timestamps.json")) {
539
+ const tsErrors = validateTimestamps(content, path);
540
+ errors.push(...tsErrors);
541
+ }
542
+ else if (path.startsWith("articles/") || path.includes("/articles/")) {
543
+ // The article's whole per-file pass (parse + structure + provenance) is
544
+ // pure in (content, path); its two effects — errors and URL entries —
545
+ // are the cached value.
546
+ const fileResult = cachedPerFile("article", () => {
547
+ const result = parseArticle(content, path);
548
+ const parsedFrontmatter = parseFrontmatter(stripAuthoringMarkup(content), path);
549
+ const body = parsedFrontmatter.error ? undefined : parsedFrontmatter.body;
550
+ // Standalone-page segments are emitted only when the page differs
551
+ // from the raw body. Without inline imports they depend on this file
552
+ // alone, so they are built (and cached) here; with imports they need
553
+ // the file map and are built below.
554
+ const parsedBody = body === undefined ? undefined : parseArticleBody(content, path);
555
+ // Elements resolve against other files (transcripts, widgets, prompt
556
+ // imports), so an article that has any is flattened with the file map
557
+ // below.
558
+ const hasImports = parsedBody?.markers.some((m) => m.kind === "element") ?? false;
559
+ let standaloneSegments;
560
+ if (parsedBody && !hasImports) {
561
+ const { segments } = flattenStandaloneArticle(path, new ContentView(new Map([[path, content]])));
562
+ // Linked author names (author_url) only exist on processor
563
+ // segments; the API's plain fallback carries the name alone.
564
+ const plain = segments.length === 1 &&
565
+ segments[0].type === "article" &&
566
+ segments[0].content === parsedBody.body &&
567
+ !segments[0].authorLinks;
568
+ if (!plain)
569
+ standaloneSegments = segments;
570
+ }
571
+ const fileErrors = [
572
+ ...result.errors,
573
+ ...validateArticleStructure(content, path),
574
+ ...validateArticleReviewProvenance(content, path),
575
+ ];
576
+ const urls = [];
577
+ if (result.article) {
578
+ // Only carry the flag when set true, so URL entries (and thus the
579
+ // serialized output / Python parity) are unchanged for normal articles.
580
+ const overrideFlag = result.article.allowUnreachableUrl
581
+ ? { allowUnreachable: true }
582
+ : {};
583
+ urls.push({
584
+ url: result.article.sourceUrl,
585
+ file: path,
586
+ line: 2,
587
+ label: "source_url",
588
+ ...overrideFlag,
589
+ });
590
+ for (const url of result.article.authorUrls) {
595
591
  urls.push({
596
- url: result.article.sourceUrl,
592
+ url,
597
593
  file: path,
598
594
  line: 2,
599
- label: "source_url",
595
+ label: "author_url",
596
+ ...overrideFlag,
597
+ });
598
+ }
599
+ for (const img of result.article.imageUrls) {
600
+ urls.push({
601
+ url: img.url,
602
+ file: path,
603
+ line: img.line,
604
+ label: "Image URL",
600
605
  ...overrideFlag,
601
606
  });
602
- for (const url of result.article.authorUrls) {
603
- urls.push({
604
- url,
605
- file: path,
606
- line: 2,
607
- label: "author_url",
608
- ...overrideFlag,
609
- });
610
- }
611
- for (const img of result.article.imageUrls) {
612
- urls.push({
613
- url: img.url,
614
- file: path,
615
- line: img.line,
616
- label: "Image URL",
617
- ...overrideFlag,
618
- });
619
- }
620
607
  }
621
- return {
622
- errors: fileErrors,
623
- urls,
624
- hasImports,
625
- standaloneSegments,
626
- elements: result.article?.elements ?? [],
627
- };
628
- });
629
- errors.push(...fileResult.errors);
630
- urlsToValidate.push(...fileResult.urls);
631
- // Questions declared inline in the article body carry ids like any
632
- // response segment
633
- collectResponseSegmentIds(fileResult.elements, "lens", path, uuidEntries, seenResponseIds, errors);
634
- if (fileResult.hasImports) {
635
- const standalone = inputs.cached("standalone-article", path, content, (f, t) => {
636
- const { segments, errors } = flattenStandaloneArticle(path, f, t);
637
- return { segments, errors };
638
- });
639
- errors.push(...standalone.errors);
640
- articleSegments[path] = standalone.segments;
641
- }
642
- else if (fileResult.standaloneSegments) {
643
- articleSegments[path] = fileResult.standaloneSegments;
644
608
  }
609
+ return {
610
+ errors: fileErrors,
611
+ urls,
612
+ hasImports,
613
+ standaloneSegments,
614
+ elements: result.article?.elements ?? [],
615
+ };
616
+ });
617
+ errors.push(...fileResult.errors);
618
+ unit.urls.push(...fileResult.urls);
619
+ // Questions declared inline in the article body carry ids like any
620
+ // response segment
621
+ collectResponseSegmentIds(fileResult.elements, "lens", path, uuidEntries, seenResponseIds, errors);
622
+ if (fileResult.hasImports) {
623
+ const standalone = crossFile("standalone-article", (f, t) => flattenStandaloneArticle(path, f, t));
624
+ errors.push(...standalone.errors);
625
+ unit.articleSegments = standalone.segments;
626
+ // The platform shows the article as one section, with no contentId,
627
+ // on page article/<slug>.
628
+ const page = { slug: "article/" + fileNameToSlug(path) };
629
+ const items = standalone.dropped.map((drop) => placedAfter(0, [], drop));
630
+ recordDropped(pageEntries(path, [page], [items]));
645
631
  }
646
- else if (path.startsWith("video_transcripts/") ||
647
- path.includes("/video_transcripts/")) {
648
- const result = parseVideoTranscript(content, path);
649
- errors.push(...result.errors);
650
- if (result.transcript) {
651
- urlsToValidate.push({
652
- url: result.transcript.url,
653
- file: path,
654
- line: 2,
655
- label: "url",
656
- ...(result.transcript.allowUnreachableUrl
657
- ? { allowUnreachable: true }
658
- : {}),
659
- });
660
- }
632
+ else if (fileResult.standaloneSegments) {
633
+ unit.articleSegments = fileResult.standaloneSegments;
661
634
  }
662
- else if (isGlossaryPath(path) && path.endsWith(".md")) {
663
- const result = parseGlossaryEntry(content, path);
664
- errors.push(...result.errors);
665
- if (result.entry)
666
- glossaryEntries.set(path, result.entry);
635
+ }
636
+ else if (path.startsWith("video_transcripts/") ||
637
+ path.includes("/video_transcripts/")) {
638
+ const result = parseVideoTranscript(content, path);
639
+ errors.push(...result.errors);
640
+ if (result.transcript) {
641
+ unit.urls.push({
642
+ url: result.transcript.url,
643
+ file: path,
644
+ line: 2,
645
+ label: "url",
646
+ ...(result.transcript.allowUnreachableUrl
647
+ ? { allowUnreachable: true }
648
+ : {}),
649
+ });
667
650
  }
668
- else if (isPromptPath(path)) {
669
- // Fully validate prompt file (frontmatter + non-empty prompt text)
670
- const result = parsePrompt(content, path);
671
- errors.push(...result.errors);
651
+ }
652
+ else if (isGlossaryPath(path) && path.endsWith(".md")) {
653
+ const result = parseGlossaryEntry(content, path);
654
+ errors.push(...result.errors);
655
+ if (result.entry)
656
+ unit.glossaryEntry = result.entry;
657
+ }
658
+ else if (isPromptPath(path)) {
659
+ // Fully validate prompt file (frontmatter + non-empty prompt text)
660
+ const result = parsePrompt(content, path);
661
+ errors.push(...result.errors);
662
+ }
663
+ else if (isWidgetFile(path)) {
664
+ // Widget file: frontmatter + HTML body (validity, size, visible text)
665
+ if (path.endsWith(".html") && files.has(path.replace(/\.html$/, ".md"))) {
666
+ errors.push({
667
+ file: path,
668
+ message: `Two files for one widget: ${path} and its .md twin`,
669
+ suggestion: "A link to the widget cannot tell them apart. Delete the .md file once the .html one is complete",
670
+ severity: "error",
671
+ });
672
672
  }
673
- else if (isWidgetFile(path)) {
674
- // Widget file: frontmatter + HTML body (validity, size, visible text)
675
- if (path.endsWith(".html") && files.has(path.replace(/\.html$/, ".md"))) {
676
- errors.push({
677
- file: path,
678
- message: `Two files for one widget: ${path} and its .md twin`,
679
- suggestion: "A link to the widget cannot tell them apart. Delete the .md file once the .html one is complete",
680
- severity: "error",
681
- });
673
+ const widgetResult = cachedPerFile("widget", () => {
674
+ const result = parseWidget(content, path);
675
+ return { errors: result.errors, id: result.widget?.id };
676
+ });
677
+ errors.push(...widgetResult.errors);
678
+ if (widgetResult.id) {
679
+ uuidEntries.push({ uuid: widgetResult.id, file: path, field: "id" });
680
+ }
681
+ }
682
+ else {
683
+ // File didn't match any known directory pattern — check for near-misses via Levenshtein distance
684
+ const dir = path.split("/")[0];
685
+ const VALID_DIRS = [
686
+ "modules",
687
+ "courses",
688
+ "surveys",
689
+ "articles",
690
+ "Lenses",
691
+ "Questions",
692
+ "video_transcripts",
693
+ "Learning Outcomes",
694
+ "Domains and Topics",
695
+ "prompts",
696
+ "widgets",
697
+ "glossary",
698
+ ];
699
+ let closest = "";
700
+ let minDist = Infinity;
701
+ for (const valid of VALID_DIRS) {
702
+ const dist = levenshtein(dir.toLowerCase(), valid.toLowerCase());
703
+ if (dist < minDist) {
704
+ minDist = dist;
705
+ closest = valid;
682
706
  }
683
- const widgetResult = cachedPerFile("widget", path, content, () => {
684
- const result = parseWidget(content, path);
685
- return { errors: result.errors, id: result.widget?.id };
707
+ }
708
+ // Threshold: distance <= 3 or <= 40% of the directory name length (whichever is smaller)
709
+ const threshold = Math.min(3, Math.ceil(dir.length * 0.4));
710
+ if (minDist > 0 && minDist <= threshold) {
711
+ errors.push({
712
+ file: path,
713
+ message: `File in directory '${dir}/' not recognized as content`,
714
+ suggestion: `Did you mean '${closest}/'?`,
715
+ severity: "warning",
686
716
  });
687
- errors.push(...widgetResult.errors);
688
- if (widgetResult.id) {
689
- uuidEntries.push({ uuid: widgetResult.id, file: path, field: "id" });
690
- }
691
717
  }
692
- else {
693
- // File didn't match any known directory pattern — check for near-misses via Levenshtein distance
694
- const dir = path.split("/")[0];
695
- const VALID_DIRS = [
696
- "modules",
697
- "courses",
698
- "surveys",
699
- "articles",
700
- "Lenses",
701
- "video_transcripts",
702
- "Learning Outcomes",
703
- "Domains and Topics",
704
- "prompts",
705
- "widgets",
706
- "glossary",
707
- ];
708
- let closest = "";
709
- let minDist = Infinity;
710
- for (const valid of VALID_DIRS) {
711
- const dist = levenshtein(dir.toLowerCase(), valid.toLowerCase());
712
- if (dist < minDist) {
713
- minDist = dist;
714
- closest = valid;
718
+ }
719
+ return unit;
720
+ }
721
+ export function processContent(files, cache) {
722
+ // Parsed markdown is memoized for the length of one run (markdown-parse.ts).
723
+ // Drop the trees afterwards so a long-lived caller does not keep them.
724
+ try {
725
+ return processContentRun(files, cache);
726
+ }
727
+ finally {
728
+ clearParsedMarkdown();
729
+ }
730
+ }
731
+ function processContentRun(input, cache) {
732
+ // Iteration order of `files` decides the order of every output array below, so it must
733
+ // not depend on how a caller happened to enumerate the filesystem. Callers do differ:
734
+ // the Node vault reader and the Python backend both walk directories without sorting,
735
+ // and the --stdin path takes whatever order its JSON arrived in. Sorting here once
736
+ // makes the output a function of the content alone, for every caller.
737
+ const files = inPathOrder(input);
738
+ // Pre-scan: frontmatter tags of every file, and the tier they imply
739
+ const tags = buildTagMap(files);
740
+ const tiers = buildTierMap(tags);
741
+ const views = {
742
+ files,
743
+ tierMap: new ContentView(tiers),
744
+ tagMap: new ContentView(tags),
745
+ };
746
+ // With a cache, each file's steps are cached (runPathUnit, dependency-cache.ts).
747
+ const steps = cache && new RunInputs(new Map(files), tiers, cache);
748
+ const units = [...files.keys()].map((path) => runPathUnit(path, views, steps));
749
+ return assembleContent(units, views);
750
+ }
751
+ /**
752
+ * Everything after the file units: the whole-content step, every page
753
+ * finished, and the errors in today's order. `units` holds one entry per
754
+ * content file, in path order; their pages are finished in place. The engine
755
+ * runs the same three steps on stored results (engine/build.ts), so a rule
756
+ * changes in one place for both.
757
+ */
758
+ export function assembleContent(units, views) {
759
+ const whole = wholeContentStep(units.map(summarizeUnit), views);
760
+ const pages = units.flatMap((unit) => unit.modules.map((page, index) => ({
761
+ page,
762
+ key: pageKey(unit.path, index),
763
+ })));
764
+ const finished = pages.map(({ page, key }) => ({
765
+ source: key,
766
+ finish: finishPage(page, key, whole.tables, views),
767
+ }));
768
+ const { errors } = assembleErrors(units.map((unit) => ({ source: unit.path, errors: unit.errors })), whole.errors, finished, views.tierMap);
769
+ return processResult(pages.map(({ page }) => page), whole, errors);
770
+ }
771
+ export function summarizeUnit({ modules, ...unit }) {
772
+ return {
773
+ ...unit,
774
+ pages: modules.map((page) => ({
775
+ slug: page.slug,
776
+ parentSlug: page.parentSlug,
777
+ contentIds: page.sections.flatMap((section) => section.contentId ? [section.contentId] : []),
778
+ tests: page.sections.flatMap((section) => section.type === "test" && section.sourcePath
779
+ ? [
780
+ {
781
+ sourcePath: section.sourcePath,
782
+ outcomes: section.learningOutcomes,
783
+ },
784
+ ]
785
+ : []),
786
+ })),
787
+ };
788
+ }
789
+ /**
790
+ * The whole-content step: merge every file's summary into the tables that
791
+ * span files and run the checks across files. It reads facts and page
792
+ * summaries, never a page body. `units` holds one entry per content file, in
793
+ * path order; their courses and question entries are changed in place.
794
+ */
795
+ export function wholeContentStep(units, { files, tierMap, tagMap }) {
796
+ const articleSegments = {};
797
+ const courses = [];
798
+ const surveys = [];
799
+ const surveyPathToId = new Map();
800
+ const meetingDocs = {};
801
+ const urlsToValidate = [];
802
+ const uuidEntries = [];
803
+ const slugEntries = [];
804
+ const slugToPath = new Map();
805
+ const filePathToSlug = new Map(); // Reverse: file path → slug (survives duplicate slugs)
806
+ const courseSlugToFile = new Map();
807
+ const parentSlugToChildren = new Map();
808
+ const pages = [];
809
+ // --- Skill-tree collections (assembled after the merge) ---
810
+ // Domain folder path → folder; topic file path → topic (+ its folder).
811
+ const domainFolders = new Map();
812
+ const topicFiles = new Map();
813
+ const learningOutcomesByPath = new Map();
814
+ // LO path → module path → the submodules (local slug → title) that
815
+ // reference it; empty when the module is not split or the LO is module-level.
816
+ const moduleLoRefs = new Map();
817
+ const glossaryEntries = new Map();
818
+ const skillTreeModules = new Map();
819
+ for (const unit of units) {
820
+ const { path } = unit;
821
+ unit.pages.forEach((page, index) => {
822
+ pages.push({ ...page, key: pageKey(path, index) });
823
+ slugToPath.set(page.slug, path);
824
+ // Track parent→children mapping for course expansion
825
+ if (page.parentSlug) {
826
+ if (!parentSlugToChildren.has(page.parentSlug)) {
827
+ parentSlugToChildren.set(page.parentSlug, []);
715
828
  }
829
+ parentSlugToChildren.get(page.parentSlug).push(page.slug);
716
830
  }
717
- // Threshold: distance <= 3 or <= 40% of the directory name length (whichever is smaller)
718
- const threshold = Math.min(3, Math.ceil(dir.length * 0.4));
719
- if (minDist > 0 && minDist <= threshold) {
720
- errors.push({
721
- file: path,
722
- message: `File in directory '${dir}/' not recognized as content`,
723
- suggestion: `Did you mean '${closest}/'?`,
724
- severity: "warning",
725
- });
831
+ slugEntries.push({ slug: page.slug, file: path });
832
+ });
833
+ if (unit.moduleSlug !== undefined) {
834
+ filePathToSlug.set(path, unit.moduleSlug);
835
+ }
836
+ urlsToValidate.push(...unit.urls);
837
+ uuidEntries.push(...unit.uuids);
838
+ if (unit.course) {
839
+ courses.push(unit.course);
840
+ courseSlugToFile.set(unit.course.slug, path);
841
+ }
842
+ if (unit.survey) {
843
+ surveys.push(unit.survey);
844
+ surveyPathToId.set(path, unit.survey.id);
845
+ }
846
+ if (unit.meetingDoc !== undefined)
847
+ meetingDocs[path] = unit.meetingDoc;
848
+ if (unit.articleSegments)
849
+ articleSegments[path] = unit.articleSegments;
850
+ if (unit.skillTreeModule)
851
+ skillTreeModules.set(path, unit.skillTreeModule);
852
+ if (unit.learningOutcome) {
853
+ learningOutcomesByPath.set(path, unit.learningOutcome);
854
+ }
855
+ for (const { lo, submodule } of unit.loRefs ?? []) {
856
+ let byModule = moduleLoRefs.get(lo);
857
+ if (!byModule) {
858
+ byModule = new Map();
859
+ moduleLoRefs.set(lo, byModule);
860
+ }
861
+ let submodules = byModule.get(path);
862
+ if (!submodules) {
863
+ submodules = new Map();
864
+ byModule.set(path, submodules);
726
865
  }
866
+ if (submodule)
867
+ submodules.set(submodule.slug, submodule.title);
727
868
  }
728
- }
729
- errors.push(...validateGlossaryAliases(glossaryEntries));
730
- // Build contentId → moduleSlug mapping and populate moduleSlug in cross-module card links.
731
- // Prefer real modules over standalone lenses (lens/ prefix) — a lens that appears in
732
- // both a module and as a standalone should map to the module.
733
- const contentIdToModuleSlug = new Map();
734
- for (const mod of modules) {
735
- if (mod.slug.startsWith("lens/"))
736
- continue; // Skip standalone lenses
737
- for (const section of mod.sections) {
738
- if (section.contentId) {
739
- contentIdToModuleSlug.set(section.contentId, mod.slug);
869
+ if (unit.domainFolder) {
870
+ // The first file in a folder (in path order) declares it.
871
+ const { dir, number, title, metadata } = unit.domainFolder;
872
+ let folder = domainFolders.get(dir);
873
+ if (!folder) {
874
+ folder = { number, title, declaredBy: path };
875
+ domainFolders.set(dir, folder);
740
876
  }
877
+ if (metadata)
878
+ folder.metadata = metadata;
741
879
  }
880
+ if (unit.topic)
881
+ topicFiles.set(path, unit.topic);
882
+ if (unit.glossaryEntry)
883
+ glossaryEntries.set(path, unit.glossaryEntry);
742
884
  }
743
- for (const mod of modules) {
744
- populateCardModuleSlugs(mod.sections, contentIdToModuleSlug);
745
- resolveInlineLensModuleSlugs(mod.sections, contentIdToModuleSlug);
746
- }
747
- // Glossary marks are offsets into segment text, so they are placed after
748
- // every pass that rewrites it (card links and lens links above, card
749
- // metadata in the flattener).
750
- for (const mod of modules) {
751
- for (const section of mod.sections) {
752
- if (section.glossaryTerms === undefined)
753
- continue;
754
- const result = applyGlossary(section.glossaryTerms, section.segments, section.sourcePath ?? mod.sourcePath ?? mod.slug, files, glossaryEntries, tierMap);
755
- errors.push(...result.errors);
756
- section.segments = result.segments;
757
- if (result.glossary.length)
758
- section.glossary = result.glossary;
759
- delete section.glossaryTerms;
885
+ const glossaryAliases = validateGlossaryAliases(glossaryEntries);
886
+ // Which module a card or a lens link names: the module page that holds the
887
+ // lens. Real modules only: a lens that appears in both a module and as a
888
+ // standalone page maps to the module.
889
+ const contentIdToModule = new Map();
890
+ for (const page of pages) {
891
+ if (page.slug.startsWith("lens/"))
892
+ continue; // Skip standalone lenses
893
+ for (const contentId of page.contentIds) {
894
+ contentIdToModule.set(contentId, page.slug);
760
895
  }
761
896
  }
762
- // Card-to-module membership check. Runs after card link resolution above so
763
- // every card has its data-lens-card JSON populated, including allowExternal.
764
- errors.push(...validateCardModuleMembership(modules));
765
- // After card resolution: each require_x_optional_lenses list becomes its
766
- // section's optionalLensSets (the lens gate reads those).
767
- errors.push(...collectOptionalLensSets(modules));
897
+ const courseErrors = [];
768
898
  /**
769
899
  * Resolve one survey wikilink (from a meeting's survey:: /
770
900
  * facilitator-survey::, or the course's application-survey::) to the
@@ -780,11 +910,11 @@ function processContentRun(files, cache) {
780
910
  const childTier = tierMap.get(actualFile) ?? "production";
781
911
  const violation = checkTierViolation(courseFile, parentTier, actualFile, childTier, label);
782
912
  if (violation) {
783
- errors.push(violation);
913
+ courseErrors.push(violation);
784
914
  }
785
915
  return surveyPathToId.get(actualFile);
786
916
  }
787
- errors.push({
917
+ courseErrors.push({
788
918
  file: courseFile,
789
919
  message: `Survey reference could not be resolved: "${surveyPath}"`,
790
920
  suggestion: `Check that ${label}:: points to an existing, error-free file in surveys/`,
@@ -818,7 +948,7 @@ function processContentRun(files, cache) {
818
948
  }
819
949
  }
820
950
  if (!matched) {
821
- errors.push({
951
+ courseErrors.push({
822
952
  file: courseFile,
823
953
  message: `Module reference could not be resolved: "${item.path}"`,
824
954
  suggestion: "Check that the wikilink path points to an existing module file",
@@ -853,7 +983,7 @@ function processContentRun(files, cache) {
853
983
  item.docPath = actualFile;
854
984
  }
855
985
  else {
856
- errors.push({
986
+ courseErrors.push({
857
987
  file: courseFile,
858
988
  message: `Meeting doc reference could not be resolved: "${item.docPath}"`,
859
989
  suggestion: "Check that meeting-doc:: points to an existing, error-free file in meetings/",
@@ -923,7 +1053,7 @@ function processContentRun(files, cache) {
923
1053
  const childTier = tierMap.get(modulePath) ?? "production";
924
1054
  const violation = checkTierViolation(coursePath, parentTier, modulePath, childTier, "module");
925
1055
  if (violation) {
926
- errors.push(violation);
1056
+ courseErrors.push(violation);
927
1057
  }
928
1058
  }
929
1059
  }
@@ -944,7 +1074,7 @@ function processContentRun(files, cache) {
944
1074
  for (const alias of course.slugAliases ?? []) {
945
1075
  const existing = allCourseSlugs.get(alias);
946
1076
  if (existing) {
947
- errors.push({
1077
+ courseErrors.push({
948
1078
  file,
949
1079
  message: `Course slug alias '${alias}' collides with ${existing === file ? "its own primary slug" : `slug in ${existing}`}`,
950
1080
  suggestion: `Choose a different alias or remove the conflicting slug`,
@@ -968,12 +1098,9 @@ function processContentRun(files, cache) {
968
1098
  }
969
1099
  }
970
1100
  // Validate all collected UUIDs
971
- const uuidValidation = validateUuids(uuidEntries);
972
- errors.push(...uuidValidation.errors);
973
- errors.push(...detectSharedQuestionsInOneModule(modules));
1101
+ const uuidErrors = validateUuids(uuidEntries).errors;
974
1102
  // Validate for duplicate slugs
975
- const duplicateSlugErrors = detectDuplicateSlugs(slugEntries);
976
- errors.push(...duplicateSlugErrors);
1103
+ const slugsAndTranscripts = detectDuplicateSlugs(slugEntries);
977
1104
  // Validate video transcript / timestamps.json pairing
978
1105
  const transcriptPaths = [...files.keys()].filter((p) => (p.startsWith("video_transcripts/") ||
979
1106
  p.includes("/video_transcripts/")) &&
@@ -983,7 +1110,7 @@ function processContentRun(files, cache) {
983
1110
  for (const mdPath of transcriptPaths) {
984
1111
  const expectedTs = mdPath.replace(/\.md$/, ".timestamps.json");
985
1112
  if (!timestampPaths.has(expectedTs)) {
986
- errors.push({
1113
+ slugsAndTranscripts.push({
987
1114
  file: mdPath,
988
1115
  message: `Missing timestamps.json: expected ${expectedTs}`,
989
1116
  severity: "error",
@@ -995,20 +1122,42 @@ function processContentRun(files, cache) {
995
1122
  if (tierMap.get(expectedMd) === "ignored")
996
1123
  continue;
997
1124
  if (!files.has(expectedMd)) {
998
- errors.push({
1125
+ slugsAndTranscripts.push({
999
1126
  file: tsPath,
1000
1127
  message: `Orphaned timestamps file: no matching .md transcript found`,
1001
1128
  severity: "warning",
1002
1129
  });
1003
1130
  }
1004
1131
  }
1005
- // Safety-net: catch empty sections/segments in final output
1006
- const integrityErrors = validateOutputIntegrity(modules, slugToPath);
1007
- errors.push(...integrityErrors);
1008
- tagCourseOutcomes(modules, courses, (loPath) => {
1132
+ // A course teaches an outcome across units but tests it once, so a lens
1133
+ // also carries every outcome that suggests it and is tested in any module
1134
+ // of a course the lens's module is in (marked testedElsewhere, after the
1135
+ // tags from the lens's own submodule). Courses link to module slugs (split
1136
+ // modules expanded above); the last page with a slug is the one a slug
1137
+ // names. Each page gets one outcome list per course it is in, in course
1138
+ // order, and finishPage tags its lens sections with them.
1139
+ const lensPathsOf = (loPath) => {
1009
1140
  const lo = learningOutcomesByPath.get(loPath);
1010
- return lo ? suggestedLensPaths(lo, files) : new Set();
1011
- });
1141
+ return lo ? suggestedLensPaths(lo, files) : [];
1142
+ };
1143
+ const pageBySlug = new Map(pages.map((page) => [page.slug, page]));
1144
+ const courseOutcomes = new Map();
1145
+ for (const course of courses) {
1146
+ const coursePages = course.progression.flatMap((item) => {
1147
+ const page = item.slug ? pageBySlug.get(item.slug) : undefined;
1148
+ return item.type === "module" && page ? [page] : [];
1149
+ });
1150
+ const outcomes = coursePages.flatMap((page) => page.tests.flatMap((test) => test.outcomes.map((ref) => ({
1151
+ ref: { ...ref, testedElsewhere: true },
1152
+ lensPaths: lensPathsOf(test.sourcePath),
1153
+ }))));
1154
+ for (const page of new Set(coursePages)) {
1155
+ let lists = courseOutcomes.get(page.key);
1156
+ if (!lists)
1157
+ courseOutcomes.set(page.key, (lists = []));
1158
+ lists.push(outcomes);
1159
+ }
1160
+ }
1012
1161
  // --- Skill tree assembly ---
1013
1162
  // Course progression was expanded to submodule slugs above; map back to the
1014
1163
  // parent module slug, which is what taughtBy points at.
@@ -1042,44 +1191,167 @@ function processContentRun(files, cache) {
1042
1191
  courses: skillTreeCourses,
1043
1192
  files,
1044
1193
  });
1045
- errors.push(...skillTreeResult.errors);
1046
- // Sections learned their outcomes before topics were resolved; name each
1047
- // outcome's primary topic now that the tree knows it.
1048
- const topicByOutcomeId = new Map();
1194
+ // Sections learned their outcomes before topics were resolved; finishPage
1195
+ // names each outcome's primary topic from the tree.
1196
+ const topics = new Map();
1049
1197
  const topicBySlug = new Map(skillTreeResult.skillTree.topics.map((t) => [t.slug, t]));
1050
1198
  for (const card of skillTreeResult.skillTree.cards) {
1051
1199
  const topic = topicBySlug.get(card.topic);
1052
1200
  if (topic) {
1053
- topicByOutcomeId.set(card.id, { slug: topic.slug, title: topic.title });
1201
+ topics.set(card.id, { slug: topic.slug, title: topic.title });
1054
1202
  }
1055
1203
  }
1056
- for (const module of modules) {
1057
- for (const section of module.sections) {
1058
- for (const ref of section.learningOutcomes) {
1059
- const topic = topicByOutcomeId.get(ref.id);
1060
- if (topic)
1061
- ref.topic = topic;
1062
- }
1204
+ return {
1205
+ errors: {
1206
+ glossaryAliases,
1207
+ courses: courseErrors,
1208
+ uuids: uuidErrors,
1209
+ slugsAndTranscripts,
1210
+ skillTree: skillTreeResult.errors,
1211
+ },
1212
+ tables: {
1213
+ contentIdToModule,
1214
+ glossary: glossaryEntries,
1215
+ moduleContentIds: cardModuleGroups(pages),
1216
+ slugToPath,
1217
+ courseOutcomes,
1218
+ topics,
1219
+ },
1220
+ courses,
1221
+ surveys,
1222
+ meetingDocs,
1223
+ urlsToValidate,
1224
+ skillTree: skillTreeResult.skillTree,
1225
+ articleSegments,
1226
+ };
1227
+ }
1228
+ /**
1229
+ * Finish one flattened page with what other pages contribute: a card's and a
1230
+ * lens link's module, glossary marks, optional-lens lists, outcomes tested
1231
+ * elsewhere in a course, and outcome topics; and run the checks on the
1232
+ * finished page. Changes `page` in place, so it starts from a page fresh
1233
+ * from flattening: a card's module, once set, is never cleared. `key` is
1234
+ * pageKey of the page.
1235
+ *
1236
+ * Anything a page takes from other pages belongs here, never in flattening.
1237
+ * Flattening runs in one file's unit before the other units have run, so a
1238
+ * value it took from another page would go stale when that page changes,
1239
+ * with no test failing at once.
1240
+ */
1241
+ export function finishPage(page, key, tables, { files, tierMap }) {
1242
+ populateCardModuleSlugs(page.sections, tables.contentIdToModule);
1243
+ resolveInlineLensModuleSlugs(page.sections, tables.contentIdToModule);
1244
+ // Glossary marks are offsets into segment text, so they are placed after
1245
+ // every pass that rewrites it (card links and lens links above, card
1246
+ // metadata in the flattener).
1247
+ const glossary = [];
1248
+ for (const section of page.sections) {
1249
+ if (section.glossaryTerms === undefined)
1250
+ continue;
1251
+ const result = applyGlossary(section.glossaryTerms, section.segments, section.sourcePath ?? page.sourcePath ?? page.slug, files, tables.glossary, tierMap);
1252
+ glossary.push(...result.errors);
1253
+ section.segments = result.segments;
1254
+ if (result.glossary.length)
1255
+ section.glossary = result.glossary;
1256
+ delete section.glossaryTerms;
1257
+ }
1258
+ // Card-to-module membership check. Runs after card link resolution above so
1259
+ // every card has its data-lens-card JSON populated, including allowExternal.
1260
+ const cardMembership = checkCardModuleMembership(page, tables.moduleContentIds);
1261
+ // After card resolution: each require_x_optional_lenses list becomes its
1262
+ // section's optionalLensSets (the lens gate reads those).
1263
+ const optionalLensSets = collectOptionalLensSets([page]);
1264
+ const sharedQuestions = sharedQuestionCandidates(page);
1265
+ // Safety-net: catch empty sections/segments in final output
1266
+ const integrity = outputIntegrityCandidates(page, tables.slugToPath.get(page.slug) ?? page.slug);
1267
+ for (const outcomes of tables.courseOutcomes.get(key) ?? []) {
1268
+ for (const section of page.sections)
1269
+ tagLensOutcomes(section, outcomes);
1270
+ }
1271
+ for (const section of page.sections) {
1272
+ for (const ref of section.learningOutcomes) {
1273
+ const topic = tables.topics.get(ref.id);
1274
+ if (topic)
1275
+ ref.topic = topic;
1063
1276
  }
1064
1277
  }
1065
- // Post-process: assign category to errors that don't already have one
1066
- for (const error of errors) {
1278
+ return {
1279
+ glossary,
1280
+ cardMembership,
1281
+ optionalLensSets,
1282
+ sharedQuestions,
1283
+ integrity,
1284
+ };
1285
+ }
1286
+ /**
1287
+ * The final error list in today's order: every unit's errors in path order,
1288
+ * then the checks after the file units in the order they ran, the page
1289
+ * checks in page order within their step. Each error gets its category (the
1290
+ * tier of its file), and dedupeErrors' rule keeps the first copy. `sources`
1291
+ * names, for each final error, the sources (as given) that reported it.
1292
+ */
1293
+ export function assembleErrors(units, whole, pages, tierMap) {
1294
+ const reported = [];
1295
+ const add = (source, errors) => {
1296
+ for (const error of errors)
1297
+ reported.push({ error, source });
1298
+ };
1299
+ const fromPages = (step) => {
1300
+ for (const { source, finish } of pages)
1301
+ add(source, finish[step]);
1302
+ };
1303
+ // A cross-page check: the first page (in page order) with a key reports it.
1304
+ const onceFromPages = (step) => reported.push(...reportOnce(pages.flatMap(({ source, finish }) => finish[step].map((candidate) => ({ ...candidate, source })))));
1305
+ for (const { source, errors } of units)
1306
+ add(source, errors);
1307
+ add(WHOLE_CONTENT, whole.glossaryAliases);
1308
+ fromPages("glossary");
1309
+ fromPages("cardMembership");
1310
+ fromPages("optionalLensSets");
1311
+ add(WHOLE_CONTENT, whole.courses);
1312
+ add(WHOLE_CONTENT, whole.uuids);
1313
+ onceFromPages("sharedQuestions");
1314
+ add(WHOLE_CONTENT, whole.slugsAndTranscripts);
1315
+ onceFromPages("integrity");
1316
+ add(WHOLE_CONTENT, whole.skillTree);
1317
+ // The same file is validated once per pass (standalone + once per module
1318
+ // that flattens it), so identical errors are emitted multiple times. Collapse
1319
+ // them so each problem is reported once.
1320
+ const errors = [];
1321
+ const sources = [];
1322
+ const at = new Map();
1323
+ for (const { error, source } of reported) {
1067
1324
  if (!error.category) {
1068
1325
  const tier = tierMap.get(error.file);
1069
1326
  error.category = tier === "wip" ? "wip" : "production";
1070
1327
  }
1328
+ const key = errorKey(error);
1329
+ const index = at.get(key);
1330
+ if (index === undefined) {
1331
+ at.set(key, errors.length);
1332
+ errors.push(error);
1333
+ sources.push([source]);
1334
+ }
1335
+ else if (!sources[index].includes(source)) {
1336
+ sources[index].push(source);
1337
+ }
1071
1338
  }
1072
- // The same file is validated once per pass (standalone + once per module
1073
- // that flattens it), so identical errors are emitted multiple times. Collapse
1074
- // them so each problem is reported once.
1339
+ return { errors, sources };
1340
+ }
1341
+ /** The source assembleErrors gives errors of the whole-content step. */
1342
+ const WHOLE_CONTENT = "whole";
1343
+ /** The ProcessResult of finished pages, the whole-content step and the final errors. */
1344
+ export function processResult(modules, whole, errors) {
1345
+ const { courses, surveys, meetingDocs, urlsToValidate, skillTree } = whole;
1346
+ const { articleSegments } = whole;
1075
1347
  return {
1076
1348
  modules,
1077
1349
  courses,
1078
1350
  surveys,
1079
1351
  ...(Object.keys(meetingDocs).length > 0 ? { meetingDocs } : {}),
1080
- errors: dedupeErrors(errors),
1352
+ errors,
1081
1353
  urlsToValidate,
1082
- skillTree: skillTreeResult.skillTree,
1354
+ skillTree,
1083
1355
  ...(Object.keys(articleSegments).length > 0 ? { articleSegments } : {}),
1084
1356
  };
1085
1357
  }