lens-content-processor 0.79.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 (130) 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 +245 -114
  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 +883 -639
  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 +5 -1
  87. package/dist/parser/lens.js +22 -7
  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 +4 -3
  96. package/dist/parser/question-embeds.js.map +1 -1
  97. package/dist/parser/sections.d.ts +10 -0
  98. package/dist/parser/sections.js +8 -3
  99. package/dist/parser/sections.js.map +1 -1
  100. package/dist/parser/widget.d.ts +2 -1
  101. package/dist/parser/widget.js.map +1 -1
  102. package/dist/parser/wikilink.d.ts +13 -3
  103. package/dist/parser/wikilink.js +55 -34
  104. package/dist/parser/wikilink.js.map +1 -1
  105. package/dist/reference-graph.d.ts +21 -3
  106. package/dist/reference-graph.js +35 -12
  107. package/dist/reference-graph.js.map +1 -1
  108. package/dist/same-page-links.d.ts +11 -1
  109. package/dist/same-page-links.js +9 -0
  110. package/dist/same-page-links.js.map +1 -1
  111. package/dist/skill-tree.d.ts +2 -1
  112. package/dist/skill-tree.js.map +1 -1
  113. package/dist/source-location.d.ts +0 -4
  114. package/dist/source-location.js +0 -43
  115. package/dist/source-location.js.map +1 -1
  116. package/dist/validator/card-module-membership.d.ts +17 -1
  117. package/dist/validator/card-module-membership.js +60 -56
  118. package/dist/validator/card-module-membership.js.map +1 -1
  119. package/dist/validator/criticmarkup.js +1 -1
  120. package/dist/validator/criticmarkup.js.map +1 -1
  121. package/dist/validator/field-typos.d.ts +1 -1
  122. package/dist/validator/field-typos.js +21 -21
  123. package/dist/validator/field-typos.js.map +1 -1
  124. package/dist/validator/output-integrity.d.ts +9 -5
  125. package/dist/validator/output-integrity.js +43 -45
  126. package/dist/validator/output-integrity.js.map +1 -1
  127. package/dist/validator/tier.d.ts +2 -2
  128. package/dist/validator/tier.js.map +1 -1
  129. package/dist/validator/uuid.d.ts +1 -1
  130. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
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";
@@ -11,7 +13,7 @@ import { isQuestionFilePath, parseQuestionFile, questionEmbedCacheKey, questionE
11
13
  import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion, } from "./parser/wikilink.js";
12
14
  import { validateUuids, } from "./validator/uuid.js";
13
15
  import { detectDuplicateSlugs, } from "./validator/duplicates.js";
14
- import { validateOutputIntegrity } from "./validator/output-integrity.js";
16
+ import { outputIntegrityCandidates } from "./validator/output-integrity.js";
15
17
  import { parseArticleBody } from "./bundler/article.js";
16
18
  import { parseFrontmatter } from "./parser/frontmatter.js";
17
19
  import { parseArticle } from "./parser/article.js";
