lens-content-processor 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/bundler/article.d.ts +7 -2
  2. package/dist/bundler/article.js +23 -24
  3. package/dist/bundler/article.js.map +1 -1
  4. package/dist/cli.d.ts +1 -2
  5. package/dist/cli.js +6 -10
  6. package/dist/cli.js.map +1 -1
  7. package/dist/content-schema.js +8 -6
  8. package/dist/content-schema.js.map +1 -1
  9. package/dist/flattener/index.d.ts +41 -2
  10. package/dist/flattener/index.js +699 -48
  11. package/dist/flattener/index.js.map +1 -1
  12. package/dist/flattener/test-helpers.d.ts +23 -0
  13. package/dist/flattener/test-helpers.js +58 -0
  14. package/dist/flattener/test-helpers.js.map +1 -0
  15. package/dist/fs/read-vault.d.ts +1 -4
  16. package/dist/fs/read-vault.js +4 -8
  17. package/dist/fs/read-vault.js.map +1 -1
  18. package/dist/index.d.ts +34 -6
  19. package/dist/index.js +170 -33
  20. package/dist/index.js.map +1 -1
  21. package/dist/parser/article.d.ts +1 -1
  22. package/dist/parser/article.js +14 -4
  23. package/dist/parser/article.js.map +1 -1
  24. package/dist/parser/course.d.ts +2 -2
  25. package/dist/parser/course.js +16 -23
  26. package/dist/parser/course.js.map +1 -1
  27. package/dist/parser/learning-outcome.d.ts +11 -2
  28. package/dist/parser/learning-outcome.js +135 -62
  29. package/dist/parser/learning-outcome.js.map +1 -1
  30. package/dist/parser/lens.d.ts +63 -1
  31. package/dist/parser/lens.js +138 -11
  32. package/dist/parser/lens.js.map +1 -1
  33. package/dist/parser/module.d.ts +1 -1
  34. package/dist/parser/module.js +14 -9
  35. package/dist/parser/module.js.map +1 -1
  36. package/dist/parser/sections.d.ts +2 -0
  37. package/dist/parser/sections.js +57 -21
  38. package/dist/parser/sections.js.map +1 -1
  39. package/dist/parser/video-transcript.js +3 -0
  40. package/dist/parser/video-transcript.js.map +1 -1
  41. package/dist/parser/wikilink.js +6 -1
  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/directives.d.ts +12 -0
  50. package/dist/validator/directives.js +428 -0
  51. package/dist/validator/directives.js.map +1 -0
  52. package/dist/validator/field-typos.js +0 -9
  53. package/dist/validator/field-typos.js.map +1 -1
  54. package/dist/validator/field-values.js +14 -0
  55. package/dist/validator/field-values.js.map +1 -1
  56. package/dist/validator/tier.d.ts +18 -0
  57. package/dist/validator/tier.js +74 -0
  58. package/dist/validator/tier.js.map +1 -0
  59. package/dist/validator/url-reachability.js +3 -1
  60. package/dist/validator/url-reachability.js.map +1 -1
  61. package/dist/validator/uuid.js +9 -6
  62. package/dist/validator/uuid.js.map +1 -1
  63. package/package.json +5 -5
@@ -1,10 +1,35 @@
1
+ import { checkTierViolation } from '../validator/tier.js';
1
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';
6
- import { extractArticleExcerpt } from '../bundler/article.js';
7
+ import { fileNameToSlug } from '../utils/slug.js';
8
+ import { extractArticleExcerpt, bundleArticleWithCollapsed } from '../bundler/article.js';
7
9
  import { extractVideoExcerpt } from '../bundler/video.js';
