lens-content-processor 0.79.0 → 0.81.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.
- package/dist/authoring-markup.d.ts +22 -0
- package/dist/authoring-markup.js +24 -0
- package/dist/authoring-markup.js.map +1 -1
- package/dist/cache.d.ts +10 -0
- package/dist/cache.js +6 -5
- package/dist/cache.js.map +1 -1
- package/dist/cli-steps.d.ts +37 -0
- package/dist/cli-steps.js +80 -0
- package/dist/cli-steps.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +52 -70
- package/dist/cli.js.map +1 -1
- package/dist/content-schema.js +4 -0
- package/dist/content-schema.js.map +1 -1
- package/dist/course-graph.d.ts +2 -2
- package/dist/course-graph.js +7 -2
- package/dist/course-graph.js.map +1 -1
- package/dist/criticmarkup-forms.d.ts +8 -6
- package/dist/criticmarkup-forms.js +22 -19
- package/dist/criticmarkup-forms.js.map +1 -1
- package/dist/dedupe-errors.d.ts +16 -0
- package/dist/dedupe-errors.js +23 -9
- package/dist/dedupe-errors.js.map +1 -1
- package/dist/dependency-cache.d.ts +15 -5
- package/dist/dependency-cache.js +33 -74
- package/dist/dependency-cache.js.map +1 -1
- package/dist/dropped.d.ts +114 -0
- package/dist/dropped.js +253 -0
- package/dist/dropped.js.map +1 -0
- package/dist/engine/assemble.d.ts +44 -0
- package/dist/engine/assemble.js +132 -0
- package/dist/engine/assemble.js.map +1 -0
- package/dist/engine/build.d.ts +8 -0
- package/dist/engine/build.js +558 -0
- package/dist/engine/build.js.map +1 -0
- package/dist/engine/file-facts.d.ts +26 -0
- package/dist/engine/file-facts.js +63 -0
- package/dist/engine/file-facts.js.map +1 -0
- package/dist/engine/git-source.d.ts +35 -0
- package/dist/engine/git-source.js +242 -0
- package/dist/engine/git-source.js.map +1 -0
- package/dist/engine/overlay.d.ts +43 -0
- package/dist/engine/overlay.js +167 -0
- package/dist/engine/overlay.js.map +1 -0
- package/dist/engine/protocol.d.ts +146 -0
- package/dist/engine/protocol.js +40 -0
- package/dist/engine/protocol.js.map +1 -0
- package/dist/engine/reader.d.ts +133 -0
- package/dist/engine/reader.js +298 -0
- package/dist/engine/reader.js.map +1 -0
- package/dist/engine/sources.d.ts +39 -0
- package/dist/engine/sources.js +137 -0
- package/dist/engine/sources.js.map +1 -0
- package/dist/engine/sqlite-store.d.ts +99 -0
- package/dist/engine/sqlite-store.js +452 -0
- package/dist/engine/sqlite-store.js.map +1 -0
- package/dist/engine/store.d.ts +85 -0
- package/dist/engine/store.js +31 -0
- package/dist/engine/store.js.map +1 -0
- package/dist/engine/tables.d.ts +15 -0
- package/dist/engine/tables.js +70 -0
- package/dist/engine/tables.js.map +1 -0
- package/dist/engine/units.d.ts +58 -0
- package/dist/engine/units.js +186 -0
- package/dist/engine/units.js.map +1 -0
- package/dist/engine/version.d.ts +6 -0
- package/dist/engine/version.js +21 -0
- package/dist/engine/version.js.map +1 -0
- package/dist/engine/worker.d.ts +1 -0
- package/dist/engine/worker.js +220 -0
- package/dist/engine/worker.js.map +1 -0
- package/dist/flattener/index.d.ts +34 -17
- package/dist/flattener/index.js +252 -115
- package/dist/flattener/index.js.map +1 -1
- package/dist/flattener/resolve-text-links.d.ts +15 -14
- package/dist/flattener/resolve-text-links.js +24 -25
- package/dist/flattener/resolve-text-links.js.map +1 -1
- package/dist/glossary.d.ts +2 -2
- package/dist/glossary.js.map +1 -1
- package/dist/index.d.ts +171 -2
- package/dist/index.js +883 -639
- package/dist/index.js.map +1 -1
- package/dist/markdown-parse.d.ts +6 -0
- package/dist/markdown-parse.js +14 -0
- package/dist/markdown-parse.js.map +1 -1
- package/dist/parser/learning-outcome.js +11 -2
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +7 -1
- package/dist/parser/lens.js +43 -9
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/meeting-doc.d.ts +2 -1
- package/dist/parser/meeting-doc.js +2 -1
- package/dist/parser/meeting-doc.js.map +1 -1
- package/dist/parser/module.d.ts +3 -1
- package/dist/parser/module.js +7 -5
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/question-embeds.d.ts +4 -3
- package/dist/parser/question-embeds.js.map +1 -1
- package/dist/parser/response-segments.d.ts +4 -0
- package/dist/parser/response-segments.js +1 -1
- package/dist/parser/response-segments.js.map +1 -1
- package/dist/parser/sections.d.ts +10 -0
- package/dist/parser/sections.js +8 -3
- package/dist/parser/sections.js.map +1 -1
- package/dist/parser/widget.d.ts +2 -1
- package/dist/parser/widget.js.map +1 -1
- package/dist/parser/wikilink.d.ts +13 -3
- package/dist/parser/wikilink.js +55 -34
- package/dist/parser/wikilink.js.map +1 -1
- package/dist/reference-graph.d.ts +21 -3
- package/dist/reference-graph.js +35 -12
- package/dist/reference-graph.js.map +1 -1
- package/dist/same-page-links.d.ts +11 -1
- package/dist/same-page-links.js +9 -0
- package/dist/same-page-links.js.map +1 -1
- package/dist/skill-tree.d.ts +2 -1
- package/dist/skill-tree.js.map +1 -1
- package/dist/source-location.d.ts +0 -4
- package/dist/source-location.js +0 -43
- package/dist/source-location.js.map +1 -1
- package/dist/validator/card-module-membership.d.ts +17 -1
- package/dist/validator/card-module-membership.js +60 -56
- package/dist/validator/card-module-membership.js.map +1 -1
- package/dist/validator/criticmarkup.js +1 -1
- package/dist/validator/criticmarkup.js.map +1 -1
- package/dist/validator/field-typos.d.ts +1 -1
- package/dist/validator/field-typos.js +21 -21
- package/dist/validator/field-typos.js.map +1 -1
- package/dist/validator/output-integrity.d.ts +9 -5
- package/dist/validator/output-integrity.js +43 -45
- package/dist/validator/output-integrity.js.map +1 -1
- package/dist/validator/segment-fields.js +5 -8
- package/dist/validator/segment-fields.js.map +1 -1
- package/dist/validator/tier.d.ts +2 -2
- package/dist/validator/tier.js.map +1 -1
- package/dist/validator/uuid.d.ts +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { flattenModule,
|
|
2
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
116
|
+
* are detected here instead, via the file's own set of ids already seen.
|
|
112
117
|
*/
|
|
113
|
-
function collectResponseSegmentIds(segments, context, path, uuidEntries,
|
|
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,
|
|
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,
|
|
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,
|
|
177
|
+
collectInlineLensUUIDs(section.children, path, uuidEntries, seenInFile, errors);
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
-
function collectLearningOutcomeTestUUIDs(learningOutcome, path, uuidEntries,
|
|
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,
|
|
188
|
+
collectResponseSegmentIds(learningOutcome.test.segments, "test", path, uuidEntries, seenInFile, errors);
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 (
|
|
394
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
475
|
-
|
|
384
|
+
else {
|
|
385
|
+
target[prop] = resolved.value;
|
|
476
386
|
}
|
|
477
387
|
}
|
|
478
388
|
}
|
|
479
|
-
|
|
480
|
-
|
|
389
|
+
if (!expansionFailed) {
|
|
390
|
+
unit.survey = { ...result.survey, sourcePath: path };
|
|
481
391
|
uuidEntries.push({
|
|
482
|
-
uuid: result.
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
|
592
|
+
url,
|
|
624
593
|
file: path,
|
|
625
594
|
line: 2,
|
|
626
|
-
label: "
|
|
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 (
|
|
674
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
for (const
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
913
|
+
courseErrors.push(violation);
|
|
812
914
|
}
|
|
813
915
|
return surveyPathToId.get(actualFile);
|
|
814
916
|
}
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1000
|
-
errors.push(...uuidValidation.errors);
|
|
1001
|
-
errors.push(...detectSharedQuestionsInOneModule(modules));
|
|
1101
|
+
const uuidErrors = validateUuids(uuidEntries).errors;
|
|
1002
1102
|
// Validate for duplicate slugs
|
|
1003
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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) :
|
|
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
|
-
|
|
1074
|
-
//
|
|
1075
|
-
|
|
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
|
-
|
|
1201
|
+
topics.set(card.id, { slug: topic.slug, title: topic.title });
|
|
1082
1202
|
}
|
|
1083
1203
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
1352
|
+
errors,
|
|
1109
1353
|
urlsToValidate,
|
|
1110
|
-
skillTree
|
|
1354
|
+
skillTree,
|
|
1111
1355
|
...(Object.keys(articleSegments).length > 0 ? { articleSegments } : {}),
|
|
1112
1356
|
};
|
|
1113
1357
|
}
|