lens-content-processor 0.1.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 (103) hide show
  1. package/dist/bundler/article.d.ts +41 -0
  2. package/dist/bundler/article.js +240 -0
  3. package/dist/bundler/article.js.map +1 -0
  4. package/dist/bundler/article.test.d.ts +1 -0
  5. package/dist/bundler/article.test.js +212 -0
  6. package/dist/bundler/article.test.js.map +1 -0
  7. package/dist/bundler/video.d.ts +50 -0
  8. package/dist/bundler/video.js +213 -0
  9. package/dist/bundler/video.js.map +1 -0
  10. package/dist/bundler/video.test.d.ts +1 -0
  11. package/dist/bundler/video.test.js +95 -0
  12. package/dist/bundler/video.test.js.map +1 -0
  13. package/dist/cli.d.ts +10 -0
  14. package/dist/cli.js +80 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/cli.test.d.ts +1 -0
  17. package/dist/cli.test.js +45 -0
  18. package/dist/cli.test.js.map +1 -0
  19. package/dist/flattener/index.d.ts +20 -0
  20. package/dist/flattener/index.js +707 -0
  21. package/dist/flattener/index.js.map +1 -0
  22. package/dist/flattener/index.test.d.ts +1 -0
  23. package/dist/flattener/index.test.js +547 -0
  24. package/dist/flattener/index.test.js.map +1 -0
  25. package/dist/fs/read-vault.d.ts +4 -0
  26. package/dist/fs/read-vault.js +28 -0
  27. package/dist/fs/read-vault.js.map +1 -0
  28. package/dist/fs/read-vault.test.d.ts +1 -0
  29. package/dist/fs/read-vault.test.js +37 -0
  30. package/dist/fs/read-vault.test.js.map +1 -0
  31. package/dist/index.d.ts +75 -0
  32. package/dist/index.js +137 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/parser/course.d.ts +17 -0
  35. package/dist/parser/course.js +145 -0
  36. package/dist/parser/course.js.map +1 -0
  37. package/dist/parser/course.test.d.ts +1 -0
  38. package/dist/parser/course.test.js +117 -0
  39. package/dist/parser/course.test.js.map +1 -0
  40. package/dist/parser/frontmatter.d.ts +8 -0
  41. package/dist/parser/frontmatter.js +58 -0
  42. package/dist/parser/frontmatter.js.map +1 -0
  43. package/dist/parser/frontmatter.test.d.ts +1 -0
  44. package/dist/parser/frontmatter.test.js +54 -0
  45. package/dist/parser/frontmatter.test.js.map +1 -0
  46. package/dist/parser/learning-outcome.d.ts +21 -0
  47. package/dist/parser/learning-outcome.js +116 -0
  48. package/dist/parser/learning-outcome.js.map +1 -0
  49. package/dist/parser/learning-outcome.test.d.ts +1 -0
  50. package/dist/parser/learning-outcome.test.js +75 -0
  51. package/dist/parser/learning-outcome.test.js.map +1 -0
  52. package/dist/parser/lens.d.ts +51 -0
  53. package/dist/parser/lens.js +328 -0
  54. package/dist/parser/lens.js.map +1 -0
  55. package/dist/parser/lens.test.d.ts +1 -0
  56. package/dist/parser/lens.test.js +453 -0
  57. package/dist/parser/lens.test.js.map +1 -0
  58. package/dist/parser/module.d.ts +21 -0
  59. package/dist/parser/module.js +128 -0
  60. package/dist/parser/module.js.map +1 -0
  61. package/dist/parser/module.test.d.ts +1 -0
  62. package/dist/parser/module.test.js +99 -0
  63. package/dist/parser/module.test.js.map +1 -0
  64. package/dist/parser/sections.d.ts +18 -0
  65. package/dist/parser/sections.js +128 -0
  66. package/dist/parser/sections.js.map +1 -0
  67. package/dist/parser/sections.test.d.ts +1 -0
  68. package/dist/parser/sections.test.js +297 -0
  69. package/dist/parser/sections.test.js.map +1 -0
  70. package/dist/parser/wikilink.d.ts +12 -0
  71. package/dist/parser/wikilink.js +35 -0
  72. package/dist/parser/wikilink.js.map +1 -0
  73. package/dist/parser/wikilink.test.d.ts +1 -0
  74. package/dist/parser/wikilink.test.js +76 -0
  75. package/dist/parser/wikilink.test.js.map +1 -0
  76. package/dist/validator/field-typos.d.ts +10 -0
  77. package/dist/validator/field-typos.js +96 -0
  78. package/dist/validator/field-typos.js.map +1 -0
  79. package/dist/validator/field-typos.test.d.ts +1 -0
  80. package/dist/validator/field-typos.test.js +41 -0
  81. package/dist/validator/field-typos.test.js.map +1 -0
  82. package/dist/validator/field-values.d.ts +11 -0
  83. package/dist/validator/field-values.js +37 -0
  84. package/dist/validator/field-values.js.map +1 -0
  85. package/dist/validator/field-values.test.d.ts +1 -0
  86. package/dist/validator/field-values.test.js +45 -0
  87. package/dist/validator/field-values.test.js.map +1 -0
  88. package/dist/validator/segment-fields.d.ts +12 -0
  89. package/dist/validator/segment-fields.js +55 -0
  90. package/dist/validator/segment-fields.js.map +1 -0
  91. package/dist/validator/segment-fields.test.d.ts +1 -0
  92. package/dist/validator/segment-fields.test.js +73 -0
  93. package/dist/validator/segment-fields.test.js.map +1 -0
  94. package/dist/validator/standalone.test.d.ts +1 -0
  95. package/dist/validator/standalone.test.js +190 -0
  96. package/dist/validator/standalone.test.js.map +1 -0
  97. package/dist/validator/uuid.d.ts +17 -0
  98. package/dist/validator/uuid.js +46 -0
  99. package/dist/validator/uuid.js.map +1 -0
  100. package/dist/validator/uuid.test.d.ts +1 -0
  101. package/dist/validator/uuid.test.js +229 -0
  102. package/dist/validator/uuid.test.js.map +1 -0
  103. package/package.json +45 -0
