lens-content-processor 0.4.0 → 0.6.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 +71 -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 +507 -745
  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 +53 -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 +40 -41
  37. package/dist/parser/lens.js +365 -349
  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,269 @@ 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
+ // optional:: / hide:: only count as lens-level when they appear BEFORE the
634
+ // first #### segment header. Otherwise a #### Chat (or any segment) with
635
+ // optional:: true would leak its field up to section.fields and wrongly
636
+ // mark the whole inline lens optional. (Same discipline as source:: below.)
637
+ const effectiveOptional = hasFieldBeforeSegmentHeaders(section.body, "optional", section.level) &&
638
+ section.fields.optional?.toLowerCase() === "true";
639
+ const effectiveHide = hasFieldBeforeSegmentHeaders(section.body, "hide", section.level) &&
640
+ section.fields.hide?.toLowerCase() === "true";
641
+ const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
642
+ if (hideConflict)
643
+ errors.push(hideConflict);
644
+ const resultSection = {
645
+ type: "lens",
646
+ meta: { title: inlineLens.title || section.title },
647
+ segments,
648
+ sourcePath: modulePath,
649
+ optional: effectiveOptional,
650
+ ...(effectiveHide && { hide: true }),
651
+ learningOutcomeId: null,
652
+ learningOutcomeName: null,
653
+ contentId: inlineLens.id ?? null,
654
+ tldr: inlineLens.tldr,
655
+ summaryForTutor: inlineLens.summaryForTutor,
656
+ addToAiContext: inlineLens.addToAiContext,
657
+ ...computeSectionStats(segments),
658
+ displayType: computeDisplayType(segments),
659
+ };
660
+ sections.push(resultSection);
661
+ return { sections, errors };
662
+ }
663
+ // Referenced lens: has source:: at section level (not from segment fields)
664
+ const hasSectionSource = hasFieldBeforeSegmentHeaders(section.body, "source", section.level);
665
+ const source = hasSectionSource ? section.fields.source : undefined;
666
+ if (!source) {
712
667
  errors.push({
713
668
  file: modulePath,
714
669
  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',
670
+ message: "Lens section missing source:: field (or id:: for inline lens)",
671
+ suggestion: "Add 'source:: [[../Lenses/filename.md|Display]]' or 'id:: <uuid>' with #### segments",
672
+ severity: "error",
718
673
  });
719
674
  return { sections: [], errors };
720
675
  }
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;
676
+ // Parse and resolve the wikilink
677
+ const wikilink = parseWikilink(source);
678
+ if (!wikilink || wikilink.error) {
679
+ const suggestion = wikilink?.correctedPath
680
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
681
+ : "Use format [[../Lenses/filename.md|Display Text]]";
682
+ errors.push({
683
+ file: modulePath,
684
+ line: section.line,
685
+ message: wikilink?.error
686
+ ? `${wikilink.error}: ${source}`
687
+ : `Invalid wikilink format: ${source}`,
688
+ suggestion,
689
+ severity: "error",
690
+ });
691
+ return { sections: [], errors };
692
+ }
693
+ const lensPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
694
+ const lensPath = findFileWithExtension(lensPathResolved, files);
695
+ if (!lensPath) {
696
+ const similarFiles = findSimilarFiles(lensPathResolved, files, "Lenses");
697
+ const suggestion = formatSuggestion(similarFiles, modulePath) ??
698
+ "Check the file path in the wiki-link";
699
+ errors.push({
700
+ file: modulePath,
701
+ message: `Referenced lens file not found: ${lensPathResolved}`,
702
+ suggestion,
703
+ severity: "error",
704
+ });
705
+ return { sections: [], errors };
706
+ }
707
+ // Check tier violation
708
+ if (tierMap) {
709
+ const parentTier = tierMap.get(modulePath) ?? "production";
710
+ const childTier = tierMap.get(lensPath) ?? "production";
711
+ const violation = checkTierViolation(modulePath, parentTier, lensPath, childTier, "lens");
712
+ if (violation) {
713
+ errors.push(violation);
764
714
  }
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);
715
+ if (childTier === "ignored") {
716
+ return { sections: [], errors };
849
717
  }
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
718
  }
