lens-content-processor 0.2.1 → 0.3.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 (75) hide show
  1. package/dist/bundler/video.js +4 -4
  2. package/dist/bundler/video.js.map +1 -1
  3. package/dist/cli.d.ts +1 -2
  4. package/dist/cli.js +11 -9
  5. package/dist/cli.js.map +1 -1
  6. package/dist/content-schema.d.ts +34 -0
  7. package/dist/content-schema.js +62 -0
  8. package/dist/content-schema.js.map +1 -0
  9. package/dist/flattener/index.d.ts +21 -1
  10. package/dist/flattener/index.js +316 -33
  11. package/dist/flattener/index.js.map +1 -1
  12. package/dist/fs/read-vault.d.ts +1 -4
  13. package/dist/fs/read-vault.js +4 -8
  14. package/dist/fs/read-vault.js.map +1 -1
  15. package/dist/index.d.ts +25 -2
  16. package/dist/index.js +231 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/parser/article.d.ts +16 -0
  19. package/dist/parser/article.js +70 -0
  20. package/dist/parser/article.js.map +1 -0
  21. package/dist/parser/course.d.ts +2 -2
  22. package/dist/parser/course.js +18 -35
  23. package/dist/parser/course.js.map +1 -1
  24. package/dist/parser/frontmatter.js +2 -0
  25. package/dist/parser/frontmatter.js.map +1 -1
  26. package/dist/parser/learning-outcome.d.ts +4 -2
  27. package/dist/parser/learning-outcome.js +65 -23
  28. package/dist/parser/learning-outcome.js.map +1 -1
  29. package/dist/parser/lens.d.ts +38 -1
  30. package/dist/parser/lens.js +173 -18
  31. package/dist/parser/lens.js.map +1 -1
  32. package/dist/parser/module.d.ts +11 -5
  33. package/dist/parser/module.js +172 -76
  34. package/dist/parser/module.js.map +1 -1
  35. package/dist/parser/sections.js +116 -13
  36. package/dist/parser/sections.js.map +1 -1
  37. package/dist/parser/video-transcript.d.ts +11 -0
  38. package/dist/parser/video-transcript.js +39 -0
  39. package/dist/parser/video-transcript.js.map +1 -0
  40. package/dist/parser/wikilink.d.ts +2 -0
  41. package/dist/parser/wikilink.js +88 -24
  42. package/dist/parser/wikilink.js.map +1 -1
  43. package/dist/utils/slug.d.ts +5 -0
  44. package/dist/utils/slug.js +16 -0
  45. package/dist/utils/slug.js.map +1 -0
  46. package/dist/validator/chat-precedence.d.ts +9 -0
  47. package/dist/validator/chat-precedence.js +32 -0
  48. package/dist/validator/chat-precedence.js.map +1 -0
  49. package/dist/validator/duplicates.d.ts +13 -0
  50. package/dist/validator/duplicates.js +27 -0
  51. package/dist/validator/duplicates.js.map +1 -0
  52. package/dist/validator/field-typos.d.ts +11 -0
  53. package/dist/validator/field-typos.js +36 -28
  54. package/dist/validator/field-typos.js.map +1 -1
  55. package/dist/validator/field-values.d.ts +14 -0
  56. package/dist/validator/field-values.js +61 -7
  57. package/dist/validator/field-values.js.map +1 -1
  58. package/dist/validator/output-integrity.d.ts +7 -0
  59. package/dist/validator/output-integrity.js +57 -0
  60. package/dist/validator/output-integrity.js.map +1 -0
  61. package/dist/validator/segment-fields.js +1 -15
  62. package/dist/validator/segment-fields.js.map +1 -1
  63. package/dist/validator/tier.d.ts +18 -0
  64. package/dist/validator/tier.js +74 -0
  65. package/dist/validator/tier.js.map +1 -0
  66. package/dist/validator/timestamps.d.ts +2 -0
  67. package/dist/validator/timestamps.js +91 -0
  68. package/dist/validator/timestamps.js.map +1 -0
  69. package/dist/validator/url-reachability.d.ts +8 -0
  70. package/dist/validator/url-reachability.js +60 -0
  71. package/dist/validator/url-reachability.js.map +1 -0
  72. package/dist/validator/validate-frontmatter.d.ts +9 -0
  73. package/dist/validator/validate-frontmatter.js +53 -0
  74. package/dist/validator/validate-frontmatter.js.map +1 -0
  75. package/package.json +5 -5