@@ -23,7 +25,7 @@ import { applyGlossary, isGlossaryPath, parseGlossaryEntry, validateGlossaryAlia
23
25
  import { validateTimestamps } from "./validator/timestamps.js";
24
26
  import { levenshtein } from "./validator/field-typos.js";
25
27
  import { buildTagMap, buildTierMap, checkTierViolation, } from "./validator/tier.js";
26
- import { validateCardModuleMembership } from "./validator/card-module-membership.js";
28
+ import { cardModuleGroups, checkCardModuleMembership, } from "./validator/card-module-membership.js";
27
29
  import { collectOptionalLensSets, } from "./validator/optional-lens-sets.js";
28
30
  import { detectPendingCriticMarkup, isCriticMarkupCheckedPath, } from "./validator/criticmarkup.js";
29
31
  import { validateMath } from "./validator/math.js";
@@ -36,6 +38,8 @@ import { DOMAIN_METADATA_FILE, parseDomainMetadata, parseDomainPath, parseTopicF
36
38
  import { RunInputs } from "./dependency-cache.js";
37
39
  export { FileResultCache } from "./cache.js";
38
40
  import { clearParsedMarkdown } from "./markdown-parse.js";
41
+ import { ContentView, inPathOrder, } from "./engine/reader.js";
42
+ import { pageKey } from "./engine/units.js";
39
43
  /** Authored field name of each flattened prompt-import-capable property. */
40
44
  const PROMPT_FIELD_NAMES = {
41
45
  assessmentInstructions: "assessment-instructions",
@@ -48,58 +52,59 @@ const PROMPT_FIELD_NAMES = {
48
52
  * their sourcePath, so their own id tells them apart. The backend finds a question by (id, module), so two
49
53
  * different copies in one module would both be graded, length-checked and
50
54
  * coached by the first. This also catches a lens and the article it embeds
51
- * 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.
52
58
  */
53
- function detectSharedQuestionsInOneModule(modules) {
54
- const errors = [];
55
- const reported = new Set(); // one error per lens, not per module
56
- for (const mod of modules) {
57
- // lowercased id → where it first appeared in this module
58
- const first = new Map();
59
- for (const section of mod.sections) {
60
- const lens = section.sourcePath ?? mod.sourcePath ?? mod.slug;
61
- // Inline lenses share the module file as their path: name them.
62
- const where = section.sourcePath === mod.sourcePath &&
63
- lens.startsWith("modules/") &&
64
- section.meta.title
65
- ? `lens '${section.meta.title}' in ${lens}`
66
- : lens;
67
- for (const seg of section.segments) {
68
- 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)
69
83
  continue;
70
- const from = seg.partOfArticle ? `an article in ${where}` : where;
71
- const id = seg.responseId.toLowerCase();
72
- const seen = first.get(id);
73
- if (seen === undefined) {
74
- first.set(id, { section, from, inArticle: !!seg.partOfArticle });
75
- }
76
- else if (seen.section === section) {
77
- // Two in the lens's own text are collectResponseSegmentIds's error.
78
- if (!seen.inArticle && !seg.partOfArticle)
79
- continue;
80
- if (reported.has(`${lens}\n${id}`))
81
- continue;
82
- reported.add(`${lens}\n${id}`);
83
- errors.push({
84
+ candidates.push({
85
+ error: {
84
86
  file: lens,
85
87
  message: `Question id '${seg.responseId}' appears twice in this lens: in ${seen.from} and in ${from}`,
86
88
  suggestion: "Give one of the two questions its own UUID",
87
89
  severity: "error",
88
- });
89
- }
90
- else if (seen.section.sourcePath !== section.sourcePath ||
91
- seen.section.contentId !== section.contentId) {
92
- 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: {
93
98
  file: mod.sourcePath ?? lens,
94
99
  message: `Module '${mod.slug}' holds question id '${seg.responseId}' twice: in ${seen.from} and in ${from}`,
95
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",
96
101
  severity: "error",
97
- });
98
- }
102
+ },
103
+ });
99
104
  }
100
105
  }
101
106
  }
102
- return errors;
107
+ return candidates;
103
108
  }
104
109
  /**
105
110
  * Collect the stable id:: of every question segment (from lenses, inline
@@ -108,19 +113,14 @@ function detectSharedQuestionsInOneModule(modules) {
108
113
  * files share an id only for the same question (validator/uuid.ts).
109
114
  * validateUuids deliberately allows same-file, same-field duplicates
110
115
  * (contentId sharing across submodule groups), so duplicates within one file
111
- * 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.
112
117
  */
113
- function collectResponseSegmentIds(segments, context, path, uuidEntries, seenResponseIds, errors) {
118
+ function collectResponseSegmentIds(segments, context, path, uuidEntries, seenInFile, errors) {
114
119
  for (const seg of segments) {
115
120
  if (seg.type !== "question" || !("responseId" in seg) || !seg.responseId) {
116
121
  continue;
117
122
  }
118
123
  const lower = seg.responseId.toLowerCase();
119
- let seenInFile = seenResponseIds.get(path);
120
- if (!seenInFile) {
121
- seenInFile = new Set();
122
- seenResponseIds.set(path, seenInFile);
123
- }
124
124
  if (seenInFile.has(lower)) {
125
125
  errors.push({
126
126
  file: path,
@@ -142,7 +142,7 @@ function collectResponseSegmentIds(segments, context, path, uuidEntries, seenRes
142
142
  /**
143
143
  * Recursively collect UUIDs from inline lens sections (including submodule children).
144
144
  */
145
- function collectInlineLensUUIDs(sections, path, uuidEntries, seenResponseIds, errors) {
145
+ function collectInlineLensUUIDs(sections, path, uuidEntries, seenInFile, errors) {
146
146
  for (const section of sections) {
147
147
  if (section.type === "lens") {
148
148
  if (section.inlineLens) {
@@ -152,7 +152,7 @@ function collectInlineLensUUIDs(sections, path, uuidEntries, seenResponseIds, er
152
152
  file: path,
153
153
  field: "section id",
154
154
  });
155
- collectResponseSegmentIds(section.inlineLens.segments, "lens", path, uuidEntries, seenResponseIds, errors);
155
+ collectResponseSegmentIds(section.inlineLens.segments, "lens", path, uuidEntries, seenInFile, errors);
156
156
  }
157
157
  else if (!section.fields.source && !section.fields.id) {
158
158
  // No section-level source:: and no id:: → missing required id
@@ -174,625 +174,727 @@ function collectInlineLensUUIDs(sections, path, uuidEntries, seenResponseIds, er
174
174
  }
175
175
  }
176
176
  if (section.children) {
177
- collectInlineLensUUIDs(section.children, path, uuidEntries, seenResponseIds, errors);
177
+ collectInlineLensUUIDs(section.children, path, uuidEntries, seenInFile, errors);
178
178
  }
179
179
  }
180
180
  }
181
- function collectLearningOutcomeTestUUIDs(learningOutcome, path, uuidEntries, seenResponseIds, errors) {
181
+ function collectLearningOutcomeTestUUIDs(learningOutcome, path, uuidEntries, seenInFile, errors) {
182
182
  if (learningOutcome.test) {
183
183
  uuidEntries.push({
184
184
  uuid: learningOutcome.test.id,
185
185
  file: path,
186
186
  field: "test section id",
187
187
  });
188
- collectResponseSegmentIds(learningOutcome.test.segments, "test", path, uuidEntries, seenResponseIds, errors);
188
+ collectResponseSegmentIds(learningOutcome.test.segments, "test", path, uuidEntries, seenInFile, errors);
189
189
  }
190
190
  }
191
- export function processContent(files, cache) {
192
- // Parsed markdown is memoized for the length of one run (markdown-parse.ts).
193
- // Drop the trees afterwards so a long-lived caller does not keep them.
194
- try {
195
- return processContentRun(files, cache);
196
- }
197
- finally {
198
- 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
+ }
199
220
  }
221
+ return refs;
200
222
  }
201
- function processContentRun(files, cache) {
202
- // Iteration order of `files` decides the order of every output array below, so it must
203
- // not depend on how a caller happened to enumerate the filesystem. Callers do differ:
204
- // the Node vault reader and the Python backend both walk directories without sorting,
205
- // and the --stdin path takes whatever order its JSON arrived in. Sorting here once
206
- // makes the output a function of the content alone, for every caller.
207
- files = new Map([...files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
208
- // Memoize a pure function of one file's content (see cache.ts for the key's
209
- // correctness argument; its entry wrapper rejects any bytes put() did not
210
- // write). Without a cache this is just fn(): the pipeline below runs
211
- // identically either way — same passes, same order — a hit only skips
212
- // recomputing what an identical input already produced.
213
- function cachedPerFile(kind, path, content, fn) {
214
- const hit = cache?.get(kind, path, content);
215
- if (hit !== undefined)
216
- return hit;
217
- const value = fn();
218
- cache?.put(kind, path, content, value);
219
- 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;
220
242
  }
221
- const modules = [];
222
- const articleSegments = {};
223
- const courses = [];
224
- const surveys = [];
225
- const surveyPathToId = new Map();
226
- const meetingDocs = {};
227
- const errors = [];
228
- const urlsToValidate = [];
229
- const uuidEntries = [];
230
- // Response segment ids already seen, per file (within-file duplicate check)
231
- const seenResponseIds = new Map();
232
- const slugEntries = [];
233
- const slugToPath = new Map();
234
- const filePathToSlug = new Map(); // Reverse: file path → slug (survives duplicate slugs)
235
- const courseSlugToFile = new Map();
236
- const parentSlugToChildren = new Map();
237
- // Pre-scan: frontmatter tags of every file, and the tier they imply
238
- const tagMap = buildTagMap(files);
239
- const tierMap = buildTierMap(tagMap);
240
- // Steps that read other files are cached by what they read (see
241
- // dependency-cache.ts); each is a pure function of the file and tier maps.
242
- const inputs = new RunInputs(files, tierMap, cache);
243
- // --- Skill-tree collections (assembled after the main loop) ---
244
- // Domain folder path → folder; topic file path → topic (+ its folder).
245
- const domainFolders = new Map();
246
- const topicFiles = new Map();
247
- const learningOutcomesByPath = new Map();
248
- // LO path → module path → the submodules (local slug → title) that
249
- // reference it; empty when the module is not split or the LO is module-level.
250
- const moduleLoRefs = new Map();
251
- const glossaryEntries = new Map();
252
- const skillTreeModules = new Map();
253
- // Record that `modulePath` references each LO reachable from `sections`,
254
- // remembering which submodule it sits in. A "# Submodule:" heading opens a
255
- // group that runs until the next one (its siblings belong to it, as in
256
- // splitAtBoundaries), and any children nest under it too. Works off the raw
257
- // module parse, so an LO counts as "taught by" the module even when it
258
- // contributes no flattened sections (e.g. it has no test yet).
259
- function collectModuleLoRefs(sections, modulePath, submodule = null) {
260
- for (const section of sections) {
261
- if (section.type === "submodule") {
262
- // Same slug rule as splitAtBoundaries, so the link lands on the page.
263
- submodule = {
264
- slug: section.fields.slug ?? toKebabCase(section.title),
265
- title: section.title,
266
- };
267
- }
268
- if (section.type === "learning-outcome" && section.fields.source) {
269
- const wikilink = parseWikilink(section.fields.source, modulePath);
270
- if (wikilink && !wikilink.error) {
271
- const resolved = resolveWikilinkPath(wikilink.path, modulePath);
272
- const actualPath = findFileWithExtension(resolved, files);
273
- if (actualPath) {
274
- let byModule = moduleLoRefs.get(actualPath);
275
- if (!byModule) {
276
- byModule = new Map();
277
- moduleLoRefs.set(actualPath, byModule);
278
- }
279
- let submodules = byModule.get(modulePath);
280
- if (!submodules) {
281
- submodules = new Map();
282
- byModule.set(modulePath, submodules);
283
- }
284
- if (submodule)
285
- submodules.set(submodule.slug, submodule.title);
286
- }
287
- }
288
- }
289
- if (section.children) {
290
- collectModuleLoRefs(section.children, modulePath, submodule);
291
- }
292
- }
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);
293
257
  }
294
- // Identify file types by path
295
- for (const [path, content] of files.entries()) {
296
- // Skip ignored files entirely
297
- if (tierMap.get(path) === "ignored") {
298
- continue;
299
- }
300
- if (path.endsWith(".md") || isCriticMarkupCheckedPath(path)) {
301
- const prechecks = cachedPerFile("prechecks", path, content, () => {
302
- const pre = [];
303
- // A widget body is HTML in an iframe, never rendered as math. Only
304
- // the root widgets/ folder: a nested widgets/ under Lenses/ or
305
- // articles/ is parsed as a lens or article below, and renders math.
306
- if (path.endsWith(".md") && !path.startsWith("widgets/")) {
307
- const mathErrors = validateMath(content, path);
308
- if (path.startsWith("articles/") || path.includes("/articles/")) {
309
- for (const error of mathErrors) {
310
- error.code ??= error.message.includes("unclosed $$")
311
- ? "article.math-unclosed"
312
- : "article.math-invalid";
313
- }
314
- }
315
- pre.push(...mathErrors);
316
- }
317
- // Warn about pending CriticMarkup suggestions on the raw content — the
318
- // parsers strip them (reject-all behavior) before any other processing.
319
- if (isCriticMarkupCheckedPath(path)) {
320
- pre.push(...detectPendingCriticMarkup(content, path));
321
- }
322
- // A lone ![[../Questions/...]] line is a question only in a lens file
323
- const isLensFile = path.startsWith("Lenses/") || path.includes("/Lenses/");
324
- if (path.endsWith(".md") && !isLensFile && !isQuestionFilePath(path)) {
325
- pre.push(...questionEmbedsOutsideLenses(content, path));
326
- }
327
- return pre;
328
- });
329
- errors.push(...prechecks);
330
- }
331
- if (path.startsWith("modules/")) {
332
- const result = inputs.cached("flatten-module", path, content, (f, t) => {
333
- const { modules, errors } = flattenModule(path, f, new Set(), t);
334
- return { modules, errors };
335
- });
336
- for (const mod of result.modules) {
337
- modules.push(mod);
338
- slugToPath.set(mod.slug, path);
339
- // Track parent→children mapping for course expansion
340
- if (mod.parentSlug) {
341
- if (!parentSlugToChildren.has(mod.parentSlug)) {
342
- 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";
343
276
  }
344
- parentSlugToChildren.get(mod.parentSlug).push(mod.slug);
345
- }
346
- else {
347
- filePathToSlug.set(path, mod.slug);
348
- }
349
- slugEntries.push({ slug: mod.slug, file: path });
350
- if (mod.contentId) {
351
- uuidEntries.push({
352
- uuid: mod.contentId,
353
- file: path,
354
- field: "contentId",
355
- });
356
277
  }
278
+ pre.push(...mathErrors);
357
279
  }
358
- // If no submodules, set filePathToSlug for the primary module
359
- if (result.modules.length === 1 && !result.modules[0].parentSlug) {
360
- 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));
361
284
  }
362
- else if (result.modules.length > 0 && result.modules[0].parentSlug) {
363
- // For split modules, map the file path to the parent slug
364
- 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));
365
289
  }
366
- // Collect section-level id:: fields from Lens sections (inline lenses).
367
- // Referenced lenses (with source::) get their id from the lens file itself.
368
- if (result.modules.length > 0) {
369
- const rawParse = parseModule(content, path);
370
- if (rawParse.module) {
371
- collectInlineLensUUIDs(rawParse.module.sections, path, uuidEntries, seenResponseIds, errors);
372
- collectModuleLoRefs(rawParse.module.sections, path);
373
- }
374
- // Skill tree points at the user-visible module page: the primary
375
- // (parent) module for split modules.
376
- const primary = result.modules[0];
377
- skillTreeModules.set(path, {
378
- slug: primary.parentSlug ?? primary.slug,
379
- title: primary.parentTitle ?? primary.title,
380
- 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",
381
309
  });
382
310
  }
383
- errors.push(...result.errors);
384
311
  }
385
- else if (path.startsWith("courses/")) {
386
- const result = parseCourse(content, path);
387
- if (result.course) {
388
- courses.push(result.course);
389
- courseSlugToFile.set(result.course.slug, path);
390
- }
391
- 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;
392
316
  }
393
- else if (path.startsWith("meetings/") &&
394
- path.endsWith(".md") &&
395
- !path.startsWith(MEETING_DOC_SHARED_DIR)) {
396
- const result = inputs.cached("meeting-doc", path, content, (f) => parseMeetingDoc(content, path, f));
397
- errors.push(...result.errors);
398
- if (result.markdown !== null)
399
- meetingDocs[path] = result.markdown;
317
+ else if (result.modules.length > 0 && result.modules[0].parentSlug) {
318
+ unit.moduleSlug = result.modules[0].parentSlug;
400
319
  }
401
- else if (path.startsWith("surveys/") || path.includes("/surveys/")) {
402
- const result = parseSurvey(content, path);
403
- errors.push(...result.errors);
404
- if (result.survey) {
405
- // Response segments and interviews carry prompt-capable instruction
406
- // fields. Surveys have no flatten pass, so their prompt imports are
407
- // expanded here; a failed import drops the survey, like a failed
408
- // import drops a lens segment in the flattener.
409
- let expansionFailed = false;
410
- for (const seg of result.survey.segments) {
411
- const promptProps = seg.type === "interview"
412
- ? ["assessmentInstructions"]
413
- : seg.type === "question" && "questionType" in seg
414
- ? ["assessmentInstructions", "feedbackInstructions"]
415
- : [];
416
- for (const prop of promptProps) {
417
- const target = seg;
418
- const value = target[prop];
419
- if (!value)
420
- continue;
421
- const resolved = expandPromptImports(value, PROMPT_FIELD_NAMES[prop], path, files, tierMap);
422
- errors.push(...resolved.errors);
423
- if (resolved.value === null) {
424
- expansionFailed = true;
425
- }
426
- else {
427
- target[prop] = resolved.value;
428
- }
429
- }
430
- }
431
- if (!expansionFailed) {
432
- surveys.push({ ...result.survey, sourcePath: path });
433
- surveyPathToId.set(path, result.survey.id);
434
- uuidEntries.push({
435
- uuid: result.survey.id,
436
- file: path,
437
- field: "id",
438
- });
439
- collectResponseSegmentIds(result.survey.segments, "survey", path, uuidEntries, seenResponseIds, errors);
440
- }
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);
441
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
+ };
442
339
  }
443
- else if (path.startsWith("Learning Outcomes/") ||
444
- path.includes("/Learning Outcomes/")) {
445
- // Fully validate Learning Outcome (structure, fields, wikilink syntax)
446
- const result = parseLearningOutcome(content, path);
447
- errors.push(...result.errors);
448
- // Check that suggested lens files exist
449
- if (result.learningOutcome) {
450
- collectLearningOutcomeTestUUIDs(result.learningOutcome, path, uuidEntries, seenResponseIds, errors);
451
- for (const lensRef of result.learningOutcome.suggestedLenses) {
452
- const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
453
- if (!lensPath) {
454
- // Find similar files to suggest
455
- const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
456
- const suggestion = formatSuggestion(similarFiles, path) ??
457
- "Check the file path in the wiki-link";
458
- errors.push({
459
- file: path,
460
- message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
461
- suggestion,
462
- severity: "error",
463
- });
464
- continue;
465
- }
466
- // Check tier violation (LO → Lens)
467
- const parentTier = tierMap.get(path) ?? "production";
468
- const childTier = tierMap.get(lensPath) ?? "production";
469
- const violation = checkTierViolation(path, parentTier, lensPath, childTier, "lens");
470
- if (violation) {
471
- 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)
472
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;
473
383
  }
474
- if (childTier === "ignored") {
475
- continue;
384
+ else {
385
+ target[prop] = resolved.value;
476
386
  }
477
387
  }
478
388
  }
479
- // Collect id for UUID validation
480
- if (result.learningOutcome?.id) {
389
+ if (!expansionFailed) {
390
+ unit.survey = { ...result.survey, sourcePath: path };
481
391
  uuidEntries.push({
482
- uuid: result.learningOutcome.id,
392
+ uuid: result.survey.id,
483
393
  file: path,
484
394
  field: "id",
485
395
  });
486
- }
487
- if (result.learningOutcome) {
488
- learningOutcomesByPath.set(path, result.learningOutcome);
396
+ collectResponseSegmentIds(result.survey.segments, "survey", path, uuidEntries, seenResponseIds, errors);
489
397
  }
490
398
  }
491
- else if (path.startsWith("Domains and Topics/") ||
492
- path.includes("/Domains and Topics/")) {
493
- // Skill-tree domain folders: `Domains and Topics/<N> <Title>/` holds the
494
- // domain's topic files and an optional _domain_metadata.md. The folder
495
- // exists as soon as any file inside it does.
496
- if (!path.endsWith(".md"))
497
- continue;
498
- const located = parseDomainPath(path);
499
- if (!located.location) {
500
- errors.push(located.error);
501
- continue;
502
- }
503
- const { dir, number, title, fileName } = located.location;
504
- let folder = domainFolders.get(dir);
505
- if (!folder) {
506
- folder = { number, title, declaredBy: path };
507
- domainFolders.set(dir, folder);
508
- }
509
- if (fileName === DOMAIN_METADATA_FILE) {
510
- const result = parseDomainMetadata(content, path);
511
- errors.push(...result.errors);
512
- if (result.metadata)
513
- folder.metadata = result.metadata;
514
- }
515
- else {
516
- const result = parseTopicFile(content, path);
517
- errors.push(...result.errors);
518
- if (result.topic) {
519
- topicFiles.set(path, { ...result.topic, domainDir: dir });
520
- 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;
521
433
  }
522
434
  }
523
435
  }
524
- else if (isQuestionFilePath(path)) {
525
- // A question file: one question, embedded by lenses with ![[...]]
526
- const fileResult = cachedPerFile("question", path, content, () => {
527
- const parsed = parseQuestionFile(stripAuthoringMarkupForValidation(content), path);
528
- const fileErrors = [...parsed.errors];
529
- // Checked as the question it becomes in a lens
530
- const converted = parsed.segment
531
- ? convertRawSegments([parsed.segment], path)
532
- : { segments: [], errors: [] };
533
- fileErrors.push(...converted.errors);
534
- return { errors: fileErrors, segments: converted.segments };
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",
535
442
  });
536
- errors.push(...fileResult.errors);
537
- // Its id is taken: anything else using it must be this same question
538
- if (fileResult.segments.length > 0) {
539
- collectResponseSegmentIds(fileResult.segments, "lens", path, uuidEntries, seenResponseIds, errors);
540
- }
541
443
  }
542
- else if (path.startsWith("Lenses/") || path.includes("/Lenses/")) {
543
- // Fully validate Lens (structure, segments, fields)
544
- // A lens's parse also depends on the question files it embeds, and so
545
- // does the flatten step below, which reuses the parse: both key on them
546
- const lensKey = content + questionEmbedCacheKey(content, path, files);
547
- const result = cachedPerFile("lens", path, lensKey, () => parseLens(content, path, files));
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);
548
464
  errors.push(...result.errors);
549
- // Collect id for UUID validation
550
- if (result.lens?.id) {
551
- uuidEntries.push({
552
- uuid: result.lens.id,
553
- file: path,
554
- field: "id",
555
- });
556
- }
557
- if (result.lens) {
558
- collectResponseSegmentIds(result.lens.segments, "lens", path, uuidEntries, seenResponseIds, errors);
559
- }
560
- // Flatten lens as standalone module (pass pre-parsed lens to avoid re-parsing)
561
- if (result.lens) {
562
- const lens = result.lens;
563
- const lensModuleResult = inputs.cached("flatten-lens", path, lensKey, (f, t) => {
564
- const { module, errors } = flattenLens(path, f, t, lens);
565
- return { module, errors };
566
- });
567
- if (lensModuleResult.module) {
568
- modules.push(lensModuleResult.module);
569
- slugEntries.push({ slug: lensModuleResult.module.slug, file: path });
570
- slugToPath.set(lensModuleResult.module.slug, path);
571
- }
572
- 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" });
573
474
  }
574
475
  }
575
- else if (path.endsWith(".timestamps.json")) {
576
- const tsErrors = validateTimestamps(content, path);
577
- 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);
578
493
  }
579
- else if (path.startsWith("articles/") || path.includes("/articles/")) {
580
- // The article's whole per-file pass (parse + structure + provenance) is
581
- // pure in (content, path); its two effects — errors and URL entries —
582
- // are the cached value.
583
- const fileResult = cachedPerFile("article", path, content, () => {
584
- const result = parseArticle(content, path);
585
- const parsedFrontmatter = parseFrontmatter(stripAuthoringMarkup(content), path);
586
- const body = parsedFrontmatter.error
587
- ? undefined
588
- : parsedFrontmatter.body;
589
- // Standalone-page segments are emitted only when the page differs
590
- // from the raw body. Without inline imports they depend on this file
591
- // alone, so they are built (and cached) here; with imports they need
592
- // the file map and are built below.
593
- const parsedBody = body === undefined ? undefined : parseArticleBody(content, path);
594
- // Elements resolve against other files (transcripts, widgets, prompt
595
- // imports), so an article that has any is flattened with the file map
596
- // below.
597
- const hasImports = parsedBody?.markers.some((m) => m.kind === "element") ?? false;
598
- let standaloneSegments;
599
- if (parsedBody && !hasImports) {
600
- const { segments } = flattenStandaloneArticle(path, new Map([[path, content]]));
601
- // Linked author names (author_url) only exist on processor
602
- // segments; the API's plain fallback carries the name alone.
603
- const plain = segments.length === 1 &&
604
- segments[0].type === "article" &&
605
- segments[0].content === parsedBody.body &&
606
- !segments[0].authorLinks;
607
- if (!plain)
608
- standaloneSegments = segments;
609
- }
610
- const fileErrors = [
611
- ...result.errors,
612
- ...validateArticleStructure(content, path),
613
- ...validateArticleReviewProvenance(content, path),
614
- ];
615
- const urls = [];
616
- if (result.article) {
617
- // Only carry the flag when set true, so URL entries (and thus the
618
- // serialized output / Python parity) are unchanged for normal articles.
619
- const overrideFlag = result.article.allowUnreachableUrl
620
- ? { allowUnreachable: true }
621
- : {};
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) {
622
591
  urls.push({
623
- url: result.article.sourceUrl,
592
+ url,
624
593
  file: path,
625
594
  line: 2,
626
- 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",
627
605
  ...overrideFlag,
628
606
  });
629
- for (const url of result.article.authorUrls) {
630
- urls.push({
631
- url,
632
- file: path,
633
- line: 2,
634
- label: "author_url",
635
- ...overrideFlag,
636
- });
637
- }
638
- for (const img of result.article.imageUrls) {
639
- urls.push({
640
- url: img.url,
641
- file: path,
642
- line: img.line,
643
- label: "Image URL",
644
- ...overrideFlag,
645
- });
646
- }
647
607
  }
648
- return {
649
- errors: fileErrors,
650
- urls,
651
- hasImports,
652
- standaloneSegments,
653
- elements: result.article?.elements ?? [],
654
- };
655
- });
656
- errors.push(...fileResult.errors);
657
- urlsToValidate.push(...fileResult.urls);
658
- // Questions declared inline in the article body carry ids like any
659
- // response segment
660
- collectResponseSegmentIds(fileResult.elements, "lens", path, uuidEntries, seenResponseIds, errors);
661
- if (fileResult.hasImports) {
662
- const standalone = inputs.cached("standalone-article", path, content, (f, t) => {
663
- const { segments, errors } = flattenStandaloneArticle(path, f, t);
664
- return { segments, errors };
665
- });
666
- errors.push(...standalone.errors);
667
- articleSegments[path] = standalone.segments;
668
- }
669
- else if (fileResult.standaloneSegments) {
670
- articleSegments[path] = fileResult.standaloneSegments;
671
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]));
672
631
  }
673
- else if (path.startsWith("video_transcripts/") ||
674
- path.includes("/video_transcripts/")) {
675
- const result = parseVideoTranscript(content, path);
676
- errors.push(...result.errors);
677
- if (result.transcript) {
678
- urlsToValidate.push({
679
- url: result.transcript.url,
680
- file: path,
681
- line: 2,
682
- label: "url",
683
- ...(result.transcript.allowUnreachableUrl
684
- ? { allowUnreachable: true }
685
- : {}),
686
- });
687
- }
632
+ else if (fileResult.standaloneSegments) {
633
+ unit.articleSegments = fileResult.standaloneSegments;
688
634
  }
689
- else if (isGlossaryPath(path) && path.endsWith(".md")) {
690
- const result = parseGlossaryEntry(content, path);
691
- errors.push(...result.errors);
692
- if (result.entry)
693
- 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
+ });
694
650
  }
695
- else if (isPromptPath(path)) {
696
- // Fully validate prompt file (frontmatter + non-empty prompt text)
697
- const result = parsePrompt(content, path);
698
- 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
+ });
699
672
  }
700
- else if (isWidgetFile(path)) {
701
- // Widget file: frontmatter + HTML body (validity, size, visible text)
702
- if (path.endsWith(".html") && files.has(path.replace(/\.html$/, ".md"))) {
703
- errors.push({
704
- file: path,
705
- message: `Two files for one widget: ${path} and its .md twin`,
706
- suggestion: "A link to the widget cannot tell them apart. Delete the .md file once the .html one is complete",
707
- severity: "error",
708
- });
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;
709
706
  }
710
- const widgetResult = cachedPerFile("widget", path, content, () => {
711
- const result = parseWidget(content, path);
712
- 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",
713
716
  });
714
- errors.push(...widgetResult.errors);
715
- if (widgetResult.id) {
716
- uuidEntries.push({ uuid: widgetResult.id, file: path, field: "id" });
717
- }
718
717
  }
719
- else {
720
- // File didn't match any known directory pattern — check for near-misses via Levenshtein distance
721
- const dir = path.split("/")[0];
722
- const VALID_DIRS = [
723
- "modules",
724
- "courses",
725
- "surveys",
726
- "articles",
727
- "Lenses",
728
- "Questions",
729
- "video_transcripts",
730
- "Learning Outcomes",
731
- "Domains and Topics",
732
- "prompts",
733
- "widgets",
734
- "glossary",
735
- ];
736
- let closest = "";
737
- let minDist = Infinity;
738
- for (const valid of VALID_DIRS) {
739
- const dist = levenshtein(dir.toLowerCase(), valid.toLowerCase());
740
- if (dist < minDist) {
741
- minDist = dist;
742
- 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, []);
743
828
  }
829
+ parentSlugToChildren.get(page.parentSlug).push(page.slug);
744
830
  }
745
- // Threshold: distance <= 3 or <= 40% of the directory name length (whichever is smaller)
746
- const threshold = Math.min(3, Math.ceil(dir.length * 0.4));
747
- if (minDist > 0 && minDist <= threshold) {
748
- errors.push({
749
- file: path,
750
- message: `File in directory '${dir}/' not recognized as content`,
751
- suggestion: `Did you mean '${closest}/'?`,
752
- severity: "warning",
753
- });
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);
754
865
  }
866
+ if (submodule)
867
+ submodules.set(submodule.slug, submodule.title);
755
868
  }
756
- }
757
- errors.push(...validateGlossaryAliases(glossaryEntries));
758
- // Build contentId → moduleSlug mapping and populate moduleSlug in cross-module card links.
759
- // Prefer real modules over standalone lenses (lens/ prefix) — a lens that appears in
760
- // both a module and as a standalone should map to the module.
761
- const contentIdToModuleSlug = new Map();
762
- for (const mod of modules) {
763
- if (mod.slug.startsWith("lens/"))
764
- continue; // Skip standalone lenses
765
- for (const section of mod.sections) {
766
- if (section.contentId) {
767
- 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);
768
876
  }
877
+ if (metadata)
878
+ folder.metadata = metadata;
769
879
  }
