lens-content-processor 0.4.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 (68) hide show
  1. package/dist/bundler/video.d.ts +12 -6
  2. package/dist/bundler/video.js +57 -34
  3. package/dist/bundler/video.js.map +1 -1
  4. package/dist/cli.d.ts +11 -2
  5. package/dist/cli.js +36 -19
  6. package/dist/cli.js.map +1 -1
  7. package/dist/content-schema.js +64 -16
  8. package/dist/content-schema.js.map +1 -1
  9. package/dist/dedupe-errors.d.ts +14 -0
  10. package/dist/dedupe-errors.js +33 -0
  11. package/dist/dedupe-errors.js.map +1 -0
  12. package/dist/flattener/index.d.ts +3 -28
  13. package/dist/flattener/index.js +497 -741
  14. package/dist/flattener/index.js.map +1 -1
  15. package/dist/flattener/resolve-text-links.d.ts +25 -0
  16. package/dist/flattener/resolve-text-links.js +248 -0
  17. package/dist/flattener/resolve-text-links.js.map +1 -0
  18. package/dist/flattener/test-helpers.d.ts +1 -1
  19. package/dist/flattener/test-helpers.js +1 -3
  20. package/dist/flattener/test-helpers.js.map +1 -1
  21. package/dist/index.d.ts +51 -21
  22. package/dist/index.js +285 -140
  23. package/dist/index.js.map +1 -1
  24. package/dist/parser/article.d.ts +2 -1
  25. package/dist/parser/article.js +75 -14
  26. package/dist/parser/article.js.map +1 -1
  27. package/dist/parser/course.d.ts +1 -1
  28. package/dist/parser/course.js +79 -22
  29. package/dist/parser/course.js.map +1 -1
  30. package/dist/parser/frontmatter.d.ts +1 -1
  31. package/dist/parser/frontmatter.js +38 -12
  32. package/dist/parser/frontmatter.js.map +1 -1
  33. package/dist/parser/learning-outcome.d.ts +5 -2
  34. package/dist/parser/learning-outcome.js +75 -37
  35. package/dist/parser/learning-outcome.js.map +1 -1
  36. package/dist/parser/lens.d.ts +33 -34
  37. package/dist/parser/lens.js +205 -271
  38. package/dist/parser/lens.js.map +1 -1
  39. package/dist/parser/module.d.ts +7 -15
  40. package/dist/parser/module.js +135 -171
  41. package/dist/parser/module.js.map +1 -1
  42. package/dist/parser/sections.d.ts +12 -1
  43. package/dist/parser/sections.js +74 -95
  44. package/dist/parser/sections.js.map +1 -1
  45. package/dist/parser/video-transcript.d.ts +2 -1
  46. package/dist/parser/video-transcript.js +8 -7
  47. package/dist/parser/video-transcript.js.map +1 -1
  48. package/dist/reference-graph.d.ts +8 -0
  49. package/dist/reference-graph.js +105 -0
  50. package/dist/reference-graph.js.map +1 -0
  51. package/dist/validator/card-module-membership.d.ts +2 -0
  52. package/dist/validator/card-module-membership.js +80 -0
  53. package/dist/validator/card-module-membership.js.map +1 -0
  54. package/dist/validator/output-integrity.js +5 -5
  55. package/dist/validator/output-integrity.js.map +1 -1
  56. package/dist/validator/segment-fields.d.ts +1 -1
  57. package/dist/validator/segment-fields.js +2 -2
  58. package/dist/validator/segment-fields.js.map +1 -1
  59. package/dist/validator/test-segments.d.ts +12 -0
  60. package/dist/validator/test-segments.js +40 -0
  61. package/dist/validator/test-segments.js.map +1 -0
  62. package/dist/validator/url-reachability.d.ts +2 -1
  63. package/dist/validator/url-reachability.js +40 -20
  64. package/dist/validator/url-reachability.js.map +1 -1
  65. package/package.json +1 -1
  66. package/dist/validator/chat-precedence.d.ts +0 -9
  67. package/dist/validator/chat-precedence.js +0 -32
  68. package/dist/validator/chat-precedence.js.map +0 -1
@@ -1,24 +1,43 @@
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, bundleArticleWithCollapsed } 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";
10
11
  function countWords(text) {
11
12
  return text.trim().split(/\s+/).filter(Boolean).length;
12
13
  }
14
+ /**
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
+ }
13
32
  /** Compute word count and video duration from a section's segments. */