10
+ function countWords(text) {
11
+ return text.trim().split(/\s+/).filter(Boolean).length;
12
+ }
13
+ /** Compute word count and video duration from a section's segments. */
14
+ function computeSectionStats(segments) {
15
+ let words = 0;
16
+ let videoSeconds = 0;
17
+ for (const seg of segments) {
18
+ if (seg.type === 'text' || seg.type === 'article-excerpt') {
19
+ words += countWords(seg.content);
20
+ }
21
+ else if (seg.type === 'video-excerpt') {
22
+ if (seg.to != null)
23
+ videoSeconds += seg.to - seg.from;
24
+ }
25
+ }
26
+ const stats = {};
27
+ if (words > 0)
28
+ stats.wordCount = words;
29
+ if (videoSeconds > 0)
30
+ stats.videoDurationSeconds = videoSeconds;
31
+ return stats;
32
+ }
8
33
  /**
9
34
  * Extract YouTube video ID from a YouTube URL.
10
35
  *
@@ -20,6 +45,98 @@ function extractVideoIdFromUrl(url) {
20
45
  const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
21
46
  return match ? match[1] : null;
22
47
  }
48
+ export function isBoundary(item) {
49
+ return '__boundary' in item && item.__boundary === true;
50
+ }
51
+ function toKebabCase(str) {
52
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
53
+ }
54
+ export function validateBoundaries(items, file) {
55
+ const errors = [];
56
+ const hasBoundaries = items.some(isBoundary);
57
+ if (!hasBoundaries)
58
+ return errors;
59
+ // Check: sections before first boundary
60
+ const firstBoundaryIdx = items.findIndex(isBoundary);
61
+ const sectionsBefore = items.slice(0, firstBoundaryIdx).filter(i => !isBoundary(i));
62
+ if (sectionsBefore.length > 0) {
63
+ errors.push({
64
+ file,
65
+ message: 'Content found outside submodule boundaries — all content must be inside a submodule',
66
+ severity: 'error',
67
+ });
68
+ }
69
+ // Check: consecutive boundaries and trailing boundary
70
+ for (let i = 0; i < items.length; i++) {
71
+ if (isBoundary(items[i])) {
72
+ // Find next non-boundary item
73
+ let nextNonBoundary = i + 1;
74
+ while (nextNonBoundary < items.length && isBoundary(items[nextNonBoundary])) {
75
+ nextNonBoundary++;
76
+ }
77
+ // If this boundary has no sections before next boundary or end
78
+ if (nextNonBoundary > i + 1 || nextNonBoundary >= items.length) {
79
+ // nextNonBoundary > i + 1 means consecutive boundaries
80
+ // nextNonBoundary >= items.length means trailing boundary with no content
81
+ if (nextNonBoundary > i + 1) {
82
+ errors.push({
83
+ file,
84
+ message: `Submodule "${items[i].title}" is empty — no content before next submodule`,
85
+ severity: 'error',
86
+ });
87
+ }
88
+ else if (nextNonBoundary >= items.length) {
89
+ errors.push({
90
+ file,
91
+ message: `Submodule "${items[i].title}" is empty — no content after this marker`,
92
+ severity: 'error',
93
+ });
94
+ }
95
+ }
96
+ }
97
+ }
98
+ return errors;
99
+ }
100
+ export function splitAtBoundaries(items, parentSlug, parentTitle) {
101
+ const hasBoundaries = items.some(isBoundary);
102
+ if (!hasBoundaries) {
103
+ // No split — return single group without parentSlug
104
+ return [{
105
+ slug: parentSlug,
106
+ title: parentTitle,
107
+ parentSlug: undefined,
108
+ parentTitle: undefined,
109
+ sections: items.filter(i => !isBoundary(i)),
110
+ contentId: null,
111
+ }];
112
+ }
113
+ const groups = [];
114
+ let currentGroup = null;
115
+ for (const item of items) {
116
+ if (isBoundary(item)) {
117
+ if (currentGroup) {
118
+ groups.push(currentGroup);
119
+ }
120
+ const slug = item.customSlug ?? toKebabCase(item.title);
121
+ currentGroup = {
122
+ slug: `${parentSlug}/${slug}`,
123
+ title: item.title,
124
+ parentSlug,
125
+ parentTitle,
126
+ sections: [],
127
+ contentId: null,
128
+ };
129
+ }
130
+ else if (currentGroup) {
131
+ currentGroup.sections.push(item);
132
+ }
133
+ // sections before first boundary are orphans — handled by validateBoundaries
134
+ }
135
+ if (currentGroup) {
136
+ groups.push(currentGroup);
137
+ }
138
+ return groups;
139
+ }
23
140
  /**
24
141
  * Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
25
142
  *
@@ -34,11 +151,12 @@ function extractVideoIdFromUrl(url) {
34
151
  * @param visitedPaths - Optional set of already-visited paths for cycle detection
35
152
  * @returns Flattened module with resolved sections and segments, plus any errors
36
153
  */