719
+ // Check for circular reference
720
+ if (visitedPaths.has(lensPath)) {
721
+ errors.push({
722
+ file: modulePath,
723
+ message: `Circular reference detected: ${lensPath}`,
724
+ severity: "error",
725
+ });
726
+ return { sections: [], errors };
727
+ }
728
+ visitedPaths.add(lensPath);
729
+ const lensContent = files.get(lensPath);
730
+ const lensResult = parseLens(lensContent, lensPath);
731
+ errors.push(...lensResult.errors);
732
+ if (!lensResult.lens) {
733
+ return { sections: [], errors };
734
+ }
735
+ const lens = lensResult.lens;
736
+ const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
737
+ errors.push(...flattenErrors);
738
+ const effectiveOptional = section.fields.optional?.toLowerCase() === "true";
739
+ const effectiveHide = section.fields.hide?.toLowerCase() === "true";
740
+ const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
741
+ if (hideConflict)
742
+ errors.push(hideConflict);
743
+ const resultSection = {
744
+ type: "lens",
745
+ meta: { title: lens.title || section.title },
746
+ segments,
747
+ sourcePath: lensPath,
748
+ optional: effectiveOptional,
749
+ ...(effectiveHide && { hide: true }),
750
+ learningOutcomeId: null,
751
+ learningOutcomeName: null,
752
+ contentId: lens.id ?? null,
753
+ tldr: lens.tldr,
754
+ summaryForTutor: lens.summaryForTutor,
755
+ addToAiContext: lens.addToAiContext,
756
+ ...computeSectionStats(segments),
757
+ displayType: computeDisplayType(segments),
758
+ };
759
+ sections.push(resultSection);
865
760
  return { sections, errors };
866
761
  }
762
+ // ─── Collapsed content and segment conversion ───
867
763
  /**
868
- * Parse ## Lens: subsections from an Uncategorized section's body.
869
- * Returns an array of lens references with resolved paths.
764
+ * After converting segments, apply collapsed_before/collapsed_after to article segments.
765
+ * Groups article segments by their resolved source path, then calls bundleArticleWithCollapsed
766
+ * for each group.
870
767
  */
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 = [];
768
+ function applyCollapsedContent(segments, parsedSegments, lensPath, files) {
769
+ // Build groups of article segments by resolved source path
770
+ const sourceGroups = new Map();
771
+ // Map output article segments back to parsed segments for source info
772
+ let articleOutputIdx = 0;
773
+ for (let i = 0; i < parsedSegments.length; i++) {
774
+ const ps = parsedSegments[i];
775
+ if (ps.type !== "article")
908
776
  continue;
777
+ // Find corresponding output segment
778
+ while (articleOutputIdx < segments.length &&
779
+ segments[articleOutputIdx].type !== "article") {
780
+ articleOutputIdx++;
909
781
  }
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',
782
+ if (articleOutputIdx >= segments.length)
783
+ break;
784
+ if (ps.source) {
785
+ const wikilink = parseWikilink(ps.source);
786
+ if (wikilink && !wikilink.error) {
787
+ const resolved = resolveWikilinkPath(wikilink.path, lensPath);
788
+ const actualPath = findFileWithExtension(resolved, files);
789
+ if (actualPath) {
790
+ if (!sourceGroups.has(actualPath)) {
791
+ sourceGroups.set(actualPath, []);
792
+ }
793
+ sourceGroups.get(actualPath).push({
794
+ segmentIndex: articleOutputIdx,
795
+ fromAnchor: ps.fromAnchor,
796
+ toAnchor: ps.toAnchor,
924
797
  });
925
798
  }
926
799
  }
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
800
  }
801
+ articleOutputIdx++;
957
802
  }
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
- });
803
+ // For each source group, apply collapsed content
804
+ for (const [articlePath, excerptInfos] of sourceGroups) {
805
+ // Only apply collapsed if at least one excerpt has anchors
806
+ const hasAnchors = excerptInfos.some((e) => e.fromAnchor || e.toAnchor);
807
+ if (!hasAnchors)
808
+ continue;
809
+ const articleContent = files.get(articlePath);
810
+ if (!articleContent)
811
+ continue;
812
+ const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map((e) => ({ from: e.fromAnchor, to: e.toAnchor })), articlePath);
813
+ for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
814
+ const segIdx = excerptInfos[i].segmentIndex;
815
+ const collapsed = collapsedResults[i];
816
+ const segment = segments[segIdx];
817
+ if (collapsed.collapsed_before) {
818
+ segment.collapsed_before = collapsed.collapsed_before;
819
+ }
820
+ if (collapsed.collapsed_after) {
821
+ segment.collapsed_after = collapsed.collapsed_after;
972
822
  }
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
823
  }