880
+ if (unit.topic)
881
+ topicFiles.set(path, unit.topic);
882
+ if (unit.glossaryEntry)
883
+ glossaryEntries.set(path, unit.glossaryEntry);
770
884
  }
771
- for (const mod of modules) {
772
- populateCardModuleSlugs(mod.sections, contentIdToModuleSlug);
773
- resolveInlineLensModuleSlugs(mod.sections, contentIdToModuleSlug);
774
- }
775
- // Glossary marks are offsets into segment text, so they are placed after
776
- // every pass that rewrites it (card links and lens links above, card
777
- // metadata in the flattener).
778
- for (const mod of modules) {
779
- for (const section of mod.sections) {
780
- if (section.glossaryTerms === undefined)
781
- continue;
782
- const result = applyGlossary(section.glossaryTerms, section.segments, section.sourcePath ?? mod.sourcePath ?? mod.slug, files, glossaryEntries, tierMap);
783
- errors.push(...result.errors);
784
- section.segments = result.segments;
785
- if (result.glossary.length)
786
- section.glossary = result.glossary;
787
- 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);
788
895
  }
789
896
  }
790
- // Card-to-module membership check. Runs after card link resolution above so
791
- // every card has its data-lens-card JSON populated, including allowExternal.
792
- errors.push(...validateCardModuleMembership(modules));
793
- // After card resolution: each require_x_optional_lenses list becomes its
794
- // section's optionalLensSets (the lens gate reads those).
795
- errors.push(...collectOptionalLensSets(modules));
897
+ const courseErrors = [];
796
898
  /**
797
899
  * Resolve one survey wikilink (from a meeting's survey:: /
798
900
  * facilitator-survey::, or the course's application-survey::) to the
@@ -808,11 +910,11 @@ function processContentRun(files, cache) {
808
910
  const childTier = tierMap.get(actualFile) ?? "production";
809
911
  const violation = checkTierViolation(courseFile, parentTier, actualFile, childTier, label);
810
912
  if (violation) {
811
- errors.push(violation);
913
+ courseErrors.push(violation);
812
914
  }
813
915
  return surveyPathToId.get(actualFile);
814
916
  }
815
- errors.push({
917
+ courseErrors.push({
816
918
  file: courseFile,
817
919
  message: `Survey reference could not be resolved: "${surveyPath}"`,
818
920
  suggestion: `Check that ${label}:: points to an existing, error-free file in surveys/`,
@@ -846,7 +948,7 @@ function processContentRun(files, cache) {
846
948
  }
847
949
  }
848
950
  if (!matched) {
849
- errors.push({
951
+ courseErrors.push({
850
952
  file: courseFile,
851
953
  message: `Module reference could not be resolved: "${item.path}"`,
852
954
  suggestion: "Check that the wikilink path points to an existing module file",
@@ -881,7 +983,7 @@ function processContentRun(files, cache) {
881
983
  item.docPath = actualFile;
882
984
  }
883
985
  else {
884
- errors.push({
986
+ courseErrors.push({
885
987
  file: courseFile,
886
988
  message: `Meeting doc reference could not be resolved: "${item.docPath}"`,
887
989
  suggestion: "Check that meeting-doc:: points to an existing, error-free file in meetings/",
@@ -951,7 +1053,7 @@ function processContentRun(files, cache) {
951
1053
  const childTier = tierMap.get(modulePath) ?? "production";
952
1054
  const violation = checkTierViolation(coursePath, parentTier, modulePath, childTier, "module");
953
1055
  if (violation) {
954
- errors.push(violation);
1056
+ courseErrors.push(violation);
955
1057
  }
956
1058
  }
957
1059
  }
@@ -972,7 +1074,7 @@ function processContentRun(files, cache) {
972
1074
  for (const alias of course.slugAliases ?? []) {
973
1075
  const existing = allCourseSlugs.get(alias);
974
1076
  if (existing) {
975
- errors.push({
1077
+ courseErrors.push({
976
1078
  file,
977
1079
  message: `Course slug alias '${alias}' collides with ${existing === file ? "its own primary slug" : `slug in ${existing}`}`,
978
1080
  suggestion: `Choose a different alias or remove the conflicting slug`,
@@ -996,12 +1098,9 @@ function processContentRun(files, cache) {
996
1098
  }
997
1099
  }
998
1100
  // Validate all collected UUIDs
999
- const uuidValidation = validateUuids(uuidEntries);
1000
- errors.push(...uuidValidation.errors);
1001
- errors.push(...detectSharedQuestionsInOneModule(modules));
1101
+ const uuidErrors = validateUuids(uuidEntries).errors;
1002
1102
  // Validate for duplicate slugs
1003
- const duplicateSlugErrors = detectDuplicateSlugs(slugEntries);
1004
- errors.push(...duplicateSlugErrors);
1103
+ const slugsAndTranscripts = detectDuplicateSlugs(slugEntries);
1005
1104
  // Validate video transcript / timestamps.json pairing
1006
1105
  const transcriptPaths = [...files.keys()].filter((p) => (p.startsWith("video_transcripts/") ||
1007
1106
  p.includes("/video_transcripts/")) &&
@@ -1011,7 +1110,7 @@ function processContentRun(files, cache) {
1011
1110
  for (const mdPath of transcriptPaths) {
1012
1111
  const expectedTs = mdPath.replace(/\.md$/, ".timestamps.json");
1013
1112
  if (!timestampPaths.has(expectedTs)) {
1014
- errors.push({
1113
+ slugsAndTranscripts.push({
1015
1114
  file: mdPath,
1016
1115
  message: `Missing timestamps.json: expected ${expectedTs}`,
1017
1116
  severity: "error",
@@ -1023,20 +1122,42 @@ function processContentRun(files, cache) {
1023
1122
  if (tierMap.get(expectedMd) === "ignored")
1024
1123
  continue;
1025
1124
  if (!files.has(expectedMd)) {
1026
- errors.push({
1125
+ slugsAndTranscripts.push({
1027
1126
  file: tsPath,
1028
1127
  message: `Orphaned timestamps file: no matching .md transcript found`,
1029
1128
  severity: "warning",
1030
1129
  });
1031
1130
  }
1032
1131
  }
1033
- // Safety-net: catch empty sections/segments in final output
1034
- const integrityErrors = validateOutputIntegrity(modules, slugToPath);
1035
- errors.push(...integrityErrors);
1036
- 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) => {
1037
1140
  const lo = learningOutcomesByPath.get(loPath);
1038
- return lo ? suggestedLensPaths(lo, files) : new Set();
1039
- });
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
+ }
1040
1161
  // --- Skill tree assembly ---
1041
1162
  // Course progression was expanded to submodule slugs above; map back to the
1042
1163
  // parent module slug, which is what taughtBy points at.
@@ -1070,44 +1191,167 @@ function processContentRun(files, cache) {
1070
1191
  courses: skillTreeCourses,
1071
1192
  files,
1072
1193
  });
1073
- errors.push(...skillTreeResult.errors);
1074
- // Sections learned their outcomes before topics were resolved; name each
1075
- // outcome's primary topic now that the tree knows it.
1076
- 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();
1077
1197
  const topicBySlug = new Map(skillTreeResult.skillTree.topics.map((t) => [t.slug, t]));
1078
1198
  for (const card of skillTreeResult.skillTree.cards) {
1079
1199
  const topic = topicBySlug.get(card.topic);
1080
1200
  if (topic) {
1081
- topicByOutcomeId.set(card.id, { slug: topic.slug, title: topic.title });
1201
+ topics.set(card.id, { slug: topic.slug, title: topic.title });
1082
1202
  }
1083
1203
  }
1084
- for (const module of modules) {
1085
- for (const section of module.sections) {
1086
- for (const ref of section.learningOutcomes) {
1087
- const topic = topicByOutcomeId.get(ref.id);
1088
- if (topic)
1089
- ref.topic = topic;
1090
- }
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;
1091
1276
  }
1092
1277
  }
1093
- // Post-process: assign category to errors that don't already have one
1094
- 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) {
1095
1324
  if (!error.category) {
1096
1325
  const tier = tierMap.get(error.file);
1097
1326
  error.category = tier === "wip" ? "wip" : "production";
1098
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
+ }
1099
1338
  }
1100
- // The same file is validated once per pass (standalone + once per module
1101
- // that flattens it), so identical errors are emitted multiple times. Collapse
1102
- // 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;
1103
1347
  return {
1104
1348
  modules,
1105
1349
  courses,
1106
1350
  surveys,
1107
1351
  ...(Object.keys(meetingDocs).length > 0 ? { meetingDocs } : {}),
1108
- errors: dedupeErrors(errors),
1352
+ errors,
1109
1353
  urlsToValidate,
1110
- skillTree: skillTreeResult.skillTree,
1354
+ skillTree,
1111
1355
  ...(Object.keys(articleSegments).length > 0 ? { articleSegments } : {}),
1112
1356
  };
1113
1357
  }