37
- export function flattenModule(modulePath, files, visitedPaths = new Set()) {
154
+ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierMap) {
38
155
  // Check for circular reference
39
156
  if (visitedPaths.has(modulePath)) {
40
157
  return {
41
158
  module: null,
159
+ modules: [],
42
160
  errors: [{
43
161
  file: modulePath,
44
162
  message: `Circular reference detected: ${modulePath}`,
@@ -57,70 +175,295 @@ export function flattenModule(modulePath, files, visitedPaths = new Set()) {
57
175
  message: `Module file not found: ${modulePath}`,
58
176
  severity: 'error',
59
177
  });
60
- return { module: null, errors };
178
+ return { module: null, modules: [], errors };
61
179
  }
62
180
  // Parse the module
63
181
  const moduleResult = parseModule(moduleContent, modulePath);
64
182
  errors.push(...moduleResult.errors);
65
183
  if (!moduleResult.module) {
66
- return { module: null, errors };
184
+ return { module: null, modules: [], errors };
67
185
  }
68
186
  const parsedModule = moduleResult.module;
69
- const flattenedSections = [];
70
- // Process each section in the module
71
- for (const section of parsedModule.sections) {
187
+ const flatItems = [];
188
+ // Helper: process a section (LO, Page, Uncategorized) and return Section[]
189
+ function processSection(section, subsectionLevel) {
72
190
  if (section.type === 'learning-outcome') {
73
- // Resolve the Learning Outcome reference
74
- // Create a copy of visitedPaths for this section's reference chain
75
- // This allows the same file to be referenced in different sections
76
- // while still detecting cycles within a single chain
77
191
  const sectionVisitedPaths = new Set(visitedPaths);
78
- const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths);
192
+ const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths, tierMap);
79
193
  errors.push(...result.errors);
80
- flattenedSections.push(...result.sections);
194
+ return result.sections;
81
195
  }
82
196
  else if (section.type === 'page') {
83
- // Page sections don't have LO references, they have inline content
84
- // Parse the section body for ## Text subsections
85
- const textResult = parsePageSegments(section.body, modulePath, section.line);
197
+ const level = subsectionLevel ?? 2;
198
+ const textResult = parsePageSegments(section.body, modulePath, section.line, level);
86
199
  errors.push(...textResult.errors);
87
- const pageSection = {
88
- type: 'page',
89
- meta: { title: section.title },
90
- segments: textResult.segments,
91
- optional: section.fields.optional?.toLowerCase() === 'true',
92
- contentId: section.fields.id ?? null,
93
- learningOutcomeId: null,
94
- learningOutcomeName: null,
95
- videoId: null,
96
- };
97
- flattenedSections.push(pageSection);
200
+ return [{
201
+ type: 'page',
202
+ meta: { title: section.title },
203
+ segments: textResult.segments,
204
+ optional: section.fields.optional?.toLowerCase() === 'true',
205
+ contentId: section.fields.id ?? null,
206
+ learningOutcomeId: null,
207
+ learningOutcomeName: null,
208
+ videoId: null,
209
+ ...computeSectionStats(textResult.segments),
210
+ }];
98
211
  }
99
212
  else if (section.type === 'uncategorized') {
100
- // Uncategorized sections can contain ## Lens: references
101
- // Each lens becomes its own section (lens-video, lens-article, or page)
102
213
  const sectionVisitedPaths = new Set(visitedPaths);
103
- const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths);
214
+ const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths, tierMap);
104
215
  errors.push(...result.errors);
105
- flattenedSections.push(...result.sections);
216
+ return result.sections;
106
217
  }
218
+ return [];
107
219
  }
