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