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.
Files changed (43) hide show
  1. package/dist/bundler/article.d.ts +7 -2
  2. package/dist/bundler/article.js +19 -8
  3. package/dist/bundler/article.js.map +1 -1
  4. package/dist/content-schema.js +3 -2
  5. package/dist/content-schema.js.map +1 -1
  6. package/dist/flattener/index.d.ts +20 -1
  7. package/dist/flattener/index.js +476 -72
  8. package/dist/flattener/index.js.map +1 -1
  9. package/dist/flattener/test-helpers.d.ts +23 -0
  10. package/dist/flattener/test-helpers.js +58 -0
  11. package/dist/flattener/test-helpers.js.map +1 -0
  12. package/dist/index.d.ts +18 -2
  13. package/dist/index.js +53 -23
  14. package/dist/index.js.map +1 -1
  15. package/dist/parser/article.d.ts +1 -1
  16. package/dist/parser/article.js +8 -1
  17. package/dist/parser/article.js.map +1 -1
  18. package/dist/parser/course.js +11 -8
  19. package/dist/parser/course.js.map +1 -1
  20. package/dist/parser/learning-outcome.d.ts +7 -0
  21. package/dist/parser/learning-outcome.js +129 -86
  22. package/dist/parser/learning-outcome.js.map +1 -1
  23. package/dist/parser/lens.d.ts +26 -1
  24. package/dist/parser/lens.js +99 -10
  25. package/dist/parser/lens.js.map +1 -1
  26. package/dist/parser/module.d.ts +1 -1
  27. package/dist/parser/module.js +13 -8
  28. package/dist/parser/module.js.map +1 -1
  29. package/dist/parser/sections.d.ts +2 -0
  30. package/dist/parser/sections.js +53 -19
  31. package/dist/parser/sections.js.map +1 -1
  32. package/dist/parser/video-transcript.js +3 -0
  33. package/dist/parser/video-transcript.js.map +1 -1
  34. package/dist/validator/directives.d.ts +12 -0
  35. package/dist/validator/directives.js +428 -0
  36. package/dist/validator/directives.js.map +1 -0
  37. package/dist/validator/field-values.js +14 -0
  38. package/dist/validator/field-values.js.map +1 -1
  39. package/dist/validator/url-reachability.js +3 -1
  40. package/dist/validator/url-reachability.js.map +1 -1
  41. package/dist/validator/uuid.js +9 -6
  42. package/dist/validator/uuid.js.map +1 -1
  43. package/package.json +1 -1
@@ -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 flattenedSections = [];
72
- // Process each section in the module
73
- for (const section of parsedModule.sections) {
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
- flattenedSections.push(...result.sections);
194
+ return result.sections;
83
195
  }
84
196
  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);
197
+ const level = subsectionLevel ?? 2;
198
+ const textResult = parsePageSegments(section.body, modulePath, section.line, level);
88
199
  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);
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
- flattenedSections.push(...result.sections);
216
+ return result.sections;
108
217
  }
218
+ return [];
109
219
  }
110
- const flattenedModule = {
111
- slug: parsedModule.slug,
112
- title: parsedModule.title,
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: flattenedSections,
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
- if (moduleError) {
117
- flattenedModule.error = moduleError;
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
- return { module: flattenedModule, errors };
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 && lo.test.segments.length > 0) {
347
- const testSegments = [];
348
- for (const parsedSegment of lo.test.segments) {
349
- // For test sections with question/chat/text segments, no source file resolution needed
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