108
- const flattenedModule = {
109
- slug: parsedModule.slug,
110
- title: parsedModule.title,
220
+ // Process each section in the module
221
+ for (const section of parsedModule.sections) {
222
+ if (section.type === 'submodule') {
223
+ // Emit boundary marker
224
+ flatItems.push({
225
+ __boundary: true,
226
+ title: section.title,
227
+ customSlug: section.fields.slug,
228
+ });
229
+ // Process children at level+1
230
+ if (section.children) {
231
+ for (const child of section.children) {
232
+ const childSections = processSection(child, child.level + 1);
233
+ flatItems.push(...childSections);
234
+ }
235
+ }
236
+ }
237
+ else {
238
+ // Check if this is an LO with submodules
239
+ if (section.type === 'learning-outcome') {
240
+ const loSections = processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors);
241
+ if (loSections) {
242
+ flatItems.push(...loSections);
243
+ continue;
244
+ }
245
+ }
246
+ const sections = processSection(section);
247
+ flatItems.push(...sections);
248
+ }
249
+ }
250
+ // Validate and split at boundaries
251
+ const boundaryErrors = validateBoundaries(flatItems, modulePath);
252
+ errors.push(...boundaryErrors);
253
+ const groups = splitAtBoundaries(flatItems, parsedModule.slug, parsedModule.title);
254
+ const resultModules = groups.map(g => ({
255
+ slug: g.slug,
256
+ title: g.title,
111
257
  contentId: parsedModule.contentId,
112
- sections: flattenedSections,
258
+ sections: g.sections,
259
+ ...(g.parentSlug ? { parentSlug: g.parentSlug, parentTitle: g.parentTitle } : {}),
260
+ ...(moduleError ? { error: moduleError } : {}),
261
+ }));
262
+ // Backwards compat: module is the first result or a merged single module
263
+ const primaryModule = resultModules.length > 0 ? resultModules[0] : null;
264
+ return { module: resultModules.length === 1 && !resultModules[0].parentSlug ? resultModules[0] : primaryModule, modules: resultModules, errors };
265
+ }
266
+ /**
267
+ * Build a test Section from a ParsedTestRef with inline segments.
268
+ * Shared by both processLOWithSubmodules() and flattenLearningOutcomeSection().
269
+ */
270
+ function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, tierMap, errors) {
271
+ if (!testRef.segments.length)
272
+ return null;
273
+ const testSegments = [];
274
+ const stubLensSection = {
275
+ type: 'page',
276
+ title: 'Test',
277
+ segments: [],
278
+ line: 0,
113
279
  };
114
- if (moduleError) {
115
- flattenedModule.error = moduleError;
280
+ for (const parsedSegment of testRef.segments) {
281
+ const segmentResult = convertSegment(parsedSegment, stubLensSection, loPath, files, new Set(visitedPaths), tierMap);
282
+ errors.push(...segmentResult.errors);
283
+ if (segmentResult.segment) {
284
+ testSegments.push(segmentResult.segment);
285
+ }
286
+ }
287
+ if (testSegments.length === 0)
288
+ return null;
289
+ const hasFeedback = testSegments.some((s) => s.type === 'question' && s.feedback);
290
+ return {
291
+ type: 'test',
292
+ meta: { title: 'Test' },
293
+ segments: testSegments,
294
+ optional: false,
295
+ ...(hasFeedback && { feedback: true }),
296
+ contentId: null,
297
+ learningOutcomeId: loId,
298
+ learningOutcomeName: loName,
299
+ videoId: null,
300
+ };
301
+ }
302
+ /**
303
+ * Check if a Learning Outcome section resolves to an LO with submodules.
304
+ * If so, emit boundary markers + sections. Returns null if no submodules.
305
+ */
306
+ function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors) {
307
+ // Resolve the LO file to check for submodules
308
+ const source = section.fields.source;
309
+ if (!source)
310
+ return null;
311
+ const wikilink = parseWikilink(source);
312
+ if (!wikilink || wikilink.error)
313
+ return null;
314
+ const loPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
315
+ const loPath = findFileWithExtension(loPathResolved, files);
316
+ if (!loPath)
317
+ return null;
318
+ const loContent = files.get(loPath);
319
+ if (!loContent)
320
+ return null;
321
+ const loResult = parseLearningOutcome(loContent, loPath);
322
+ if (!loResult.learningOutcome?.submodules)
323
+ return null;
324
+ // This LO has submodules — emit boundaries and resolve lenses
325
+ const items = [];
326
+ for (const sub of loResult.learningOutcome.submodules) {
327
+ items.push({
328
+ __boundary: true,
329
+ title: sub.title,
330
+ customSlug: sub.customSlug,
331
+ });
332
+ // Resolve each lens in this submodule group
333
+ for (const lensRef of sub.lenses) {
334
+ const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
335
+ if (!lensPath) {
336
+ const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, 'Lenses');
337
+ const suggestion = formatSuggestion(similarFiles, loPath) ?? 'Check the file path in the wiki-link';
338
+ errors.push({
339
+ file: loPath,
340
+ message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
341
+ suggestion,
342
+ severity: 'error',
343
+ });
344
+ continue;
345
+ }
346
+ // Check tier violation
347
+ if (tierMap) {
348
+ const parentTier = tierMap.get(loPath) ?? 'production';
349
+ const childTier = tierMap.get(lensPath) ?? 'production';
350
+ const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, 'lens');
351
+ if (violation) {
352
+ errors.push(violation);
353
+ }
354
+ if (childTier === 'ignored')
355
+ continue;
356
+ }
357
+ if (visitedPaths.has(lensPath)) {
358
+ errors.push({
359
+ file: loPath,
360
+ message: `Circular reference detected: ${lensPath}`,
361
+ severity: 'error',
362
+ });
363
+ continue;
364
+ }
365
+ const sectionVisitedPaths = new Set(visitedPaths);
366
+ sectionVisitedPaths.add(lensPath);
367
+ const lensContent = files.get(lensPath);
368
+ const lensResult = parseLens(lensContent, lensPath);
369
+ errors.push(...lensResult.errors);
370
+ if (!lensResult.lens)
371
+ continue;
372
+ const lens = lensResult.lens;
373
+ let sectionType = 'page';
374
+ const meta = { title: section.title };
375
+ const segments = [];
376
+ let videoId;
377
+ for (const lensSection of lens.sections) {
378
+ if (lensSection.type === 'lens-article') {
379
+ sectionType = 'lens-article';
380
+ if (lensSection.source) {
381
+ const articleWikilink = parseWikilink(lensSection.source);
382
+ if (articleWikilink && !articleWikilink.error) {
383
+ const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
384
+ const articlePath = findFileWithExtension(articlePathResolved, files);
385
+ if (articlePath) {
386
+ const articleContent = files.get(articlePath);
387
+ const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
388
+ if (articleFrontmatter.frontmatter.title)
389
+ meta.title = articleFrontmatter.frontmatter.title;
390
+ if (articleFrontmatter.frontmatter.author) {
391
+ const raw = articleFrontmatter.frontmatter.author;
392
+ meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
393
+ }
394
+ if (articleFrontmatter.frontmatter.source_url)
395
+ meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ else if (lensSection.type === 'page') {
401
+ sectionType = 'page';
402
+ if (lensSection.title)
403
+ meta.title = lensSection.title;
404
+ }
405
+ else if (lensSection.type === 'lens-video') {
406
+ sectionType = 'lens-video';
407
+ if (lensSection.source) {
408
+ const videoWikilink = parseWikilink(lensSection.source);
409
+ if (videoWikilink && !videoWikilink.error) {
410
+ const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
411
+ const videoPath = findFileWithExtension(videoPathResolved, files);
412
+ if (videoPath) {
413
+ const videoContent = files.get(videoPath);
414
+ const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
415
+ if (videoFrontmatter.frontmatter.title)
416
+ meta.title = videoFrontmatter.frontmatter.title;
417
+ if (videoFrontmatter.frontmatter.channel)
418
+ meta.channel = videoFrontmatter.frontmatter.channel;
419
+ if (videoFrontmatter.frontmatter.url) {
420
+ const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
421
+ if (extractedVideoId)
422
+ videoId = extractedVideoId;
423
+ }
424
+ }
425
+ }
426
+ }
427
+ }
428
+ for (const parsedSegment of lensSection.segments) {
429
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, sectionVisitedPaths, tierMap);
430
+ errors.push(...segmentResult.errors);
431
+ if (segmentResult.segment)
432
+ segments.push(segmentResult.segment);
433
+ }
434
+ applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
435
+ }
436
+ const lo = loResult.learningOutcome;
437
+ items.push({
438
+ type: sectionType,
439
+ meta,
440
+ segments,
441
+ optional: section.fields.optional?.toLowerCase() === 'true' || lensRef.optional,
442
+ learningOutcomeId: lo.id ?? null,
443
+ learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
444
+ contentId: lens.id ?? null,
445
+ tldr: lens.tldr,
446
+ videoId: videoId ?? null,
447
+ ...computeSectionStats(segments),
448
+ });
449
+ }
450
+ // Process test section if present in this submodule
451
+ if (sub.test) {
452
+ const lo = loResult.learningOutcome;
453
+ const testSection = buildTestSection(sub.test, lo.id ?? null, loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null, loPath, files, visitedPaths, tierMap, errors);
454
+ if (testSection)
455
+ items.push(testSection);
456
+ }
116
457
  }
117
- return { module: flattenedModule, errors };
458
+ // Also emit LO parse errors (we skipped earlier since we returned non-null)
459
+ errors.push(...loResult.errors);
460
+ return items;
118
461
  }
119
462
  /**
120
463
  * Flatten a Learning Outcome section by resolving its LO file and all referenced lenses.
121
464
  * Each lens in the LO becomes its own section.
122
465
  */
123
- function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths) {
466
+ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths, tierMap) {
124
467
  const errors = [];
125
468
  const sections = [];
126
469
  // Get the source wikilink
@@ -144,7 +487,9 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
144
487
  errors.push({
145
488
  file: modulePath,
146
489
  line: section.line,
147
- message: `Invalid wikilink format: ${source}`,
490
+ message: wikilink?.error
491
+ ? `${wikilink.error}: ${source}`
492
+ : `Invalid wikilink format: ${source}`,
148
493
  suggestion,
149
494
  severity: 'error',
150
495
  });
@@ -166,6 +511,19 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
166
511
  });