@@ -1,8 +1,10 @@
1
- import { parseModule, parsePageTextSegments } from '../parser/module.js';
1
+ import { checkTierViolation } from '../validator/tier.js';
2
+ import { parseModule, parsePageSegments } from '../parser/module.js';
2
3
  import { parseLearningOutcome } from '../parser/learning-outcome.js';
3
4
  import { parseLens } from '../parser/lens.js';
4
5
  import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion } from '../parser/wikilink.js';
5
6
  import { parseFrontmatter } from '../parser/frontmatter.js';
7
+ import { fileNameToSlug } from '../utils/slug.js';
6
8
  import { extractArticleExcerpt } from '../bundler/article.js';
7
9
  import { extractVideoExcerpt } from '../bundler/video.js';
8
10
  /**
@@ -34,7 +36,7 @@ function extractVideoIdFromUrl(url) {
34
36
  * @param visitedPaths - Optional set of already-visited paths for cycle detection
35
37
  * @returns Flattened module with resolved sections and segments, plus any errors
36
38
  */
37
- export function flattenModule(modulePath, files, visitedPaths = new Set()) {
39
+ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierMap) {
38
40
  // Check for circular reference
39
41
  if (visitedPaths.has(modulePath)) {
40
42
  return {
@@ -75,21 +77,23 @@ export function flattenModule(modulePath, files, visitedPaths = new Set()) {
75
77
  // This allows the same file to be referenced in different sections
76
78
  // while still detecting cycles within a single chain
77
79
  const sectionVisitedPaths = new Set(visitedPaths);
78
- const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths);
80
+ const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths, tierMap);
79
81
  errors.push(...result.errors);
80
82
  flattenedSections.push(...result.sections);
81
83
  }
82
84
  else if (section.type === 'page') {
83
85
  // Page sections don't have LO references, they have inline content
84
86
  // Parse the section body for ## Text subsections
85
- const textSegments = parsePageTextSegments(section.body);
87
+ const textResult = parsePageSegments(section.body, modulePath, section.line);
88
+ errors.push(...textResult.errors);
86
89
  const pageSection = {
87
90
  type: 'page',
88
91
  meta: { title: section.title },
89
- segments: textSegments,
90
- optional: section.fields.optional === 'true',
92
+ segments: textResult.segments,
93
+ optional: section.fields.optional?.toLowerCase() === 'true',
91
94
  contentId: section.fields.id ?? null,
92
95
  learningOutcomeId: null,
96
+ learningOutcomeName: null,
93
97
  videoId: null,
94
98
  };
95
99
  flattenedSections.push(pageSection);
@@ -98,7 +102,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set()) {
98
102
  // Uncategorized sections can contain ## Lens: references
99
103
  // Each lens becomes its own section (lens-video, lens-article, or page)
100
104
  const sectionVisitedPaths = new Set(visitedPaths);
101
- const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths);
105
+ const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths, tierMap);
102
106
  errors.push(...result.errors);
103
107
  flattenedSections.push(...result.sections);
104
108
  }
@@ -118,7 +122,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set()) {
118
122
  * Flatten a Learning Outcome section by resolving its LO file and all referenced lenses.
119
123
  * Each lens in the LO becomes its own section.
120
124
  */
121
- function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths) {
125
+ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths, tierMap) {
122
126
  const errors = [];
123
127
  const sections = [];
124
128
  // Get the source wikilink
@@ -135,12 +139,17 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
135
139
  }
136
140
  // Parse and resolve the wikilink
137
141
  const wikilink = parseWikilink(source);
138
- if (!wikilink) {
142
+ if (!wikilink || wikilink.error) {
143
+ const suggestion = wikilink?.correctedPath
144
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
145
+ : 'Use format [[../Learning Outcomes/filename.md|Display Text]]';
139
146
  errors.push({
140
147
  file: modulePath,
141
148
  line: section.line,
142
- message: `Invalid wikilink format: ${source}`,
143
- suggestion: 'Use format [[../Learning Outcomes/filename.md|Display Text]]',
149
+ message: wikilink?.error
150
+ ? `${wikilink.error}: ${source}`
151
+ : `Invalid wikilink format: ${source}`,
152
+ suggestion,
144
153
  severity: 'error',
145
154
  });
146
155
  return { sections: [], errors };
@@ -161,6 +170,19 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
161
170
  });