1028
824
  }
1029
825
  }
1030
826
  /**
1031
827
  * Convert a parsed lens segment into a final flattened segment.
1032
- * For article-excerpt and video-excerpt, this involves extracting content from source files.
828
+ * For article and video, this involves extracting content from source files.
829
+ * Source comes from the parsed segment itself (set via inheritance in parseLens).
1033
830
  */
1034
- function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap) {
831
+ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
1035
832
  const errors = [];
1036
833
  switch (parsedSegment.type) {
1037
- case 'text': {
834
+ case "text": {
835
+ const resolved = resolveTextLinks(parsedSegment.content, lensPath, files);
836
+ errors.push(...resolved.errors);
1038
837
  const segment = {
1039
- type: 'text',
1040
- content: parsedSegment.content,
838
+ type: "text",
839
+ content: resolved.content,
1041
840
  };
1042
841
  if (parsedSegment.optional) {
1043
842
  segment.optional = true;
1044
843
  }
1045
844
  return { segment, errors };
1046
845
  }
1047
- case 'chat': {
846
+ case "chat": {
1048
847
  const segment = {
1049
- type: 'chat',
848
+ type: "chat",
1050
849
  };
1051
850
  if (parsedSegment.instructions) {
1052
851
  segment.instructions = parsedSegment.instructions;
@@ -1057,73 +856,72 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1057
856
  if (parsedSegment.hidePreviousContentFromTutor) {
1058
857
  segment.hidePreviousContentFromTutor = true;
1059
858
  }
1060
- if (parsedSegment.optional) {
1061
- segment.optional = true;
859
+ if (parsedSegment.minMessages !== undefined) {
860
+ segment.minMessages = parsedSegment.minMessages;
1062
861
  }
1063
862
  return { segment, errors };
1064
863
  }
1065
- case 'article-excerpt': {
1066
- // Need to resolve the article path from the lens section's source field
1067
- if (!lensSection.source) {
864
+ case "article": {
865
+ // Source comes from the parsed segment (set via inheritance in parseLens)
866
+ if (!parsedSegment.source) {
1068
867
  errors.push({
1069
868
  file: lensPath,
1070
- message: 'Article section missing source:: field for article-excerpt',
1071
- severity: 'error',
869
+ message: "Article segment missing source (should have been set via inheritance)",
870
+ severity: "error",
1072
871
  });
1073
872
  return { segment: null, errors };
1074
873
  }
1075
- const wikilink = parseWikilink(lensSection.source);
874
+ const wikilink = parseWikilink(parsedSegment.source);
1076
875
  if (!wikilink || wikilink.error) {
1077
876
  const suggestion = wikilink?.correctedPath
1078
877
  ? `Did you mean '[[${wikilink.correctedPath}]]'?`
1079
878
  : undefined;
1080
879
  errors.push({
1081
880
  file: lensPath,
1082
- message: `Invalid wikilink in article source: ${lensSection.source}`,
881
+ message: `Invalid wikilink in article source: ${parsedSegment.source}`,
1083
882
  suggestion,
1084
- severity: 'error',
883
+ severity: "error",
1085
884
  });
1086
885
  return { segment: null, errors };
1087
886
  }
1088
887
  const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
1089
888
  const articlePath = findFileWithExtension(articlePathResolved, files);
1090
889
  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';
890
+ const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
891
+ const suggestion = formatSuggestion(similarFiles, lensPath) ??
892
+ "Check the file path in the wiki-link";
1094
893
  errors.push({
1095
894
  file: lensPath,
1096
895
  message: `Referenced article file not found: ${articlePathResolved}`,
1097
896
  suggestion,
1098
- severity: 'error',
897
+ severity: "error",
1099
898
  });
1100
899
  return { segment: null, errors };
1101
900
  }
1102
- // Check tier violation (Lens Article)
901
+ // Check tier violation (Lens -> Article)
1103
902
  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');
903
+ const parentTier = tierMap.get(lensPath) ?? "production";
904
+ const childTier = tierMap.get(articlePath) ?? "production";
905
+ const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
1107
906
  if (violation) {
1108
907
  errors.push(violation);
1109
908
  }
1110
- if (childTier === 'ignored') {
909
+ if (childTier === "ignored") {
1111
910
  return { segment: null, errors };
1112
911
  }
1113
912
  }
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
913
  if (visitedPaths.has(articlePath)) {
1119
914
  errors.push({
1120
915
  file: lensPath,
1121
916
  message: `Circular reference detected: ${articlePath}`,
1122
- severity: 'error',
917
+ severity: "error",
1123
918
  });
1124
919
  return { segment: null, errors };
1125
920
  }
1126
921
  const articleContent = files.get(articlePath);
922
+ // Extract article metadata from frontmatter
923
+ const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
924
+ const fm = articleFrontmatter.frontmatter;
1127
925
  // Extract the excerpt
1128
926
  const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
1129
927
  if (excerptResult.error) {
@@ -1131,88 +929,93 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1131
929
  return { segment: null, errors };
1132
930
  }
1133
931
  const segment = {
1134
- type: 'article-excerpt',
932
+ type: "article",
1135
933
  content: excerptResult.content,
1136
934
  };
935
+ // Populate metadata from article frontmatter
936
+ if (fm.title)
937
+ segment.title = fm.title;
938
+ if (fm.author) {
939
+ const raw = fm.author;
940
+ segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
941
+ }
942
+ if (fm.source_url)
943
+ segment.sourceUrl = fm.source_url;
944
+ if (fm.published)
945
+ segment.published = String(fm.published);
946
+ segment.sourcePath = articlePath;
1137
947
  if (parsedSegment.optional) {
1138
948
  segment.optional = true;
1139
949
  }
1140
950
  return { segment, errors };
1141
951
  }
1142
- case 'video-excerpt': {
1143
- // Need to resolve the video/transcript path from the lens section's source field
1144
- if (!lensSection.source) {
952
+ case "video": {
953
+ // Source comes from the parsed segment (set via inheritance in parseLens)
954
+ if (!parsedSegment.source) {
1145
955
  errors.push({
1146
956
  file: lensPath,
1147
- message: 'Video section missing source:: field for video-excerpt',
1148
- severity: 'error',
957
+ message: "Video segment missing source (should have been set via inheritance)",
958
+ severity: "error",
1149
959
  });
1150
960
  return { segment: null, errors };
1151
961
  }
1152
- const wikilink = parseWikilink(lensSection.source);
962
+ const wikilink = parseWikilink(parsedSegment.source);
1153
963
  if (!wikilink || wikilink.error) {
1154
964
  const suggestion = wikilink?.correctedPath
1155
965
  ? `Did you mean '[[${wikilink.correctedPath}]]'?`
1156
966
  : undefined;
1157
967
  errors.push({
1158
968
  file: lensPath,
1159
- message: `Invalid wikilink in video source: ${lensSection.source}`,
969
+ message: `Invalid wikilink in video source: ${parsedSegment.source}`,
1160
970
  suggestion,
1161
- severity: 'error',
971
+ severity: "error",
1162
972
  });
1163
973
  return { segment: null, errors };
1164
974
  }
1165
975
  const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
1166
976
  const videoPath = findFileWithExtension(videoPathResolved, files);
1167
977
  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';
978
+ const similarFiles = findSimilarFiles(videoPathResolved, files, "video_transcripts");
979
+ const suggestion = formatSuggestion(similarFiles, lensPath) ??
980
+ "Check the file path in the wiki-link";
1171
981
  errors.push({
1172
982
  file: lensPath,
1173
983
  message: `Referenced video transcript file not found: ${videoPathResolved}`,
1174
984
  suggestion,
1175
- severity: 'error',
985
+ severity: "error",
1176
986
  });
1177
987
  return { segment: null, errors };
1178
988
  }
1179
- // Check tier violation (Lens Video)
989
+ // Check tier violation (Lens -> Video)
1180
990
  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');
991
+ const parentTier = tierMap.get(lensPath) ?? "production";
992
+ const childTier = tierMap.get(videoPath) ?? "production";
993
+ const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, "video transcript");
1184
994
  if (violation) {
1185
995
  errors.push(violation);
1186
996
  }
1187
- if (childTier === 'ignored') {
997
+ if (childTier === "ignored") {
1188
998
  return { segment: null, errors };
1189
999
  }
1190
1000
  }
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
1001
  if (visitedPaths.has(videoPath)) {
1196
1002
  errors.push({
1197
1003
  file: lensPath,
1198
1004
  message: `Circular reference detected: ${videoPath}`,
1199
- severity: 'error',
1005
+ severity: "error",
1200
1006
  });
1201
1007
  return { segment: null, errors };
1202
1008
  }
1203
1009
  const transcriptContent = files.get(videoPath);
1010
+ // Extract video metadata from frontmatter
1011
+ const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
1012
+ const vfm = videoFrontmatter.frontmatter;
1204
1013
  // 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');
1014
+ const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
1207
1015
  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
1016
  if (files.has(timestampsPath)) {
1213
1017
  try {
1214
1018
  timestamps = JSON.parse(files.get(timestampsPath));
1215
- // console.log('DEBUG loaded timestamps count:', timestamps.length);
1216
1019
  }
1217
1020
  catch {
1218
1021
  // JSON parse error - will fall back to inline timestamps
@@ -1225,19 +1028,32 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1225
1028
  return { segment: null, errors };
1226
1029
  }
1227
1030
  const segment = {
1228
- type: 'video-excerpt',
1031
+ type: "video",
1229
1032
  from: excerptResult.from,
1230
1033
  to: excerptResult.to,
1231
1034
  transcript: excerptResult.transcript,
1232
1035
  };
1036
+ // Populate metadata from video transcript frontmatter
1037
+ if (vfm.title)
1038
+ segment.title = vfm.title;
1039
+ if (vfm.channel)
1040
+ segment.channel = vfm.channel;
1041
+ if (vfm.url) {
1042
+ const extracted = extractVideoFromUrl(vfm.url);
1043
+ if (extracted) {
1044
+ segment.videoId = extracted.videoId;
1045
+ if (extracted.isShort)
1046
+ segment.isShort = true;
1047
+ }
1048
+ }
1233
1049
  if (parsedSegment.optional) {
1234
1050
  segment.optional = true;
1235
1051
  }
1236
1052
  return { segment, errors };
1237
1053
  }
1238
- case 'question': {
1054
+ case "question": {
1239
1055
  const segment = {
1240
- type: 'question',
1056
+ type: "question",
1241
1057
  content: parsedSegment.content,
1242
1058
  };
1243
1059
  if (parsedSegment.assessmentInstructions)
@@ -1248,15 +1064,15 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1248
1064
  segment.maxChars = parsedSegment.maxChars;
1249
1065
  if (parsedSegment.enforceVoice)
1250
1066
  segment.enforceVoice = true;
1251
- if (parsedSegment.optional)
1252
- segment.optional = true;
1253
1067
  if (parsedSegment.feedback)
1254
1068
  segment.feedback = true;
1069
+ if (parsedSegment.minMessages !== undefined)
1070
+ segment.minMessages = parsedSegment.minMessages;
1255
1071
  return { segment, errors };
1256
1072
  }
1257
- case 'roleplay': {
1073
+ case "roleplay": {
1258
1074
  const segment = {
1259
- type: 'roleplay',
1075
+ type: "roleplay",
1260
1076
  id: parsedSegment.id,
1261
1077
  content: parsedSegment.content,
1262
1078
  aiInstructions: parsedSegment.aiInstructions,
@@ -1265,6 +1081,8 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1265
1081
  segment.openingMessage = parsedSegment.openingMessage;
1266
1082
  if (parsedSegment.assessmentInstructions)
1267
1083
  segment.assessmentInstructions = parsedSegment.assessmentInstructions;
1084
+ if (parsedSegment.userCustomizable)
1085
+ segment.userCustomizable = true;
1268
1086
  if (parsedSegment.optional)
1269
1087
  segment.optional = true;
1270
1088
  if (parsedSegment.feedback)
@@ -1277,25 +1095,11 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
1277
1095
  }
1278
1096
  /**
1279
1097
  * 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
1098
  */
1295
1099
  export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1296
1100
  const errors = [];
1297
1101
  // Skip ignored lenses
1298
- if (tierMap?.get(lensPath) === 'ignored') {
1102
+ if (tierMap?.get(lensPath) === "ignored") {
1299
1103
  return { module: null, modules: [], errors };
1300
1104
  }
1301
1105
  const lensContent = files.get(lensPath);
@@ -1303,109 +1107,67 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1303
1107
  errors.push({
1304
1108
  file: lensPath,
1305
1109
  message: `Lens file not found: ${lensPath}`,
1306
- severity: 'error',
1110
+ severity: "error",
1307
1111
  });
1308
1112
  return { module: null, modules: [], errors };
1309
1113
  }
1310
1114
  // 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
- })();
1115
+ const lens = preParsedLens ??
1116
+ (() => {
1117
+ const lensResult = parseLens(lensContent, lensPath);
1118
+ errors.push(...lensResult.errors);
1119
+ return lensResult.lens;
1120
+ })();
1316
1121
  if (!lens) {
1317
1122
  return { module: null, modules: [], errors };
1318
1123
  }
1319
1124
  const visitedPaths = new Set([lensPath]);
1320
- // Process lens sections into a single flattened Section
1321
- let sectionType = 'page';
1125
+ // Flatten lens segments using shared helper
1126
+ const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
1127
+ errors.push(...flattenErrors);
1128
+ // Derive title: frontmatter > first article/video segment > filename
1322
1129
  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
- }
1130
+ if (lens.title) {
1131
+ meta.title = lens.title;
1132
+ }
1133
+ else {
1134
+ for (const seg of segments) {
1135
+ if (seg.type === "article" && seg.title) {
1136
+ meta.title = seg.title;
1137
+ break;
1347
1138
  }
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
- }
1139
+ else if (seg.type === "video" && seg.title) {
1140
+ meta.title = seg.title;
1141
+ break;
1376
1142
  }
1377
1143
  }
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);
1144
+ if (!meta.title) {
1145
+ meta.title = fileNameToSlug(lensPath)
1146
+ .replace(/-/g, " ")
1147
+ .replace(/\b\w/g, (c) => c.toUpperCase());
1384
1148
  }
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
1149
  }
1392
1150
  const section = {
1393
- type: sectionType,
1151
+ type: "lens",
1394
1152
  meta,
1395
1153
  segments,
1154
+ sourcePath: lensPath,
1396
1155
  optional: false,
1397
1156
  learningOutcomeId: null,
1398
1157
  learningOutcomeName: null,
1399
1158
  contentId: lens.id ?? null,
1400
1159
  tldr: lens.tldr,
1401
- videoId: videoId ?? null,
1160
+ summaryForTutor: lens.summaryForTutor,
1161
+ addToAiContext: lens.addToAiContext,
1402
1162
  ...computeSectionStats(segments),
1163
+ displayType: computeDisplayType(segments),
1403
1164
  };
1404
1165
  const flattenedModule = {
1405
- slug: 'lens/' + fileNameToSlug(lensPath),
1166
+ slug: "lens/" + fileNameToSlug(lensPath),
1406
1167
  title: meta.title ?? fileNameToSlug(lensPath),
1407
1168
  contentId: lens.id ?? null,
1408
1169
  sections: [section],
1170
+ sourcePath: lensPath,
1409
1171
  };
1410
1172
  return { module: flattenedModule, modules: [flattenedModule], errors };
1411
1173
  }