167
512
  return { sections: [], errors };
168
513
  }
514
+ // Check tier violation (module → LO)
515
+ 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);
519
+ if (violation) {
520
+ errors.push(violation);
521
+ }
522
+ // Skip ignored children silently (they're not processed)
523
+ if (childTier === 'ignored') {
524
+ return { sections: [], errors };
525
+ }
526
+ }
169
527
  // Check for circular reference
170
528
  if (visitedPaths.has(loPath)) {
171
529
  errors.push({
@@ -200,6 +558,18 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
200
558
  });
201
559
  continue;
202
560
  }
561
+ // Check tier violation (LO → Lens)
562
+ 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');
566
+ if (violation) {
567
+ errors.push(violation);
568
+ }
569
+ if (childTier === 'ignored') {
570
+ continue;
571
+ }
572
+ }
203
573
  // Check for circular reference
204
574
  if (visitedPaths.has(lensPath)) {
205
575
  errors.push({
@@ -242,11 +612,15 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
242
612
  meta.title = articleFrontmatter.frontmatter.title;
243
613
  }
244
614
  if (articleFrontmatter.frontmatter.author) {
245
- meta.author = articleFrontmatter.frontmatter.author;
615
+ const raw = articleFrontmatter.frontmatter.author;
616
+ meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
246
617
  }
247
618
  if (articleFrontmatter.frontmatter.source_url) {
248
619
  meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
249
620
  }
621
+ if (articleFrontmatter.frontmatter.published) {
622
+ meta.published = String(articleFrontmatter.frontmatter.published);
623
+ }
250
624
  }
251
625
  }
