lens-content-processor 0.3.0 → 0.5.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/bundler/article.d.ts +7 -2
- package/dist/bundler/article.js +19 -8
- package/dist/bundler/article.js.map +1 -1
- package/dist/bundler/video.d.ts +12 -6
- package/dist/bundler/video.js +57 -34
- package/dist/bundler/video.js.map +1 -1
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +36 -19
- package/dist/cli.js.map +1 -1
- package/dist/content-schema.js +64 -15
- package/dist/content-schema.js.map +1 -1
- package/dist/dedupe-errors.d.ts +14 -0
- package/dist/dedupe-errors.js +33 -0
- package/dist/dedupe-errors.js.map +1 -0
- package/dist/flattener/index.d.ts +22 -28
- package/dist/flattener/index.js +773 -613
- package/dist/flattener/index.js.map +1 -1
- package/dist/flattener/resolve-text-links.d.ts +25 -0
- package/dist/flattener/resolve-text-links.js +248 -0
- package/dist/flattener/resolve-text-links.js.map +1 -0
- package/dist/flattener/test-helpers.d.ts +23 -0
- package/dist/flattener/test-helpers.js +56 -0
- package/dist/flattener/test-helpers.js.map +1 -0
- package/dist/index.d.ts +66 -20
- package/dist/index.js +327 -152
- package/dist/index.js.map +1 -1
- package/dist/parser/article.d.ts +3 -2
- package/dist/parser/article.js +80 -12
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.d.ts +1 -1
- package/dist/parser/course.js +88 -28
- package/dist/parser/course.js.map +1 -1
- package/dist/parser/frontmatter.d.ts +1 -1
- package/dist/parser/frontmatter.js +38 -12
- package/dist/parser/frontmatter.js.map +1 -1
- package/dist/parser/learning-outcome.d.ts +12 -2
- package/dist/parser/learning-outcome.js +177 -96
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +52 -28
- package/dist/parser/lens.js +283 -260
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.d.ts +7 -15
- package/dist/parser/module.js +139 -170
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/sections.d.ts +14 -1
- package/dist/parser/sections.js +94 -81
- package/dist/parser/sections.js.map +1 -1
- package/dist/parser/video-transcript.d.ts +2 -1
- package/dist/parser/video-transcript.js +10 -6
- package/dist/parser/video-transcript.js.map +1 -1
- package/dist/reference-graph.d.ts +8 -0
- package/dist/reference-graph.js +105 -0
- package/dist/reference-graph.js.map +1 -0
- package/dist/validator/card-module-membership.d.ts +2 -0
- package/dist/validator/card-module-membership.js +80 -0
- package/dist/validator/card-module-membership.js.map +1 -0
- package/dist/validator/directives.d.ts +12 -0
- package/dist/validator/directives.js +428 -0
- package/dist/validator/directives.js.map +1 -0
- package/dist/validator/field-values.js +14 -0
- package/dist/validator/field-values.js.map +1 -1
- package/dist/validator/output-integrity.js +5 -5
- package/dist/validator/output-integrity.js.map +1 -1
- package/dist/validator/segment-fields.d.ts +1 -1
- package/dist/validator/segment-fields.js +2 -2
- package/dist/validator/segment-fields.js.map +1 -1
- package/dist/validator/test-segments.d.ts +12 -0
- package/dist/validator/test-segments.js +40 -0
- package/dist/validator/test-segments.js.map +1 -0
- package/dist/validator/url-reachability.d.ts +2 -1
- package/dist/validator/url-reachability.js +42 -20
- package/dist/validator/url-reachability.js.map +1 -1
- package/dist/validator/uuid.js +9 -6
- package/dist/validator/uuid.js.map +1 -1
- package/package.json +1 -1
- package/dist/validator/chat-precedence.d.ts +0 -9
- package/dist/validator/chat-precedence.js +0 -32
- package/dist/validator/chat-precedence.js.map +0 -1
package/dist/flattener/index.js
CHANGED
|
@@ -1,51 +1,218 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
1
|
+
import { resolveTextLinks, enrichCardLinks } from "./resolve-text-links.js";
|
|
2
|
+
import { checkTierViolation } from "../validator/tier.js";
|
|
3
|
+
import { parseModule, hasFieldBeforeSegmentHeaders, } from "../parser/module.js";
|
|
4
|
+
import { parseLearningOutcome, } from "../parser/learning-outcome.js";
|
|
5
|
+
import { parseLens, } from "../parser/lens.js";
|
|
6
|
+
import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion, } from "../parser/wikilink.js";
|
|
7
|
+
import { parseFrontmatter } from "../parser/frontmatter.js";
|
|
8
|
+
import { fileNameToSlug } from "../utils/slug.js";
|
|
9
|
+
import { extractArticleExcerpt, bundleArticleWithCollapsed, } from "../bundler/article.js";
|
|
10
|
+
import { extractVideoExcerpt } from "../bundler/video.js";
|
|
11
|
+
function countWords(text) {
|
|
12
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
13
|
+
}
|
|
10
14
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
* A lens that is hidden (hide:: true) but not optional is a contradiction:
|
|
16
|
+
* users never see it, yet it counts as required to finish the section, so the
|
|
17
|
+
* section can't be completed. Flag it so editors either make it optional or
|
|
18
|
+
* unhide it.
|
|
19
|
+
*/
|
|
20
|
+
function checkHideOptionalConflict(optional, hide, file, line) {
|
|
21
|
+
if (hide && !optional) {
|
|
22
|
+
return {
|
|
23
|
+
file,
|
|
24
|
+
line,
|
|
25
|
+
message: "Lens is hidden (hide:: true) but required (optional is not true)",
|
|
26
|
+
suggestion: "A hidden lens cannot be completed by users — set optional:: true, or remove hide:: true to show it",
|
|
27
|
+
severity: "error",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Compute word count and video duration from a section's segments. */
|
|
33
|
+
function computeSectionStats(segments) {
|
|
34
|
+
let words = 0;
|
|
35
|
+
let videoSeconds = 0;
|
|
36
|
+
for (const seg of segments) {
|
|
37
|
+
if (seg.type === "text" || seg.type === "article") {
|
|
38
|
+
words += countWords(seg.content);
|
|
39
|
+
}
|
|
40
|
+
else if (seg.type === "video") {
|
|
41
|
+
if (seg.to != null)
|
|
42
|
+
videoSeconds += seg.to - seg.from;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const stats = {};
|
|
46
|
+
if (words > 0)
|
|
47
|
+
stats.wordCount = words;
|
|
48
|
+
if (videoSeconds > 0)
|
|
49
|
+
stats.videoDurationSeconds = videoSeconds;
|
|
50
|
+
return stats;
|
|
51
|
+
}
|
|
52
|
+
/** Derive display type from segment content for UI icons/labels. */
|
|
53
|
+
function computeDisplayType(segments) {
|
|
54
|
+
let hasArticle = false;
|
|
55
|
+
let hasVideo = false;
|
|
56
|
+
for (const seg of segments) {
|
|
57
|
+
if (seg.type === "article")
|
|
58
|
+
hasArticle = true;
|
|
59
|
+
if (seg.type === "video")
|
|
60
|
+
hasVideo = true;
|
|
61
|
+
}
|
|
62
|
+
if (hasArticle && hasVideo)
|
|
63
|
+
return "lens-mixed";
|
|
64
|
+
if (hasArticle)
|
|
65
|
+
return "lens-article";
|
|
66
|
+
if (hasVideo)
|
|
67
|
+
return "lens-video";
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract YouTube video metadata from a URL. Returns the video ID and whether
|
|
72
|
+
* the URL is a YouTube Short (`/shorts/<id>`), so callers can pick a 9:16 layout.
|
|
73
|
+
*/
|
|
74
|
+
function extractVideoFromUrl(url) {
|
|
75
|
+
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/(shorts)\/)([a-zA-Z0-9_-]+)/);
|
|
76
|
+
if (!match)
|
|
77
|
+
return null;
|
|
78
|
+
return { videoId: match[2], isShort: match[1] === "shorts" };
|
|
79
|
+
}
|
|
80
|
+
export function isBoundary(item) {
|
|
81
|
+
return "__boundary" in item && item.__boundary === true;
|
|
82
|
+
}
|
|
83
|
+
function toKebabCase(str) {
|
|
84
|
+
return str
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
87
|
+
.replace(/(^-|-$)/g, "");
|
|
88
|
+
}
|
|
89
|
+
export function validateBoundaries(items, file) {
|
|
90
|
+
const errors = [];
|
|
91
|
+
const hasBoundaries = items.some(isBoundary);
|
|
92
|
+
if (!hasBoundaries)
|
|
93
|
+
return errors;
|
|
94
|
+
// Check: sections before first boundary
|
|
95
|
+
const firstBoundaryIdx = items.findIndex(isBoundary);
|
|
96
|
+
const sectionsBefore = items
|
|
97
|
+
.slice(0, firstBoundaryIdx)
|
|
98
|
+
.filter((i) => !isBoundary(i));
|
|
99
|
+
if (sectionsBefore.length > 0) {
|
|
100
|
+
errors.push({
|
|
101
|
+
file,
|
|
102
|
+
message: "Content found outside submodule boundaries — all content must be inside a submodule",
|
|
103
|
+
severity: "error",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Check: consecutive boundaries and trailing boundary
|
|
107
|
+
for (let i = 0; i < items.length; i++) {
|
|
108
|
+
if (isBoundary(items[i])) {
|
|
109
|
+
// Find next non-boundary item
|
|
110
|
+
let nextNonBoundary = i + 1;
|
|
111
|
+
while (nextNonBoundary < items.length &&
|
|
112
|
+
isBoundary(items[nextNonBoundary])) {
|
|
113
|
+
nextNonBoundary++;
|
|
114
|
+
}
|
|
115
|
+
// If this boundary has no sections before next boundary or end
|
|
116
|
+
if (nextNonBoundary > i + 1 || nextNonBoundary >= items.length) {
|
|
117
|
+
if (nextNonBoundary > i + 1) {
|
|
118
|
+
errors.push({
|
|
119
|
+
file,
|
|
120
|
+
message: `Submodule "${items[i].title}" is empty — no content before next submodule`,
|
|
121
|
+
severity: "error",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else if (nextNonBoundary >= items.length) {
|
|
125
|
+
errors.push({
|
|
126
|
+
file,
|
|
127
|
+
message: `Submodule "${items[i].title}" is empty — no content after this marker`,
|
|
128
|
+
severity: "error",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return errors;
|
|
135
|
+
}
|
|
136
|
+
export function splitAtBoundaries(items, parentSlug, parentTitle) {
|
|
137
|
+
const hasBoundaries = items.some(isBoundary);
|
|
138
|
+
if (!hasBoundaries) {
|
|
139
|
+
// No split — return single group without parentSlug
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
slug: parentSlug,
|
|
143
|
+
title: parentTitle,
|
|
144
|
+
parentSlug: undefined,
|
|
145
|
+
parentTitle: undefined,
|
|
146
|
+
sections: items.filter((i) => !isBoundary(i)),
|
|
147
|
+
contentId: null,
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
const groups = [];
|
|
152
|
+
let currentGroup = null;
|
|
153
|
+
for (const item of items) {
|
|
154
|
+
if (isBoundary(item)) {
|
|
155
|
+
if (currentGroup) {
|
|
156
|
+
groups.push(currentGroup);
|
|
157
|
+
}
|
|
158
|
+
const slug = item.customSlug ?? toKebabCase(item.title);
|
|
159
|
+
currentGroup = {
|
|
160
|
+
slug: `${parentSlug}/${slug}`,
|
|
161
|
+
title: item.title,
|
|
162
|
+
parentSlug,
|
|
163
|
+
parentTitle,
|
|
164
|
+
sections: [],
|
|
165
|
+
contentId: null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
else if (currentGroup) {
|
|
169
|
+
currentGroup.sections.push(item);
|
|
170
|
+
}
|
|
171
|
+
// sections before first boundary are orphans — handled by validateBoundaries
|
|
172
|
+
}
|
|
173
|
+
if (currentGroup) {
|
|
174
|
+
groups.push(currentGroup);
|
|
175
|
+
}
|
|
176
|
+
return groups;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Flatten a single ParsedLens into output Segments.
|
|
180
|
+
* This is the shared core used by all lens-processing functions.
|
|
18
181
|
*
|
|
19
|
-
*
|
|
182
|
+
* For each segment in the lens, calls convertSegment to resolve article/video content.
|
|
183
|
+
* Then applies collapsed content for article segments from the same source.
|
|
20
184
|
*/
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
|
|
185
|
+
function flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap) {
|
|
186
|
+
const errors = [];
|
|
187
|
+
const segments = [];
|
|
188
|
+
for (const parsedSegment of lens.segments) {
|
|
189
|
+
const segmentResult = convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap);
|
|
190
|
+
errors.push(...segmentResult.errors);
|
|
191
|
+
if (segmentResult.segment) {
|
|
192
|
+
segments.push(segmentResult.segment);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Apply collapsed content for article segments
|
|
196
|
+
applyCollapsedContent(segments, lens.segments, lensPath, files);
|
|
197
|
+
return { segments, errors };
|
|
24
198
|
}
|
|
199
|
+
// ─── Module flattening ───
|
|
25
200
|
/**
|
|
26
201
|
* Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
|
|
27
|
-
*
|
|
28
|
-
* This function:
|
|
29
|
-
* 1. Parses the module file
|
|
30
|
-
* 2. For each Learning Outcome section, resolves the LO file
|
|
31
|
-
* 3. For each Lens in the LO, resolves the lens file
|
|
32
|
-
* 4. For each segment, extracts content from articles/videos as needed
|
|
33
|
-
*
|
|
34
|
-
* @param modulePath - Path to the module file within the files Map
|
|
35
|
-
* @param files - Map of all file paths to their content
|
|
36
|
-
* @param visitedPaths - Optional set of already-visited paths for cycle detection
|
|
37
|
-
* @returns Flattened module with resolved sections and segments, plus any errors
|
|
38
202
|
*/
|
|
39
203
|
export function flattenModule(modulePath, files, visitedPaths = new Set(), tierMap) {
|
|
40
204
|
// Check for circular reference
|
|
41
205
|
if (visitedPaths.has(modulePath)) {
|
|
42
206
|
return {
|
|
43
207
|
module: null,
|
|
44
|
-
|
|
208
|
+
modules: [],
|
|
209
|
+
errors: [
|
|
210
|
+
{
|
|
45
211
|
file: modulePath,
|
|
46
212
|
message: `Circular reference detected: ${modulePath}`,
|
|
47
|
-
severity:
|
|
48
|
-
}
|
|
213
|
+
severity: "error",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
49
216
|
};
|
|
50
217
|
}
|
|
51
218
|
visitedPaths.add(modulePath);
|
|
@@ -57,66 +224,236 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
57
224
|
errors.push({
|
|
58
225
|
file: modulePath,
|
|
59
226
|
message: `Module file not found: ${modulePath}`,
|
|
60
|
-
severity:
|
|
227
|
+
severity: "error",
|
|
61
228
|
});
|
|
62
|
-
return { module: null, errors };
|
|
229
|
+
return { module: null, modules: [], errors };
|
|
63
230
|
}
|
|
64
231
|
// Parse the module
|
|
65
232
|
const moduleResult = parseModule(moduleContent, modulePath);
|
|
66
233
|
errors.push(...moduleResult.errors);
|
|
67
234
|
if (!moduleResult.module) {
|
|
68
|
-
return { module: null, errors };
|
|
235
|
+
return { module: null, modules: [], errors };
|
|
69
236
|
}
|
|
70
237
|
const parsedModule = moduleResult.module;
|
|
71
|
-
const
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
if (section.type ===
|
|
75
|
-
// Resolve the Learning Outcome reference
|
|
76
|
-
// Create a copy of visitedPaths for this section's reference chain
|
|
77
|
-
// This allows the same file to be referenced in different sections
|
|
78
|
-
// while still detecting cycles within a single chain
|
|
238
|
+
const flatItems = [];
|
|
239
|
+
// Helper: process a section (LO or Lens) and return Section[]
|
|
240
|
+
function processSection(section, _subsectionLevel) {
|
|
241
|
+
if (section.type === "learning-outcome") {
|
|
79
242
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
80
243
|
const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths, tierMap);
|
|
81
244
|
errors.push(...result.errors);
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
else if (section.type === 'page') {
|
|
85
|
-
// Page sections don't have LO references, they have inline content
|
|
86
|
-
// Parse the section body for ## Text subsections
|
|
87
|
-
const textResult = parsePageSegments(section.body, modulePath, section.line);
|
|
88
|
-
errors.push(...textResult.errors);
|
|
89
|
-
const pageSection = {
|
|
90
|
-
type: 'page',
|
|
91
|
-
meta: { title: section.title },
|
|
92
|
-
segments: textResult.segments,
|
|
93
|
-
optional: section.fields.optional?.toLowerCase() === 'true',
|
|
94
|
-
contentId: section.fields.id ?? null,
|
|
95
|
-
learningOutcomeId: null,
|
|
96
|
-
learningOutcomeName: null,
|
|
97
|
-
videoId: null,
|
|
98
|
-
};
|
|
99
|
-
flattenedSections.push(pageSection);
|
|
245
|
+
return result.sections;
|
|
100
246
|
}
|
|
101
|
-
else if (section.type ===
|
|
102
|
-
//
|
|
103
|
-
// Each lens becomes its own section (lens-video, lens-article, or page)
|
|
247
|
+
else if (section.type === "lens") {
|
|
248
|
+
// Referenced lens (has source::) or inline lens
|
|
104
249
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
105
|
-
const result =
|
|
250
|
+
const result = flattenLensSection(section, modulePath, files, sectionVisitedPaths, tierMap, section.inlineLens);
|
|
106
251
|
errors.push(...result.errors);
|
|
107
|
-
|
|
252
|
+
return result.sections;
|
|
108
253
|
}
|
|
254
|
+
return [];
|
|
109
255
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
256
|
+
// Process each section in the module
|
|
257
|
+
for (const section of parsedModule.sections) {
|
|
258
|
+
if (section.type === "submodule") {
|
|
259
|
+
// Emit boundary marker
|
|
260
|
+
flatItems.push({
|
|
261
|
+
__boundary: true,
|
|
262
|
+
title: section.title,
|
|
263
|
+
customSlug: section.fields.slug,
|
|
264
|
+
});
|
|
265
|
+
// Process children
|
|
266
|
+
if (section.children) {
|
|
267
|
+
for (const child of section.children) {
|
|
268
|
+
const childSections = processSection(child, child.level + 1);
|
|
269
|
+
flatItems.push(...childSections);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Check if this is an LO with submodules
|
|
275
|
+
if (section.type === "learning-outcome") {
|
|
276
|
+
const loSections = processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors);
|
|
277
|
+
if (loSections) {
|
|
278
|
+
flatItems.push(...loSections);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const sections = processSection(section);
|
|
283
|
+
flatItems.push(...sections);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Validate and split at boundaries
|
|
287
|
+
const boundaryErrors = validateBoundaries(flatItems, modulePath);
|
|
288
|
+
errors.push(...boundaryErrors);
|
|
289
|
+
const groups = splitAtBoundaries(flatItems, parsedModule.slug, parsedModule.title);
|
|
290
|
+
const resultModules = groups.map((g) => ({
|
|
291
|
+
slug: g.slug,
|
|
292
|
+
title: g.title,
|
|
113
293
|
contentId: parsedModule.contentId,
|
|
114
|
-
sections:
|
|
294
|
+
sections: g.sections,
|
|
295
|
+
sourcePath: modulePath,
|
|
296
|
+
...(g.parentSlug
|
|
297
|
+
? { parentSlug: g.parentSlug, parentTitle: g.parentTitle }
|
|
298
|
+
: {}),
|
|
299
|
+
...(moduleError ? { error: moduleError } : {}),
|
|
300
|
+
...("addToAiContext" in parsedModule
|
|
301
|
+
? { addToAiContext: parsedModule.addToAiContext }
|
|
302
|
+
: {}),
|
|
303
|
+
}));
|
|
304
|
+
// Enrich ::card links in text segments with computed metadata (duration, attribution, displayType)
|
|
305
|
+
for (const mod of resultModules) {
|
|
306
|
+
enrichCardLinks(mod.sections);
|
|
307
|
+
}
|
|
308
|
+
// Backwards compat: module is the first result or a merged single module
|
|
309
|
+
const primaryModule = resultModules.length > 0 ? resultModules[0] : null;
|
|
310
|
+
return {
|
|
311
|
+
module: resultModules.length === 1 && !resultModules[0].parentSlug
|
|
312
|
+
? resultModules[0]
|
|
313
|
+
: primaryModule,
|
|
314
|
+
modules: resultModules,
|
|
315
|
+
errors,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Build a test Section from a ParsedTestRef with inline segments.
|
|
320
|
+
* Shared by both processLOWithSubmodules() and flattenLearningOutcomeSection().
|
|
321
|
+
*/
|
|
322
|
+
function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, tierMap, errors) {
|
|
323
|
+
if (!testRef.segments.length)
|
|
324
|
+
return null;
|
|
325
|
+
const testSegments = [];
|
|
326
|
+
for (const parsedSegment of testRef.segments) {
|
|
327
|
+
const segmentResult = convertSegment(parsedSegment, loPath, files, new Set(visitedPaths), tierMap);
|
|
328
|
+
errors.push(...segmentResult.errors);
|
|
329
|
+
if (segmentResult.segment) {
|
|
330
|
+
testSegments.push(segmentResult.segment);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (testSegments.length === 0)
|
|
334
|
+
return null;
|
|
335
|
+
const hasFeedback = testSegments.some((s) => s.type === "question" && s.feedback);
|
|
336
|
+
return {
|
|
337
|
+
type: "test",
|
|
338
|
+
meta: { title: "Test" },
|
|
339
|
+
segments: testSegments,
|
|
340
|
+
sourcePath: loPath,
|
|
341
|
+
optional: false,
|
|
342
|
+
...(hasFeedback && { feedback: true }),
|
|
343
|
+
contentId: testRef.id,
|
|
344
|
+
learningOutcomeId: loId,
|
|
345
|
+
learningOutcomeName: loName,
|
|
115
346
|
};
|
|
116
|
-
|
|
117
|
-
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Check if a Learning Outcome section resolves to an LO with submodules.
|
|
350
|
+
* If so, emit boundary markers + sections. Returns null if no submodules.
|
|
351
|
+
*/
|
|
352
|
+
function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors) {
|
|
353
|
+
// Resolve the LO file to check for submodules
|
|
354
|
+
const source = section.fields.source;
|
|
355
|
+
if (!source)
|
|
356
|
+
return null;
|
|
357
|
+
const wikilink = parseWikilink(source);
|
|
358
|
+
if (!wikilink || wikilink.error)
|
|
359
|
+
return null;
|
|
360
|
+
const loPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
|
|
361
|
+
const loPath = findFileWithExtension(loPathResolved, files);
|
|
362
|
+
if (!loPath)
|
|
363
|
+
return null;
|
|
364
|
+
const loContent = files.get(loPath);
|
|
365
|
+
if (!loContent)
|
|
366
|
+
return null;
|
|
367
|
+
const loResult = parseLearningOutcome(loContent, loPath);
|
|
368
|
+
if (!loResult.learningOutcome?.submodules)
|
|
369
|
+
return null;
|
|
370
|
+
// This LO has submodules — emit boundaries and resolve lenses
|
|
371
|
+
const items = [];
|
|
372
|
+
for (const sub of loResult.learningOutcome.submodules) {
|
|
373
|
+
items.push({
|
|
374
|
+
__boundary: true,
|
|
375
|
+
title: sub.title,
|
|
376
|
+
customSlug: sub.customSlug,
|
|
377
|
+
});
|
|
378
|
+
// Resolve each lens in this submodule group
|
|
379
|
+
for (const lensRef of sub.lenses) {
|
|
380
|
+
const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
|
|
381
|
+
if (!lensPath) {
|
|
382
|
+
const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
|
|
383
|
+
const suggestion = formatSuggestion(similarFiles, loPath) ??
|
|
384
|
+
"Check the file path in the wiki-link";
|
|
385
|
+
errors.push({
|
|
386
|
+
file: loPath,
|
|
387
|
+
message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
|
|
388
|
+
suggestion,
|
|
389
|
+
severity: "error",
|
|
390
|
+
});
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
// Check tier violation
|
|
394
|
+
if (tierMap) {
|
|
395
|
+
const parentTier = tierMap.get(loPath) ?? "production";
|
|
396
|
+
const childTier = tierMap.get(lensPath) ?? "production";
|
|
397
|
+
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
|
|
398
|
+
if (violation) {
|
|
399
|
+
errors.push(violation);
|
|
400
|
+
}
|
|
401
|
+
if (childTier === "ignored")
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (visitedPaths.has(lensPath)) {
|
|
405
|
+
errors.push({
|
|
406
|
+
file: loPath,
|
|
407
|
+
message: `Circular reference detected: ${lensPath}`,
|
|
408
|
+
severity: "error",
|
|
409
|
+
});
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const sectionVisitedPaths = new Set(visitedPaths);
|
|
413
|
+
sectionVisitedPaths.add(lensPath);
|
|
414
|
+
const lensContent = files.get(lensPath);
|
|
415
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
416
|
+
errors.push(...lensResult.errors);
|
|
417
|
+
if (!lensResult.lens)
|
|
418
|
+
continue;
|
|
419
|
+
const lens = lensResult.lens;
|
|
420
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, sectionVisitedPaths, tierMap);
|
|
421
|
+
errors.push(...flattenErrors);
|
|
422
|
+
const lo = loResult.learningOutcome;
|
|
423
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true" || lensRef.optional;
|
|
424
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true" || lensRef.hide;
|
|
425
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, loPath, section.line);
|
|
426
|
+
if (hideConflict)
|
|
427
|
+
errors.push(hideConflict);
|
|
428
|
+
items.push({
|
|
429
|
+
type: "lens",
|
|
430
|
+
meta: { title: lens.title || section.title },
|
|
431
|
+
segments,
|
|
432
|
+
sourcePath: lensPath,
|
|
433
|
+
optional: effectiveOptional,
|
|
434
|
+
...(effectiveHide && { hide: true }),
|
|
435
|
+
learningOutcomeId: lo.id ?? null,
|
|
436
|
+
learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
|
|
437
|
+
contentId: lens.id ?? null,
|
|
438
|
+
tldr: lens.tldr,
|
|
439
|
+
summaryForTutor: lens.summaryForTutor,
|
|
440
|
+
addToAiContext: lens.addToAiContext,
|
|
441
|
+
addToAiContextLo: lo.addToAiContext,
|
|
442
|
+
...computeSectionStats(segments),
|
|
443
|
+
displayType: computeDisplayType(segments),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
// Process test section if present in this submodule
|
|
447
|
+
if (sub.test) {
|
|
448
|
+
const lo = loResult.learningOutcome;
|
|
449
|
+
const testSection = buildTestSection(sub.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
|
|
450
|
+
if (testSection)
|
|
451
|
+
items.push(testSection);
|
|
452
|
+
}
|
|
118
453
|
}
|
|
119
|
-
|
|
454
|
+
// Also emit LO parse errors
|
|
455
|
+
errors.push(...loResult.errors);
|
|
456
|
+
return items;
|
|
120
457
|
}
|
|
121
458
|
/**
|
|
122
459
|
* Flatten a Learning Outcome section by resolving its LO file and all referenced lenses.
|
|
@@ -131,9 +468,9 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
131
468
|
errors.push({
|
|
132
469
|
file: modulePath,
|
|
133
470
|
line: section.line,
|
|
134
|
-
message:
|
|
471
|
+
message: "Learning Outcome section missing source:: field",
|
|
135
472
|
suggestion: "Add 'source:: [[../Learning Outcomes/filename.md|Display]]'",
|
|
136
|
-
severity:
|
|
473
|
+
severity: "error",
|
|
137
474
|
});
|
|
138
475
|
return { sections: [], errors };
|
|
139
476
|
}
|
|
@@ -142,7 +479,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
142
479
|
if (!wikilink || wikilink.error) {
|
|
143
480
|
const suggestion = wikilink?.correctedPath
|
|
144
481
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
145
|
-
:
|
|
482
|
+
: "Use format [[../Learning Outcomes/filename.md|Display Text]]";
|
|
146
483
|
errors.push({
|
|
147
484
|
file: modulePath,
|
|
148
485
|
line: section.line,
|
|
@@ -150,7 +487,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
150
487
|
? `${wikilink.error}: ${source}`
|
|
151
488
|
: `Invalid wikilink format: ${source}`,
|
|
152
489
|
suggestion,
|
|
153
|
-
severity:
|
|
490
|
+
severity: "error",
|
|
154
491
|
});
|
|
155
492
|
return { sections: [], errors };
|
|
156
493
|
}
|
|
@@ -158,28 +495,27 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
158
495
|
const loPath = findFileWithExtension(loPathResolved, files);
|
|
159
496
|
// Get the LO file content
|
|
160
497
|
if (!loPath) {
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
498
|
+
const similarFiles = findSimilarFiles(loPathResolved, files, "Learning Outcomes");
|
|
499
|
+
const suggestion = formatSuggestion(similarFiles, modulePath) ??
|
|
500
|
+
"Check the file path in the wiki-link";
|
|
164
501
|
errors.push({
|
|
165
502
|
file: modulePath,
|
|
166
503
|
line: section.line,
|
|
167
504
|
message: `Referenced file not found: ${loPathResolved}`,
|
|
168
505
|
suggestion,
|
|
169
|
-
severity:
|
|
506
|
+
severity: "error",
|
|
170
507
|
});
|
|
171
508
|
return { sections: [], errors };
|
|
172
509
|
}
|
|
173
|
-
// Check tier violation (module
|
|
510
|
+
// Check tier violation (module -> LO)
|
|
174
511
|
if (tierMap) {
|
|
175
|
-
const parentTier = tierMap.get(modulePath) ??
|
|
176
|
-
const childTier = tierMap.get(loPath) ??
|
|
177
|
-
const violation = checkTierViolation(modulePath, parentTier, loPath, childTier,
|
|
512
|
+
const parentTier = tierMap.get(modulePath) ?? "production";
|
|
513
|
+
const childTier = tierMap.get(loPath) ?? "production";
|
|
514
|
+
const violation = checkTierViolation(modulePath, parentTier, loPath, childTier, "learning outcome", section.line);
|
|
178
515
|
if (violation) {
|
|
179
516
|
errors.push(violation);
|
|
180
517
|
}
|
|
181
|
-
|
|
182
|
-
if (childTier === 'ignored') {
|
|
518
|
+
if (childTier === "ignored") {
|
|
183
519
|
return { sections: [], errors };
|
|
184
520
|
}
|
|
185
521
|
}
|
|
@@ -189,7 +525,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
189
525
|
file: modulePath,
|
|
190
526
|
line: section.line,
|
|
191
527
|
message: `Circular reference detected: ${loPath}`,
|
|
192
|
-
severity:
|
|
528
|
+
severity: "error",
|
|
193
529
|
});
|
|
194
530
|
return { sections: [], errors };
|
|
195
531
|
}
|
|
@@ -206,26 +542,26 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
206
542
|
for (const lensRef of lo.lenses) {
|
|
207
543
|
const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
|
|
208
544
|
if (!lensPath) {
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
545
|
+
const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
|
|
546
|
+
const suggestion = formatSuggestion(similarFiles, loPath) ??
|
|
547
|
+
"Check the file path in the wiki-link";
|
|
212
548
|
errors.push({
|
|
213
549
|
file: loPath,
|
|
214
550
|
message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
|
|
215
551
|
suggestion,
|
|
216
|
-
severity:
|
|
552
|
+
severity: "error",
|
|
217
553
|
});
|
|
218
554
|
continue;
|
|
219
555
|
}
|
|
220
|
-
// Check tier violation (LO
|
|
556
|
+
// Check tier violation (LO -> Lens)
|
|
221
557
|
if (tierMap) {
|
|
222
|
-
const parentTier = tierMap.get(loPath) ??
|
|
223
|
-
const childTier = tierMap.get(lensPath) ??
|
|
224
|
-
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier,
|
|
558
|
+
const parentTier = tierMap.get(loPath) ?? "production";
|
|
559
|
+
const childTier = tierMap.get(lensPath) ?? "production";
|
|
560
|
+
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
|
|
225
561
|
if (violation) {
|
|
226
562
|
errors.push(violation);
|
|
227
563
|
}
|
|
228
|
-
if (childTier ===
|
|
564
|
+
if (childTier === "ignored") {
|
|
229
565
|
continue;
|
|
230
566
|
}
|
|
231
567
|
}
|
|
@@ -234,7 +570,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
234
570
|
errors.push({
|
|
235
571
|
file: loPath,
|
|
236
572
|
message: `Circular reference detected: ${lensPath}`,
|
|
237
|
-
severity:
|
|
573
|
+
severity: "error",
|
|
238
574
|
});
|
|
239
575
|
continue;
|
|
240
576
|
}
|
|
@@ -247,425 +583,263 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
247
583
|
continue;
|
|
248
584
|
}
|
|
249
585
|
const lens = lensResult.lens;
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const segments = [];
|
|
254
|
-
let videoId;
|
|
255
|
-
// Process each section in the lens
|
|
256
|
-
for (const lensSection of lens.sections) {
|
|
257
|
-
// Determine section type from lens section
|
|
258
|
-
if (lensSection.type === 'lens-article') {
|
|
259
|
-
sectionType = 'lens-article';
|
|
260
|
-
// Extract article metadata from the article file's frontmatter
|
|
261
|
-
if (lensSection.source) {
|
|
262
|
-
const articleWikilink = parseWikilink(lensSection.source);
|
|
263
|
-
if (articleWikilink && !articleWikilink.error) {
|
|
264
|
-
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
265
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
266
|
-
if (articlePath) {
|
|
267
|
-
const articleContent = files.get(articlePath);
|
|
268
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
269
|
-
// Extract metadata fields
|
|
270
|
-
if (articleFrontmatter.frontmatter.title) {
|
|
271
|
-
meta.title = articleFrontmatter.frontmatter.title;
|
|
272
|
-
}
|
|
273
|
-
if (articleFrontmatter.frontmatter.author) {
|
|
274
|
-
const raw = articleFrontmatter.frontmatter.author;
|
|
275
|
-
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
276
|
-
}
|
|
277
|
-
if (articleFrontmatter.frontmatter.source_url) {
|
|
278
|
-
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
else if (lensSection.type === 'page') {
|
|
285
|
-
sectionType = 'page';
|
|
286
|
-
// For page sections, use the title from the ### Page: header
|
|
287
|
-
if (lensSection.title) {
|
|
288
|
-
meta.title = lensSection.title;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
else if (lensSection.type === 'lens-video') {
|
|
292
|
-
sectionType = 'lens-video';
|
|
293
|
-
// Extract video metadata from the video transcript file's frontmatter
|
|
294
|
-
if (lensSection.source) {
|
|
295
|
-
const videoWikilink = parseWikilink(lensSection.source);
|
|
296
|
-
if (videoWikilink && !videoWikilink.error) {
|
|
297
|
-
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
298
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
299
|
-
if (videoPath) {
|
|
300
|
-
const videoContent = files.get(videoPath);
|
|
301
|
-
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
302
|
-
// Extract metadata fields
|
|
303
|
-
if (videoFrontmatter.frontmatter.title) {
|
|
304
|
-
meta.title = videoFrontmatter.frontmatter.title;
|
|
305
|
-
}
|
|
306
|
-
if (videoFrontmatter.frontmatter.channel) {
|
|
307
|
-
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
308
|
-
}
|
|
309
|
-
// Extract video ID from YouTube URL
|
|
310
|
-
if (videoFrontmatter.frontmatter.url) {
|
|
311
|
-
const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
312
|
-
if (extractedVideoId) {
|
|
313
|
-
videoId = extractedVideoId;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
// Process segments
|
|
321
|
-
for (const parsedSegment of lensSection.segments) {
|
|
322
|
-
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
|
|
323
|
-
errors.push(...segmentResult.errors);
|
|
324
|
-
if (segmentResult.segment) {
|
|
325
|
-
segments.push(segmentResult.segment);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
586
|
+
// Flatten lens segments using shared helper
|
|
587
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
588
|
+
errors.push(...flattenErrors);
|
|
329
589
|
// Create a section for this lens
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
590
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true" || lensRef.optional;
|
|
591
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true" || lensRef.hide;
|
|
592
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, loPath, section.line);
|
|
593
|
+
if (hideConflict)
|
|
594
|
+
errors.push(hideConflict);
|
|
333
595
|
const resultSection = {
|
|
334
|
-
type:
|
|
335
|
-
meta,
|
|
596
|
+
type: "lens",
|
|
597
|
+
meta: { title: lens.title || section.title },
|
|
336
598
|
segments,
|
|
337
|
-
|
|
599
|
+
sourcePath: lensPath,
|
|
600
|
+
optional: effectiveOptional,
|
|
601
|
+
...(effectiveHide && { hide: true }),
|
|
338
602
|
learningOutcomeId: lo.id ?? null,
|
|
339
|
-
learningOutcomeName: loPath.split(
|
|
603
|
+
learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
|
|
340
604
|
contentId: lens.id ?? null,
|
|
341
|
-
|
|
605
|
+
tldr: lens.tldr,
|
|
606
|
+
summaryForTutor: lens.summaryForTutor,
|
|
607
|
+
addToAiContext: lens.addToAiContext,
|
|
608
|
+
addToAiContextLo: lo.addToAiContext,
|
|
609
|
+
...computeSectionStats(segments),
|
|
610
|
+
displayType: computeDisplayType(segments),
|
|
342
611
|
};
|
|
343
612
|
sections.push(resultSection);
|
|
344
613
|
}
|
|
345
614
|
// After lens processing, add test section if present with inline segments
|
|
346
|
-
if (lo.test
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// Create a minimal stub lensSection since these segment types don't need it
|
|
351
|
-
const stubLensSection = {
|
|
352
|
-
type: 'page',
|
|
353
|
-
title: 'Test',
|
|
354
|
-
segments: [],
|
|
355
|
-
line: 0,
|
|
356
|
-
};
|
|
357
|
-
const segmentResult = convertSegment(parsedSegment, stubLensSection, loPath, files, visitedPaths, tierMap);
|
|
358
|
-
errors.push(...segmentResult.errors);
|
|
359
|
-
if (segmentResult.segment) {
|
|
360
|
-
testSegments.push(segmentResult.segment);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
if (testSegments.length > 0) {
|
|
364
|
-
const hasFeedback = testSegments.some((s) => s.type === 'question' && s.feedback);
|
|
365
|
-
sections.push({
|
|
366
|
-
type: 'test',
|
|
367
|
-
meta: { title: 'Test' },
|
|
368
|
-
segments: testSegments,
|
|
369
|
-
optional: false,
|
|
370
|
-
...(hasFeedback && { feedback: true }),
|
|
371
|
-
contentId: null,
|
|
372
|
-
learningOutcomeId: lo.id ?? null,
|
|
373
|
-
learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
|
|
374
|
-
videoId: null,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
615
|
+
if (lo.test) {
|
|
616
|
+
const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
|
|
617
|
+
if (testSection)
|
|
618
|
+
sections.push(testSection);
|
|
377
619
|
}
|
|
378
620
|
return { sections, errors };
|
|
379
621
|
}
|
|
380
622
|
/**
|
|
381
|
-
* Flatten
|
|
382
|
-
*
|
|
623
|
+
* Flatten a # Lens: section from a module file.
|
|
624
|
+
* Handles both referenced lenses (with source:: wikilink) and inline lenses (with id:: + segments).
|
|
383
625
|
*/
|
|
384
|
-
function
|
|
626
|
+
function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, inlineLens) {
|
|
385
627
|
const errors = [];
|
|
386
628
|
const sections = [];
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
629
|
+
// Inline lens: has id:: + segments (no source:: at section level)
|
|
630
|
+
if (inlineLens) {
|
|
631
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(inlineLens, modulePath, files, visitedPaths, tierMap);
|
|
632
|
+
errors.push(...flattenErrors);
|
|
633
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true";
|
|
634
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true";
|
|
635
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
636
|
+
if (hideConflict)
|
|
637
|
+
errors.push(hideConflict);
|
|
638
|
+
const resultSection = {
|
|
639
|
+
type: "lens",
|
|
640
|
+
meta: { title: inlineLens.title || section.title },
|
|
641
|
+
segments,
|
|
642
|
+
sourcePath: modulePath,
|
|
643
|
+
optional: effectiveOptional,
|
|
644
|
+
...(effectiveHide && { hide: true }),
|
|
645
|
+
learningOutcomeId: null,
|
|
646
|
+
learningOutcomeName: null,
|
|
647
|
+
contentId: inlineLens.id ?? null,
|
|
648
|
+
tldr: inlineLens.tldr,
|
|
649
|
+
summaryForTutor: inlineLens.summaryForTutor,
|
|
650
|
+
addToAiContext: inlineLens.addToAiContext,
|
|
651
|
+
...computeSectionStats(segments),
|
|
652
|
+
displayType: computeDisplayType(segments),
|
|
653
|
+
};
|
|
654
|
+
sections.push(resultSection);
|
|
655
|
+
return { sections, errors };
|
|
656
|
+
}
|
|
657
|
+
// Referenced lens: has source:: at section level (not from segment fields)
|
|
658
|
+
const hasSectionSource = hasFieldBeforeSegmentHeaders(section.body, "source", section.level);
|
|
659
|
+
const source = hasSectionSource ? section.fields.source : undefined;
|
|
660
|
+
if (!source) {
|
|
391
661
|
errors.push({
|
|
392
662
|
file: modulePath,
|
|
393
663
|
line: section.line,
|
|
394
|
-
message:
|
|
395
|
-
suggestion: "Add '
|
|
396
|
-
severity:
|
|
664
|
+
message: "Lens section missing source:: field (or id:: for inline lens)",
|
|
665
|
+
suggestion: "Add 'source:: [[../Lenses/filename.md|Display]]' or 'id:: <uuid>' with #### segments",
|
|
666
|
+
severity: "error",
|
|
397
667
|
});
|
|
398
668
|
return { sections: [], errors };
|
|
399
669
|
}
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
// Parse the lens
|
|
439
|
-
const lensResult = parseLens(lensContent, lensPath);
|
|
440
|
-
errors.push(...lensResult.errors);
|
|
441
|
-
if (!lensResult.lens) {
|
|
442
|
-
continue;
|
|
670
|
+
// Parse and resolve the wikilink
|
|
671
|
+
const wikilink = parseWikilink(source);
|
|
672
|
+
if (!wikilink || wikilink.error) {
|
|
673
|
+
const suggestion = wikilink?.correctedPath
|
|
674
|
+
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
675
|
+
: "Use format [[../Lenses/filename.md|Display Text]]";
|
|
676
|
+
errors.push({
|
|
677
|
+
file: modulePath,
|
|
678
|
+
line: section.line,
|
|
679
|
+
message: wikilink?.error
|
|
680
|
+
? `${wikilink.error}: ${source}`
|
|
681
|
+
: `Invalid wikilink format: ${source}`,
|
|
682
|
+
suggestion,
|
|
683
|
+
severity: "error",
|
|
684
|
+
});
|
|
685
|
+
return { sections: [], errors };
|
|
686
|
+
}
|
|
687
|
+
const lensPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
|
|
688
|
+
const lensPath = findFileWithExtension(lensPathResolved, files);
|
|
689
|
+
if (!lensPath) {
|
|
690
|
+
const similarFiles = findSimilarFiles(lensPathResolved, files, "Lenses");
|
|
691
|
+
const suggestion = formatSuggestion(similarFiles, modulePath) ??
|
|
692
|
+
"Check the file path in the wiki-link";
|
|
693
|
+
errors.push({
|
|
694
|
+
file: modulePath,
|
|
695
|
+
message: `Referenced lens file not found: ${lensPathResolved}`,
|
|
696
|
+
suggestion,
|
|
697
|
+
severity: "error",
|
|
698
|
+
});
|
|
699
|
+
return { sections: [], errors };
|
|
700
|
+
}
|
|
701
|
+
// Check tier violation
|
|
702
|
+
if (tierMap) {
|
|
703
|
+
const parentTier = tierMap.get(modulePath) ?? "production";
|
|
704
|
+
const childTier = tierMap.get(lensPath) ?? "production";
|
|
705
|
+
const violation = checkTierViolation(modulePath, parentTier, lensPath, childTier, "lens");
|
|
706
|
+
if (violation) {
|
|
707
|
+
errors.push(violation);
|
|
443
708
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
let sectionType = 'page';
|
|
447
|
-
const meta = { title: section.title };
|
|
448
|
-
const segments = [];
|
|
449
|
-
let videoId;
|
|
450
|
-
// Process each section in the lens
|
|
451
|
-
for (const lensSection of lens.sections) {
|
|
452
|
-
// Determine section type from lens section
|
|
453
|
-
if (lensSection.type === 'lens-article') {
|
|
454
|
-
sectionType = 'lens-article';
|
|
455
|
-
// Extract article metadata from the article file's frontmatter
|
|
456
|
-
if (lensSection.source) {
|
|
457
|
-
const articleWikilink = parseWikilink(lensSection.source);
|
|
458
|
-
if (articleWikilink && !articleWikilink.error) {
|
|
459
|
-
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
460
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
461
|
-
if (articlePath) {
|
|
462
|
-
const articleContent = files.get(articlePath);
|
|
463
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
464
|
-
// Extract metadata fields
|
|
465
|
-
if (articleFrontmatter.frontmatter.title) {
|
|
466
|
-
meta.title = articleFrontmatter.frontmatter.title;
|
|
467
|
-
}
|
|
468
|
-
if (articleFrontmatter.frontmatter.author) {
|
|
469
|
-
const raw = articleFrontmatter.frontmatter.author;
|
|
470
|
-
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
471
|
-
}
|
|
472
|
-
if (articleFrontmatter.frontmatter.source_url) {
|
|
473
|
-
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
else if (lensSection.type === 'page') {
|
|
480
|
-
sectionType = 'page';
|
|
481
|
-
// For page sections, use the title from the ### Page: header
|
|
482
|
-
if (lensSection.title) {
|
|
483
|
-
meta.title = lensSection.title;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
else if (lensSection.type === 'lens-video') {
|
|
487
|
-
sectionType = 'lens-video';
|
|
488
|
-
// Extract video metadata from the video transcript file's frontmatter
|
|
489
|
-
if (lensSection.source) {
|
|
490
|
-
const videoWikilink = parseWikilink(lensSection.source);
|
|
491
|
-
if (videoWikilink && !videoWikilink.error) {
|
|
492
|
-
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
493
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
494
|
-
if (videoPath) {
|
|
495
|
-
const videoContent = files.get(videoPath);
|
|
496
|
-
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
497
|
-
// Extract metadata fields
|
|
498
|
-
if (videoFrontmatter.frontmatter.title) {
|
|
499
|
-
meta.title = videoFrontmatter.frontmatter.title;
|
|
500
|
-
}
|
|
501
|
-
if (videoFrontmatter.frontmatter.channel) {
|
|
502
|
-
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
503
|
-
}
|
|
504
|
-
// Extract video ID from YouTube URL
|
|
505
|
-
if (videoFrontmatter.frontmatter.url) {
|
|
506
|
-
const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
507
|
-
if (extractedVideoId) {
|
|
508
|
-
videoId = extractedVideoId;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
// Process segments
|
|
516
|
-
for (const parsedSegment of lensSection.segments) {
|
|
517
|
-
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
|
|
518
|
-
errors.push(...segmentResult.errors);
|
|
519
|
-
if (segmentResult.segment) {
|
|
520
|
-
segments.push(segmentResult.segment);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
709
|
+
if (childTier === "ignored") {
|
|
710
|
+
return { sections: [], errors };
|
|
523
711
|
}
|
|
524
|
-
// Create a section for this lens
|
|
525
|
-
const resultSection = {
|
|
526
|
-
type: sectionType,
|
|
527
|
-
meta,
|
|
528
|
-
segments,
|
|
529
|
-
optional: lensRef.optional,
|
|
530
|
-
learningOutcomeId: null,
|
|
531
|
-
learningOutcomeName: null,
|
|
532
|
-
contentId: lens.id ?? null,
|
|
533
|
-
videoId: videoId ?? null,
|
|
534
|
-
};
|
|
535
|
-
sections.push(resultSection);
|
|
536
712
|
}
|
|
713
|
+
// Check for circular reference
|
|
714
|
+
if (visitedPaths.has(lensPath)) {
|
|
715
|
+
errors.push({
|
|
716
|
+
file: modulePath,
|
|
717
|
+
message: `Circular reference detected: ${lensPath}`,
|
|
718
|
+
severity: "error",
|
|
719
|
+
});
|
|
720
|
+
return { sections: [], errors };
|
|
721
|
+
}
|
|
722
|
+
visitedPaths.add(lensPath);
|
|
723
|
+
const lensContent = files.get(lensPath);
|
|
724
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
725
|
+
errors.push(...lensResult.errors);
|
|
726
|
+
if (!lensResult.lens) {
|
|
727
|
+
return { sections: [], errors };
|
|
728
|
+
}
|
|
729
|
+
const lens = lensResult.lens;
|
|
730
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
731
|
+
errors.push(...flattenErrors);
|
|
732
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true";
|
|
733
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true";
|
|
734
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
735
|
+
if (hideConflict)
|
|
736
|
+
errors.push(hideConflict);
|
|
737
|
+
const resultSection = {
|
|
738
|
+
type: "lens",
|
|
739
|
+
meta: { title: lens.title || section.title },
|
|
740
|
+
segments,
|
|
741
|
+
sourcePath: lensPath,
|
|
742
|
+
optional: effectiveOptional,
|
|
743
|
+
...(effectiveHide && { hide: true }),
|
|
744
|
+
learningOutcomeId: null,
|
|
745
|
+
learningOutcomeName: null,
|
|
746
|
+
contentId: lens.id ?? null,
|
|
747
|
+
tldr: lens.tldr,
|
|
748
|
+
summaryForTutor: lens.summaryForTutor,
|
|
749
|
+
addToAiContext: lens.addToAiContext,
|
|
750
|
+
...computeSectionStats(segments),
|
|
751
|
+
displayType: computeDisplayType(segments),
|
|
752
|
+
};
|
|
753
|
+
sections.push(resultSection);
|
|
537
754
|
return { sections, errors };
|
|
538
755
|
}
|
|
756
|
+
// ─── Collapsed content and segment conversion ───
|
|
539
757
|
/**
|
|
540
|
-
*
|
|
541
|
-
*
|
|
758
|
+
* After converting segments, apply collapsed_before/collapsed_after to article segments.
|
|
759
|
+
* Groups article segments by their resolved source path, then calls bundleArticleWithCollapsed
|
|
760
|
+
* for each group.
|
|
542
761
|
*/
|
|
543
|
-
function
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
let
|
|
548
|
-
let
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const FIELD_PATTERN = /^(\w+)::\s*(.*)$/;
|
|
552
|
-
for (let i = 0; i < lines.length; i++) {
|
|
553
|
-
const line = lines[i];
|
|
554
|
-
// Check for ## Lens: header
|
|
555
|
-
const lensMatch = line.match(LENS_HEADER_PATTERN);
|
|
556
|
-
if (lensMatch) {
|
|
557
|
-
// Save previous lens if we were in one
|
|
558
|
-
if (inLensSection) {
|
|
559
|
-
// First, finalize current field if we were collecting one
|
|
560
|
-
// This must happen BEFORE checking currentFields.source
|
|
561
|
-
if (currentField) {
|
|
562
|
-
currentFields[currentField] = currentValue.join('\n').trim();
|
|
563
|
-
}
|
|
564
|
-
if (currentFields.source) {
|
|
565
|
-
const wikilink = parseWikilink(currentFields.source);
|
|
566
|
-
if (wikilink) {
|
|
567
|
-
const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
|
|
568
|
-
lensRefs.push({
|
|
569
|
-
source: currentFields.source,
|
|
570
|
-
resolvedPath,
|
|
571
|
-
optional: currentFields.optional?.toLowerCase() === 'true',
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
inLensSection = true;
|
|
577
|
-
currentFields = {};
|
|
578
|
-
currentField = null;
|
|
579
|
-
currentValue = [];
|
|
762
|
+
function applyCollapsedContent(segments, parsedSegments, lensPath, files) {
|
|
763
|
+
// Build groups of article segments by resolved source path
|
|
764
|
+
const sourceGroups = new Map();
|
|
765
|
+
// Map output article segments back to parsed segments for source info
|
|
766
|
+
let articleOutputIdx = 0;
|
|
767
|
+
for (let i = 0; i < parsedSegments.length; i++) {
|
|
768
|
+
const ps = parsedSegments[i];
|
|
769
|
+
if (ps.type !== "article")
|
|
580
770
|
continue;
|
|
771
|
+
// Find corresponding output segment
|
|
772
|
+
while (articleOutputIdx < segments.length &&
|
|
773
|
+
segments[articleOutputIdx].type !== "article") {
|
|
774
|
+
articleOutputIdx++;
|
|
581
775
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
776
|
+
if (articleOutputIdx >= segments.length)
|
|
777
|
+
break;
|
|
778
|
+
if (ps.source) {
|
|
779
|
+
const wikilink = parseWikilink(ps.source);
|
|
780
|
+
if (wikilink && !wikilink.error) {
|
|
781
|
+
const resolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
782
|
+
const actualPath = findFileWithExtension(resolved, files);
|
|
783
|
+
if (actualPath) {
|
|
784
|
+
if (!sourceGroups.has(actualPath)) {
|
|
785
|
+
sourceGroups.set(actualPath, []);
|
|
786
|
+
}
|
|
787
|
+
sourceGroups.get(actualPath).push({
|
|
788
|
+
segmentIndex: articleOutputIdx,
|
|
789
|
+
fromAnchor: ps.fromAnchor,
|
|
790
|
+
toAnchor: ps.toAnchor,
|
|
596
791
|
});
|
|
597
792
|
}
|
|
598
793
|
}
|
|
599
|
-
inLensSection = false;
|
|
600
|
-
currentFields = {};
|
|
601
|
-
currentField = null;
|
|
602
|
-
currentValue = [];
|
|
603
|
-
continue;
|
|
604
|
-
}
|
|
605
|
-
if (inLensSection) {
|
|
606
|
-
// Parse fields
|
|
607
|
-
const fieldMatch = line.match(FIELD_PATTERN);
|
|
608
|
-
if (fieldMatch) {
|
|
609
|
-
// Save previous field
|
|
610
|
-
if (currentField) {
|
|
611
|
-
currentFields[currentField] = currentValue.join('\n').trim();
|
|
612
|
-
}
|
|
613
|
-
currentField = fieldMatch[1];
|
|
614
|
-
const inlineValue = fieldMatch[2].trim();
|
|
615
|
-
currentValue = inlineValue ? [inlineValue] : [];
|
|
616
|
-
}
|
|
617
|
-
else if (currentField) {
|
|
618
|
-
// Check if line starts a new section header
|
|
619
|
-
if (line.match(/^#/)) {
|
|
620
|
-
currentFields[currentField] = currentValue.join('\n').trim();
|
|
621
|
-
currentField = null;
|
|
622
|
-
currentValue = [];
|
|
623
|
-
}
|
|
624
|
-
else {
|
|
625
|
-
currentValue.push(line);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
794
|
}
|
|
795
|
+
articleOutputIdx++;
|
|
629
796
|
}
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
if
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
797
|
+
// For each source group, apply collapsed content
|
|
798
|
+
for (const [articlePath, excerptInfos] of sourceGroups) {
|
|
799
|
+
// Only apply collapsed if at least one excerpt has anchors
|
|
800
|
+
const hasAnchors = excerptInfos.some((e) => e.fromAnchor || e.toAnchor);
|
|
801
|
+
if (!hasAnchors)
|
|
802
|
+
continue;
|
|
803
|
+
const articleContent = files.get(articlePath);
|
|
804
|
+
if (!articleContent)
|
|
805
|
+
continue;
|
|
806
|
+
const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map((e) => ({ from: e.fromAnchor, to: e.toAnchor })), articlePath);
|
|
807
|
+
for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
|
|
808
|
+
const segIdx = excerptInfos[i].segmentIndex;
|
|
809
|
+
const collapsed = collapsedResults[i];
|
|
810
|
+
const segment = segments[segIdx];
|
|
811
|
+
if (collapsed.collapsed_before) {
|
|
812
|
+
segment.collapsed_before = collapsed.collapsed_before;
|
|
813
|
+
}
|
|
814
|
+
if (collapsed.collapsed_after) {
|
|
815
|
+
segment.collapsed_after = collapsed.collapsed_after;
|
|
644
816
|
}
|
|
645
817
|
}
|
|
646
818
|
}
|
|
647
|
-
return lensRefs;
|
|
648
819
|
}
|
|
649
820
|
/**
|
|
650
821
|
* Convert a parsed lens segment into a final flattened segment.
|
|
651
|
-
* For article
|
|
822
|
+
* For article and video, this involves extracting content from source files.
|
|
823
|
+
* Source comes from the parsed segment itself (set via inheritance in parseLens).
|
|
652
824
|
*/
|
|
653
|
-
function convertSegment(parsedSegment,
|
|
825
|
+
function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
654
826
|
const errors = [];
|
|
655
827
|
switch (parsedSegment.type) {
|
|
656
|
-
case
|
|
828
|
+
case "text": {
|
|
829
|
+
const resolved = resolveTextLinks(parsedSegment.content, lensPath, files);
|
|
830
|
+
errors.push(...resolved.errors);
|
|
657
831
|
const segment = {
|
|
658
|
-
type:
|
|
659
|
-
content:
|
|
832
|
+
type: "text",
|
|
833
|
+
content: resolved.content,
|
|
660
834
|
};
|
|
661
835
|
if (parsedSegment.optional) {
|
|
662
836
|
segment.optional = true;
|
|
663
837
|
}
|
|
664
838
|
return { segment, errors };
|
|
665
839
|
}
|
|
666
|
-
case
|
|
840
|
+
case "chat": {
|
|
667
841
|
const segment = {
|
|
668
|
-
type:
|
|
842
|
+
type: "chat",
|
|
669
843
|
};
|
|
670
844
|
if (parsedSegment.instructions) {
|
|
671
845
|
segment.instructions = parsedSegment.instructions;
|
|
@@ -681,157 +855,161 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
681
855
|
}
|
|
682
856
|
return { segment, errors };
|
|
683
857
|
}
|
|
684
|
-
case
|
|
685
|
-
//
|
|
686
|
-
if (!
|
|
858
|
+
case "article": {
|
|
859
|
+
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
860
|
+
if (!parsedSegment.source) {
|
|
687
861
|
errors.push({
|
|
688
862
|
file: lensPath,
|
|
689
|
-
message:
|
|
690
|
-
severity:
|
|
863
|
+
message: "Article segment missing source (should have been set via inheritance)",
|
|
864
|
+
severity: "error",
|
|
691
865
|
});
|
|
692
866
|
return { segment: null, errors };
|
|
693
867
|
}
|
|
694
|
-
const wikilink = parseWikilink(
|
|
868
|
+
const wikilink = parseWikilink(parsedSegment.source);
|
|
695
869
|
if (!wikilink || wikilink.error) {
|
|
696
870
|
const suggestion = wikilink?.correctedPath
|
|
697
871
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
698
872
|
: undefined;
|
|
699
873
|
errors.push({
|
|
700
874
|
file: lensPath,
|
|
701
|
-
message: `Invalid wikilink in article source: ${
|
|
875
|
+
message: `Invalid wikilink in article source: ${parsedSegment.source}`,
|
|
702
876
|
suggestion,
|
|
703
|
-
severity:
|
|
877
|
+
severity: "error",
|
|
704
878
|
});
|
|
705
879
|
return { segment: null, errors };
|
|
706
880
|
}
|
|
707
881
|
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
708
882
|
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
709
883
|
if (!articlePath) {
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
|
|
884
|
+
const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
|
|
885
|
+
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
886
|
+
"Check the file path in the wiki-link";
|
|
713
887
|
errors.push({
|
|
714
888
|
file: lensPath,
|
|
715
889
|
message: `Referenced article file not found: ${articlePathResolved}`,
|
|
716
890
|
suggestion,
|
|
717
|
-
severity:
|
|
891
|
+
severity: "error",
|
|
718
892
|
});
|
|
719
893
|
return { segment: null, errors };
|
|
720
894
|
}
|
|
721
|
-
// Check tier violation (Lens
|
|
895
|
+
// Check tier violation (Lens -> Article)
|
|
722
896
|
if (tierMap) {
|
|
723
|
-
const parentTier = tierMap.get(lensPath) ??
|
|
724
|
-
const childTier = tierMap.get(articlePath) ??
|
|
725
|
-
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier,
|
|
897
|
+
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
898
|
+
const childTier = tierMap.get(articlePath) ?? "production";
|
|
899
|
+
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
|
|
726
900
|
if (violation) {
|
|
727
901
|
errors.push(violation);
|
|
728
902
|
}
|
|
729
|
-
if (childTier ===
|
|
903
|
+
if (childTier === "ignored") {
|
|
730
904
|
return { segment: null, errors };
|
|
731
905
|
}
|
|
732
906
|
}
|
|
733
|
-
// Check if the article path points back to an already-visited structural file
|
|
734
|
-
// This would indicate a circular reference (e.g., a lens source pointing back to an LO)
|
|
735
|
-
// Note: We only check, we don't add article paths to visitedPaths since
|
|
736
|
-
// multiple segments can legitimately reference the same article
|
|
737
907
|
if (visitedPaths.has(articlePath)) {
|
|
738
908
|
errors.push({
|
|
739
909
|
file: lensPath,
|
|
740
910
|
message: `Circular reference detected: ${articlePath}`,
|
|
741
|
-
severity:
|
|
911
|
+
severity: "error",
|
|
742
912
|
});
|
|
743
913
|
return { segment: null, errors };
|
|
744
914
|
}
|
|
745
915
|
const articleContent = files.get(articlePath);
|
|
916
|
+
// Extract article metadata from frontmatter
|
|
917
|
+
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
918
|
+
const fm = articleFrontmatter.frontmatter;
|
|
746
919
|
// Extract the excerpt
|
|
747
920
|
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
748
921
|
if (excerptResult.error) {
|
|
749
|
-
errors.push(excerptResult.error);
|
|
922
|
+
errors.push({ ...excerptResult.error, file: lensPath });
|
|
750
923
|
return { segment: null, errors };
|
|
751
924
|
}
|
|
752
925
|
const segment = {
|
|
753
|
-
type:
|
|
926
|
+
type: "article",
|
|
754
927
|
content: excerptResult.content,
|
|
755
928
|
};
|
|
929
|
+
// Populate metadata from article frontmatter
|
|
930
|
+
if (fm.title)
|
|
931
|
+
segment.title = fm.title;
|
|
932
|
+
if (fm.author) {
|
|
933
|
+
const raw = fm.author;
|
|
934
|
+
segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
|
|
935
|
+
}
|
|
936
|
+
if (fm.source_url)
|
|
937
|
+
segment.sourceUrl = fm.source_url;
|
|
938
|
+
if (fm.published)
|
|
939
|
+
segment.published = String(fm.published);
|
|
940
|
+
segment.sourcePath = articlePath;
|
|
756
941
|
if (parsedSegment.optional) {
|
|
757
942
|
segment.optional = true;
|
|
758
943
|
}
|
|
759
944
|
return { segment, errors };
|
|
760
945
|
}
|
|
761
|
-
case
|
|
762
|
-
//
|
|
763
|
-
if (!
|
|
946
|
+
case "video": {
|
|
947
|
+
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
948
|
+
if (!parsedSegment.source) {
|
|
764
949
|
errors.push({
|
|
765
950
|
file: lensPath,
|
|
766
|
-
message:
|
|
767
|
-
severity:
|
|
951
|
+
message: "Video segment missing source (should have been set via inheritance)",
|
|
952
|
+
severity: "error",
|
|
768
953
|
});
|
|
769
954
|
return { segment: null, errors };
|
|
770
955
|
}
|
|
771
|
-
const wikilink = parseWikilink(
|
|
956
|
+
const wikilink = parseWikilink(parsedSegment.source);
|
|
772
957
|
if (!wikilink || wikilink.error) {
|
|
773
958
|
const suggestion = wikilink?.correctedPath
|
|
774
959
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
775
960
|
: undefined;
|
|
776
961
|
errors.push({
|
|
777
962
|
file: lensPath,
|
|
778
|
-
message: `Invalid wikilink in video source: ${
|
|
963
|
+
message: `Invalid wikilink in video source: ${parsedSegment.source}`,
|
|
779
964
|
suggestion,
|
|
780
|
-
severity:
|
|
965
|
+
severity: "error",
|
|
781
966
|
});
|
|
782
967
|
return { segment: null, errors };
|
|
783
968
|
}
|
|
784
969
|
const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
785
970
|
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
786
971
|
if (!videoPath) {
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
972
|
+
const similarFiles = findSimilarFiles(videoPathResolved, files, "video_transcripts");
|
|
973
|
+
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
974
|
+
"Check the file path in the wiki-link";
|
|
790
975
|
errors.push({
|
|
791
976
|
file: lensPath,
|
|
792
977
|
message: `Referenced video transcript file not found: ${videoPathResolved}`,
|
|
793
978
|
suggestion,
|
|
794
|
-
severity:
|
|
979
|
+
severity: "error",
|
|
795
980
|
});
|
|
796
981
|
return { segment: null, errors };
|
|
797
982
|
}
|
|
798
|
-
// Check tier violation (Lens
|
|
983
|
+
// Check tier violation (Lens -> Video)
|
|
799
984
|
if (tierMap) {
|
|
800
|
-
const parentTier = tierMap.get(lensPath) ??
|
|
801
|
-
const childTier = tierMap.get(videoPath) ??
|
|
802
|
-
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier,
|
|
985
|
+
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
986
|
+
const childTier = tierMap.get(videoPath) ?? "production";
|
|
987
|
+
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, "video transcript");
|
|
803
988
|
if (violation) {
|
|
804
989
|
errors.push(violation);
|
|
805
990
|
}
|
|
806
|
-
if (childTier ===
|
|
991
|
+
if (childTier === "ignored") {
|
|
807
992
|
return { segment: null, errors };
|
|
808
993
|
}
|
|
809
994
|
}
|
|
810
|
-
// Check if the video path points back to an already-visited structural file
|
|
811
|
-
// This would indicate a circular reference
|
|
812
|
-
// Note: We only check, we don't add video paths to visitedPaths since
|
|
813
|
-
// multiple segments can legitimately reference the same video
|
|
814
995
|
if (visitedPaths.has(videoPath)) {
|
|
815
996
|
errors.push({
|
|
816
997
|
file: lensPath,
|
|
817
998
|
message: `Circular reference detected: ${videoPath}`,
|
|
818
|
-
severity:
|
|
999
|
+
severity: "error",
|
|
819
1000
|
});
|
|
820
1001
|
return { segment: null, errors };
|
|
821
1002
|
}
|
|
822
1003
|
const transcriptContent = files.get(videoPath);
|
|
1004
|
+
// Extract video metadata from frontmatter
|
|
1005
|
+
const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
|
|
1006
|
+
const vfm = videoFrontmatter.frontmatter;
|
|
823
1007
|
// Look for corresponding .timestamps.json file
|
|
824
|
-
|
|
825
|
-
const timestampsPath = videoPath.replace(/\.md$/, '.timestamps.json');
|
|
1008
|
+
const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
|
|
826
1009
|
let timestamps;
|
|
827
|
-
// Debug: Log path resolution (uncomment to debug)
|
|
828
|
-
// console.log('DEBUG videoPath:', videoPath);
|
|
829
|
-
// console.log('DEBUG timestampsPath:', timestampsPath);
|
|
830
|
-
// console.log('DEBUG files has timestamps:', files.has(timestampsPath));
|
|
831
1010
|
if (files.has(timestampsPath)) {
|
|
832
1011
|
try {
|
|
833
1012
|
timestamps = JSON.parse(files.get(timestampsPath));
|
|
834
|
-
// console.log('DEBUG loaded timestamps count:', timestamps.length);
|
|
835
1013
|
}
|
|
836
1014
|
catch {
|
|
837
1015
|
// JSON parse error - will fall back to inline timestamps
|
|
@@ -840,23 +1018,36 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
840
1018
|
// Extract the video excerpt
|
|
841
1019
|
const excerptResult = extractVideoExcerpt(transcriptContent, parsedSegment.fromTimeStr, parsedSegment.toTimeStr, videoPath, timestamps);
|
|
842
1020
|
if (excerptResult.error) {
|
|
843
|
-
errors.push(excerptResult.error);
|
|
1021
|
+
errors.push({ ...excerptResult.error, file: lensPath });
|
|
844
1022
|
return { segment: null, errors };
|
|
845
1023
|
}
|
|
846
1024
|
const segment = {
|
|
847
|
-
type:
|
|
1025
|
+
type: "video",
|
|
848
1026
|
from: excerptResult.from,
|
|
849
1027
|
to: excerptResult.to,
|
|
850
1028
|
transcript: excerptResult.transcript,
|
|
851
1029
|
};
|
|
1030
|
+
// Populate metadata from video transcript frontmatter
|
|
1031
|
+
if (vfm.title)
|
|
1032
|
+
segment.title = vfm.title;
|
|
1033
|
+
if (vfm.channel)
|
|
1034
|
+
segment.channel = vfm.channel;
|
|
1035
|
+
if (vfm.url) {
|
|
1036
|
+
const extracted = extractVideoFromUrl(vfm.url);
|
|
1037
|
+
if (extracted) {
|
|
1038
|
+
segment.videoId = extracted.videoId;
|
|
1039
|
+
if (extracted.isShort)
|
|
1040
|
+
segment.isShort = true;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
852
1043
|
if (parsedSegment.optional) {
|
|
853
1044
|
segment.optional = true;
|
|
854
1045
|
}
|
|
855
1046
|
return { segment, errors };
|
|
856
1047
|
}
|
|
857
|
-
case
|
|
1048
|
+
case "question": {
|
|
858
1049
|
const segment = {
|
|
859
|
-
type:
|
|
1050
|
+
type: "question",
|
|
860
1051
|
content: parsedSegment.content,
|
|
861
1052
|
};
|
|
862
1053
|
if (parsedSegment.assessmentInstructions)
|
|
@@ -873,136 +1064,105 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
873
1064
|
segment.feedback = true;
|
|
874
1065
|
return { segment, errors };
|
|
875
1066
|
}
|
|
1067
|
+
case "roleplay": {
|
|
1068
|
+
const segment = {
|
|
1069
|
+
type: "roleplay",
|
|
1070
|
+
id: parsedSegment.id,
|
|
1071
|
+
content: parsedSegment.content,
|
|
1072
|
+
aiInstructions: parsedSegment.aiInstructions,
|
|
1073
|
+
};
|
|
1074
|
+
if (parsedSegment.openingMessage)
|
|
1075
|
+
segment.openingMessage = parsedSegment.openingMessage;
|
|
1076
|
+
if (parsedSegment.assessmentInstructions)
|
|
1077
|
+
segment.assessmentInstructions = parsedSegment.assessmentInstructions;
|
|
1078
|
+
if (parsedSegment.userCustomizable)
|
|
1079
|
+
segment.userCustomizable = true;
|
|
1080
|
+
if (parsedSegment.optional)
|
|
1081
|
+
segment.optional = true;
|
|
1082
|
+
if (parsedSegment.feedback)
|
|
1083
|
+
segment.feedback = true;
|
|
1084
|
+
return { segment, errors };
|
|
1085
|
+
}
|
|
876
1086
|
default:
|
|
877
1087
|
return { segment: null, errors };
|
|
878
1088
|
}
|
|
879
1089
|
}
|
|
880
1090
|
/**
|
|
881
1091
|
* Flatten a single Lens file into a FlattenedModule.
|
|
882
|
-
*
|
|
883
|
-
* This wraps a standalone Lens as a single-section module so it can be
|
|
884
|
-
* rendered by the frontend using the existing Module.tsx component.
|
|
885
|
-
*
|
|
886
|
-
* The resulting module has:
|
|
887
|
-
* - slug: 'lens/' + fileNameToSlug(lensPath)
|
|
888
|
-
* - title: extracted from source metadata or lens header
|
|
889
|
-
* - sections: exactly one section (lens-article, lens-video, or page)
|
|
890
|
-
*
|
|
891
|
-
* @param lensPath - Path to the lens file within the files Map
|
|
892
|
-
* @param files - Map of all file paths to their content
|
|
893
|
-
* @param tierMap - Optional tier map for filtering ignored content
|
|
894
|
-
* @param preParsedLens - Optional pre-parsed lens to avoid re-parsing
|
|
895
|
-
* @returns Flattened module with a single section, plus any errors
|
|
896
1092
|
*/
|
|
897
1093
|
export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
898
1094
|
const errors = [];
|
|
899
1095
|
// Skip ignored lenses
|
|
900
|
-
if (tierMap?.get(lensPath) ===
|
|
901
|
-
return { module: null, errors };
|
|
1096
|
+
if (tierMap?.get(lensPath) === "ignored") {
|
|
1097
|
+
return { module: null, modules: [], errors };
|
|
902
1098
|
}
|
|
903
1099
|
const lensContent = files.get(lensPath);
|
|
904
1100
|
if (!lensContent) {
|
|
905
1101
|
errors.push({
|
|
906
1102
|
file: lensPath,
|
|
907
1103
|
message: `Lens file not found: ${lensPath}`,
|
|
908
|
-
severity:
|
|
1104
|
+
severity: "error",
|
|
909
1105
|
});
|
|
910
|
-
return { module: null, errors };
|
|
1106
|
+
return { module: null, modules: [], errors };
|
|
911
1107
|
}
|
|
912
1108
|
// Use pre-parsed lens if provided, otherwise parse
|
|
913
|
-
const lens = preParsedLens ??
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1109
|
+
const lens = preParsedLens ??
|
|
1110
|
+
(() => {
|
|
1111
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
1112
|
+
errors.push(...lensResult.errors);
|
|
1113
|
+
return lensResult.lens;
|
|
1114
|
+
})();
|
|
918
1115
|
if (!lens) {
|
|
919
|
-
return { module: null, errors };
|
|
1116
|
+
return { module: null, modules: [], errors };
|
|
920
1117
|
}
|
|
921
1118
|
const visitedPaths = new Set([lensPath]);
|
|
922
|
-
//
|
|
923
|
-
|
|
1119
|
+
// Flatten lens segments using shared helper
|
|
1120
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
1121
|
+
errors.push(...flattenErrors);
|
|
1122
|
+
// Derive title: frontmatter > first article/video segment > filename
|
|
924
1123
|
const meta = {};
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if (articleWikilink && !articleWikilink.error) {
|
|
934
|
-
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
935
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
936
|
-
if (articlePath) {
|
|
937
|
-
const articleContent = files.get(articlePath);
|
|
938
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
939
|
-
if (articleFrontmatter.frontmatter.title)
|
|
940
|
-
meta.title = articleFrontmatter.frontmatter.title;
|
|
941
|
-
if (articleFrontmatter.frontmatter.author)
|
|
942
|
-
meta.author = articleFrontmatter.frontmatter.author;
|
|
943
|
-
if (articleFrontmatter.frontmatter.source_url)
|
|
944
|
-
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
1124
|
+
if (lens.title) {
|
|
1125
|
+
meta.title = lens.title;
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
for (const seg of segments) {
|
|
1129
|
+
if (seg.type === "article" && seg.title) {
|
|
1130
|
+
meta.title = seg.title;
|
|
1131
|
+
break;
|
|
947
1132
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
if (lensSection.title)
|
|
952
|
-
meta.title = lensSection.title;
|
|
953
|
-
}
|
|
954
|
-
else if (lensSection.type === 'lens-video') {
|
|
955
|
-
sectionType = 'lens-video';
|
|
956
|
-
// Extract video metadata from the video transcript file's frontmatter
|
|
957
|
-
if (lensSection.source) {
|
|
958
|
-
const videoWikilink = parseWikilink(lensSection.source);
|
|
959
|
-
if (videoWikilink && !videoWikilink.error) {
|
|
960
|
-
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
961
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
962
|
-
if (videoPath) {
|
|
963
|
-
const videoContent = files.get(videoPath);
|
|
964
|
-
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
965
|
-
if (videoFrontmatter.frontmatter.title)
|
|
966
|
-
meta.title = videoFrontmatter.frontmatter.title;
|
|
967
|
-
if (videoFrontmatter.frontmatter.channel)
|
|
968
|
-
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
969
|
-
if (videoFrontmatter.frontmatter.url) {
|
|
970
|
-
const extractedId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
971
|
-
if (extractedId)
|
|
972
|
-
videoId = extractedId;
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
}
|
|
1133
|
+
else if (seg.type === "video" && seg.title) {
|
|
1134
|
+
meta.title = seg.title;
|
|
1135
|
+
break;
|
|
976
1136
|
}
|
|
977
1137
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
if (segmentResult.segment)
|
|
983
|
-
segments.push(segmentResult.segment);
|
|
1138
|
+
if (!meta.title) {
|
|
1139
|
+
meta.title = fileNameToSlug(lensPath)
|
|
1140
|
+
.replace(/-/g, " ")
|
|
1141
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
984
1142
|
}
|
|
985
1143
|
}
|
|
986
|
-
// Fallback title from filename if not extracted from source metadata
|
|
987
|
-
if (!meta.title) {
|
|
988
|
-
meta.title = fileNameToSlug(lensPath).replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
989
|
-
}
|
|
990
1144
|
const section = {
|
|
991
|
-
type:
|
|
1145
|
+
type: "lens",
|
|
992
1146
|
meta,
|
|
993
1147
|
segments,
|
|
1148
|
+
sourcePath: lensPath,
|
|
994
1149
|
optional: false,
|
|
995
1150
|
learningOutcomeId: null,
|
|
996
1151
|
learningOutcomeName: null,
|
|
997
1152
|
contentId: lens.id ?? null,
|
|
998
|
-
|
|
1153
|
+
tldr: lens.tldr,
|
|
1154
|
+
summaryForTutor: lens.summaryForTutor,
|
|
1155
|
+
addToAiContext: lens.addToAiContext,
|
|
1156
|
+
...computeSectionStats(segments),
|
|
1157
|
+
displayType: computeDisplayType(segments),
|
|
999
1158
|
};
|
|
1000
1159
|
const flattenedModule = {
|
|
1001
|
-
slug:
|
|
1160
|
+
slug: "lens/" + fileNameToSlug(lensPath),
|
|
1002
1161
|
title: meta.title ?? fileNameToSlug(lensPath),
|
|
1003
1162
|
contentId: lens.id ?? null,
|
|
1004
1163
|
sections: [section],
|
|
1164
|
+
sourcePath: lensPath,
|
|
1005
1165
|
};
|
|
1006
|
-
return { module: flattenedModule, errors };
|
|
1166
|
+
return { module: flattenedModule, modules: [flattenedModule], errors };
|
|
1007
1167
|
}
|
|
1008
1168
|
//# sourceMappingURL=index.js.map
|