162
171
  return { sections: [], errors };
163
172
  }
173
+ // Check tier violation (module → LO)
174
+ if (tierMap) {
175
+ const parentTier = tierMap.get(modulePath) ?? 'production';
176
+ const childTier = tierMap.get(loPath) ?? 'production';
177
+ const violation = checkTierViolation(modulePath, parentTier, loPath, childTier, 'learning outcome', section.line);
178
+ if (violation) {
179
+ errors.push(violation);
180
+ }
181
+ // Skip ignored children silently (they're not processed)
182
+ if (childTier === 'ignored') {
183
+ return { sections: [], errors };
184
+ }
185
+ }
164
186
  // Check for circular reference
165
187
  if (visitedPaths.has(loPath)) {
166
188
  errors.push({
@@ -195,6 +217,18 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
195
217
  });
196
218
  continue;
197
219
  }
220
+ // Check tier violation (LO → Lens)
221
+ if (tierMap) {
222
+ const parentTier = tierMap.get(loPath) ?? 'production';
223
+ const childTier = tierMap.get(lensPath) ?? 'production';
224
+ const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, 'lens');
225
+ if (violation) {
226
+ errors.push(violation);
227
+ }
228
+ if (childTier === 'ignored') {
229
+ continue;
230
+ }
231
+ }
198
232
  // Check for circular reference