252
626
  }
@@ -289,12 +663,14 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
289
663
  }
290
664
  // Process segments
291
665
  for (const parsedSegment of lensSection.segments) {
292
- const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
666
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
293
667
  errors.push(...segmentResult.errors);
294
668
  if (segmentResult.segment) {
295
669
  segments.push(segmentResult.segment);
296
670
  }
297
671
  }
672
+ // Apply collapsed content to article-excerpt segments
673
+ applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
298
674
  }
299
675
  // Create a section for this lens
300
676
  // Optional can come from either:
@@ -308,17 +684,25 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths)
308
684
  learningOutcomeId: lo.id ?? null,
309
685
  learningOutcomeName: loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null,
310
686
  contentId: lens.id ?? null,
687
+ tldr: lens.tldr,
311
688
  videoId: videoId ?? null,
689
+ ...computeSectionStats(segments),
312
690
  };
313
691
  sections.push(resultSection);
314
692
  }
693
+ // After lens processing, add test section if present with inline segments
694
+ if (lo.test) {
695
+ const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split('/').pop()?.replace(/\.md$/i, '') ?? null, loPath, files, visitedPaths, tierMap, errors);
696
+ if (testSection)
697
+ sections.push(testSection);
698
+ }
315
699
  return { sections, errors };
316
700
  }
317
701
  /**
318
702
  * Flatten an Uncategorized section by parsing its ## Lens: references.
319
703
  * Each lens becomes its own section with the appropriate type (lens-video, lens-article, or page).
320
704
  */
321
- function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
705
+ function flattenUncategorizedSection(section, modulePath, files, visitedPaths, tierMap) {
322
706
  const errors = [];
323
707
  const sections = [];
324
708
  // Parse the section body for ## Lens: subsections
@@ -349,6 +733,18 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
349
733
  });
350
734
  continue;
351
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
+ }
352
748
  // Check for circular reference
353
749
  if (visitedPaths.has(lensPath)) {
354
750
  errors.push({
@@ -391,11 +787,15 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
391
787
  meta.title = articleFrontmatter.frontmatter.title;
392
788
  }
393
789
  if (articleFrontmatter.frontmatter.author) {
394
- meta.author = articleFrontmatter.frontmatter.author;
790
+ const raw = articleFrontmatter.frontmatter.author;
791
+ meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
395
792
  }
396
793
  if (articleFrontmatter.frontmatter.source_url) {
397
794
  meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
398
795
  }
796
+ if (articleFrontmatter.frontmatter.published) {
797
+ meta.published = String(articleFrontmatter.frontmatter.published);
798
+ }
399
799
  }
400
800
  }
401
801
  }
@@ -438,12 +838,14 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
438
838
  }
439
839
  // Process segments
440
840
  for (const parsedSegment of lensSection.segments) {
441
- const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
841
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
442
842
  errors.push(...segmentResult.errors);
443
843
  if (segmentResult.segment) {
444
844
  segments.push(segmentResult.segment);
445
845
  }
446
846
  }
847
+ // Apply collapsed content to article-excerpt segments
848
+ applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
447
849
  }
448
850
  // Create a section for this lens
