lens-content-processor 0.2.2 → 0.4.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 +23 -24
- package/dist/bundler/article.js.map +1 -1
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +6 -10
- package/dist/cli.js.map +1 -1
- package/dist/content-schema.js +8 -6
- package/dist/content-schema.js.map +1 -1
- package/dist/flattener/index.d.ts +41 -2
- package/dist/flattener/index.js +699 -48
- package/dist/flattener/index.js.map +1 -1
- package/dist/flattener/test-helpers.d.ts +23 -0
- package/dist/flattener/test-helpers.js +58 -0
- package/dist/flattener/test-helpers.js.map +1 -0
- package/dist/fs/read-vault.d.ts +1 -4
- package/dist/fs/read-vault.js +4 -8
- package/dist/fs/read-vault.js.map +1 -1
- package/dist/index.d.ts +34 -6
- package/dist/index.js +170 -33
- package/dist/index.js.map +1 -1
- package/dist/parser/article.d.ts +1 -1
- package/dist/parser/article.js +14 -4
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.d.ts +2 -2
- package/dist/parser/course.js +16 -23
- package/dist/parser/course.js.map +1 -1
- package/dist/parser/learning-outcome.d.ts +11 -2
- package/dist/parser/learning-outcome.js +135 -62
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +63 -1
- package/dist/parser/lens.js +138 -11
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.d.ts +1 -1
- package/dist/parser/module.js +14 -9
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/sections.d.ts +2 -0
- package/dist/parser/sections.js +57 -21
- package/dist/parser/sections.js.map +1 -1
- package/dist/parser/video-transcript.js +3 -0
- package/dist/parser/video-transcript.js.map +1 -1
- package/dist/parser/wikilink.js +6 -1
- package/dist/parser/wikilink.js.map +1 -1
- package/dist/utils/slug.d.ts +5 -0
- package/dist/utils/slug.js +16 -0
- package/dist/utils/slug.js.map +1 -0
- package/dist/validator/chat-precedence.d.ts +9 -0
- package/dist/validator/chat-precedence.js +32 -0
- package/dist/validator/chat-precedence.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-typos.js +0 -9
- package/dist/validator/field-typos.js.map +1 -1
- package/dist/validator/field-values.js +14 -0
- package/dist/validator/field-values.js.map +1 -1
- package/dist/validator/tier.d.ts +18 -0
- package/dist/validator/tier.js +74 -0
- package/dist/validator/tier.js.map +1 -0
- package/dist/validator/url-reachability.js +3 -1
- 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 +5 -5
package/dist/flattener/index.js
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
|
+
import { checkTierViolation } from '../validator/tier.js';
|
|
1
2
|
import { parseModule, parsePageSegments } from '../parser/module.js';
|
|
2
3
|
import { parseLearningOutcome } from '../parser/learning-outcome.js';
|
|
3
4
|
import { parseLens } from '../parser/lens.js';
|
|
4
5
|
import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion } from '../parser/wikilink.js';
|
|
5
6
|
import { parseFrontmatter } from '../parser/frontmatter.js';
|
|
6
|
-
import {
|
|
7
|
+
import { fileNameToSlug } from '../utils/slug.js';
|
|
8
|
+
import { extractArticleExcerpt, bundleArticleWithCollapsed } from '../bundler/article.js';
|
|
7
9
|
import { extractVideoExcerpt } from '../bundler/video.js';
|
|
10
|
+
function countWords(text) {
|
|
11
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
12
|
+
}
|
|
13
|
+
/** Compute word count and video duration from a section's segments. */
|
|
14
|
+
function computeSectionStats(segments) {
|
|
15
|
+
let words = 0;
|
|
16
|
+
let videoSeconds = 0;
|
|
17
|
+
for (const seg of segments) {
|
|
18
|
+
if (seg.type === 'text' || seg.type === 'article-excerpt') {
|
|
19
|
+
words += countWords(seg.content);
|
|
20
|
+
}
|
|
21
|
+
else if (seg.type === 'video-excerpt') {
|
|
22
|
+
if (seg.to != null)
|
|
23
|
+
videoSeconds += seg.to - seg.from;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const stats = {};
|
|
27
|
+
if (words > 0)
|
|
28
|
+
stats.wordCount = words;
|
|
29
|
+
if (videoSeconds > 0)
|
|
30
|
+
stats.videoDurationSeconds = videoSeconds;
|
|
31
|
+
return stats;
|
|
32
|
+
}
|
|
8
33
|
/**
|
|
9
34
|
* Extract YouTube video ID from a YouTube URL.
|
|
10
35
|
*
|
|
@@ -20,6 +45,98 @@ function extractVideoIdFromUrl(url) {
|
|
|
20
45
|
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
|
|
21
46
|
return match ? match[1] : null;
|
|
22
47
|
}
|
|
48
|
+
export function isBoundary(item) {
|
|
49
|
+
return '__boundary' in item && item.__boundary === true;
|
|
50
|
+
}
|
|
51
|
+
function toKebabCase(str) {
|
|
52
|
+
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
53
|
+
}
|
|
54
|
+
export function validateBoundaries(items, file) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
const hasBoundaries = items.some(isBoundary);
|
|
57
|
+
if (!hasBoundaries)
|
|
58
|
+
return errors;
|
|
59
|
+
// Check: sections before first boundary
|
|
60
|
+
const firstBoundaryIdx = items.findIndex(isBoundary);
|
|
61
|
+
const sectionsBefore = items.slice(0, firstBoundaryIdx).filter(i => !isBoundary(i));
|
|
62
|
+
if (sectionsBefore.length > 0) {
|
|
63
|
+
errors.push({
|
|
64
|
+
file,
|
|
65
|
+
message: 'Content found outside submodule boundaries — all content must be inside a submodule',
|
|
66
|
+
severity: 'error',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Check: consecutive boundaries and trailing boundary
|
|
70
|
+
for (let i = 0; i < items.length; i++) {
|
|
71
|
+
if (isBoundary(items[i])) {
|
|
72
|
+
// Find next non-boundary item
|
|
73
|
+
let nextNonBoundary = i + 1;
|
|
74
|
+
while (nextNonBoundary < items.length && isBoundary(items[nextNonBoundary])) {
|
|
75
|
+
nextNonBoundary++;
|
|
76
|
+
}
|
|
77
|
+
// If this boundary has no sections before next boundary or end
|
|
78
|
+
if (nextNonBoundary > i + 1 || nextNonBoundary >= items.length) {
|
|
79
|
+
// nextNonBoundary > i + 1 means consecutive boundaries
|
|
80
|
+
// nextNonBoundary >= items.length means trailing boundary with no content
|
|
81
|
+
if (nextNonBoundary > i + 1) {
|
|
82
|
+
errors.push({
|
|
83
|
+
file,
|
|
84
|
+
message: `Submodule "${items[i].title}" is empty — no content before next submodule`,
|
|
85
|
+
severity: 'error',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else if (nextNonBoundary >= items.length) {
|
|
89
|
+
errors.push({
|
|
90
|
+
file,
|
|
91
|
+
message: `Submodule "${items[i].title}" is empty — no content after this marker`,
|
|
92
|
+
severity: 'error',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return errors;
|
|
99
|
+
}
|
|
100
|
+
export function splitAtBoundaries(items, parentSlug, parentTitle) {
|
|
101
|
+
const hasBoundaries = items.some(isBoundary);
|
|
102
|
+
if (!hasBoundaries) {
|
|
103
|
+
// No split — return single group without parentSlug
|
|
104
|
+
return [{
|
|
105
|
+
slug: parentSlug,
|
|
106
|
+
title: parentTitle,
|
|
107
|
+
parentSlug: undefined,
|
|
108
|
+
parentTitle: undefined,
|
|
109
|
+
sections: items.filter(i => !isBoundary(i)),
|
|
110
|
+
contentId: null,
|
|
111
|
+
}];
|
|
112
|
+
}
|
|
113
|
+
const groups = [];
|
|
114
|
+
let currentGroup = null;
|
|
115
|
+
for (const item of items) {
|
|
116
|
+
if (isBoundary(item)) {
|
|
117
|
+
if (currentGroup) {
|
|
118
|
+
groups.push(currentGroup);
|
|
119
|
+
}
|
|
120
|
+
const slug = item.customSlug ?? toKebabCase(item.title);
|
|
121
|
+
currentGroup = {
|
|
122
|
+
slug: `${parentSlug}/${slug}`,
|
|
123
|
+
title: item.title,
|
|
124
|
+
parentSlug,
|
|
125
|
+
parentTitle,
|
|
126
|
+
sections: [],
|
|
127
|
+
contentId: null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
else if (currentGroup) {
|
|
131
|
+
currentGroup.sections.push(item);
|
|
132
|
+
}
|
|
133
|
+
// sections before first boundary are orphans — handled by validateBoundaries
|
|
134
|
+
}
|
|
135
|
+
if (currentGroup) {
|
|
136
|
+
groups.push(currentGroup);
|
|
137
|
+
}
|
|
138
|
+
return groups;
|
|
139
|
+
}
|
|
23
140
|
/**
|
|
24
141
|
* Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
|
|
25
142
|
*
|
|
@@ -34,11 +151,12 @@ function extractVideoIdFromUrl(url) {
|
|
|
34
151
|
* @param visitedPaths - Optional set of already-visited paths for cycle detection
|
|
35
152
|
* @returns Flattened module with resolved sections and segments, plus any errors
|
|
36
153
|
*/
|
|
37
|
-
export function flattenModule(modulePath, files, visitedPaths = new Set()) {
|
|
154
|
+
export function flattenModule(modulePath, files, visitedPaths = new Set(), tierMap) {
|
|
38
155
|
// Check for circular reference
|
|
39
156
|
if (visitedPaths.has(modulePath)) {
|
|
40
157
|
return {
|
|
41
158
|
module: null,
|
|
159
|
+
modules: [],
|
|
42
160
|
errors: [{
|
|
43
161
|
file: modulePath,
|
|
44
162
|
message: `Circular reference detected: ${modulePath}`,
|
|
@@ -57,70 +175,295 @@ export function flattenModule(modulePath, files, visitedPaths = new Set()) {
|
|
|
57
175
|
message: `Module file not found: ${modulePath}`,
|
|
58
176
|
severity: 'error',
|
|
59
177
|
});
|
|
60
|
-
return { module: null, errors };
|
|
178
|
+
return { module: null, modules: [], errors };
|
|
61
179
|
}
|
|
62
180
|
// Parse the module
|
|
63
181
|
const moduleResult = parseModule(moduleContent, modulePath);
|
|
64
182
|
errors.push(...moduleResult.errors);
|
|
65
183
|
if (!moduleResult.module) {
|
|
66
|
-
return { module: null, errors };
|
|
184
|
+
return { module: null, modules: [], errors };
|
|
67
185
|
}
|
|
68
186
|
const parsedModule = moduleResult.module;
|
|
69
|
-
const
|
|
70
|
-
//
|
|
71
|
-
|
|
187
|
+
const flatItems = [];
|
|
188
|
+
// Helper: process a section (LO, Page, Uncategorized) and return Section[]
|
|
189
|
+
function processSection(section, subsectionLevel) {
|
|
72
190
|
if (section.type === 'learning-outcome') {
|
|
73
|
-
// Resolve the Learning Outcome reference
|
|
74
|
-
// Create a copy of visitedPaths for this section's reference chain
|
|
75
|
-
// This allows the same file to be referenced in different sections
|
|
76
|
-
// while still detecting cycles within a single chain
|
|
77
191
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
78
|
-
const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths);
|
|
192
|
+
const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths, tierMap);
|
|
79
193
|
errors.push(...result.errors);
|
|
80
|
-
|
|
194
|
+
return result.sections;
|
|
81
195
|
}
|
|
82
196
|
else if (section.type === 'page') {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const textResult = parsePageSegments(section.body, modulePath, section.line);
|
|
197
|
+
const level = subsectionLevel ?? 2;
|
|
198
|
+
const textResult = parsePageSegments(section.body, modulePath, section.line, level);
|
|
86
199
|
errors.push(...textResult.errors);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
200
|
+
return [{
|
|
201
|
+
type: 'page',
|
|
202
|
+
meta: { title: section.title },
|
|
203
|
+
segments: textResult.segments,
|
|
204
|
+
optional: section.fields.optional?.toLowerCase() === 'true',
|
|
205
|
+
contentId: section.fields.id ?? null,
|
|
206
|
+
learningOutcomeId: null,
|
|
207
|
+
learningOutcomeName: null,
|
|
208
|
+
videoId: null,
|
|
209
|
+
...computeSectionStats(textResult.segments),
|
|
210
|
+
}];
|
|
98
211
|
}
|
|
99
212
|
else if (section.type === 'uncategorized') {
|
|
100
|
-
// Uncategorized sections can contain ## Lens: references
|
|
101
|
-
// Each lens becomes its own section (lens-video, lens-article, or page)
|
|
102
213
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
103
|
-
const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths);
|
|
214
|
+
const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths, tierMap);
|
|
104
215
|
errors.push(...result.errors);
|
|
105
|
-
|
|
216
|
+
return result.sections;
|
|
106
217
|
}
|
|
218
|
+
return [];
|
|
107
219
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
220
|
+
// Process each section in the module
|
|
221
|
+
for (const section of parsedModule.sections) {
|
|
222
|
+
if (section.type === 'submodule') {
|
|
223
|
+
// Emit boundary marker
|
|
224
|
+
flatItems.push({
|
|
225
|
+
__boundary: true,
|
|
226
|
+
title: section.title,
|
|
227
|
+
customSlug: section.fields.slug,
|
|
228
|
+
});
|
|
229
|
+
// Process children at level+1
|
|
230
|
+
if (section.children) {
|
|
231
|
+
for (const child of section.children) {
|
|
232
|
+
const childSections = processSection(child, child.level + 1);
|
|
233
|
+
flatItems.push(...childSections);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
// Check if this is an LO with submodules
|
|
239
|
+
if (section.type === 'learning-outcome') {
|
|
240
|
+
const loSections = processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors);
|
|
241
|
+
if (loSections) {
|
|
242
|
+
flatItems.push(...loSections);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const sections = processSection(section);
|
|
247
|
+
flatItems.push(...sections);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Validate and split at boundaries
|
|
251
|
+
const boundaryErrors = validateBoundaries(flatItems, modulePath);
|
|
252
|
+
errors.push(...boundaryErrors);
|
|
253
|
+
const groups = splitAtBoundaries(flatItems, parsedModule.slug, parsedModule.title);
|
|
254
|
+
const resultModules = groups.map(g => ({
|
|
255
|
+
slug: g.slug,
|
|
256
|
+
title: g.title,
|
|
111
257
|
contentId: parsedModule.contentId,
|
|
112
|
-
sections:
|
|
258
|
+
sections: g.sections,
|
|
259
|
+
...(g.parentSlug ? { parentSlug: g.parentSlug, parentTitle: g.parentTitle } : {}),
|
|
260
|
+
...(moduleError ? { error: moduleError } : {}),
|
|
261
|
+
}));
|
|
262
|
+
// Backwards compat: module is the first result or a merged single module
|
|
263
|
+
const primaryModule = resultModules.length > 0 ? resultModules[0] : null;
|
|
264
|
+
return { module: resultModules.length === 1 && !resultModules[0].parentSlug ? resultModules[0] : primaryModule, modules: resultModules, errors };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Build a test Section from a ParsedTestRef with inline segments.
|
|
268
|
+
* Shared by both processLOWithSubmodules() and flattenLearningOutcomeSection().
|
|
269
|
+
*/
|
|
270
|
+
function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, tierMap, errors) {
|
|
271
|
+
if (!testRef.segments.length)
|
|
272
|
+
return null;
|
|
273
|
+
const testSegments = [];
|
|
274
|
+
const stubLensSection = {
|
|
275
|
+
type: 'page',
|
|
276
|
+
title: 'Test',
|
|
277
|
+
segments: [],
|
|
278
|
+
line: 0,
|
|
113
279
|
};
|
|
114
|
-
|
|
115
|
-
|
|
280
|
+
for (const parsedSegment of testRef.segments) {
|
|
281
|
+
const segmentResult = convertSegment(parsedSegment, stubLensSection, loPath, files, new Set(visitedPaths), tierMap);
|
|
282
|
+
errors.push(...segmentResult.errors);
|
|
283
|
+
if (segmentResult.segment) {
|
|
284
|
+
testSegments.push(segmentResult.segment);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (testSegments.length === 0)
|
|
288
|
+
return null;
|
|
289
|
+
const hasFeedback = testSegments.some((s) => s.type === 'question' && s.feedback);
|
|
290
|
+
return {
|
|
291
|
+
type: 'test',
|
|
292
|
+
meta: { title: 'Test' },
|
|
293
|
+
segments: testSegments,
|
|
294
|
+
optional: false,
|
|
295
|
+
...(hasFeedback && { feedback: true }),
|
|
296
|
+
contentId: null,
|
|
297
|
+
learningOutcomeId: loId,
|
|
298
|
+
learningOutcomeName: loName,
|
|
299
|
+
videoId: null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Check if a Learning Outcome section resolves to an LO with submodules.
|
|
304
|
+
* If so, emit boundary markers + sections. Returns null if no submodules.
|
|
305
|
+
*/
|
|
306
|
+
function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors) {
|
|
307
|
+
// Resolve the LO file to check for submodules
|
|
308
|
+
const source = section.fields.source;
|
|
309
|
+
if (!source)
|
|
310
|
+
return null;
|
|
311
|
+
const wikilink = parseWikilink(source);
|
|
312
|
+
if (!wikilink || wikilink.error)
|
|
313
|
+
return null;
|
|
314
|
+
const loPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
|
|
315
|
+
const loPath = findFileWithExtension(loPathResolved, files);
|
|
316
|
+
if (!loPath)
|
|
317
|
+
return null;
|
|
318
|
+
const loContent = files.get(loPath);
|
|
319
|
+
if (!loContent)
|
|
320
|
+
return null;
|
|
321
|
+
const loResult = parseLearningOutcome(loContent, loPath);
|
|
322
|
+
if (!loResult.learningOutcome?.submodules)
|
|
323
|
+
return null;
|
|
324
|
+
// This LO has submodules — emit boundaries and resolve lenses
|
|
325
|
+
const items = [];
|
|
326
|
+
for (const sub of loResult.learningOutcome.submodules) {
|
|
327
|
+
items.push({
|
|
328
|
+
__boundary: true,
|
|
329
|
+
title: sub.title,
|
|
330
|
+
customSlug: sub.customSlug,
|
|
331
|
+
});
|
|
332
|
+
// Resolve each lens in this submodule group
|
|
333
|
+
for (const lensRef of sub.lenses) {
|
|
334
|
+
const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
|
|
335
|
+
if (!lensPath) {
|
|
336
|
+
const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, 'Lenses');
|
|
337
|
+
const suggestion = formatSuggestion(similarFiles, loPath) ?? 'Check the file path in the wiki-link';
|
|
338
|
+
errors.push({
|
|
339
|
+
file: loPath,
|
|
340
|
+
message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
|
|
341
|
+
suggestion,
|
|
342
|
+
severity: 'error',
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// Check tier violation
|
|
347
|
+
if (tierMap) {
|
|
348
|
+
const parentTier = tierMap.get(loPath) ?? 'production';
|
|
349
|
+
const childTier = tierMap.get(lensPath) ?? 'production';
|
|
350
|
+
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, 'lens');
|
|
351
|
+
if (violation) {
|
|
352
|
+
errors.push(violation);
|
|
353
|
+
}
|
|
354
|
+
if (childTier === 'ignored')
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (visitedPaths.has(lensPath)) {
|
|
358
|
+
errors.push({
|
|
359
|
+
file: loPath,
|
|
360
|
+
message: `Circular reference detected: ${lensPath}`,
|
|
361
|
+
severity: 'error',
|
|
362
|
+
});
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const sectionVisitedPaths = new Set(visitedPaths);
|
|
366
|
+
sectionVisitedPaths.add(lensPath);
|
|
367
|
+
const lensContent = files.get(lensPath);
|
|
368
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
369
|
+
errors.push(...lensResult.errors);
|
|
370
|
+
if (!lensResult.lens)
|
|
371
|
+
continue;
|
|
372
|
+
const lens = lensResult.lens;
|
|
373
|
+
let sectionType = 'page';
|
|
374
|
+
const meta = { title: section.title };
|
|
375
|
+
const segments = [];
|
|
376
|
+
let videoId;
|
|
377
|
+
for (const lensSection of lens.sections) {
|
|
378
|
+
if (lensSection.type === 'lens-article') {
|
|
379
|
+
sectionType = 'lens-article';
|
|
380
|
+
if (lensSection.source) {
|
|
381
|
+
const articleWikilink = parseWikilink(lensSection.source);
|
|
382
|
+
if (articleWikilink && !articleWikilink.error) {
|
|
383
|
+
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
384
|
+
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
385
|
+
if (articlePath) {
|
|
386
|
+
const articleContent = files.get(articlePath);
|
|
387
|
+
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
388
|
+
if (articleFrontmatter.frontmatter.title)
|
|
389
|
+
meta.title = articleFrontmatter.frontmatter.title;
|
|
390
|
+
if (articleFrontmatter.frontmatter.author) {
|
|
391
|
+
const raw = articleFrontmatter.frontmatter.author;
|
|
392
|
+
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
393
|
+
}
|
|
394
|
+
if (articleFrontmatter.frontmatter.source_url)
|
|
395
|
+
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (lensSection.type === 'page') {
|
|
401
|
+
sectionType = 'page';
|
|
402
|
+
if (lensSection.title)
|
|
403
|
+
meta.title = lensSection.title;
|
|
404
|
+
}
|
|
405
|
+
else if (lensSection.type === 'lens-video') {
|
|
406
|
+
sectionType = 'lens-video';
|
|
407
|
+
if (lensSection.source) {
|
|
408
|
+
const videoWikilink = parseWikilink(lensSection.source);
|
|
409
|
+
if (videoWikilink && !videoWikilink.error) {
|
|
410
|
+
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
411
|
+
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
412
|
+
if (videoPath) {
|
|
413
|
+
const videoContent = files.get(videoPath);
|
|
414
|
+
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
415
|
+
if (videoFrontmatter.frontmatter.title)
|
|
416
|
+
meta.title = videoFrontmatter.frontmatter.title;
|
|
417
|
+
if (videoFrontmatter.frontmatter.channel)
|
|
418
|
+
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
419
|
+
if (videoFrontmatter.frontmatter.url) {
|
|
420
|
+
const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
421
|
+
if (extractedVideoId)
|
|
422
|
+
videoId = extractedVideoId;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const parsedSegment of lensSection.segments) {
|
|
429
|
+
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, sectionVisitedPaths, tierMap);
|
|
430
|
+
errors.push(...segmentResult.errors);
|
|
431
|
+
if (segmentResult.segment)
|
|
432
|
+
segments.push(segmentResult.segment);
|
|
433
|
+
}
|
|
434
|
+
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
435
|
+
}
|
|
436
|
+
const lo = loResult.learningOutcome;
|
|
437
|
+
items.push({
|
|
438
|
+
type: sectionType,
|
|
439
|
+
meta,
|
|
440
|
+
segments,
|
|
441
|
+
optional: section.fields.optional?.toLowerCase() === 'true' || lensRef.optional,
|
|
442
|
+
learningOutcomeId: lo.id ?? null,
|
|
443
|
+
learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
|
|
444
|
+
contentId: lens.id ?? null,
|
|
445
|
+
tldr: lens.tldr,
|
|
446
|
+
videoId: videoId ?? null,
|
|
447
|
+
...computeSectionStats(segments),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
// Process test section if present in this submodule
|
|
451
|
+
if (sub.test) {
|
|
452
|
+
const lo = loResult.learningOutcome;
|
|
453
|
+
const testSection = buildTestSection(sub.test, lo.id ?? null, loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null, loPath, files, visitedPaths, tierMap, errors);
|
|
454
|
+
if (testSection)
|
|
455
|
+
items.push(testSection);
|
|
456
|
+
}
|
|
116
457
|
}
|
|
117
|
-
|
|
458
|
+
// Also emit LO parse errors (we skipped earlier since we returned non-null)
|
|
459
|
+
errors.push(...loResult.errors);
|
|
460
|
+
return items;
|
|
118
461
|
}
|
|
119
462
|
/**
|
|
120
463
|
* Flatten a Learning Outcome section by resolving its LO file and all referenced lenses.
|
|
121
464
|
* Each lens in the LO becomes its own section.
|
|
122
465
|
*/
|
|
123
|
-
function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths) {
|
|
466
|
+
function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths, tierMap) {
|
|
124
467
|
const errors = [];
|
|
125
468
|
const sections = [];
|
|
126
469
|
// Get the source wikilink
|
|
@@ -144,7 +487,9 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
|
|
|
144
487
|
errors.push({
|
|
145
488
|
file: modulePath,
|
|
146
489
|
line: section.line,
|
|
147
|
-
message:
|
|
490
|
+
message: wikilink?.error
|
|
491
|
+
? `${wikilink.error}: ${source}`
|
|
492
|
+
: `Invalid wikilink format: ${source}`,
|
|
148
493
|
suggestion,
|
|
149
494
|
severity: 'error',
|
|
150
495
|
});
|
|
@@ -166,6 +511,19 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
|
|
|
166
511
|
});
|
|
167
512
|
return { sections: [], errors };
|
|
168
513
|
}
|
|
514
|
+
// Check tier violation (module → LO)
|
|
515
|
+
if (tierMap) {
|
|
516
|
+
const parentTier = tierMap.get(modulePath) ?? 'production';
|
|
517
|
+
const childTier = tierMap.get(loPath) ?? 'production';
|
|
518
|
+
const violation = checkTierViolation(modulePath, parentTier, loPath, childTier, 'learning outcome', section.line);
|
|
519
|
+
if (violation) {
|
|
520
|
+
errors.push(violation);
|
|
521
|
+
}
|
|
522
|
+
// Skip ignored children silently (they're not processed)
|
|
523
|
+
if (childTier === 'ignored') {
|
|
524
|
+
return { sections: [], errors };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
169
527
|
// Check for circular reference
|
|
170
528
|
if (visitedPaths.has(loPath)) {
|
|
171
529
|
errors.push({
|
|
@@ -200,6 +558,18 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
|
|
|
200
558
|
});
|
|
201
559
|
continue;
|
|
202
560
|
}
|
|
561
|
+
// Check tier violation (LO → Lens)
|
|
562
|
+
if (tierMap) {
|
|
563
|
+
const parentTier = tierMap.get(loPath) ?? 'production';
|
|
564
|
+
const childTier = tierMap.get(lensPath) ?? 'production';
|
|
565
|
+
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, 'lens');
|
|
566
|
+
if (violation) {
|
|
567
|
+
errors.push(violation);
|
|
568
|
+
}
|
|
569
|
+
if (childTier === 'ignored') {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
203
573
|
// Check for circular reference
|
|
204
574
|
if (visitedPaths.has(lensPath)) {
|
|
205
575
|
errors.push({
|
|
@@ -242,11 +612,15 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
|
|
|
242
612
|
meta.title = articleFrontmatter.frontmatter.title;
|
|
243
613
|
}
|
|
244
614
|
if (articleFrontmatter.frontmatter.author) {
|
|
245
|
-
|
|
615
|
+
const raw = articleFrontmatter.frontmatter.author;
|
|
616
|
+
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
246
617
|
}
|
|
247
618
|
if (articleFrontmatter.frontmatter.source_url) {
|
|
248
619
|
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
249
620
|
}
|
|
621
|
+
if (articleFrontmatter.frontmatter.published) {
|
|
622
|
+
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
623
|
+
}
|
|
250
624
|
}
|
|
251
625
|
}
|
|
252
626
|
}
|
|
@@ -289,12 +663,14 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
|
|
|
289
663
|
}
|
|
290
664
|
// Process segments
|
|
291
665
|
for (const parsedSegment of lensSection.segments) {
|
|
292
|
-
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
|
|
666
|
+
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
|
|
293
667
|
errors.push(...segmentResult.errors);
|
|
294
668
|
if (segmentResult.segment) {
|
|
295
669
|
segments.push(segmentResult.segment);
|
|
296
670
|
}
|
|
297
671
|
}
|
|
672
|
+
// Apply collapsed content to article-excerpt segments
|
|
673
|
+
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
298
674
|
}
|
|
299
675
|
// Create a section for this lens
|
|
300
676
|
// Optional can come from either:
|
|
@@ -308,17 +684,25 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
|
|
|
308
684
|
learningOutcomeId: lo.id ?? null,
|
|
309
685
|
learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
|
|
310
686
|
contentId: lens.id ?? null,
|
|
687
|
+
tldr: lens.tldr,
|
|
311
688
|
videoId: videoId ?? null,
|
|
689
|
+
...computeSectionStats(segments),
|
|
312
690
|
};
|
|
313
691
|
sections.push(resultSection);
|
|
314
692
|
}
|
|
693
|
+
// After lens processing, add test section if present with inline segments
|
|
694
|
+
if (lo.test) {
|
|
695
|
+
const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null, loPath, files, visitedPaths, tierMap, errors);
|
|
696
|
+
if (testSection)
|
|
697
|
+
sections.push(testSection);
|
|
698
|
+
}
|
|
315
699
|
return { sections, errors };
|
|
316
700
|
}
|
|
317
701
|
/**
|
|
318
702
|
* Flatten an Uncategorized section by parsing its ## Lens: references.
|
|
319
703
|
* Each lens becomes its own section with the appropriate type (lens-video, lens-article, or page).
|
|
320
704
|
*/
|
|
321
|
-
function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
|
|
705
|
+
function flattenUncategorizedSection(section, modulePath, files, visitedPaths, tierMap) {
|
|
322
706
|
const errors = [];
|
|
323
707
|
const sections = [];
|
|
324
708
|
// Parse the section body for ## Lens: subsections
|
|
@@ -349,6 +733,18 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
|
|
|
349
733
|
});
|
|
350
734
|
continue;
|
|
351
735
|
}
|
|
736
|
+
// Check tier violation (Uncategorized/Module → Lens)
|
|
737
|
+
if (tierMap) {
|
|
738
|
+
const parentTier = tierMap.get(modulePath) ?? 'production';
|
|
739
|
+
const childTier = tierMap.get(lensPath) ?? 'production';
|
|
740
|
+
const violation = checkTierViolation(modulePath, parentTier, lensPath, childTier, 'lens');
|
|
741
|
+
if (violation) {
|
|
742
|
+
errors.push(violation);
|
|
743
|
+
}
|
|
744
|
+
if (childTier === 'ignored') {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
352
748
|
// Check for circular reference
|
|
353
749
|
if (visitedPaths.has(lensPath)) {
|
|
354
750
|
errors.push({
|
|
@@ -391,11 +787,15 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
|
|
|
391
787
|
meta.title = articleFrontmatter.frontmatter.title;
|
|
392
788
|
}
|
|
393
789
|
if (articleFrontmatter.frontmatter.author) {
|
|
394
|
-
|
|
790
|
+
const raw = articleFrontmatter.frontmatter.author;
|
|
791
|
+
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
395
792
|
}
|
|
396
793
|
if (articleFrontmatter.frontmatter.source_url) {
|
|
397
794
|
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
398
795
|
}
|
|
796
|
+
if (articleFrontmatter.frontmatter.published) {
|
|
797
|
+
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
798
|
+
}
|
|
399
799
|
}
|
|
400
800
|
}
|
|
401
801
|
}
|
|
@@ -438,12 +838,14 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
|
|
|
438
838
|
}
|
|
439
839
|
// Process segments
|
|
440
840
|
for (const parsedSegment of lensSection.segments) {
|
|
441
|
-
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
|
|
841
|
+
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
|
|
442
842
|
errors.push(...segmentResult.errors);
|
|
443
843
|
if (segmentResult.segment) {
|
|
444
844
|
segments.push(segmentResult.segment);
|
|
445
845
|
}
|
|
446
846
|
}
|
|
847
|
+
// Apply collapsed content to article-excerpt segments
|
|
848
|
+
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
447
849
|
}
|
|
448
850
|
// Create a section for this lens
|
|
449
851
|
const resultSection = {
|
|
@@ -454,7 +856,9 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
|
|
|
454
856
|
learningOutcomeId: null,
|
|
455
857
|
learningOutcomeName: null,
|
|
456
858
|
contentId: lens.id ?? null,
|
|
859
|
+
tldr: lens.tldr,
|
|
457
860
|
videoId: videoId ?? null,
|
|
861
|
+
...computeSectionStats(segments),
|
|
458
862
|
};
|
|
459
863
|
sections.push(resultSection);
|
|
460
864
|
}
|
|
@@ -570,11 +974,64 @@ function parseUncategorizedLensRefs(body, parentPath) {
|
|
|
570
974
|
}
|
|
571
975
|
return lensRefs;
|
|
572
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* After converting segments, apply collapsed_before/collapsed_after to article-excerpt segments.
|
|
979
|
+
* This resolves the article source once and calls bundleArticleWithCollapsed to compute
|
|
980
|
+
* which parts of the article are outside the excerpted ranges.
|
|
981
|
+
*/
|
|
982
|
+
function applyCollapsedContent(segments, parsedSegments, lensSection, lensPath, files) {
|
|
983
|
+
// Only process lens-article sections with article-excerpt segments
|
|
984
|
+
if (lensSection.type !== 'lens-article' || !lensSection.source)
|
|
985
|
+
return;
|
|
986
|
+
// Collect article-excerpt segment indices
|
|
987
|
+
const excerptIndices = [];
|
|
988
|
+
for (let i = 0; i < segments.length; i++) {
|
|
989
|
+
if (segments[i].type === 'article-excerpt') {
|
|
990
|
+
excerptIndices.push(i);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (excerptIndices.length === 0)
|
|
994
|
+
return;
|
|
995
|
+
// Map to parsed segments to get anchors (order-preserving)
|
|
996
|
+
const parsedExcerpts = parsedSegments.filter(ps => ps.type === 'article-excerpt');
|
|
997
|
+
const excerptInfos = excerptIndices.map((segIdx, i) => ({
|
|
998
|
+
index: segIdx,
|
|
999
|
+
from: parsedExcerpts[i]?.fromAnchor,
|
|
1000
|
+
to: parsedExcerpts[i]?.toAnchor,
|
|
1001
|
+
}));
|
|
1002
|
+
// Only apply collapsed if at least one excerpt has anchors
|
|
1003
|
+
const hasAnchors = excerptInfos.some(e => e.from || e.to);
|
|
1004
|
+
if (!hasAnchors)
|
|
1005
|
+
return;
|
|
1006
|
+
// Resolve article path
|
|
1007
|
+
const wikilink = parseWikilink(lensSection.source);
|
|
1008
|
+
if (!wikilink || wikilink.error)
|
|
1009
|
+
return;
|
|
1010
|
+
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1011
|
+
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1012
|
+
if (!articlePath)
|
|
1013
|
+
return;
|
|
1014
|
+
const articleContent = files.get(articlePath);
|
|
1015
|
+
// Call bundleArticleWithCollapsed
|
|
1016
|
+
const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map(e => ({ from: e.from, to: e.to })), articlePath);
|
|
1017
|
+
// Overlay collapsed_before/collapsed_after onto segments
|
|
1018
|
+
for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
|
|
1019
|
+
const segIdx = excerptInfos[i].index;
|
|
1020
|
+
const collapsed = collapsedResults[i];
|
|
1021
|
+
const segment = segments[segIdx];
|
|
1022
|
+
if (collapsed.collapsed_before) {
|
|
1023
|
+
segment.collapsed_before = collapsed.collapsed_before;
|
|
1024
|
+
}
|
|
1025
|
+
if (collapsed.collapsed_after) {
|
|
1026
|
+
segment.collapsed_after = collapsed.collapsed_after;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
573
1030
|
/**
|
|
574
1031
|
* Convert a parsed lens segment into a final flattened segment.
|
|
575
1032
|
* For article-excerpt and video-excerpt, this involves extracting content from source files.
|
|
576
1033
|
*/
|
|
577
|
-
function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths) {
|
|
1034
|
+
function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap) {
|
|
578
1035
|
const errors = [];
|
|
579
1036
|
switch (parsedSegment.type) {
|
|
580
1037
|
case 'text': {
|
|
@@ -642,6 +1099,18 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
642
1099
|
});
|
|
643
1100
|
return { segment: null, errors };
|
|
644
1101
|
}
|
|
1102
|
+
// Check tier violation (Lens → Article)
|
|
1103
|
+
if (tierMap) {
|
|
1104
|
+
const parentTier = tierMap.get(lensPath) ?? 'production';
|
|
1105
|
+
const childTier = tierMap.get(articlePath) ?? 'production';
|
|
1106
|
+
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, 'article');
|
|
1107
|
+
if (violation) {
|
|
1108
|
+
errors.push(violation);
|
|
1109
|
+
}
|
|
1110
|
+
if (childTier === 'ignored') {
|
|
1111
|
+
return { segment: null, errors };
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
645
1114
|
// Check if the article path points back to an already-visited structural file
|
|
646
1115
|
// This would indicate a circular reference (e.g., a lens source pointing back to an LO)
|
|
647
1116
|
// Note: We only check, we don't add article paths to visitedPaths since
|
|
@@ -658,7 +1127,7 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
658
1127
|
// Extract the excerpt
|
|
659
1128
|
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
660
1129
|
if (excerptResult.error) {
|
|
661
|
-
errors.push(excerptResult.error);
|
|
1130
|
+
errors.push({ ...excerptResult.error, file: lensPath });
|
|
662
1131
|
return { segment: null, errors };
|
|
663
1132
|
}
|
|
664
1133
|
const segment = {
|
|
@@ -707,6 +1176,18 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
707
1176
|
});
|
|
708
1177
|
return { segment: null, errors };
|
|
709
1178
|
}
|
|
1179
|
+
// Check tier violation (Lens → Video)
|
|
1180
|
+
if (tierMap) {
|
|
1181
|
+
const parentTier = tierMap.get(lensPath) ?? 'production';
|
|
1182
|
+
const childTier = tierMap.get(videoPath) ?? 'production';
|
|
1183
|
+
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, 'video transcript');
|
|
1184
|
+
if (violation) {
|
|
1185
|
+
errors.push(violation);
|
|
1186
|
+
}
|
|
1187
|
+
if (childTier === 'ignored') {
|
|
1188
|
+
return { segment: null, errors };
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
710
1191
|
// Check if the video path points back to an already-visited structural file
|
|
711
1192
|
// This would indicate a circular reference
|
|
712
1193
|
// Note: We only check, we don't add video paths to visitedPaths since
|
|
@@ -740,7 +1221,7 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
740
1221
|
// Extract the video excerpt
|
|
741
1222
|
const excerptResult = extractVideoExcerpt(transcriptContent, parsedSegment.fromTimeStr, parsedSegment.toTimeStr, videoPath, timestamps);
|
|
742
1223
|
if (excerptResult.error) {
|
|
743
|
-
errors.push(excerptResult.error);
|
|
1224
|
+
errors.push({ ...excerptResult.error, file: lensPath });
|
|
744
1225
|
return { segment: null, errors };
|
|
745
1226
|
}
|
|
746
1227
|
const segment = {
|
|
@@ -754,8 +1235,178 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
754
1235
|
}
|
|
755
1236
|
return { segment, errors };
|
|
756
1237
|
}
|
|
1238
|
+
case 'question': {
|
|
1239
|
+
const segment = {
|
|
1240
|
+
type: 'question',
|
|
1241
|
+
content: parsedSegment.content,
|
|
1242
|
+
};
|
|
1243
|
+
if (parsedSegment.assessmentInstructions)
|
|
1244
|
+
segment.assessmentInstructions = parsedSegment.assessmentInstructions;
|
|
1245
|
+
if (parsedSegment.maxTime)
|
|
1246
|
+
segment.maxTime = parsedSegment.maxTime;
|
|
1247
|
+
if (parsedSegment.maxChars !== undefined)
|
|
1248
|
+
segment.maxChars = parsedSegment.maxChars;
|
|
1249
|
+
if (parsedSegment.enforceVoice)
|
|
1250
|
+
segment.enforceVoice = true;
|
|
1251
|
+
if (parsedSegment.optional)
|
|
1252
|
+
segment.optional = true;
|
|
1253
|
+
if (parsedSegment.feedback)
|
|
1254
|
+
segment.feedback = true;
|
|
1255
|
+
return { segment, errors };
|
|
1256
|
+
}
|
|
1257
|
+
case 'roleplay': {
|
|
1258
|
+
const segment = {
|
|
1259
|
+
type: 'roleplay',
|
|
1260
|
+
id: parsedSegment.id,
|
|
1261
|
+
content: parsedSegment.content,
|
|
1262
|
+
aiInstructions: parsedSegment.aiInstructions,
|
|
1263
|
+
};
|
|
1264
|
+
if (parsedSegment.openingMessage)
|
|
1265
|
+
segment.openingMessage = parsedSegment.openingMessage;
|
|
1266
|
+
if (parsedSegment.assessmentInstructions)
|
|
1267
|
+
segment.assessmentInstructions = parsedSegment.assessmentInstructions;
|
|
1268
|
+
if (parsedSegment.optional)
|
|
1269
|
+
segment.optional = true;
|
|
1270
|
+
if (parsedSegment.feedback)
|
|
1271
|
+
segment.feedback = true;
|
|
1272
|
+
return { segment, errors };
|
|
1273
|
+
}
|
|
757
1274
|
default:
|
|
758
1275
|
return { segment: null, errors };
|
|
759
1276
|
}
|
|
760
1277
|
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Flatten a single Lens file into a FlattenedModule.
|
|
1280
|
+
*
|
|
1281
|
+
* This wraps a standalone Lens as a single-section module so it can be
|
|
1282
|
+
* rendered by the frontend using the existing Module.tsx component.
|
|
1283
|
+
*
|
|
1284
|
+
* The resulting module has:
|
|
1285
|
+
* - slug: 'lens/' + fileNameToSlug(lensPath)
|
|
1286
|
+
* - title: extracted from source metadata or lens header
|
|
1287
|
+
* - sections: exactly one section (lens-article, lens-video, or page)
|
|
1288
|
+
*
|
|
1289
|
+
* @param lensPath - Path to the lens file within the files Map
|
|
1290
|
+
* @param files - Map of all file paths to their content
|
|
1291
|
+
* @param tierMap - Optional tier map for filtering ignored content
|
|
1292
|
+
* @param preParsedLens - Optional pre-parsed lens to avoid re-parsing
|
|
1293
|
+
* @returns Flattened module with a single section, plus any errors
|
|
1294
|
+
*/
|
|
1295
|
+
export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
1296
|
+
const errors = [];
|
|
1297
|
+
// Skip ignored lenses
|
|
1298
|
+
if (tierMap?.get(lensPath) === 'ignored') {
|
|
1299
|
+
return { module: null, modules: [], errors };
|
|
1300
|
+
}
|
|
1301
|
+
const lensContent = files.get(lensPath);
|
|
1302
|
+
if (!lensContent) {
|
|
1303
|
+
errors.push({
|
|
1304
|
+
file: lensPath,
|
|
1305
|
+
message: `Lens file not found: ${lensPath}`,
|
|
1306
|
+
severity: 'error',
|
|
1307
|
+
});
|
|
1308
|
+
return { module: null, modules: [], errors };
|
|
1309
|
+
}
|
|
1310
|
+
// Use pre-parsed lens if provided, otherwise parse
|
|
1311
|
+
const lens = preParsedLens ?? (() => {
|
|
1312
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
1313
|
+
errors.push(...lensResult.errors);
|
|
1314
|
+
return lensResult.lens;
|
|
1315
|
+
})();
|
|
1316
|
+
if (!lens) {
|
|
1317
|
+
return { module: null, modules: [], errors };
|
|
1318
|
+
}
|
|
1319
|
+
const visitedPaths = new Set([lensPath]);
|
|
1320
|
+
// Process lens sections into a single flattened Section
|
|
1321
|
+
let sectionType = 'page';
|
|
1322
|
+
const meta = {};
|
|
1323
|
+
const segments = [];
|
|
1324
|
+
let videoId;
|
|
1325
|
+
for (const lensSection of lens.sections) {
|
|
1326
|
+
if (lensSection.type === 'lens-article') {
|
|
1327
|
+
sectionType = 'lens-article';
|
|
1328
|
+
// Extract article metadata from the article file's frontmatter
|
|
1329
|
+
if (lensSection.source) {
|
|
1330
|
+
const articleWikilink = parseWikilink(lensSection.source);
|
|
1331
|
+
if (articleWikilink && !articleWikilink.error) {
|
|
1332
|
+
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
1333
|
+
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1334
|
+
if (articlePath) {
|
|
1335
|
+
const articleContent = files.get(articlePath);
|
|
1336
|
+
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
1337
|
+
if (articleFrontmatter.frontmatter.title)
|
|
1338
|
+
meta.title = articleFrontmatter.frontmatter.title;
|
|
1339
|
+
if (articleFrontmatter.frontmatter.author)
|
|
1340
|
+
meta.author = articleFrontmatter.frontmatter.author;
|
|
1341
|
+
if (articleFrontmatter.frontmatter.source_url)
|
|
1342
|
+
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
1343
|
+
if (articleFrontmatter.frontmatter.published)
|
|
1344
|
+
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
else if (lensSection.type === 'page') {
|
|
1350
|
+
sectionType = 'page';
|
|
1351
|
+
if (lensSection.title)
|
|
1352
|
+
meta.title = lensSection.title;
|
|
1353
|
+
}
|
|
1354
|
+
else if (lensSection.type === 'lens-video') {
|
|
1355
|
+
sectionType = 'lens-video';
|
|
1356
|
+
// Extract video metadata from the video transcript file's frontmatter
|
|
1357
|
+
if (lensSection.source) {
|
|
1358
|
+
const videoWikilink = parseWikilink(lensSection.source);
|
|
1359
|
+
if (videoWikilink && !videoWikilink.error) {
|
|
1360
|
+
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
1361
|
+
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
1362
|
+
if (videoPath) {
|
|
1363
|
+
const videoContent = files.get(videoPath);
|
|
1364
|
+
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
1365
|
+
if (videoFrontmatter.frontmatter.title)
|
|
1366
|
+
meta.title = videoFrontmatter.frontmatter.title;
|
|
1367
|
+
if (videoFrontmatter.frontmatter.channel)
|
|
1368
|
+
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
1369
|
+
if (videoFrontmatter.frontmatter.url) {
|
|
1370
|
+
const extractedId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
1371
|
+
if (extractedId)
|
|
1372
|
+
videoId = extractedId;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
// Process segments
|
|
1379
|
+
for (const parsedSegment of lensSection.segments) {
|
|
1380
|
+
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
|
|
1381
|
+
errors.push(...segmentResult.errors);
|
|
1382
|
+
if (segmentResult.segment)
|
|
1383
|
+
segments.push(segmentResult.segment);
|
|
1384
|
+
}
|
|
1385
|
+
// Apply collapsed content to article-excerpt segments
|
|
1386
|
+
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
1387
|
+
}
|
|
1388
|
+
// Fallback title from filename if not extracted from source metadata
|
|
1389
|
+
if (!meta.title) {
|
|
1390
|
+
meta.title = fileNameToSlug(lensPath).replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
1391
|
+
}
|
|
1392
|
+
const section = {
|
|
1393
|
+
type: sectionType,
|
|
1394
|
+
meta,
|
|
1395
|
+
segments,
|
|
1396
|
+
optional: false,
|
|
1397
|
+
learningOutcomeId: null,
|
|
1398
|
+
learningOutcomeName: null,
|
|
1399
|
+
contentId: lens.id ?? null,
|
|
1400
|
+
tldr: lens.tldr,
|
|
1401
|
+
videoId: videoId ?? null,
|
|
1402
|
+
...computeSectionStats(segments),
|
|
1403
|
+
};
|
|
1404
|
+
const flattenedModule = {
|
|
1405
|
+
slug: 'lens/' + fileNameToSlug(lensPath),
|
|
1406
|
+
title: meta.title ?? fileNameToSlug(lensPath),
|
|
1407
|
+
contentId: lens.id ?? null,
|
|
1408
|
+
sections: [section],
|
|
1409
|
+
};
|
|
1410
|
+
return { module: flattenedModule, modules: [flattenedModule], errors };
|
|
1411
|
+
}
|
|
761
1412
|
//# sourceMappingURL=index.js.map
|