lens-content-processor 0.3.0 → 0.5.0

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