449
851
  const resultSection = {
@@ -454,7 +856,9 @@ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
454
856
  learningOutcomeId: null,
455
857
  learningOutcomeName: null,
456
858
  contentId: lens.id ?? null,
859
+ tldr: lens.tldr,
457
860
  videoId: videoId ?? null,
861
+ ...computeSectionStats(segments),
458
862
  };
459
863
  sections.push(resultSection);
460
864
  }
@@ -570,11 +974,64 @@ function parseUncategorizedLensRefs(body, parentPath) {
570
974
  }
571
975
  return lensRefs;
572
976
  }
977
+ /**
978
+ * After converting segments, apply collapsed_before/collapsed_after to article-excerpt segments.
979
+ * This resolves the article source once and calls bundleArticleWithCollapsed to compute
980
+ * which parts of the article are outside the excerpted ranges.
981
+ */
982
+ function applyCollapsedContent(segments, parsedSegments, lensSection, lensPath, files) {
983
+ // Only process lens-article sections with article-excerpt segments
984
+ if (lensSection.type !== 'lens-article' || !lensSection.source)
985
+ return;
986
+ // Collect article-excerpt segment indices
987
+ const excerptIndices = [];
988
+ for (let i = 0; i < segments.length; i++) {
989
+ if (segments[i].type === 'article-excerpt') {
990
+ excerptIndices.push(i);
991
+ }
992
+ }
993
+ if (excerptIndices.length === 0)
994
+ return;
995
+ // Map to parsed segments to get anchors (order-preserving)
996
+ const parsedExcerpts = parsedSegments.filter(ps => ps.type === 'article-excerpt');
997
+ const excerptInfos = excerptIndices.map((segIdx, i) => ({
998
+ index: segIdx,
999
+ from: parsedExcerpts[i]?.fromAnchor,
1000
+ to: parsedExcerpts[i]?.toAnchor,
1001
+ }));
1002
+ // Only apply collapsed if at least one excerpt has anchors
1003
+ const hasAnchors = excerptInfos.some(e => e.from || e.to);
1004
+ if (!hasAnchors)
1005
+ return;
1006
+ // Resolve article path
1007
+ const wikilink = parseWikilink(lensSection.source);
1008
+ if (!wikilink || wikilink.error)
1009
+ return;
1010
+ const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
1011
+ const articlePath = findFileWithExtension(articlePathResolved, files);
1012
+ if (!articlePath)
1013
+ return;
1014
+ const articleContent = files.get(articlePath);
1015
+ // Call bundleArticleWithCollapsed
1016
+ const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map(e => ({ from: e.from, to: e.to })), articlePath);
1017
+ // Overlay collapsed_before/collapsed_after onto segments
1018
+ for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
1019
+ const segIdx = excerptInfos[i].index;
1020
+ const collapsed = collapsedResults[i];
1021
+ const segment = segments[segIdx];
1022
+ if (collapsed.collapsed_before) {
1023
+ segment.collapsed_before = collapsed.collapsed_before;
1024
+ }
1025
+ if (collapsed.collapsed_after) {
1026
+ segment.collapsed_after = collapsed.collapsed_after;
1027
+ }
1028
+ }
1029
+ }
573
1030
  /**
574
1031
  * Convert a parsed lens segment into a final flattened segment.
575
1032
  * For article-excerpt and video-excerpt, this involves extracting content from source files.
576
1033
  */
577
- function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths) {
1034
+ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap) {
578
1035
  const errors = [];
579
1036
  switch (parsedSegment.type) {
580
1037
  case 'text': {
@@ -642,6 +1099,18 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
642
1099
  });
643
1100
  return { segment: null, errors };
644
1101
  }
1102
+ // Check tier violation (Lens → Article)
1103
+ 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');
1107
+ if (violation) {
1108
+ errors.push(violation);
1109
+ }
1110
+ if (childTier === 'ignored') {
1111
+ return { segment: null, errors };
1112
+ }
1113
+ }
645
1114
  // Check if the article path points back to an already-visited structural file
646
1115
  // This would indicate a circular reference (e.g., a lens source pointing back to an LO)
647
1116
  // Note: We only check, we don't add article paths to visitedPaths since
@@ -658,7 +1127,7 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
658
1127
  // Extract the excerpt
659
1128
  const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
660
1129
  if (excerptResult.error) {
661
- errors.push(excerptResult.error);
1130
+ errors.push({ ...excerptResult.error, file: lensPath });
662
1131
  return { segment: null, errors };
663
1132
  }
664
1133
  const segment = {
@@ -707,6 +1176,18 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
707
1176
  });
708
1177
  return { segment: null, errors };
709
1178
  }
