lens-content-processor 0.3.0 → 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 +19 -8
- package/dist/bundler/article.js.map +1 -1
- package/dist/content-schema.js +3 -2
- package/dist/content-schema.js.map +1 -1
- package/dist/flattener/index.d.ts +20 -1
- package/dist/flattener/index.js +476 -72
- 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/index.d.ts +18 -2
- package/dist/index.js +53 -23
- package/dist/index.js.map +1 -1
- package/dist/parser/article.d.ts +1 -1
- package/dist/parser/article.js +8 -1
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.js +11 -8
- package/dist/parser/course.js.map +1 -1
- package/dist/parser/learning-outcome.d.ts +7 -0
- package/dist/parser/learning-outcome.js +129 -86
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +26 -1
- package/dist/parser/lens.js +99 -10
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.d.ts +1 -1
- package/dist/parser/module.js +13 -8
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/sections.d.ts +2 -0
- package/dist/parser/sections.js +53 -19
- 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/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/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 +1 -1
package/dist/flattener/index.js
CHANGED
|
@@ -5,8 +5,31 @@ import { parseLens } from '../parser/lens.js';
|
|
|
5
5
|
import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion } from '../parser/wikilink.js';
|
|
6
6
|
import { parseFrontmatter } from '../parser/frontmatter.js';
|
|
7
7
|
import { fileNameToSlug } from '../utils/slug.js';
|
|
8
|
-
import { extractArticleExcerpt } from '../bundler/article.js';
|
|
8
|
+
import { extractArticleExcerpt, bundleArticleWithCollapsed } from '../bundler/article.js';
|
|
9
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
|
+
}
|
|
10
33
|
/**
|
|
11
34
|
* Extract YouTube video ID from a YouTube URL.
|
|
12
35
|
*
|
|
@@ -22,6 +45,98 @@ function extractVideoIdFromUrl(url) {
|
|
|
22
45
|
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
|
|
23
46
|
return match ? match[1] : null;
|
|
24
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
|
+
}
|
|
25
140
|
/**
|
|
26
141
|
* Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
|
|
27
142
|
*
|
|
@@ -41,6 +156,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
41
156
|
if (visitedPaths.has(modulePath)) {
|
|
42
157
|
return {
|
|
43
158
|
module: null,
|
|
159
|
+
modules: [],
|
|
44
160
|
errors: [{
|
|
45
161
|
file: modulePath,
|
|
46
162
|
message: `Circular reference detected: ${modulePath}`,
|
|
@@ -59,64 +175,289 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
59
175
|
message: `Module file not found: ${modulePath}`,
|
|
60
176
|
severity: 'error',
|
|
61
177
|
});
|
|
62
|
-
return { module: null, errors };
|
|
178
|
+
return { module: null, modules: [], errors };
|
|
63
179
|
}
|
|
64
180
|
// Parse the module
|
|
65
181
|
const moduleResult = parseModule(moduleContent, modulePath);
|
|
66
182
|
errors.push(...moduleResult.errors);
|
|
67
183
|
if (!moduleResult.module) {
|
|
68
|
-
return { module: null, errors };
|
|
184
|
+
return { module: null, modules: [], errors };
|
|
69
185
|
}
|
|
70
186
|
const parsedModule = moduleResult.module;
|
|
71
|
-
const
|
|
72
|
-
//
|
|
73
|
-
|
|
187
|
+
const flatItems = [];
|
|
188
|
+
// Helper: process a section (LO, Page, Uncategorized) and return Section[]
|
|
189
|
+
function processSection(section, subsectionLevel) {
|
|
74
190
|
if (section.type === 'learning-outcome') {
|
|
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
|
|
79
191
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
80
192
|
const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths, tierMap);
|
|
81
193
|
errors.push(...result.errors);
|
|
82
|
-
|
|
194
|
+
return result.sections;
|
|
83
195
|
}
|
|
84
196
|
else if (section.type === 'page') {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const textResult = parsePageSegments(section.body, modulePath, section.line);
|
|
197
|
+
const level = subsectionLevel ?? 2;
|
|
198
|
+
const textResult = parsePageSegments(section.body, modulePath, section.line, level);
|
|
88
199
|
errors.push(...textResult.errors);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}];
|
|
100
211
|
}
|
|
101
212
|
else if (section.type === 'uncategorized') {
|
|
102
|
-
// Uncategorized sections can contain ## Lens: references
|
|
103
|
-
// Each lens becomes its own section (lens-video, lens-article, or page)
|
|
104
213
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
105
214
|
const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths, tierMap);
|
|
106
215
|
errors.push(...result.errors);
|
|
107
|
-
|
|
216
|
+
return result.sections;
|
|
108
217
|
}
|
|
218
|
+
return [];
|
|
109
219
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,
|
|
113
257
|
contentId: parsedModule.contentId,
|
|
114
|
-
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,
|
|
115
279
|
};
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
118
286
|
}
|
|
119
|
-
|
|
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
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// Also emit LO parse errors (we skipped earlier since we returned non-null)
|
|
459
|
+
errors.push(...loResult.errors);
|
|
460
|
+
return items;
|
|
120
461
|
}
|
|
121
462
|
/**
|
|
122
463
|
* Flatten a Learning Outcome section by resolving its LO file and all referenced lenses.
|
|
@@ -277,6 +618,9 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
277
618
|
if (articleFrontmatter.frontmatter.source_url) {
|
|
278
619
|
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
279
620
|
}
|
|
621
|
+
if (articleFrontmatter.frontmatter.published) {
|
|
622
|
+
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
623
|
+
}
|
|
280
624
|
}
|
|
281
625
|
}
|
|
282
626
|
}
|
|
@@ -325,6 +669,8 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
325
669
|
segments.push(segmentResult.segment);
|
|
326
670
|
}
|
|
327
671
|
}
|
|
672
|
+
// Apply collapsed content to article-excerpt segments
|
|
673
|
+
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
328
674
|
}
|
|
329
675
|
// Create a section for this lens
|
|
330
676
|
// Optional can come from either:
|
|
@@ -338,42 +684,17 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
338
684
|
learningOutcomeId: lo.id ?? null,
|
|
339
685
|
learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
|
|
340
686
|
contentId: lens.id ?? null,
|
|
687
|
+
tldr: lens.tldr,
|
|
341
688
|
videoId: videoId ?? null,
|
|
689
|
+
...computeSectionStats(segments),
|
|
342
690
|
};
|
|
343
691
|
sections.push(resultSection);
|
|
344
692
|
}
|
|
345
693
|
// 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
|
-
}
|
|
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);
|
|
377
698
|
}
|
|
378
699
|
return { sections, errors };
|
|
379
700
|
}
|
|
@@ -472,6 +793,9 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths, t
|
|
|
472
793
|
if (articleFrontmatter.frontmatter.source_url) {
|
|
473
794
|
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
474
795
|
}
|
|
796
|
+
if (articleFrontmatter.frontmatter.published) {
|
|
797
|
+
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
798
|
+
}
|
|
475
799
|
}
|
|
476
800
|
}
|
|
477
801
|
}
|
|
@@ -520,6 +844,8 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths, t
|
|
|
520
844
|
segments.push(segmentResult.segment);
|
|
521
845
|
}
|
|
522
846
|
}
|
|
847
|
+
// Apply collapsed content to article-excerpt segments
|
|
848
|
+
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
523
849
|
}
|
|
524
850
|
// Create a section for this lens
|
|
525
851
|
const resultSection = {
|
|
@@ -530,7 +856,9 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths, t
|
|
|
530
856
|
learningOutcomeId: null,
|
|
531
857
|
learningOutcomeName: null,
|
|
532
858
|
contentId: lens.id ?? null,
|
|
859
|
+
tldr: lens.tldr,
|
|
533
860
|
videoId: videoId ?? null,
|
|
861
|
+
...computeSectionStats(segments),
|
|
534
862
|
};
|
|
535
863
|
sections.push(resultSection);
|
|
536
864
|
}
|
|
@@ -646,6 +974,59 @@ function parseUncategorizedLensRefs(body, parentPath) {
|
|
|
646
974
|
}
|
|
647
975
|
return lensRefs;
|
|
648
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
|
+
}
|
|
649
1030
|
/**
|
|
650
1031
|
* Convert a parsed lens segment into a final flattened segment.
|
|
651
1032
|
* For article-excerpt and video-excerpt, this involves extracting content from source files.
|
|
@@ -746,7 +1127,7 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
746
1127
|
// Extract the excerpt
|
|
747
1128
|
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
748
1129
|
if (excerptResult.error) {
|
|
749
|
-
errors.push(excerptResult.error);
|
|
1130
|
+
errors.push({ ...excerptResult.error, file: lensPath });
|
|
750
1131
|
return { segment: null, errors };
|
|
751
1132
|
}
|
|
752
1133
|
const segment = {
|
|
@@ -840,7 +1221,7 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
840
1221
|
// Extract the video excerpt
|
|
841
1222
|
const excerptResult = extractVideoExcerpt(transcriptContent, parsedSegment.fromTimeStr, parsedSegment.toTimeStr, videoPath, timestamps);
|
|
842
1223
|
if (excerptResult.error) {
|
|
843
|
-
errors.push(excerptResult.error);
|
|
1224
|
+
errors.push({ ...excerptResult.error, file: lensPath });
|
|
844
1225
|
return { segment: null, errors };
|
|
845
1226
|
}
|
|
846
1227
|
const segment = {
|
|
@@ -873,6 +1254,23 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
873
1254
|
segment.feedback = true;
|
|
874
1255
|
return { segment, errors };
|
|
875
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
|
+
}
|
|
876
1274
|
default:
|
|
877
1275
|
return { segment: null, errors };
|
|
878
1276
|
}
|
|
@@ -898,7 +1296,7 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
898
1296
|
const errors = [];
|
|
899
1297
|
// Skip ignored lenses
|
|
900
1298
|
if (tierMap?.get(lensPath) === 'ignored') {
|
|
901
|
-
return { module: null, errors };
|
|
1299
|
+
return { module: null, modules: [], errors };
|
|
902
1300
|
}
|
|
903
1301
|
const lensContent = files.get(lensPath);
|
|
904
1302
|
if (!lensContent) {
|
|
@@ -907,7 +1305,7 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
907
1305
|
message: `Lens file not found: ${lensPath}`,
|
|
908
1306
|
severity: 'error',
|
|
909
1307
|
});
|
|
910
|
-
return { module: null, errors };
|
|
1308
|
+
return { module: null, modules: [], errors };
|
|
911
1309
|
}
|
|
912
1310
|
// Use pre-parsed lens if provided, otherwise parse
|
|
913
1311
|
const lens = preParsedLens ?? (() => {
|
|
@@ -916,7 +1314,7 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
916
1314
|
return lensResult.lens;
|
|
917
1315
|
})();
|
|
918
1316
|
if (!lens) {
|
|
919
|
-
return { module: null, errors };
|
|
1317
|
+
return { module: null, modules: [], errors };
|
|
920
1318
|
}
|
|
921
1319
|
const visitedPaths = new Set([lensPath]);
|
|
922
1320
|
// Process lens sections into a single flattened Section
|
|
@@ -942,6 +1340,8 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
942
1340
|
meta.author = articleFrontmatter.frontmatter.author;
|
|
943
1341
|
if (articleFrontmatter.frontmatter.source_url)
|
|
944
1342
|
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
1343
|
+
if (articleFrontmatter.frontmatter.published)
|
|
1344
|
+
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
945
1345
|
}
|
|
946
1346
|
}
|
|
947
1347
|
}
|
|
@@ -982,6 +1382,8 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
982
1382
|
if (segmentResult.segment)
|
|
983
1383
|
segments.push(segmentResult.segment);
|
|
984
1384
|
}
|
|
1385
|
+
// Apply collapsed content to article-excerpt segments
|
|
1386
|
+
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
985
1387
|
}
|
|
986
1388
|
// Fallback title from filename if not extracted from source metadata
|
|
987
1389
|
if (!meta.title) {
|
|
@@ -995,7 +1397,9 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
995
1397
|
learningOutcomeId: null,
|
|
996
1398
|
learningOutcomeName: null,
|
|
997
1399
|
contentId: lens.id ?? null,
|
|
1400
|
+
tldr: lens.tldr,
|
|
998
1401
|
videoId: videoId ?? null,
|
|
1402
|
+
...computeSectionStats(segments),
|
|
999
1403
|
};
|
|
1000
1404
|
const flattenedModule = {
|
|
1001
1405
|
slug: 'lens/' + fileNameToSlug(lensPath),
|
|
@@ -1003,6 +1407,6 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
1003
1407
|
contentId: lens.id ?? null,
|
|
1004
1408
|
sections: [section],
|
|
1005
1409
|
};
|
|
1006
|
-
return { module: flattenedModule, errors };
|
|
1410
|
+
return { module: flattenedModule, modules: [flattenedModule], errors };
|
|
1007
1411
|
}
|
|
1008
1412
|
//# sourceMappingURL=index.js.map
|