14
33
  function computeSectionStats(segments) {
15
34
  let words = 0;
16
35
  let videoSeconds = 0;
17
36
  for (const seg of segments) {
18
- if (seg.type === 'text' || seg.type === 'article-excerpt') {
37
+ if (seg.type === "text" || seg.type === "article") {
19
38
  words += countWords(seg.content);
20
39
  }
21
- else if (seg.type === 'video-excerpt') {
40
+ else if (seg.type === "video") {
22
41
  if (seg.to != null)
23
42
  videoSeconds += seg.to - seg.from;
24
43
  }
@@ -30,26 +49,42 @@ function computeSectionStats(segments) {
30
49
  stats.videoDurationSeconds = videoSeconds;
31
50
  return stats;
32
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
+ }
33
70
  /**
34
- * Extract YouTube video ID from a YouTube URL.
35
- *
36
- * Supported formats:
37
- * - https://www.youtube.com/watch?v=VIDEO_ID
38
- * - https://youtube.com/watch?v=VIDEO_ID
39
- * - https://youtu.be/VIDEO_ID
40
- * - https://www.youtube.com/embed/VIDEO_ID
41
- *
42
- * @returns Video ID string, or null if URL format is not recognized
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.
43
73
  */
44
- function extractVideoIdFromUrl(url) {
45
- const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
46
- return match ? match[1] : null;
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" };
47
79
  }
48
80
  export function isBoundary(item) {
49
- return '__boundary' in item && item.__boundary === true;
81
+ return "__boundary" in item && item.__boundary === true;
50
82
  }
51
83
  function toKebabCase(str) {
52
- return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
84
+ return str
85
+ .toLowerCase()
86
+ .replace(/[^a-z0-9]+/g, "-")
87
+ .replace(/(^-|-$)/g, "");
53
88
  }
54
89
  export function validateBoundaries(items, file) {
55
90
  const errors = [];
@@ -58,12 +93,14 @@ export function validateBoundaries(items, file) {
58
93
  return errors;
59
94
  // Check: sections before first boundary
60
95
  const firstBoundaryIdx = items.findIndex(isBoundary);
61
- const sectionsBefore = items.slice(0, firstBoundaryIdx).filter(i => !isBoundary(i));
96
+ const sectionsBefore = items
97
+ .slice(0, firstBoundaryIdx)
98
+ .filter((i) => !isBoundary(i));
62
99
  if (sectionsBefore.length > 0) {
63
100
  errors.push({
64
101
  file,
65
- message: 'Content found outside submodule boundaries — all content must be inside a submodule',
66
- severity: 'error',
102
+ message: "Content found outside submodule boundaries — all content must be inside a submodule",
103
+ severity: "error",
67
104
  });
68
105
  }
69
106
  // Check: consecutive boundaries and trailing boundary
@@ -71,25 +108,24 @@ export function validateBoundaries(items, file) {
71
108
  if (isBoundary(items[i])) {
72
109
  // Find next non-boundary item
73
110
  let nextNonBoundary = i + 1;
74
- while (nextNonBoundary < items.length && isBoundary(items[nextNonBoundary])) {
111
+ while (nextNonBoundary < items.length &&
112
+ isBoundary(items[nextNonBoundary])) {
75
113
  nextNonBoundary++;
76
114
  }
77
115
  // If this boundary has no sections before next boundary or end
78
116
  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
117
  if (nextNonBoundary > i + 1) {
82
118
  errors.push({
83
119
  file,
84
120
  message: `Submodule "${items[i].title}" is empty — no content before next submodule`,
85
- severity: 'error',
121
+ severity: "error",
86
122
  });
87
123
  }
88
124
  else if (nextNonBoundary >= items.length) {
89
125
  errors.push({
90
126
  file,
91
127
  message: `Submodule "${items[i].title}" is empty — no content after this marker`,
92
- severity: 'error',
128
+ severity: "error",
93
129
  });
94
130
  }
95
131
  }
@@ -101,14 +137,16 @@ export function splitAtBoundaries(items, parentSlug, parentTitle) {
101
137
  const hasBoundaries = items.some(isBoundary);
102
138
  if (!hasBoundaries) {
103
139
  // No split — return single group without parentSlug
104
- return [{
140
+ return [
141
+ {
105
142
  slug: parentSlug,
106
143
  title: parentTitle,
107
144
  parentSlug: undefined,
108
145
  parentTitle: undefined,
109
- sections: items.filter(i => !isBoundary(i)),
146
+ sections: items.filter((i) => !isBoundary(i)),
110
147
  contentId: null,
111
- }];
148
+ },
149
+ ];
112
150
  }
113
151
  const groups = [];
114
152
  let currentGroup = null;
@@ -138,18 +176,29 @@ export function splitAtBoundaries(items, parentSlug, parentTitle) {
138
176
  return groups;
139
177
  }
140
178
  /**
141
- * Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
142
- *
143
- * This function:
144
- * 1. Parses the module file
145
- * 2. For each Learning Outcome section, resolves the LO file
146
- * 3. For each Lens in the LO, resolves the lens file
147
- * 4. For each segment, extracts content from articles/videos as needed
179
+ * Flatten a single ParsedLens into output Segments.
180
+ * This is the shared core used by all lens-processing functions.
148
181
  *
149
- * @param modulePath - Path to the module file within the files Map
150
- * @param files - Map of all file paths to their content
151
- * @param visitedPaths - Optional set of already-visited paths for cycle detection
152
- * @returns Flattened module with resolved sections and segments, plus any errors
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.
184
+ */
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 };
198
+ }
199
+ // ─── Module flattening ───
200
+ /**
201
+ * Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
153
202
  */
154
203
  export function flattenModule(modulePath, files, visitedPaths = new Set(), tierMap) {
155
204
  // Check for circular reference
@@ -157,11 +206,13 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
157
206
  return {
158
207
  module: null,
159
208
  modules: [],
160
- errors: [{
209
+ errors: [
210
+ {
161
211
  file: modulePath,
162
212
  message: `Circular reference detected: ${modulePath}`,
163
- severity: 'error',
164
- }],
213
+ severity: "error",
214
+ },
215
+ ],
165
216
  };
166
217
  }
167
218
  visitedPaths.add(modulePath);
@@ -173,7 +224,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
173
224
  errors.push({
174
225
  file: modulePath,
175
226
  message: `Module file not found: ${modulePath}`,
176
- severity: 'error',
227
+ severity: "error",
177
228
  });
178
229
  return { module: null, modules: [], errors };
179
230
  }
@@ -185,33 +236,18 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
185
236
  }
186
237
  const parsedModule = moduleResult.module;
187
238
  const flatItems = [];
188
- // Helper: process a section (LO, Page, Uncategorized) and return Section[]
189
- function processSection(section, subsectionLevel) {
190
- if (section.type === 'learning-outcome') {
239
+ // Helper: process a section (LO or Lens) and return Section[]
240
+ function processSection(section, _subsectionLevel) {
241
+ if (section.type === "learning-outcome") {
191
242
  const sectionVisitedPaths = new Set(visitedPaths);
192
243
  const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths, tierMap);
193
244
  errors.push(...result.errors);
194
245
  return result.sections;
195
246
  }
196
- else if (section.type === 'page') {
197
- const level = subsectionLevel ?? 2;
198
- const textResult = parsePageSegments(section.body, modulePath, section.line, level);
199
- errors.push(...textResult.errors);
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
- }];
211
- }
212
- else if (section.type === 'uncategorized') {
247
+ else if (section.type === "lens") {
248
+ // Referenced lens (has source::) or inline lens
213
249
  const sectionVisitedPaths = new Set(visitedPaths);
214
- const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths, tierMap);
250
+ const result = flattenLensSection(section, modulePath, files, sectionVisitedPaths, tierMap, section.inlineLens);
215
251
  errors.push(...result.errors);
216
252
  return result.sections;
217
253
  }
@@ -219,14 +255,14 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
219
255
  }
220
256
  // Process each section in the module
221
257
  for (const section of parsedModule.sections) {
222
- if (section.type === 'submodule') {
258
+ if (section.type === "submodule") {
223
259
  // Emit boundary marker
224
260
  flatItems.push({
225
261
  __boundary: true,
226
262
  title: section.title,
227
263
  customSlug: section.fields.slug,
228
264
  });
229
- // Process children at level+1
265
+ // Process children
230
266
  if (section.children) {
231
267
  for (const child of section.children) {
232
268
  const childSections = processSection(child, child.level + 1);
@@ -236,7 +272,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
236
272
  }
237
273
  else {
238
274
  // Check if this is an LO with submodules
239
- if (section.type === 'learning-outcome') {
275
+ if (section.type === "learning-outcome") {
240
276
  const loSections = processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors);
241
277
  if (loSections) {
242
278
  flatItems.push(...loSections);
@@ -251,17 +287,33 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
251
287
  const boundaryErrors = validateBoundaries(flatItems, modulePath);
252
288
  errors.push(...boundaryErrors);
253
289
  const groups = splitAtBoundaries(flatItems, parsedModule.slug, parsedModule.title);
254
- const resultModules = groups.map(g => ({
290
+ const resultModules = groups.map((g) => ({
255
291
  slug: g.slug,
256
292
  title: g.title,
257
293
  contentId: parsedModule.contentId,
258
294
  sections: g.sections,
259
- ...(g.parentSlug ? { parentSlug: g.parentSlug, parentTitle: g.parentTitle } : {}),
295
+ sourcePath: modulePath,
296
+ ...(g.parentSlug
297
+ ? { parentSlug: g.parentSlug, parentTitle: g.parentTitle }
298
+ : {}),
260
299
  ...(moduleError ? { error: moduleError } : {}),
300
+ ...("addToAiContext" in parsedModule
301
+ ? { addToAiContext: parsedModule.addToAiContext }
302
+ : {}),
261
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
+ }
262
308
  // Backwards compat: module is the first result or a merged single module
263
309
  const primaryModule = resultModules.length > 0 ? resultModules[0] : null;
264
- return { module: resultModules.length === 1 && !resultModules[0].parentSlug ? resultModules[0] : primaryModule, modules: resultModules, errors };
310
+ return {
311
+ module: resultModules.length === 1 && !resultModules[0].parentSlug
312
+ ? resultModules[0]
313
+ : primaryModule,
314
+ modules: resultModules,
315
+ errors,
316
+ };
265
317
  }
266
318
  /**
267
319
  * Build a test Section from a ParsedTestRef with inline segments.
@@ -271,14 +323,8 @@ function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, ti
271
323
  if (!testRef.segments.length)
272
324
  return null;
273
325
  const testSegments = [];
274
- const stubLensSection = {
275
- type: 'page',
276
- title: 'Test',
277
- segments: [],
278
- line: 0,
279
- };
280
326
  for (const parsedSegment of testRef.segments) {
281
- const segmentResult = convertSegment(parsedSegment, stubLensSection, loPath, files, new Set(visitedPaths), tierMap);
327
+ const segmentResult = convertSegment(parsedSegment, loPath, files, new Set(visitedPaths), tierMap);
282
328
  errors.push(...segmentResult.errors);
283
329
  if (segmentResult.segment) {
284
330
  testSegments.push(segmentResult.segment);
@@ -286,17 +332,17 @@ function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, ti
286
332
  }
287
333
  if (testSegments.length === 0)
288
334
  return null;
289
- const hasFeedback = testSegments.some((s) => s.type === 'question' && s.feedback);
335
+ const hasFeedback = testSegments.some((s) => s.type === "question" && s.feedback);
290
336
  return {
291
- type: 'test',
292
- meta: { title: 'Test' },
337
+ type: "test",
338
+ meta: { title: "Test" },
293
339
  segments: testSegments,
340
+ sourcePath: loPath,
294
341
  optional: false,
295
342
  ...(hasFeedback && { feedback: true }),
296
- contentId: null,
343
+ contentId: testRef.id,
297
344
  learningOutcomeId: loId,
298
345
  learningOutcomeName: loName,
299
- videoId: null,
300
346
  };
301
347
  }
302
348
  /**
@@ -333,32 +379,33 @@ function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierM
333
379
  for (const lensRef of sub.lenses) {
334
380
  const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
335
381
  if (!lensPath) {
336
- const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, 'Lenses');
337
- const suggestion = formatSuggestion(similarFiles, loPath) ?? 'Check the file path in the wiki-link';
382
+ const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
383
+ const suggestion = formatSuggestion(similarFiles, loPath) ??
384
+ "Check the file path in the wiki-link";
338
385
  errors.push({
339
386
  file: loPath,
340
387
  message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
341
388
  suggestion,
342
- severity: 'error',
389
+ severity: "error",
343
390
  });
344
391
  continue;
345
392
  }
346
393
  // Check tier violation
347
394
  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');
395
+ const parentTier = tierMap.get(loPath) ?? "production";
396
+ const childTier = tierMap.get(lensPath) ?? "production";
397
+ const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
351
398
  if (violation) {
352
399
  errors.push(violation);
353
400
  }
354
- if (childTier === 'ignored')
401
+ if (childTier === "ignored")
355
402
  continue;
356
403
  }
357
404
  if (visitedPaths.has(lensPath)) {
358
405
  errors.push({
359
406
  file: loPath,
360
407
  message: `Circular reference detected: ${lensPath}`,
361
- severity: 'error',
408
+ severity: "error",
362
409
  });
363
410
  continue;
364
411
  }
@@ -370,92 +417,41 @@ function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierM
370
417
  if (!lensResult.lens)
371
418
  continue;
372
419
  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
- }
420
+ const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, sectionVisitedPaths, tierMap);
421
+ errors.push(...flattenErrors);
436
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);
437
428
  items.push({
438
- type: sectionType,
439
- meta,
429
+ type: "lens",
430
+ meta: { title: lens.title || section.title },
440
431
  segments,
441
- optional: section.fields.optional?.toLowerCase() === 'true' || lensRef.optional,
432
+ sourcePath: lensPath,
433
+ optional: effectiveOptional,
434
+ ...(effectiveHide && { hide: true }),
442
435
  learningOutcomeId: lo.id ?? null,
443
- learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
436
+ learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
444
437
  contentId: lens.id ?? null,
445
438
  tldr: lens.tldr,
446
- videoId: videoId ?? null,
439
+ summaryForTutor: lens.summaryForTutor,
440
+ addToAiContext: lens.addToAiContext,
441
+ addToAiContextLo: lo.addToAiContext,
447
442
  ...computeSectionStats(segments),
443
+ displayType: computeDisplayType(segments),
448
444
  });
449
445
  }
450
446
  // Process test section if present in this submodule
451
447
  if (sub.test) {
452
448
  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);
449
+ const testSection = buildTestSection(sub.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
454
450
  if (testSection)
455
451
  items.push(testSection);
456
452
  }
457
453
  }
458
- // Also emit LO parse errors (we skipped earlier since we returned non-null)
454
+ // Also emit LO parse errors
459
455
  errors.push(...loResult.errors);
460
456
  return items;
461
457
  }
@@ -472,9 +468,9 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
472
468
  errors.push({
473
469
  file: modulePath,
474
470
  line: section.line,
475
- message: 'Learning Outcome section missing source:: field',
471
+ message: "Learning Outcome section missing source:: field",
476
472
  suggestion: "Add 'source:: [[../Learning Outcomes/filename.md|Display]]'",
477
- severity: 'error',
473
+ severity: "error",
478
474
  });
479
475
  return { sections: [], errors };
480
476
  }
@@ -483,7 +479,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
483
479
  if (!wikilink || wikilink.error) {
484
480
  const suggestion = wikilink?.correctedPath
485
481
  ? `Did you mean '[[${wikilink.correctedPath}]]'?`
486
- : 'Use format [[../Learning Outcomes/filename.md|Display Text]]';
482
+ : "Use format [[../Learning Outcomes/filename.md|Display Text]]";
487
483
  errors.push({
488
484
  file: modulePath,
489
485
  line: section.line,
@@ -491,7 +487,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
491
487
  ? `${wikilink.error}: ${source}`
492
488
  : `Invalid wikilink format: ${source}`,
493
489
  suggestion,
494
- severity: 'error',
490
+ severity: "error",
495
491
  });
496
492
  return { sections: [], errors };
497
493
  }
@@ -499,28 +495,27 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
499
495
  const loPath = findFileWithExtension(loPathResolved, files);
500
496
  // Get the LO file content
501
497
  if (!loPath) {
502
- // Find similar files to suggest
503
- const similarFiles = findSimilarFiles(loPathResolved, files, 'Learning Outcomes');
504
- 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";
505
501
  errors.push({
506
502
  file: modulePath,
507
503
  line: section.line,
508
504
  message: `Referenced file not found: ${loPathResolved}`,
509
505
  suggestion,
510
- severity: 'error',
506
+ severity: "error",
511
507
  });
512
508
  return { sections: [], errors };
513
509
  }
514
- // Check tier violation (module LO)
510
+ // Check tier violation (module -> LO)
515
511
  if (tierMap) {
516
- const parentTier = tierMap.get(modulePath) ?? 'production';
517
- const childTier = tierMap.get(loPath) ?? 'production';
518
- const violation = checkTierViolation(modulePath, parentTier, loPath, childTier, 'learning outcome', section.line);
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);
519
515
  if (violation) {
520
516
  errors.push(violation);
521
517
  }
522
- // Skip ignored children silently (they're not processed)
523
- if (childTier === 'ignored') {
518
+ if (childTier === "ignored") {
524
519
  return { sections: [], errors };
525
520
  }
526
521
  }
@@ -530,7 +525,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
530
525
  file: modulePath,
531
526
  line: section.line,
532
527
  message: `Circular reference detected: ${loPath}`,
533
- severity: 'error',
528
+ severity: "error",
534
529
  });
535
530
  return { sections: [], errors };
536
531
  }
@@ -547,26 +542,26 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
547
542
  for (const lensRef of lo.lenses) {
548
543
  const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
549
544
  if (!lensPath) {
550
- // Find similar files to suggest
551
- const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, 'Lenses');
552
- 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";
553
548
  errors.push({
554
549
  file: loPath,
555
550
  message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
556
551
  suggestion,
557
- severity: 'error',
552
+ severity: "error",
558
553
  });
559
554
  continue;
560
555
  }
561
- // Check tier violation (LO Lens)
556
+ // Check tier violation (LO -> Lens)
562
557
  if (tierMap) {
563
- const parentTier = tierMap.get(loPath) ?? 'production';
564
- const childTier = tierMap.get(lensPath) ?? 'production';
565
- const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, 'lens');
558
+ const parentTier = tierMap.get(loPath) ?? "production";
559
+ const childTier = tierMap.get(lensPath) ?? "production";
560
+ const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
566
561
  if (violation) {
567
562
  errors.push(violation);
568
563
  }
569
- if (childTier === 'ignored') {
564
+ if (childTier === "ignored") {
570
565
  continue;
571
566
  }
572
567
  }
@@ -575,7 +570,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
575
570
  errors.push({
576
571
  file: loPath,
577
572
  message: `Circular reference detected: ${lensPath}`,
578
- severity: 'error',
573
+ severity: "error",
579
574
  });
580
575
  continue;
581
576
  }
@@ -588,465 +583,263 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
588
583
  continue;
589
584
  }
590
585
  const lens = lensResult.lens;
591
- // Each lens becomes its own section
592
- let sectionType = 'page';
593
- const meta = { title: section.title };
594
- const segments = [];
595
- let videoId;
596
- // Process each section in the lens
597
- for (const lensSection of lens.sections) {
598
- // Determine section type from lens section
599
- if (lensSection.type === 'lens-article') {
600
- sectionType = 'lens-article';
601
- // Extract article metadata from the article file's frontmatter
602
- if (lensSection.source) {
603
- const articleWikilink = parseWikilink(lensSection.source);
604
- if (articleWikilink && !articleWikilink.error) {
605
- const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
606
- const articlePath = findFileWithExtension(articlePathResolved, files);
607
- if (articlePath) {
608
- const articleContent = files.get(articlePath);
609
- const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
610
- // Extract metadata fields
611
- if (articleFrontmatter.frontmatter.title) {
612
- meta.title = articleFrontmatter.frontmatter.title;
613
- }
614
- if (articleFrontmatter.frontmatter.author) {
615
- const raw = articleFrontmatter.frontmatter.author;
616
- meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
617
- }
618
- if (articleFrontmatter.frontmatter.source_url) {
619
- meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
620
- }
621
- if (articleFrontmatter.frontmatter.published) {
622
- meta.published = String(articleFrontmatter.frontmatter.published);
623
- }
624
- }
625
- }
626
- }
627
- }
628
- else if (lensSection.type === 'page') {
629
- sectionType = 'page';
630
- // For page sections, use the title from the ### Page: header
631
- if (lensSection.title) {
632
- meta.title = lensSection.title;
633
- }
634
- }
635
- else if (lensSection.type === 'lens-video') {
636
- sectionType = 'lens-video';
637
- // Extract video metadata from the video transcript file's frontmatter
638
- if (lensSection.source) {
639
- const videoWikilink = parseWikilink(lensSection.source);
640
- if (videoWikilink && !videoWikilink.error) {
641
- const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
642
- const videoPath = findFileWithExtension(videoPathResolved, files);
643
- if (videoPath) {
644
- const videoContent = files.get(videoPath);
645
- const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
646
- // Extract metadata fields
647
- if (videoFrontmatter.frontmatter.title) {
648
- meta.title = videoFrontmatter.frontmatter.title;
649
- }
650
- if (videoFrontmatter.frontmatter.channel) {
651
- meta.channel = videoFrontmatter.frontmatter.channel;
652
- }
653
- // Extract video ID from YouTube URL
654
- if (videoFrontmatter.frontmatter.url) {
655
- const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
656
- if (extractedVideoId) {
657
- videoId = extractedVideoId;
658
- }
659
- }
660
- }
661
- }
662
- }
663
- }
664
- // Process segments
665
- for (const parsedSegment of lensSection.segments) {
666
- const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
667
- errors.push(...segmentResult.errors);
668
- if (segmentResult.segment) {
669
- segments.push(segmentResult.segment);
670
- }
671
- }
672
- // Apply collapsed content to article-excerpt segments
673
- applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
674
- }
586
+ // Flatten lens segments using shared helper
587
+ const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
588
+ errors.push(...flattenErrors);
675
589
  // Create a section for this lens
676
- // Optional can come from either:
677
- // 1. The LO reference in the module (section.fields.optional) - makes ALL lenses optional
678
- // 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);
679
595
  const resultSection = {
680
- type: sectionType,
681
- meta,
596
+ type: "lens",
597
+ meta: { title: lens.title || section.title },
682
598
  segments,
683
- optional: section.fields.optional?.toLowerCase() === 'true' || lensRef.optional,
599
+ sourcePath: lensPath,
600
+ optional: effectiveOptional,
601
+ ...(effectiveHide && { hide: true }),
684
602
  learningOutcomeId: lo.id ?? null,
685
- learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
603
+ learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
686
604
  contentId: lens.id ?? null,
687
605
  tldr: lens.tldr,
688
- videoId: videoId ?? null,
606
+ summaryForTutor: lens.summaryForTutor,
607
+ addToAiContext: lens.addToAiContext,
608
+ addToAiContextLo: lo.addToAiContext,
689
609
  ...computeSectionStats(segments),
610
+ displayType: computeDisplayType(segments),
690
611
  };
691
612
  sections.push(resultSection);
692
613
  }
693
614
  // After lens processing, add test section if present with inline segments
694
615
  if (lo.test) {
695
- const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null, loPath, files, visitedPaths, tierMap, errors);
616
+ const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
696
617
  if (testSection)
697
618
  sections.push(testSection);
698
619
  }
699
620
  return { sections, errors };
700
621
  }
701
622
  /**
702
- * Flatten an Uncategorized section by parsing its ## Lens: references.
703
- * 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).
704
625
  */
705
- function flattenUncategorizedSection(section, modulePath, files, visitedPaths, tierMap) {
626
+ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, inlineLens) {
706
627
  const errors = [];
707
628
  const sections = [];
708
- // Parse the section body for ## Lens: subsections
709
- const lensRefs = parseUncategorizedLensRefs(section.body, modulePath);
710
- // If no lens refs found, warn and return empty array
711
- 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) {
712
661
  errors.push({
713
662
  file: modulePath,
714
663
  line: section.line,
715
- message: 'Uncategorized section has no ## Lens: references this section will produce no output',
716
- suggestion: "Add '## Lens: [[../Lenses/lens-name.md|Display]]' references",
717
- 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",
718
667
  });
719
668
  return { sections: [], errors };
720
669
  }
721
- // Process each lens reference as a separate section
722
- for (const lensRef of lensRefs) {
723
- const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
724
- if (!lensPath) {
725
- // Find similar files to suggest
726
- const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, 'Lenses');
727
- const suggestion = formatSuggestion(similarFiles, modulePath) ?? 'Check the file path in the wiki-link';
728
- errors.push({
729
- file: modulePath,
730
- message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
731
- suggestion,
732
- severity: 'error',
733
- });
734
- continue;
735
- }
736
- // Check tier violation (Uncategorized/Module → Lens)
737
- if (tierMap) {
738
- const parentTier = tierMap.get(modulePath) ?? 'production';
739
- const childTier = tierMap.get(lensPath) ?? 'production';
740
- const violation = checkTierViolation(modulePath, parentTier, lensPath, childTier, 'lens');
741
- if (violation) {
742
- errors.push(violation);
743
- }
744
- if (childTier === 'ignored') {
745
- continue;
746
- }
747
- }
748
- // Check for circular reference
749
- if (visitedPaths.has(lensPath)) {
750
- errors.push({
751
- file: modulePath,
752
- message: `Circular reference detected: ${lensPath}`,
753
- severity: 'error',
754
- });
755
- continue;
756
- }
757
- visitedPaths.add(lensPath);
758
- const lensContent = files.get(lensPath);
759
- // Parse the lens
760
- const lensResult = parseLens(lensContent, lensPath);
761
- errors.push(...lensResult.errors);
762
- if (!lensResult.lens) {
763
- 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);
764
708
  }
765
- const lens = lensResult.lens;
766
- // Each lens becomes its own section
767
- let sectionType = 'page';
768
- const meta = { title: section.title };
769
- const segments = [];
770
- let videoId;
771
- // Process each section in the lens
772
- for (const lensSection of lens.sections) {
773
- // Determine section type from lens section
774
- if (lensSection.type === 'lens-article') {
775
- sectionType = 'lens-article';
776
- // Extract article metadata from the article file's frontmatter
777
- if (lensSection.source) {
778
- const articleWikilink = parseWikilink(lensSection.source);
779
- if (articleWikilink && !articleWikilink.error) {
780
- const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
781
- const articlePath = findFileWithExtension(articlePathResolved, files);
782
- if (articlePath) {
783
- const articleContent = files.get(articlePath);
784
- const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
785
- // Extract metadata fields
786
- if (articleFrontmatter.frontmatter.title) {
787
- meta.title = articleFrontmatter.frontmatter.title;
788
- }
789
- if (articleFrontmatter.frontmatter.author) {
790
- const raw = articleFrontmatter.frontmatter.author;
791
- meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
792
- }
793
- if (articleFrontmatter.frontmatter.source_url) {
794
- meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
795
- }
796
- if (articleFrontmatter.frontmatter.published) {
797
- meta.published = String(articleFrontmatter.frontmatter.published);
798
- }
799
- }
800
- }
801
- }
802
- }
803
- else if (lensSection.type === 'page') {
804
- sectionType = 'page';
805
- // For page sections, use the title from the ### Page: header
806
- if (lensSection.title) {
807
- meta.title = lensSection.title;
808
- }
809
- }
810
- else if (lensSection.type === 'lens-video') {
811
- sectionType = 'lens-video';
812
- // Extract video metadata from the video transcript file's frontmatter
813
- if (lensSection.source) {
814
- const videoWikilink = parseWikilink(lensSection.source);
815
- if (videoWikilink && !videoWikilink.error) {
816
- const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
817
- const videoPath = findFileWithExtension(videoPathResolved, files);
818
- if (videoPath) {
819
- const videoContent = files.get(videoPath);
820
- const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
821
- // Extract metadata fields
822
- if (videoFrontmatter.frontmatter.title) {
823
- meta.title = videoFrontmatter.frontmatter.title;
824
- }
825
- if (videoFrontmatter.frontmatter.channel) {
826
- meta.channel = videoFrontmatter.frontmatter.channel;
827
- }
828
- // Extract video ID from YouTube URL
829
- if (videoFrontmatter.frontmatter.url) {
830
- const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
831
- if (extractedVideoId) {
832
- videoId = extractedVideoId;
833
- }
834
- }
835
- }
836
- }
837
- }
838
- }
839
- // Process segments
840
- for (const parsedSegment of lensSection.segments) {
841
- const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
842
- errors.push(...segmentResult.errors);
843
- if (segmentResult.segment) {
844
- segments.push(segmentResult.segment);
845
- }
846
- }
847
- // Apply collapsed content to article-excerpt segments
848
- applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
709
+ if (childTier === "ignored") {
710
+ return { sections: [], errors };
849
711
  }
850
- // Create a section for this lens
851
- const resultSection = {
852
- type: sectionType,
853
- meta,
854
- segments,
855
- optional: lensRef.optional,
856
- learningOutcomeId: null,
857
- learningOutcomeName: null,
858
- contentId: lens.id ?? null,
859
- tldr: lens.tldr,
860
- videoId: videoId ?? null,
861
- ...computeSectionStats(segments),
862
- };
863
- sections.push(resultSection);
864
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);
865
754
  return { sections, errors };
866
755
  }
756
+ // ─── Collapsed content and segment conversion ───
867
757
  /**
868
- * Parse ## Lens: subsections from an Uncategorized section's body.
869
- * 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.
870
761
  */
871
- function parseUncategorizedLensRefs(body, parentPath) {
872
- const lensRefs = [];
873
- const lines = body.split('\n');
874
- let inLensSection = false;
875
- let currentFields = {};
876
- let currentField = null;
877
- let currentValue = [];
878
- const LENS_HEADER_PATTERN = /^##\s+Lens:\s*(.*)$/i;
879
- const FIELD_PATTERN = /^(\w+)::\s*(.*)$/;
880
- for (let i = 0; i < lines.length; i++) {
881
- const line = lines[i];
882
- // Check for ## Lens: header
883
- const lensMatch = line.match(LENS_HEADER_PATTERN);
884
- if (lensMatch) {
885
- // Save previous lens if we were in one
886
- if (inLensSection) {
887
- // First, finalize current field if we were collecting one
888
- // This must happen BEFORE checking currentFields.source
889
- if (currentField) {
890
- currentFields[currentField] = currentValue.join('\n').trim();
891
- }
892
- if (currentFields.source) {
893
- const wikilink = parseWikilink(currentFields.source);
894
- if (wikilink) {
895
- const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
896
- lensRefs.push({
897
- source: currentFields.source,
898
- resolvedPath,
899
- optional: currentFields.optional?.toLowerCase() === 'true',
900
- });
901
- }
902
- }
903
- }
904
- inLensSection = true;
905
- currentFields = {};
906
- currentField = null;
907
- 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")
908
770
  continue;
771
+ // Find corresponding output segment
772
+ while (articleOutputIdx < segments.length &&
773
+ segments[articleOutputIdx].type !== "article") {
774
+ articleOutputIdx++;
909
775
  }
910
- // Check for another ## header (end of Lens section)
911
- if (line.match(/^##\s+\S/) && inLensSection) {
912
- // Save current lens
913
- if (currentField) {
914
- currentFields[currentField] = currentValue.join('\n').trim();
915
- }
916
- if (currentFields.source) {
917
- const wikilink = parseWikilink(currentFields.source);
918
- if (wikilink && !wikilink.error) {
919
- const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
920
- lensRefs.push({
921
- source: currentFields.source,
922
- resolvedPath,
923
- 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,
924
791
  });
925
792
  }
926
793
  }
927
- inLensSection = false;
928
- currentFields = {};
929
- currentField = null;
930
- currentValue = [];
931
- continue;
932
- }
933
- if (inLensSection) {
934
- // Parse fields
935
- const fieldMatch = line.match(FIELD_PATTERN);
936
- if (fieldMatch) {
937
- // Save previous field
938
- if (currentField) {
939
- currentFields[currentField] = currentValue.join('\n').trim();
940
- }
941
- currentField = fieldMatch[1];
942
- const inlineValue = fieldMatch[2].trim();
943
- currentValue = inlineValue ? [inlineValue] : [];
944
- }
945
- else if (currentField) {
946
- // Check if line starts a new section header
947
- if (line.match(/^#/)) {
948
- currentFields[currentField] = currentValue.join('\n').trim();
949
- currentField = null;
950
- currentValue = [];
951
- }
952
- else {
953
- currentValue.push(line);
954
- }
955
- }
956
794
  }
795
+ articleOutputIdx++;
957
796
  }
958
- // Don't forget the last lens section
959
- if (inLensSection) {
960
- if (currentField) {
961
- currentFields[currentField] = currentValue.join('\n').trim();
962
- }
963
- if (currentFields.source) {
964
- const wikilink = parseWikilink(currentFields.source);
965
- if (wikilink) {
966
- const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
967
- lensRefs.push({
968
- source: currentFields.source,
969
- resolvedPath,
970
- optional: currentFields.optional?.toLowerCase() === 'true',
971
- });
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;
972
816
  }
973
- }
974
- }
975
- return lensRefs;
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
817
  }
1028
818
  }
1029
819
  }
1030
820
  /**
1031
821
  * Convert a parsed lens segment into a final flattened segment.
1032
- * 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).
1033
824
  */
1034
- function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap) {
825
+ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
1035
826
  const errors = [];
1036
827
  switch (parsedSegment.type) {
1037
- case 'text': {
828
+ case "text": {
829
+ const resolved = resolveTextLinks(parsedSegment.content, lensPath, files);
830
+ errors.push(...resolved.errors);
1038
831
  const segment = {
1039
- type: 'text',
1040
- content: parsedSegment.content,
832
+ type: "text",
833
+ content: resolved.content,
1041
834
  };
1042
835
  if (parsedSegment.optional) {
1043
836
  segment.optional = true;
1044
837
  }
1045
838
  return { segment, errors };
1046
839
  }
1047
- case 'chat': {
840
+ case "chat": {
1048
841
  const segment = {
1049
- type: 'chat',
842
+ type: "chat",
1050
843
  };
1051
844
  if (parsedSegment.instructions) {
1052
845
  segment.instructions = parsedSegment.instructions;
@@ -1062,68 +855,67 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1062
855
  }
1063
856
  return { segment, errors };
1064
857
  }
1065
- case 'article-excerpt': {
1066
- // Need to resolve the article path from the lens section's source field
1067
- if (!lensSection.source) {
858
+ case "article": {
859
+ // Source comes from the parsed segment (set via inheritance in parseLens)
860
+ if (!parsedSegment.source) {
1068
861
  errors.push({
1069
862
  file: lensPath,
1070
- message: 'Article section missing source:: field for article-excerpt',
1071
- severity: 'error',
863
+ message: "Article segment missing source (should have been set via inheritance)",
864
+ severity: "error",
1072
865
  });
1073
866
  return { segment: null, errors };
1074
867
  }
1075
- const wikilink = parseWikilink(lensSection.source);
868
+ const wikilink = parseWikilink(parsedSegment.source);
1076
869
  if (!wikilink || wikilink.error) {
1077
870
  const suggestion = wikilink?.correctedPath
1078
871
  ? `Did you mean '[[${wikilink.correctedPath}]]'?`
1079
872
  : undefined;
1080
873
  errors.push({
1081
874
  file: lensPath,
1082
- message: `Invalid wikilink in article source: ${lensSection.source}`,
875
+ message: `Invalid wikilink in article source: ${parsedSegment.source}`,
1083
876
  suggestion,
1084
- severity: 'error',
877
+ severity: "error",
1085
878
  });
1086
879
  return { segment: null, errors };
1087
880
  }
1088
881
  const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
1089
882
  const articlePath = findFileWithExtension(articlePathResolved, files);
1090
883
  if (!articlePath) {
1091
- // Find similar files to suggest
1092
- const similarFiles = findSimilarFiles(articlePathResolved, files, 'articles');
1093
- 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";
1094
887
  errors.push({
1095
888
  file: lensPath,
1096
889
  message: `Referenced article file not found: ${articlePathResolved}`,
1097
890
  suggestion,
1098
- severity: 'error',
891
+ severity: "error",
1099
892
  });
1100
893
  return { segment: null, errors };
1101
894
  }
1102
- // Check tier violation (Lens Article)
895
+ // Check tier violation (Lens -> Article)
1103
896
  if (tierMap) {
1104
- const parentTier = tierMap.get(lensPath) ?? 'production';
1105
- const childTier = tierMap.get(articlePath) ?? 'production';
1106
- const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, 'article');
897
+ const parentTier = tierMap.get(lensPath) ?? "production";
898
+ const childTier = tierMap.get(articlePath) ?? "production";
899
+ const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
1107
900
  if (violation) {
1108
901
  errors.push(violation);
1109
902
  }
1110
- if (childTier === 'ignored') {
903
+ if (childTier === "ignored") {
1111
904
  return { segment: null, errors };
1112
905
  }
1113
906
  }
1114
- // Check if the article path points back to an already-visited structural file
1115
- // This would indicate a circular reference (e.g., a lens source pointing back to an LO)
1116
- // Note: We only check, we don't add article paths to visitedPaths since
1117
- // multiple segments can legitimately reference the same article
1118
907
  if (visitedPaths.has(articlePath)) {
1119
908
  errors.push({
1120
909
  file: lensPath,
1121
910
  message: `Circular reference detected: ${articlePath}`,
1122
- severity: 'error',
911
+ severity: "error",
1123
912
  });
1124
913
  return { segment: null, errors };
1125
914
  }
1126
915
  const articleContent = files.get(articlePath);
916
+ // Extract article metadata from frontmatter
917
+ const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
918
+ const fm = articleFrontmatter.frontmatter;
1127
919
  // Extract the excerpt
1128
920
  const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
1129
921
  if (excerptResult.error) {
@@ -1131,88 +923,93 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1131
923
  return { segment: null, errors };
1132
924
  }
1133
925
  const segment = {
1134
- type: 'article-excerpt',
926
+ type: "article",
1135
927
  content: excerptResult.content,
1136
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;
1137
941
  if (parsedSegment.optional) {
1138
942
  segment.optional = true;
1139
943
  }
1140
944
  return { segment, errors };
1141
945
  }
1142
- case 'video-excerpt': {
1143
- // Need to resolve the video/transcript path from the lens section's source field
1144
- if (!lensSection.source) {
946
+ case "video": {
947
+ // Source comes from the parsed segment (set via inheritance in parseLens)
948
+ if (!parsedSegment.source) {
1145
949
  errors.push({
1146
950
  file: lensPath,
1147
- message: 'Video section missing source:: field for video-excerpt',
1148
- severity: 'error',
951
+ message: "Video segment missing source (should have been set via inheritance)",
952
+ severity: "error",
1149
953
  });
1150
954
  return { segment: null, errors };
1151
955
  }
1152
- const wikilink = parseWikilink(lensSection.source);
956
+ const wikilink = parseWikilink(parsedSegment.source);
1153
957
  if (!wikilink || wikilink.error) {
1154
958
  const suggestion = wikilink?.correctedPath
1155
959
  ? `Did you mean '[[${wikilink.correctedPath}]]'?`
1156
960
  : undefined;
1157
961
  errors.push({
1158
962
  file: lensPath,
1159
- message: `Invalid wikilink in video source: ${lensSection.source}`,
963
+ message: `Invalid wikilink in video source: ${parsedSegment.source}`,
1160
964
  suggestion,
1161
- severity: 'error',
965
+ severity: "error",
1162
966
  });
1163
967
  return { segment: null, errors };
1164
968
  }
1165
969
  const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
1166
970
  const videoPath = findFileWithExtension(videoPathResolved, files);
1167
971
  if (!videoPath) {
1168
- // Find similar files to suggest
1169
- const similarFiles = findSimilarFiles(videoPathResolved, files, 'video_transcripts');
1170
- 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";
1171
975
  errors.push({
1172
976
  file: lensPath,
1173
977
  message: `Referenced video transcript file not found: ${videoPathResolved}`,
1174
978
  suggestion,
1175
- severity: 'error',
979
+ severity: "error",
1176
980
  });
1177
981
  return { segment: null, errors };
1178
982
  }
1179
- // Check tier violation (Lens Video)
983
+ // Check tier violation (Lens -> Video)
1180
984
  if (tierMap) {
1181
- const parentTier = tierMap.get(lensPath) ?? 'production';
1182
- const childTier = tierMap.get(videoPath) ?? 'production';
1183
- const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, 'video transcript');
985
+ const parentTier = tierMap.get(lensPath) ?? "production";
986
+ const childTier = tierMap.get(videoPath) ?? "production";
987
+ const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, "video transcript");
1184
988
  if (violation) {
1185
989
  errors.push(violation);
1186
990
  }
1187
- if (childTier === 'ignored') {
991
+ if (childTier === "ignored") {
1188
992
  return { segment: null, errors };
1189
993
  }
1190
994
  }
1191
- // Check if the video path points back to an already-visited structural file
1192
- // This would indicate a circular reference
1193
- // Note: We only check, we don't add video paths to visitedPaths since
1194
- // multiple segments can legitimately reference the same video
1195
995
  if (visitedPaths.has(videoPath)) {
1196
996
  errors.push({
1197
997
  file: lensPath,
1198
998
  message: `Circular reference detected: ${videoPath}`,
1199
- severity: 'error',
999
+ severity: "error",
1200
1000
  });
1201
1001
  return { segment: null, errors };
1202
1002
  }
1203
1003
  const transcriptContent = files.get(videoPath);
1004
+ // Extract video metadata from frontmatter
1005
+ const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
1006
+ const vfm = videoFrontmatter.frontmatter;
1204
1007
  // Look for corresponding .timestamps.json file
1205
- // e.g., video_transcripts/foo.md -> video_transcripts/foo.timestamps.json
1206
- const timestampsPath = videoPath.replace(/\.md$/, '.timestamps.json');
1008
+ const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
1207
1009
  let timestamps;
1208
- // Debug: Log path resolution (uncomment to debug)
1209
- // console.log('DEBUG videoPath:', videoPath);
1210
- // console.log('DEBUG timestampsPath:', timestampsPath);
1211
- // console.log('DEBUG files has timestamps:', files.has(timestampsPath));
1212
1010
  if (files.has(timestampsPath)) {
1213
1011
  try {
1214
1012
  timestamps = JSON.parse(files.get(timestampsPath));
1215
- // console.log('DEBUG loaded timestamps count:', timestamps.length);
1216
1013
  }
1217
1014
  catch {
1218
1015
  // JSON parse error - will fall back to inline timestamps
@@ -1225,19 +1022,32 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1225
1022
  return { segment: null, errors };
1226
1023
  }
1227
1024
  const segment = {
1228
- type: 'video-excerpt',
1025
+ type: "video",
1229
1026
  from: excerptResult.from,
1230
1027
  to: excerptResult.to,
1231
1028
  transcript: excerptResult.transcript,
1232
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
+ }
1233
1043
  if (parsedSegment.optional) {
1234
1044
  segment.optional = true;
1235
1045
  }
1236
1046
  return { segment, errors };
1237
1047
  }
1238
- case 'question': {
1048
+ case "question": {
1239
1049
  const segment = {
1240
- type: 'question',
1050
+ type: "question",
1241
1051
  content: parsedSegment.content,
1242
1052
  };
1243
1053
  if (parsedSegment.assessmentInstructions)
@@ -1254,9 +1064,9 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1254
1064
  segment.feedback = true;
1255
1065
  return { segment, errors };
1256
1066
  }
1257
- case 'roleplay': {
1067
+ case "roleplay": {
1258
1068
  const segment = {
1259
- type: 'roleplay',
1069
+ type: "roleplay",
1260
1070
  id: parsedSegment.id,
1261
1071
  content: parsedSegment.content,
1262
1072
  aiInstructions: parsedSegment.aiInstructions,
@@ -1265,6 +1075,8 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1265
1075
  segment.openingMessage = parsedSegment.openingMessage;
1266
1076
  if (parsedSegment.assessmentInstructions)
1267
1077
  segment.assessmentInstructions = parsedSegment.assessmentInstructions;
1078
+ if (parsedSegment.userCustomizable)
1079
+ segment.userCustomizable = true;
1268
1080
  if (parsedSegment.optional)
1269
1081
  segment.optional = true;
1270
1082
  if (parsedSegment.feedback)
@@ -1277,25 +1089,11 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1277
1089
  }
1278
1090
  /**
1279
1091
  * Flatten a single Lens file into a FlattenedModule.
1280
- *
1281
- * This wraps a standalone Lens as a single-section module so it can be
1282
- * rendered by the frontend using the existing Module.tsx component.
1283
- *
1284
- * The resulting module has:
1285
- * - slug: 'lens/' + fileNameToSlug(lensPath)
1286
- * - title: extracted from source metadata or lens header
1287
- * - sections: exactly one section (lens-article, lens-video, or page)
1288
- *
1289
- * @param lensPath - Path to the lens file within the files Map
1290
- * @param files - Map of all file paths to their content
1291
- * @param tierMap - Optional tier map for filtering ignored content
1292
- * @param preParsedLens - Optional pre-parsed lens to avoid re-parsing
1293
- * @returns Flattened module with a single section, plus any errors
1294
1092
  */
1295
1093
  export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1296
1094
  const errors = [];
1297
1095
  // Skip ignored lenses
1298
- if (tierMap?.get(lensPath) === 'ignored') {
1096
+ if (tierMap?.get(lensPath) === "ignored") {
1299
1097
  return { module: null, modules: [], errors };
1300
1098
  }
1301
1099
  const lensContent = files.get(lensPath);
@@ -1303,109 +1101,67 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1303
1101
  errors.push({
1304
1102
  file: lensPath,
1305
1103
  message: `Lens file not found: ${lensPath}`,
1306
- severity: 'error',
1104
+ severity: "error",
1307
1105
  });
1308
1106
  return { module: null, modules: [], errors };
1309
1107
  }
1310
1108
  // Use pre-parsed lens if provided, otherwise parse
1311
- const lens = preParsedLens ?? (() => {
1312
- const lensResult = parseLens(lensContent, lensPath);
1313
- errors.push(...lensResult.errors);
1314
- return lensResult.lens;
1315
- })();
1109
+ const lens = preParsedLens ??
1110
+ (() => {
1111
+ const lensResult = parseLens(lensContent, lensPath);
1112
+ errors.push(...lensResult.errors);
1113
+ return lensResult.lens;
1114
+ })();
1316
1115
  if (!lens) {
1317
1116
  return { module: null, modules: [], errors };
1318
1117
  }
1319
1118
  const visitedPaths = new Set([lensPath]);
1320
- // Process lens sections into a single flattened Section
1321
- 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
1322
1123
  const meta = {};
1323
- const segments = [];
1324
- let videoId;
1325
- for (const lensSection of lens.sections) {
1326
- if (lensSection.type === 'lens-article') {
1327
- sectionType = 'lens-article';
1328
- // Extract article metadata from the article file's frontmatter
1329
- if (lensSection.source) {
1330
- const articleWikilink = parseWikilink(lensSection.source);
1331
- if (articleWikilink && !articleWikilink.error) {
1332
- const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
1333
- const articlePath = findFileWithExtension(articlePathResolved, files);
1334
- if (articlePath) {
1335
- const articleContent = files.get(articlePath);
1336
- const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
1337
- if (articleFrontmatter.frontmatter.title)
1338
- meta.title = articleFrontmatter.frontmatter.title;
1339
- if (articleFrontmatter.frontmatter.author)
1340
- meta.author = articleFrontmatter.frontmatter.author;
1341
- if (articleFrontmatter.frontmatter.source_url)
1342
- meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
1343
- if (articleFrontmatter.frontmatter.published)
1344
- meta.published = String(articleFrontmatter.frontmatter.published);
1345
- }
1346
- }
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;
1347
1132
  }
1348
- }
1349
- else if (lensSection.type === 'page') {
1350
- sectionType = 'page';
1351
- if (lensSection.title)
1352
- meta.title = lensSection.title;
1353
- }
1354
- else if (lensSection.type === 'lens-video') {
1355
- sectionType = 'lens-video';
1356
- // Extract video metadata from the video transcript file's frontmatter
1357
- if (lensSection.source) {
1358
- const videoWikilink = parseWikilink(lensSection.source);
1359
- if (videoWikilink && !videoWikilink.error) {
1360
- const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
1361
- const videoPath = findFileWithExtension(videoPathResolved, files);
1362
- if (videoPath) {
1363
- const videoContent = files.get(videoPath);
1364
- const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
1365
- if (videoFrontmatter.frontmatter.title)
1366
- meta.title = videoFrontmatter.frontmatter.title;
1367
- if (videoFrontmatter.frontmatter.channel)
1368
- meta.channel = videoFrontmatter.frontmatter.channel;
1369
- if (videoFrontmatter.frontmatter.url) {
1370
- const extractedId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
1371
- if (extractedId)
1372
- videoId = extractedId;
1373
- }
1374
- }
1375
- }
1133
+ else if (seg.type === "video" && seg.title) {
1134
+ meta.title = seg.title;
1135
+ break;
1376
1136
  }
1377
1137
  }
1378
- // Process segments
1379
- for (const parsedSegment of lensSection.segments) {
1380
- const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
1381
- errors.push(...segmentResult.errors);
1382
- if (segmentResult.segment)
1383
- segments.push(segmentResult.segment);
1138
+ if (!meta.title) {
1139
+ meta.title = fileNameToSlug(lensPath)
1140
+ .replace(/-/g, " ")
1141
+ .replace(/\b\w/g, (c) => c.toUpperCase());
1384
1142
  }
1385
- // Apply collapsed content to article-excerpt segments
1386
- applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
1387
- }
1388
- // Fallback title from filename if not extracted from source metadata
1389
- if (!meta.title) {
1390
- meta.title = fileNameToSlug(lensPath).replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1391
1143
  }
1392
1144
  const section = {
1393
- type: sectionType,
1145
+ type: "lens",
1394
1146
  meta,
1395
1147
  segments,
1148
+ sourcePath: lensPath,
1396
1149
  optional: false,
1397
1150
  learningOutcomeId: null,
1398
1151
  learningOutcomeName: null,
1399
1152
  contentId: lens.id ?? null,
1400
1153
  tldr: lens.tldr,
1401
- videoId: videoId ?? null,
1154
+ summaryForTutor: lens.summaryForTutor,
1155
+ addToAiContext: lens.addToAiContext,
1402
1156
  ...computeSectionStats(segments),
1157
+ displayType: computeDisplayType(segments),
1403
1158
  };
1404
1159
  const flattenedModule = {
1405
- slug: 'lens/' + fileNameToSlug(lensPath),
1160
+ slug: "lens/" + fileNameToSlug(lensPath),
1406
1161
  title: meta.title ?? fileNameToSlug(lensPath),
1407
1162
  contentId: lens.id ?? null,
1408
1163
  sections: [section],
1164
+ sourcePath: lensPath,
1409
1165
  };
1410
1166
  return { module: flattenedModule, modules: [flattenedModule], errors };
1411
1167
  }