1179
+ // Check tier violation (Lens → Video)
1180
+ 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');
1184
+ if (violation) {
1185
+ errors.push(violation);
1186
+ }
1187
+ if (childTier === 'ignored') {
1188
+ return { segment: null, errors };
1189
+ }
1190
+ }
710
1191
  // Check if the video path points back to an already-visited structural file
711
1192
  // This would indicate a circular reference
712
1193
  // Note: We only check, we don't add video paths to visitedPaths since
@@ -740,7 +1221,7 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
740
1221
  // Extract the video excerpt
741
1222
  const excerptResult = extractVideoExcerpt(transcriptContent, parsedSegment.fromTimeStr, parsedSegment.toTimeStr, videoPath, timestamps);
742
1223
  if (excerptResult.error) {
743
- errors.push(excerptResult.error);
1224
+ errors.push({ ...excerptResult.error, file: lensPath });
744
1225
  return { segment: null, errors };
745
1226
  }
746
1227
  const segment = {
@@ -754,8 +1235,178 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
754
1235
  }
755
1236
  return { segment, errors };
756
1237
  }
1238
+ case 'question': {
1239
+ const segment = {
1240
+ type: 'question',
1241
+ content: parsedSegment.content,
1242
+ };
1243
+ if (parsedSegment.assessmentInstructions)
1244
+ segment.assessmentInstructions = parsedSegment.assessmentInstructions;
1245
+ if (parsedSegment.maxTime)
1246
+ segment.maxTime = parsedSegment.maxTime;
1247
+ if (parsedSegment.maxChars !== undefined)
1248
+ segment.maxChars = parsedSegment.maxChars;
1249
+ if (parsedSegment.enforceVoice)
1250
+ segment.enforceVoice = true;
1251
+ if (parsedSegment.optional)
1252
+ segment.optional = true;
1253
+ if (parsedSegment.feedback)
1254
+ segment.feedback = true;
1255
+ return { segment, errors };
1256
+ }
1257
+ case 'roleplay': {
1258
+ const segment = {
1259
+ type: 'roleplay',
1260
+ id: parsedSegment.id,
1261
+ content: parsedSegment.content,
1262
+ aiInstructions: parsedSegment.aiInstructions,
1263
+ };
1264
+ if (parsedSegment.openingMessage)
1265
+ segment.openingMessage = parsedSegment.openingMessage;
1266
+ if (parsedSegment.assessmentInstructions)
1267
+ segment.assessmentInstructions = parsedSegment.assessmentInstructions;
1268
+ if (parsedSegment.optional)
1269
+ segment.optional = true;
1270
+ if (parsedSegment.feedback)
1271
+ segment.feedback = true;
1272
+ return { segment, errors };
1273
+ }
757
1274
  default:
758
1275
  return { segment: null, errors };
759
1276
  }
760
1277
  }
1278
+ /**
1279
+ * 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
+ */
1295
+ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1296
+ const errors = [];
1297
+ // Skip ignored lenses
1298
+ if (tierMap?.get(lensPath) === 'ignored') {
1299
+ return { module: null, modules: [], errors };
1300
+ }
1301
+ const lensContent = files.get(lensPath);
1302
+ if (!lensContent) {
1303
+ errors.push({
1304
+ file: lensPath,
1305
+ message: `Lens file not found: ${lensPath}`,
1306
+ severity: 'error',
1307
+ });
1308
+ return { module: null, modules: [], errors };
1309
+ }
1310
+ // 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
+ })();
1316
+ if (!lens) {
1317
+ return { module: null, modules: [], errors };
1318
+ }
1319
+ const visitedPaths = new Set([lensPath]);
1320
+ // Process lens sections into a single flattened Section
1321
+ let sectionType = 'page';
1322
+ 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
+ }
1347
+ }
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
+ }
1376
+ }
1377
+ }
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);
1384
+ }
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
+ }
1392
+ const section = {
1393
+ type: sectionType,
1394
+ meta,
1395
+ segments,
1396
+ optional: false,
1397
+ learningOutcomeId: null,
1398
+ learningOutcomeName: null,
1399
+ contentId: lens.id ?? null,
1400
+ tldr: lens.tldr,
1401
+ videoId: videoId ?? null,
1402
+ ...computeSectionStats(segments),
1403
+ };
1404
+ const flattenedModule = {
1405
+ slug: 'lens/' + fileNameToSlug(lensPath),
1406
+ title: meta.title ?? fileNameToSlug(lensPath),
1407
+ contentId: lens.id ?? null,
1408
+ sections: [section],
1409
+ };
1410
+ return { module: flattenedModule, modules: [flattenedModule], errors };
1411
+ }
761
1412
  //# sourceMappingURL=index.js.map