199
233
  if (visitedPaths.has(lensPath)) {
200
234
  errors.push({
@@ -226,7 +260,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
226
260
  // Extract article metadata from the article file's frontmatter
227
261
  if (lensSection.source) {
228
262
  const articleWikilink = parseWikilink(lensSection.source);
229
- if (articleWikilink) {
263
+ if (articleWikilink && !articleWikilink.error) {
230
264
  const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
231
265
  const articlePath = findFileWithExtension(articlePathResolved, files);
232
266
  if (articlePath) {
@@ -237,21 +271,29 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
237
271
  meta.title = articleFrontmatter.frontmatter.title;
238
272
  }
239
273
  if (articleFrontmatter.frontmatter.author) {
240
- meta.author = articleFrontmatter.frontmatter.author;
274
+ const raw = articleFrontmatter.frontmatter.author;
275
+ meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
241
276
  }
242
- if (articleFrontmatter.frontmatter.sourceUrl) {
243
- meta.sourceUrl = articleFrontmatter.frontmatter.sourceUrl;
277
+ if (articleFrontmatter.frontmatter.source_url) {
278
+ meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
244
279
  }
245
280
  }
246
281
  }
247
282
  }
248
283
  }
284
+ else if (lensSection.type === 'page') {
285
+ sectionType = 'page';
286
+ // For page sections, use the title from the ### Page: header
287
+ if (lensSection.title) {
288
+ meta.title = lensSection.title;
289
+ }
290
+ }
249
291
  else if (lensSection.type === 'lens-video') {
250
292
  sectionType = 'lens-video';
251
293
  // Extract video metadata from the video transcript file's frontmatter
252
294
  if (lensSection.source) {
253
295
  const videoWikilink = parseWikilink(lensSection.source);
254
- if (videoWikilink) {
296
+ if (videoWikilink && !videoWikilink.error) {
255
297
  const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
256
298
  const videoPath = findFileWithExtension(videoPathResolved, files);
257
299
  if (videoPath) {
@@ -277,7 +319,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
277
319
  }
278
320
  // Process segments
279
321
  for (const parsedSegment of lensSection.segments) {
280
- const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
322
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
281
323
  errors.push(...segmentResult.errors);
282
324
  if (segmentResult.segment) {
283
325
  segments.push(segmentResult.segment);
@@ -292,26 +334,67 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
292
334
  type: sectionType,
293
335
  meta,
294
336
  segments,
295
- optional: section.fields.optional === 'true' || lensRef.optional,
337
+ optional: section.fields.optional?.toLowerCase() === 'true' || lensRef.optional,
296
338
  learningOutcomeId: lo.id ?? null,
339
+ learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
297
340
  contentId: lens.id ?? null,
298
341
  videoId: videoId ?? null,
299
342
  };
300
343
  sections.push(resultSection);
301
344
  }
345
+ // After lens processing, add test section if present with inline segments
346
+ if (lo.test && lo.test.segments.length > 0) {
347
+ const testSegments = [];
348
+ for (const parsedSegment of lo.test.segments) {
349
+ // For test sections with question/chat/text segments, no source file resolution needed
350
+ // Create a minimal stub lensSection since these segment types don't need it
351
+ const stubLensSection = {
352
+ type: 'page',
353
+ title: 'Test',
354
+ segments: [],
355
+ line: 0,
356
+ };
357
+ const segmentResult = convertSegment(parsedSegment, stubLensSection, loPath, files, visitedPaths, tierMap);
358
+ errors.push(...segmentResult.errors);
359
+ if (segmentResult.segment) {
360
+ testSegments.push(segmentResult.segment);
361
+ }
362
+ }
363
+ if (testSegments.length > 0) {
364
+ const hasFeedback = testSegments.some((s) => s.type === 'question' && s.feedback);
365
+ sections.push({
366
+ type: 'test',
367
+ meta: { title: 'Test' },
368
+ segments: testSegments,
369
+ optional: false,
370
+ ...(hasFeedback && { feedback: true }),
371
+ contentId: null,
372
+ learningOutcomeId: lo.id ?? null,
373
+ learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
374
+ videoId: null,
375
+ });
376
+ }
377
+ }
302
378
  return { sections, errors };
303
379
  }
304
380
  /**
305
381
  * Flatten an Uncategorized section by parsing its ## Lens: references.
306
382
  * Each lens becomes its own section with the appropriate type (lens-video, lens-article, or page).
307
383
  */
308
- function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
384
+ function flattenUncategorizedSection(section, modulePath, files, visitedPaths, tierMap) {
309
385
  const errors = [];
310
386
  const sections = [];
311
387
  // Parse the section body for ## Lens: subsections
312
388
  const lensRefs = parseUncategorizedLensRefs(section.body, modulePath);
313
- // If no lens refs found, return empty array
389
+ // If no lens refs found, warn and return empty array
314
390
  if (lensRefs.length === 0) {
391
+ errors.push({
392
+ file: modulePath,
393
+ line: section.line,
394
+ message: 'Uncategorized section has no ## Lens: references — this section will produce no output',
395
+ suggestion: "Add '## Lens: [[../Lenses/lens-name.md|Display]]' references",
396
+ severity: 'warning',
397
+ });
315
398
  return { sections: [], errors };
316
399
  }
317
400
  // Process each lens reference as a separate section
@@ -329,6 +412,18 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
329
412
  });
330
413
  continue;
331
414
  }
415
+ // Check tier violation (Uncategorized/Module → Lens)
416
+ if (tierMap) {
417
+ const parentTier = tierMap.get(modulePath) ?? 'production';
418
+ const childTier = tierMap.get(lensPath) ?? 'production';
419
+ const violation = checkTierViolation(modulePath, parentTier, lensPath, childTier, 'lens');
420
+ if (violation) {
421
+ errors.push(violation);
422
+ }
423
+ if (childTier === 'ignored') {
424
+ continue;
425
+ }
426
+ }
332
427
  // Check for circular reference
333
428
  if (visitedPaths.has(lensPath)) {
334
429
  errors.push({
@@ -360,7 +455,7 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
360
455
  // Extract article metadata from the article file's frontmatter
361
456
  if (lensSection.source) {
362
457
  const articleWikilink = parseWikilink(lensSection.source);
363
- if (articleWikilink) {
458
+ if (articleWikilink && !articleWikilink.error) {
364
459
  const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
365
460
  const articlePath = findFileWithExtension(articlePathResolved, files);
366
461
  if (articlePath) {
@@ -371,21 +466,29 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
371
466
  meta.title = articleFrontmatter.frontmatter.title;
372
467
  }
373
468
  if (articleFrontmatter.frontmatter.author) {
374
- meta.author = articleFrontmatter.frontmatter.author;
469
+ const raw = articleFrontmatter.frontmatter.author;
470
+ meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
375
471
  }
376
- if (articleFrontmatter.frontmatter.sourceUrl) {
377
- meta.sourceUrl = articleFrontmatter.frontmatter.sourceUrl;
472
+ if (articleFrontmatter.frontmatter.source_url) {
473
+ meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
378
474
  }
379
475
  }
380
476
  }
381
477
  }
382
478
  }
479
+ else if (lensSection.type === 'page') {
480
+ sectionType = 'page';
481
+ // For page sections, use the title from the ### Page: header
482
+ if (lensSection.title) {
483
+ meta.title = lensSection.title;
484
+ }
485
+ }
383
486
  else if (lensSection.type === 'lens-video') {
384
487
  sectionType = 'lens-video';
385
488
  // Extract video metadata from the video transcript file's frontmatter
386
489
  if (lensSection.source) {
387
490
  const videoWikilink = parseWikilink(lensSection.source);
388
- if (videoWikilink) {
491
+ if (videoWikilink && !videoWikilink.error) {
389
492
  const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
390
493
  const videoPath = findFileWithExtension(videoPathResolved, files);
391
494
  if (videoPath) {
@@ -411,7 +514,7 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
411
514
  }
412
515
  // Process segments
413
516
  for (const parsedSegment of lensSection.segments) {
414
- const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
517
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
415
518
  errors.push(...segmentResult.errors);
416
519
  if (segmentResult.segment) {
417
520
  segments.push(segmentResult.segment);
@@ -425,6 +528,7 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
425
528
  segments,
426
529
  optional: lensRef.optional,
427
530
  learningOutcomeId: null,
531
+ learningOutcomeName: null,
428
532
  contentId: lens.id ?? null,
429
533
  videoId: videoId ?? null,
430
534
  };
@@ -464,7 +568,7 @@ function parseUncategorizedLensRefs(body, parentPath) {
464
568
  lensRefs.push({
465
569
  source: currentFields.source,
466
570
  resolvedPath,
467
- optional: currentFields.optional === 'true',
571
+ optional: currentFields.optional?.toLowerCase() === 'true',
468
572
  });
469
573
  }
470
574
  }
@@ -483,12 +587,12 @@ function parseUncategorizedLensRefs(body, parentPath) {
483
587
  }
484
588
  if (currentFields.source) {
485
589
  const wikilink = parseWikilink(currentFields.source);
486
- if (wikilink) {
590
+ if (wikilink && !wikilink.error) {
487
591
  const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
488
592
  lensRefs.push({
489
593
  source: currentFields.source,
490
594
  resolvedPath,
491
- optional: currentFields.optional === 'true',
595
+ optional: currentFields.optional?.toLowerCase() === 'true',
492
596
  });
493
597
  }
494
598
  }
@@ -535,7 +639,7 @@ function parseUncategorizedLensRefs(body, parentPath) {
535
639
  lensRefs.push({
536
640
  source: currentFields.source,
537
641
  resolvedPath,
538
- optional: currentFields.optional === 'true',
642
+ optional: currentFields.optional?.toLowerCase() === 'true',
539
643
  });
540
644
  }
541
645
  }
@@ -546,7 +650,7 @@ function parseUncategorizedLensRefs(body, parentPath) {
546
650
  * Convert a parsed lens segment into a final flattened segment.
547
651
  * For article-excerpt and video-excerpt, this involves extracting content from source files.
548
652
  */
549
- function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths) {
653
+ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap) {
550
654
  const errors = [];
551
655
  switch (parsedSegment.type) {
552
656
  case 'text': {
@@ -588,10 +692,14 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
588
692
  return { segment: null, errors };
589
693
  }
590
694
  const wikilink = parseWikilink(lensSection.source);
591
- if (!wikilink) {
695
+ if (!wikilink || wikilink.error) {
696
+ const suggestion = wikilink?.correctedPath
697
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
698
+ : undefined;
592
699
  errors.push({
593
700
  file: lensPath,
594
701
  message: `Invalid wikilink in article source: ${lensSection.source}`,
702
+ suggestion,
595
703
  severity: 'error',
596
704
  });
597
705
  return { segment: null, errors };
@@ -610,6 +718,18 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
610
718
  });
611
719
  return { segment: null, errors };
612
720
  }
721
+ // Check tier violation (Lens → Article)
722
+ if (tierMap) {
723
+ const parentTier = tierMap.get(lensPath) ?? 'production';
724
+ const childTier = tierMap.get(articlePath) ?? 'production';
725
+ const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, 'article');
726
+ if (violation) {
727
+ errors.push(violation);
728
+ }
729
+ if (childTier === 'ignored') {
730
+ return { segment: null, errors };
731
+ }
732
+ }
613
733
  // Check if the article path points back to an already-visited structural file
614
734
  // This would indicate a circular reference (e.g., a lens source pointing back to an LO)
615
735
  // Note: We only check, we don't add article paths to visitedPaths since
@@ -649,10 +769,14 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
649
769
  return { segment: null, errors };
650
770
  }
651
771
  const wikilink = parseWikilink(lensSection.source);
652
- if (!wikilink) {
772
+ if (!wikilink || wikilink.error) {
773
+ const suggestion = wikilink?.correctedPath
774
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
775
+ : undefined;
653
776
  errors.push({
654
777
  file: lensPath,
655
778
  message: `Invalid wikilink in video source: ${lensSection.source}`,
779
+ suggestion,
656
780
  severity: 'error',
657
781
  });
658
782
  return { segment: null, errors };
@@ -671,6 +795,18 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
671
795
  });
672
796
  return { segment: null, errors };
673
797
  }
798
+ // Check tier violation (Lens → Video)
799
+ if (tierMap) {
800
+ const parentTier = tierMap.get(lensPath) ?? 'production';
801
+ const childTier = tierMap.get(videoPath) ?? 'production';
802
+ const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, 'video transcript');
803
+ if (violation) {
804
+ errors.push(violation);
805
+ }
806
+ if (childTier === 'ignored') {
807
+ return { segment: null, errors };
808
+ }
809
+ }
674
810
  // Check if the video path points back to an already-visited structural file
675
811
  // This would indicate a circular reference
676
812
  // Note: We only check, we don't add video paths to visitedPaths since
@@ -718,8 +854,155 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
718
854
  }
719
855
  return { segment, errors };
720
856
  }
857
+ case 'question': {
858
+ const segment = {
859
+ type: 'question',
860
+ content: parsedSegment.content,
861
+ };
862
+ if (parsedSegment.assessmentInstructions)
863
+ segment.assessmentInstructions = parsedSegment.assessmentInstructions;
864
+ if (parsedSegment.maxTime)
865
+ segment.maxTime = parsedSegment.maxTime;
866
+ if (parsedSegment.maxChars !== undefined)
867
+ segment.maxChars = parsedSegment.maxChars;
868
+ if (parsedSegment.enforceVoice)
869
+ segment.enforceVoice = true;
870
+ if (parsedSegment.optional)
871
+ segment.optional = true;
872
+ if (parsedSegment.feedback)
873
+ segment.feedback = true;
874
+ return { segment, errors };
875
+ }
721
876
  default:
722
877
  return { segment: null, errors };
723
878
  }
724
879
  }
880
+ /**
881
+ * Flatten a single Lens file into a FlattenedModule.
882
+ *
883
+ * This wraps a standalone Lens as a single-section module so it can be
884
+ * rendered by the frontend using the existing Module.tsx component.
885
+ *
886
+ * The resulting module has:
887
+ * - slug: 'lens/' + fileNameToSlug(lensPath)
888
+ * - title: extracted from source metadata or lens header
889
+ * - sections: exactly one section (lens-article, lens-video, or page)
890
+ *
891
+ * @param lensPath - Path to the lens file within the files Map
892
+ * @param files - Map of all file paths to their content
893
+ * @param tierMap - Optional tier map for filtering ignored content
894
+ * @param preParsedLens - Optional pre-parsed lens to avoid re-parsing
895
+ * @returns Flattened module with a single section, plus any errors
896
+ */
897
+ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
898
+ const errors = [];
899
+ // Skip ignored lenses
900
+ if (tierMap?.get(lensPath) === 'ignored') {
901
+ return { module: null, errors };
902
+ }
903
+ const lensContent = files.get(lensPath);
904
+ if (!lensContent) {
905
+ errors.push({
906
+ file: lensPath,
907
+ message: `Lens file not found: ${lensPath}`,
908
+ severity: 'error',
909
+ });
910
+ return { module: null, errors };
911
+ }
912
+ // Use pre-parsed lens if provided, otherwise parse
913
+ const lens = preParsedLens ?? (() => {
914
+ const lensResult = parseLens(lensContent, lensPath);
915
+ errors.push(...lensResult.errors);
916
+ return lensResult.lens;
917
+ })();
918
+ if (!lens) {
919
+ return { module: null, errors };
920
+ }
921
+ const visitedPaths = new Set([lensPath]);
922
+ // Process lens sections into a single flattened Section
923
+ let sectionType = 'page';
924
+ const meta = {};
925
+ const segments = [];
926
+ let videoId;
927
+ for (const lensSection of lens.sections) {
928
+ if (lensSection.type === 'lens-article') {
929
+ sectionType = 'lens-article';
930
+ // Extract article metadata from the article file's frontmatter
931
+ if (lensSection.source) {
932
+ const articleWikilink = parseWikilink(lensSection.source);
933
+ if (articleWikilink && !articleWikilink.error) {
934
+ const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
935
+ const articlePath = findFileWithExtension(articlePathResolved, files);
936
+ if (articlePath) {
937
+ const articleContent = files.get(articlePath);
938
+ const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
939
+ if (articleFrontmatter.frontmatter.title)
940
+ meta.title = articleFrontmatter.frontmatter.title;
941
+ if (articleFrontmatter.frontmatter.author)
942
+ meta.author = articleFrontmatter.frontmatter.author;
943
+ if (articleFrontmatter.frontmatter.source_url)
944
+ meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
945
+ }
946
+ }
947
+ }
948
+ }
949
+ else if (lensSection.type === 'page') {
950
+ sectionType = 'page';
951
+ if (lensSection.title)
952
+ meta.title = lensSection.title;
953
+ }
954
+ else if (lensSection.type === 'lens-video') {
955
+ sectionType = 'lens-video';
956
+ // Extract video metadata from the video transcript file's frontmatter
957
+ if (lensSection.source) {
958
+ const videoWikilink = parseWikilink(lensSection.source);
959
+ if (videoWikilink && !videoWikilink.error) {
960
+ const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
961
+ const videoPath = findFileWithExtension(videoPathResolved, files);
962
+ if (videoPath) {
963
+ const videoContent = files.get(videoPath);
964
+ const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
965
+ if (videoFrontmatter.frontmatter.title)
966
+ meta.title = videoFrontmatter.frontmatter.title;
967
+ if (videoFrontmatter.frontmatter.channel)
968
+ meta.channel = videoFrontmatter.frontmatter.channel;
969
+ if (videoFrontmatter.frontmatter.url) {
970
+ const extractedId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
971
+ if (extractedId)
972
+ videoId = extractedId;
973
+ }
974
+ }
975
+ }
976
+ }
977
+ }
978
+ // Process segments
979
+ for (const parsedSegment of lensSection.segments) {
980
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
981
+ errors.push(...segmentResult.errors);
982
+ if (segmentResult.segment)
983
+ segments.push(segmentResult.segment);
984
+ }
985
+ }
986
+ // Fallback title from filename if not extracted from source metadata
987
+ if (!meta.title) {
988
+ meta.title = fileNameToSlug(lensPath).replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
989
+ }
990
+ const section = {
991
+ type: sectionType,
992
+ meta,
993
+ segments,
994
+ optional: false,
995
+ learningOutcomeId: null,
996
+ learningOutcomeName: null,
997
+ contentId: lens.id ?? null,
998
+ videoId: videoId ?? null,
999
+ };
1000
+ const flattenedModule = {
1001
+ slug: 'lens/' + fileNameToSlug(lensPath),
1002
+ title: meta.title ?? fileNameToSlug(lensPath),
1003
+ contentId: lens.id ?? null,
1004
+ sections: [section],
1005
+ };
1006
+ return { module: flattenedModule, errors };
1007
+ }
725
1008
  //# sourceMappingURL=index.js.map