@@ -0,0 +1,707 @@
1
+ import { parseModule, parsePageTextSegments } from '../parser/module.js';
2
+ import { parseLearningOutcome } from '../parser/learning-outcome.js';
3
+ import { parseLens } from '../parser/lens.js';
4
+ import { parseWikilink, resolveWikilinkPath, findFileWithExtension } from '../parser/wikilink.js';
5
+ import { parseFrontmatter } from '../parser/frontmatter.js';
6
+ import { extractArticleExcerpt } from '../bundler/article.js';
7
+ import { extractVideoExcerpt } from '../bundler/video.js';
8
+ /**
9
+ * Extract YouTube video ID from a YouTube URL.
10
+ *
11
+ * Supported formats:
12
+ * - https://www.youtube.com/watch?v=VIDEO_ID
13
+ * - https://youtube.com/watch?v=VIDEO_ID
14
+ * - https://youtu.be/VIDEO_ID
15
+ * - https://www.youtube.com/embed/VIDEO_ID
16
+ *
17
+ * @returns Video ID string, or null if URL format is not recognized
18
+ */
19
+ function extractVideoIdFromUrl(url) {
20
+ const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
21
+ return match ? match[1] : null;
22
+ }
23
+ /**
24
+ * Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
25
+ *
26
+ * This function:
27
+ * 1. Parses the module file
28
+ * 2. For each Learning Outcome section, resolves the LO file
29
+ * 3. For each Lens in the LO, resolves the lens file
30
+ * 4. For each segment, extracts content from articles/videos as needed
31
+ *
32
+ * @param modulePath - Path to the module file within the files Map
33
+ * @param files - Map of all file paths to their content
34
+ * @param visitedPaths - Optional set of already-visited paths for cycle detection
35
+ * @returns Flattened module with resolved sections and segments, plus any errors
36
+ */
37
+ export function flattenModule(modulePath, files, visitedPaths = new Set()) {
38
+ // Check for circular reference
39
+ if (visitedPaths.has(modulePath)) {
40
+ return {
41
+ module: null,
42
+ errors: [{
43
+ file: modulePath,
44
+ message: `Circular reference detected: ${modulePath}`,
45
+ severity: 'error',
46
+ }],
47
+ };
48
+ }
49
+ visitedPaths.add(modulePath);
50
+ const errors = [];
51
+ let moduleError;
52
+ // Get module content
53
+ const moduleContent = files.get(modulePath);
54
+ if (!moduleContent) {
55
+ errors.push({
56
+ file: modulePath,
57
+ message: `Module file not found: ${modulePath}`,
58
+ severity: 'error',
59
+ });
60
+ return { module: null, errors };
61
+ }
62
+ // Parse the module
63
+ const moduleResult = parseModule(moduleContent, modulePath);
64
+ errors.push(...moduleResult.errors);
65
+ if (!moduleResult.module) {
66
+ return { module: null, errors };
67
+ }
68
+ const parsedModule = moduleResult.module;
69
+ const flattenedSections = [];
70
+ // Process each section in the module
71
+ for (const section of parsedModule.sections) {
72
+ 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
+ const sectionVisitedPaths = new Set(visitedPaths);
78
+ const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths);
79
+ errors.push(...result.errors);
80
+ flattenedSections.push(...result.sections);
81
+ }
82
+ 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 textSegments = parsePageTextSegments(section.body);
86
+ const pageSection = {
87
+ type: 'page',
88
+ meta: { title: section.title },
89
+ segments: textSegments,
90
+ optional: section.fields.optional === 'true',
91
+ contentId: section.fields.id ?? null,
92
+ learningOutcomeId: null,
93
+ videoId: null,
94
+ };
95
+ flattenedSections.push(pageSection);
96
+ }
97
+ else if (section.type === 'uncategorized') {
98
+ // Uncategorized sections can contain ## Lens: references
99
+ // Each lens becomes its own section (lens-video, lens-article, or page)
100
+ const sectionVisitedPaths = new Set(visitedPaths);
101
+ const result = flattenUncategorizedSection(section, modulePath, files, sectionVisitedPaths);
102
+ errors.push(...result.errors);
103
+ flattenedSections.push(...result.sections);
104
+ }
105
+ }
106
+ const flattenedModule = {
107
+ slug: parsedModule.slug,
108
+ title: parsedModule.title,
109
+ contentId: parsedModule.contentId,
110
+ sections: flattenedSections,
111
+ };
112
+ if (moduleError) {
113
+ flattenedModule.error = moduleError;
114
+ }
115
+ return { module: flattenedModule, errors };
116
+ }
117
+ /**
118
+ * Flatten a Learning Outcome section by resolving its LO file and all referenced lenses.
119
+ * Each lens in the LO becomes its own section.
120
+ */
121
+ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths) {
122
+ const errors = [];
123
+ const sections = [];
124
+ // Get the source wikilink
125
+ const source = section.fields.source;
126
+ if (!source) {
127
+ errors.push({
128
+ file: modulePath,
129
+ line: section.line,
130
+ message: 'Learning Outcome section missing source:: field',
131
+ suggestion: "Add 'source:: [[../Learning Outcomes/filename.md|Display]]'",
132
+ severity: 'error',
133
+ });
134
+ return { sections: [], errors };
135
+ }
136
+ // Parse and resolve the wikilink
137
+ const wikilink = parseWikilink(source);
138
+ if (!wikilink) {
139
+ errors.push({
140
+ file: modulePath,
141
+ line: section.line,
142
+ message: `Invalid wikilink format: ${source}`,
143
+ suggestion: 'Use format [[../Learning Outcomes/filename.md|Display Text]]',
144
+ severity: 'error',
145
+ });
146
+ return { sections: [], errors };
147
+ }
148
+ const loPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
149
+ const loPath = findFileWithExtension(loPathResolved, files);
150
+ // Get the LO file content
151
+ if (!loPath) {
152
+ errors.push({
153
+ file: modulePath,
154
+ line: section.line,
155
+ message: `Referenced file not found: ${loPathResolved}`,
156
+ suggestion: 'Check the file path in the wiki-link',
157
+ severity: 'error',
158
+ });
159
+ return { sections: [], errors };
160
+ }
161
+ // Check for circular reference
162
+ if (visitedPaths.has(loPath)) {
163
+ errors.push({
164
+ file: modulePath,
165
+ line: section.line,
166
+ message: `Circular reference detected: ${loPath}`,
167
+ severity: 'error',
168
+ });
169
+ return { sections: [], errors };
170
+ }
171
+ visitedPaths.add(loPath);
172
+ const loContent = files.get(loPath);
173
+ // Parse the Learning Outcome
174
+ const loResult = parseLearningOutcome(loContent, loPath);
175
+ errors.push(...loResult.errors);
176
+ if (!loResult.learningOutcome) {
177
+ return { sections: [], errors };
178
+ }
179
+ const lo = loResult.learningOutcome;
180
+ // Each lens becomes its own section
181
+ for (const lensRef of lo.lenses) {
182
+ const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
183
+ if (!lensPath) {
184
+ errors.push({
185
+ file: loPath,
186
+ message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
187
+ suggestion: 'Check the file path in the wiki-link',
188
+ severity: 'error',
189
+ });
190
+ continue;
191
+ }
192
+ // Check for circular reference
193
+ if (visitedPaths.has(lensPath)) {
194
+ errors.push({
195
+ file: loPath,
196
+ message: `Circular reference detected: ${lensPath}`,
197
+ severity: 'error',
198
+ });
199
+ continue;
200
+ }
201
+ visitedPaths.add(lensPath);
202
+ const lensContent = files.get(lensPath);
203
+ // Parse the lens
204
+ const lensResult = parseLens(lensContent, lensPath);
205
+ errors.push(...lensResult.errors);
206
+ if (!lensResult.lens) {
207
+ continue;
208
+ }
209
+ const lens = lensResult.lens;
210
+ // Each lens becomes its own section
211
+ let sectionType = 'page';
212
+ const meta = { title: section.title };
213
+ const segments = [];
214
+ let videoId;
215
+ // Process each section in the lens
216
+ for (const lensSection of lens.sections) {
217
+ // Determine section type from lens section
218
+ if (lensSection.type === 'lens-article') {
219
+ sectionType = 'lens-article';
220
+ // Extract article metadata from the article file's frontmatter
221
+ if (lensSection.source) {
222
+ const articleWikilink = parseWikilink(lensSection.source);
223
+ if (articleWikilink) {
224
+ const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
225
+ const articlePath = findFileWithExtension(articlePathResolved, files);
226
+ if (articlePath) {
227
+ const articleContent = files.get(articlePath);
228
+ const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
229
+ // Extract metadata fields
230
+ if (articleFrontmatter.frontmatter.title) {
231
+ meta.title = articleFrontmatter.frontmatter.title;
232
+ }
233
+ if (articleFrontmatter.frontmatter.author) {
234
+ meta.author = articleFrontmatter.frontmatter.author;
235
+ }
236
+ if (articleFrontmatter.frontmatter.sourceUrl) {
237
+ meta.sourceUrl = articleFrontmatter.frontmatter.sourceUrl;
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }
243
+ else if (lensSection.type === 'lens-video') {
244
+ sectionType = 'lens-video';
245
+ // Extract video metadata from the video transcript file's frontmatter
246
+ if (lensSection.source) {
247
+ const videoWikilink = parseWikilink(lensSection.source);
248
+ if (videoWikilink) {
249
+ const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
250
+ const videoPath = findFileWithExtension(videoPathResolved, files);
251
+ if (videoPath) {
252
+ const videoContent = files.get(videoPath);
253
+ const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
254
+ // Extract metadata fields
255
+ if (videoFrontmatter.frontmatter.title) {
256
+ meta.title = videoFrontmatter.frontmatter.title;
257
+ }
258
+ if (videoFrontmatter.frontmatter.channel) {
259
+ meta.channel = videoFrontmatter.frontmatter.channel;
260
+ }
261
+ // Extract video ID from YouTube URL
262
+ if (videoFrontmatter.frontmatter.url) {
263
+ const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
264
+ if (extractedVideoId) {
265
+ videoId = extractedVideoId;
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ // Process segments
273
+ for (const parsedSegment of lensSection.segments) {
274
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
275
+ errors.push(...segmentResult.errors);
276
+ if (segmentResult.segment) {
277
+ segments.push(segmentResult.segment);
278
+ }
279
+ }
280
+ }
281
+ // Create a section for this lens
282
+ const resultSection = {
283
+ type: sectionType,
284
+ meta,
285
+ segments,
286
+ optional: section.fields.optional === 'true',
287
+ learningOutcomeId: lo.id ?? null,
288
+ contentId: lens.id ?? null,
289
+ videoId: videoId ?? null,
290
+ };
291
+ sections.push(resultSection);
292
+ }
293
+ return { sections, errors };
294
+ }
295
+ /**
296
+ * Flatten an Uncategorized section by parsing its ## Lens: references.
297
+ * Each lens becomes its own section with the appropriate type (lens-video, lens-article, or page).
298
+ */
299
+ function flattenUncategorizedSection(section, modulePath, files, visitedPaths) {
300
+ const errors = [];
301
+ const sections = [];
302
+ // Parse the section body for ## Lens: subsections
303
+ const lensRefs = parseUncategorizedLensRefs(section.body, modulePath);
304
+ // If no lens refs found, return empty array
305
+ if (lensRefs.length === 0) {
306
+ return { sections: [], errors };
307
+ }
308
+ // Process each lens reference as a separate section
309
+ for (const lensRef of lensRefs) {
310
+ const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
311
+ if (!lensPath) {
312
+ errors.push({
313
+ file: modulePath,
314
+ message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
315
+ suggestion: 'Check the file path in the wiki-link',
316
+ severity: 'error',
317
+ });
318
+ continue;
319
+ }
320
+ // Check for circular reference
321
+ if (visitedPaths.has(lensPath)) {
322
+ errors.push({
323
+ file: modulePath,
324
+ message: `Circular reference detected: ${lensPath}`,
325
+ severity: 'error',
326
+ });
327
+ continue;
328
+ }
329
+ visitedPaths.add(lensPath);
330
+ const lensContent = files.get(lensPath);
331
+ // Parse the lens
332
+ const lensResult = parseLens(lensContent, lensPath);
333
+ errors.push(...lensResult.errors);
334
+ if (!lensResult.lens) {
335
+ continue;
336
+ }
337
+ const lens = lensResult.lens;
338
+ // Each lens becomes its own section
339
+ let sectionType = 'page';
340
+ const meta = { title: section.title };
341
+ const segments = [];
342
+ let videoId;
343
+ // Process each section in the lens
344
+ for (const lensSection of lens.sections) {
345
+ // Determine section type from lens section
346
+ if (lensSection.type === 'lens-article') {
347
+ sectionType = 'lens-article';
348
+ // Extract article metadata from the article file's frontmatter
349
+ if (lensSection.source) {
350
+ const articleWikilink = parseWikilink(lensSection.source);
351
+ if (articleWikilink) {
352
+ const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
353
+ const articlePath = findFileWithExtension(articlePathResolved, files);
354
+ if (articlePath) {
355
+ const articleContent = files.get(articlePath);
356
+ const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
357
+ // Extract metadata fields
358
+ if (articleFrontmatter.frontmatter.title) {
359
+ meta.title = articleFrontmatter.frontmatter.title;
360
+ }
361
+ if (articleFrontmatter.frontmatter.author) {
362
+ meta.author = articleFrontmatter.frontmatter.author;
363
+ }
364
+ if (articleFrontmatter.frontmatter.sourceUrl) {
365
+ meta.sourceUrl = articleFrontmatter.frontmatter.sourceUrl;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ }
371
+ else if (lensSection.type === 'lens-video') {
372
+ sectionType = 'lens-video';
373
+ // Extract video metadata from the video transcript file's frontmatter
374
+ if (lensSection.source) {
375
+ const videoWikilink = parseWikilink(lensSection.source);
376
+ if (videoWikilink) {
377
+ const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
378
+ const videoPath = findFileWithExtension(videoPathResolved, files);
379
+ if (videoPath) {
380
+ const videoContent = files.get(videoPath);
381
+ const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
382
+ // Extract metadata fields
383
+ if (videoFrontmatter.frontmatter.title) {
384
+ meta.title = videoFrontmatter.frontmatter.title;
385
+ }
386
+ if (videoFrontmatter.frontmatter.channel) {
387
+ meta.channel = videoFrontmatter.frontmatter.channel;
388
+ }
389
+ // Extract video ID from YouTube URL
390
+ if (videoFrontmatter.frontmatter.url) {
391
+ const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
392
+ if (extractedVideoId) {
393
+ videoId = extractedVideoId;
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+ }
400
+ // Process segments
401
+ for (const parsedSegment of lensSection.segments) {
402
+ const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths);
403
+ errors.push(...segmentResult.errors);
404
+ if (segmentResult.segment) {
405
+ segments.push(segmentResult.segment);
406
+ }
407
+ }
408
+ }
409
+ // Create a section for this lens
410
+ const resultSection = {
411
+ type: sectionType,
412
+ meta,
413
+ segments,
414
+ optional: lensRef.optional,
415
+ learningOutcomeId: null,
416
+ contentId: lens.id ?? null,
417
+ videoId: videoId ?? null,
418
+ };
419
+ sections.push(resultSection);
420
+ }
421
+ return { sections, errors };
422
+ }
423
+ /**
424
+ * Parse ## Lens: subsections from an Uncategorized section's body.
425
+ * Returns an array of lens references with resolved paths.
426
+ */
427
+ function parseUncategorizedLensRefs(body, parentPath) {
428
+ const lensRefs = [];
429
+ const lines = body.split('\n');
430
+ let inLensSection = false;
431
+ let currentFields = {};
432
+ let currentField = null;
433
+ let currentValue = [];
434
+ const LENS_HEADER_PATTERN = /^##\s+Lens:\s*(.*)$/i;
435
+ const FIELD_PATTERN = /^(\w+)::\s*(.*)$/;
436
+ for (let i = 0; i < lines.length; i++) {
437
+ const line = lines[i];
438
+ // Check for ## Lens: header
439
+ const lensMatch = line.match(LENS_HEADER_PATTERN);
440
+ if (lensMatch) {
441
+ // Save previous lens if we were in one
442
+ if (inLensSection) {
443
+ // First, finalize current field if we were collecting one
444
+ // This must happen BEFORE checking currentFields.source
445
+ if (currentField) {
446
+ currentFields[currentField] = currentValue.join('\n').trim();
447
+ }
448
+ if (currentFields.source) {
449
+ const wikilink = parseWikilink(currentFields.source);
450
+ if (wikilink) {
451
+ const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
452
+ lensRefs.push({
453
+ source: currentFields.source,
454
+ resolvedPath,
455
+ optional: currentFields.optional === 'true',
456
+ });
457
+ }
458
+ }
459
+ }
460
+ inLensSection = true;
461
+ currentFields = {};
462
+ currentField = null;
463
+ currentValue = [];
464
+ continue;
465
+ }
466
+ // Check for another ## header (end of Lens section)
467
+ if (line.match(/^##\s+\S/) && inLensSection) {
468
+ // Save current lens
469
+ if (currentField) {
470
+ currentFields[currentField] = currentValue.join('\n').trim();
471
+ }
472
+ if (currentFields.source) {
473
+ const wikilink = parseWikilink(currentFields.source);
474
+ if (wikilink) {
475
+ const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
476
+ lensRefs.push({
477
+ source: currentFields.source,
478
+ resolvedPath,
479
+ optional: currentFields.optional === 'true',
480
+ });
481
+ }
482
+ }
483
+ inLensSection = false;
484
+ currentFields = {};
485
+ currentField = null;
486
+ currentValue = [];
487
+ continue;
488
+ }
489
+ if (inLensSection) {
490
+ // Parse fields
491
+ const fieldMatch = line.match(FIELD_PATTERN);
492
+ if (fieldMatch) {
493
+ // Save previous field
494
+ if (currentField) {
495
+ currentFields[currentField] = currentValue.join('\n').trim();
496
+ }
497
+ currentField = fieldMatch[1];
498
+ const inlineValue = fieldMatch[2].trim();
499
+ currentValue = inlineValue ? [inlineValue] : [];
500
+ }
501
+ else if (currentField) {
502
+ // Check if line starts a new section header
503
+ if (line.match(/^#/)) {
504
+ currentFields[currentField] = currentValue.join('\n').trim();
505
+ currentField = null;
506
+ currentValue = [];
507
+ }
508
+ else {
509
+ currentValue.push(line);
510
+ }
511
+ }
512
+ }
513
+ }
514
+ // Don't forget the last lens section
515
+ if (inLensSection) {
516
+ if (currentField) {
517
+ currentFields[currentField] = currentValue.join('\n').trim();
518
+ }
519
+ if (currentFields.source) {
520
+ const wikilink = parseWikilink(currentFields.source);
521
+ if (wikilink) {
522
+ const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
523
+ lensRefs.push({
524
+ source: currentFields.source,
525
+ resolvedPath,
526
+ optional: currentFields.optional === 'true',
527
+ });
528
+ }
529
+ }
530
+ }
531
+ return lensRefs;
532
+ }
533
+ /**
534
+ * Convert a parsed lens segment into a final flattened segment.
535
+ * For article-excerpt and video-excerpt, this involves extracting content from source files.
536
+ */
537
+ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths) {
538
+ const errors = [];
539
+ switch (parsedSegment.type) {
540
+ case 'text': {
541
+ const segment = {
542
+ type: 'text',
543
+ content: parsedSegment.content,
544
+ };
545
+ if (parsedSegment.optional) {
546
+ segment.optional = true;
547
+ }
548
+ return { segment, errors };
549
+ }
550
+ case 'chat': {
551
+ const segment = {
552
+ type: 'chat',
553
+ };
554
+ if (parsedSegment.instructions) {
555
+ segment.instructions = parsedSegment.instructions;
556
+ }
557
+ if (parsedSegment.hidePreviousContentFromUser) {
558
+ segment.hidePreviousContentFromUser = true;
559
+ }
560
+ if (parsedSegment.hidePreviousContentFromTutor) {
561
+ segment.hidePreviousContentFromTutor = true;
562
+ }
563
+ if (parsedSegment.optional) {
564
+ segment.optional = true;
565
+ }
566
+ return { segment, errors };
567
+ }
568
+ case 'article-excerpt': {
569
+ // Need to resolve the article path from the lens section's source field
570
+ if (!lensSection.source) {
571
+ errors.push({
572
+ file: lensPath,
573
+ message: 'Article section missing source:: field for article-excerpt',
574
+ severity: 'error',
575
+ });
576
+ return { segment: null, errors };
577
+ }
578
+ const wikilink = parseWikilink(lensSection.source);
579
+ if (!wikilink) {
580
+ errors.push({
581
+ file: lensPath,
582
+ message: `Invalid wikilink in article source: ${lensSection.source}`,
583
+ severity: 'error',
584
+ });
585
+ return { segment: null, errors };
586
+ }
587
+ const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
588
+ const articlePath = findFileWithExtension(articlePathResolved, files);
589
+ if (!articlePath) {
590
+ errors.push({
591
+ file: lensPath,
592
+ message: `Referenced article file not found: ${articlePathResolved}`,
593
+ suggestion: 'Check the file path in the wiki-link',
594
+ severity: 'error',
595
+ });
596
+ return { segment: null, errors };
597
+ }
598
+ // Check if the article path points back to an already-visited structural file
599
+ // This would indicate a circular reference (e.g., a lens source pointing back to an LO)
600
+ // Note: We only check, we don't add article paths to visitedPaths since
601
+ // multiple segments can legitimately reference the same article
602
+ if (visitedPaths.has(articlePath)) {
603
+ errors.push({
604
+ file: lensPath,
605
+ message: `Circular reference detected: ${articlePath}`,
606
+ severity: 'error',
607
+ });
608
+ return { segment: null, errors };
609
+ }
610
+ const articleContent = files.get(articlePath);
611
+ // Extract the excerpt
612
+ const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
613
+ if (excerptResult.error) {
614
+ errors.push(excerptResult.error);
615
+ return { segment: null, errors };
616
+ }
617
+ const segment = {
618
+ type: 'article-excerpt',
619
+ content: excerptResult.content,
620
+ };
621
+ if (parsedSegment.optional) {
622
+ segment.optional = true;
623
+ }
624
+ return { segment, errors };
625
+ }
626
+ case 'video-excerpt': {
627
+ // Need to resolve the video/transcript path from the lens section's source field
628
+ if (!lensSection.source) {
629
+ errors.push({
630
+ file: lensPath,
631
+ message: 'Video section missing source:: field for video-excerpt',
632
+ severity: 'error',
633
+ });
634
+ return { segment: null, errors };
635
+ }
636
+ const wikilink = parseWikilink(lensSection.source);
637
+ if (!wikilink) {
638
+ errors.push({
639
+ file: lensPath,
640
+ message: `Invalid wikilink in video source: ${lensSection.source}`,
641
+ severity: 'error',
642
+ });
643
+ return { segment: null, errors };
644
+ }
645
+ const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
646
+ const videoPath = findFileWithExtension(videoPathResolved, files);
647
+ if (!videoPath) {
648
+ errors.push({
649
+ file: lensPath,
650
+ message: `Referenced video transcript file not found: ${videoPathResolved}`,
651
+ suggestion: 'Check the file path in the wiki-link',
652
+ severity: 'error',
653
+ });
654
+ return { segment: null, errors };
655
+ }
656
+ // Check if the video path points back to an already-visited structural file
657
+ // This would indicate a circular reference
658
+ // Note: We only check, we don't add video paths to visitedPaths since
659
+ // multiple segments can legitimately reference the same video
660
+ if (visitedPaths.has(videoPath)) {
661
+ errors.push({
662
+ file: lensPath,
663
+ message: `Circular reference detected: ${videoPath}`,
664
+ severity: 'error',
665
+ });
666
+ return { segment: null, errors };
667
+ }
668
+ const transcriptContent = files.get(videoPath);
669
+ // Look for corresponding .timestamps.json file
670
+ // e.g., video_transcripts/foo.md -> video_transcripts/foo.timestamps.json
671
+ const timestampsPath = videoPath.replace(/\.md$/, '.timestamps.json');
672
+ let timestamps;
673
+ // Debug: Log path resolution (uncomment to debug)
674
+ // console.log('DEBUG videoPath:', videoPath);
675
+ // console.log('DEBUG timestampsPath:', timestampsPath);
676
+ // console.log('DEBUG files has timestamps:', files.has(timestampsPath));
677
+ if (files.has(timestampsPath)) {
678
+ try {
679
+ timestamps = JSON.parse(files.get(timestampsPath));
680
+ // console.log('DEBUG loaded timestamps count:', timestamps.length);
681
+ }
682
+ catch {
683
+ // JSON parse error - will fall back to inline timestamps
684
+ }
685
+ }
686
+ // Extract the video excerpt
687
+ const excerptResult = extractVideoExcerpt(transcriptContent, parsedSegment.fromTimeStr, parsedSegment.toTimeStr, videoPath, timestamps);
688
+ if (excerptResult.error) {
689
+ errors.push(excerptResult.error);
690
+ return { segment: null, errors };
691
+ }
692
+ const segment = {
693
+ type: 'video-excerpt',
694
+ from: excerptResult.from,
695
+ to: excerptResult.to,
696
+ transcript: excerptResult.transcript,
697
+ };
698
+ if (parsedSegment.optional) {
699
+ segment.optional = true;
700
+ }
701
+ return { segment, errors };
702
+ }
703
+ default:
704
+ return { segment: null, errors };
705
+ }
706
+ }
707
+ //# sourceMappingURL=index.js.map