lens-content-processor 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/bundler/video.d.ts +12 -6
  2. package/dist/bundler/video.js +57 -34
  3. package/dist/bundler/video.js.map +1 -1
  4. package/dist/cli.d.ts +11 -2
  5. package/dist/cli.js +36 -19
  6. package/dist/cli.js.map +1 -1
  7. package/dist/content-schema.js +64 -16
  8. package/dist/content-schema.js.map +1 -1
  9. package/dist/dedupe-errors.d.ts +14 -0
  10. package/dist/dedupe-errors.js +33 -0
  11. package/dist/dedupe-errors.js.map +1 -0
  12. package/dist/flattener/index.d.ts +3 -28
  13. package/dist/flattener/index.js +497 -741
  14. package/dist/flattener/index.js.map +1 -1
  15. package/dist/flattener/resolve-text-links.d.ts +25 -0
  16. package/dist/flattener/resolve-text-links.js +248 -0
  17. package/dist/flattener/resolve-text-links.js.map +1 -0
  18. package/dist/flattener/test-helpers.d.ts +1 -1
  19. package/dist/flattener/test-helpers.js +1 -3
  20. package/dist/flattener/test-helpers.js.map +1 -1
  21. package/dist/index.d.ts +51 -21
  22. package/dist/index.js +285 -140
  23. package/dist/index.js.map +1 -1
  24. package/dist/parser/article.d.ts +2 -1
  25. package/dist/parser/article.js +75 -14
  26. package/dist/parser/article.js.map +1 -1
  27. package/dist/parser/course.d.ts +1 -1
  28. package/dist/parser/course.js +79 -22
  29. package/dist/parser/course.js.map +1 -1
  30. package/dist/parser/frontmatter.d.ts +1 -1
  31. package/dist/parser/frontmatter.js +38 -12
  32. package/dist/parser/frontmatter.js.map +1 -1
  33. package/dist/parser/learning-outcome.d.ts +5 -2
  34. package/dist/parser/learning-outcome.js +75 -37
  35. package/dist/parser/learning-outcome.js.map +1 -1
  36. package/dist/parser/lens.d.ts +33 -34
  37. package/dist/parser/lens.js +205 -271
  38. package/dist/parser/lens.js.map +1 -1
  39. package/dist/parser/module.d.ts +7 -15
  40. package/dist/parser/module.js +135 -171
  41. package/dist/parser/module.js.map +1 -1
  42. package/dist/parser/sections.d.ts +12 -1
  43. package/dist/parser/sections.js +74 -95
  44. package/dist/parser/sections.js.map +1 -1
  45. package/dist/parser/video-transcript.d.ts +2 -1
  46. package/dist/parser/video-transcript.js +8 -7
  47. package/dist/parser/video-transcript.js.map +1 -1
  48. package/dist/reference-graph.d.ts +8 -0
  49. package/dist/reference-graph.js +105 -0
  50. package/dist/reference-graph.js.map +1 -0
  51. package/dist/validator/card-module-membership.d.ts +2 -0
  52. package/dist/validator/card-module-membership.js +80 -0
  53. package/dist/validator/card-module-membership.js.map +1 -0
  54. package/dist/validator/output-integrity.js +5 -5
  55. package/dist/validator/output-integrity.js.map +1 -1
  56. package/dist/validator/segment-fields.d.ts +1 -1
  57. package/dist/validator/segment-fields.js +2 -2
  58. package/dist/validator/segment-fields.js.map +1 -1
  59. package/dist/validator/test-segments.d.ts +12 -0
  60. package/dist/validator/test-segments.js +40 -0
  61. package/dist/validator/test-segments.js.map +1 -0
  62. package/dist/validator/url-reachability.d.ts +2 -1
  63. package/dist/validator/url-reachability.js +40 -20
  64. package/dist/validator/url-reachability.js.map +1 -1
  65. package/package.json +1 -1
  66. package/dist/validator/chat-precedence.d.ts +0 -9
  67. package/dist/validator/chat-precedence.js +0 -32
  68. package/dist/validator/chat-precedence.js.map +0 -1
@@ -1,137 +1,31 @@
1
- import { ALL_KNOWN_FIELDS } from '../content-schema.js';
2
1
  import { parseFrontmatter } from './frontmatter.js';
3
- import { parseSections, LENS_SECTION_TYPES, LENS_OUTPUT_TYPE } from './sections.js';
4
2
  import { validateSegmentFields } from '../validator/segment-fields.js';
5
3
  import { validateFieldValues } from '../validator/field-values.js';
6
4
  import { detectFieldTypos } from '../validator/field-typos.js';
7
5
  import { validateFrontmatter } from '../validator/validate-frontmatter.js';
8
- import { validateChatPrecedence } from '../validator/chat-precedence.js';
9
6
  import { detectDirectivesInNonArticle } from '../validator/directives.js';
10
7
  import { parseWikilink, hasRelativePath } from './wikilink.js';
8
+ import { parseSections } from './sections.js';
11
9
  import { parseTimestamp } from '../bundler/video.js';
12
10
  // Valid segment types for lens H4 headers
13
- const LENS_SEGMENT_TYPES = new Set(['text', 'chat', 'article-excerpt', 'video-excerpt', 'question', 'roleplay']);
14
- // Valid segment types per section output type
15
- const VALID_SEGMENTS_PER_SECTION = {
16
- 'page': new Set(['text', 'chat', 'question', 'roleplay']),
17
- 'lens-article': new Set(['text', 'chat', 'article-excerpt', 'question', 'roleplay']),
18
- 'lens-video': new Set(['text', 'chat', 'video-excerpt', 'question', 'roleplay']),
19
- };
20
- // H4 segment header pattern: #### <type> or #### <type>: <title>
21
- const SEGMENT_HEADER_PATTERN = /^####\s+([^:\s]+)(?::\s*(.*?))?\s*$/i;
22
- // Field pattern: fieldname:: value
23
- const FIELD_PATTERN = /^([\w-]+)::\s*(.*)$/;
11
+ export const LENS_SEGMENT_TYPES = new Set(['text', 'chat', 'article', 'video', 'question', 'roleplay']);
24
12
  /**
25
- * Parse H4 segments from a section body.
26
- * Segments are defined by `#### <type>` headers within a section.
13
+ * Convert a raw segment to a typed ParsedLensSegment.
27
14
  */
28
- export function parseSegments(sectionBody, bodyStartLine, file) {
29
- const lines = sectionBody.split('\n');
30
- const segments = [];
15
+ export function convertSegment(raw, sectionType, file) {
31
16
  const errors = [];
32
- let currentSegment = null;
33
- let currentFieldLines = [];
34
- let preSegmentWarned = false;
35
- for (let i = 0; i < lines.length; i++) {
36
- const line = lines[i];
37
- const lineNum = bodyStartLine + i;
38
- const headerMatch = line.match(SEGMENT_HEADER_PATTERN);
39
- if (headerMatch) {
40
- // Save previous segment
41
- if (currentSegment) {
42
- parseFieldsIntoSegment(currentSegment, currentFieldLines);
43
- segments.push(currentSegment);
44
- }
45
- const rawType = headerMatch[1].trim();
46
- const normalizedType = rawType.toLowerCase();
47
- const title = headerMatch[2]?.trim() || undefined;
48
- if (!LENS_SEGMENT_TYPES.has(normalizedType)) {
49
- errors.push({
50
- file,
51
- line: lineNum,
52
- message: `Unknown segment type: ${rawType}`,
53
- suggestion: `Valid types: ${[...LENS_SEGMENT_TYPES].join(', ')}`,
54
- severity: 'error',
55
- });
56
- }
57
- currentSegment = {
58
- type: normalizedType,
59
- title,
60
- fields: {},
61
- line: lineNum,
62
- };
63
- currentFieldLines = [];
64
- }
65
- else if (currentSegment) {
66
- // Check for single-colon field that should be double-colon
67
- const singleColonMatch = line.match(/^([\w-]+):\s+(.*)$/);
68
- if (singleColonMatch && !line.match(/^https?:/) && !FIELD_PATTERN.test(line) && ALL_KNOWN_FIELDS.includes(singleColonMatch[1])) {
17
+ switch (raw.type) {
18
+ case 'text': {
19
+ if (raw.title) {
20
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
69
21
  errors.push({
70
22
  file,
71
- line: lineNum,
72
- message: `Found '${singleColonMatch[1]}:' with single colon did you mean '${singleColonMatch[1]}::'?`,
73
- suggestion: `Change '${singleColonMatch[1]}:' to '${singleColonMatch[1]}::' (double colon)`,
23
+ line: raw.line,
24
+ message: `Titles are not supported for ${capitalized} segmentsuse just '#### ${capitalized}'`,
25
+ suggestion: `Remove the title after '${capitalized}:'`,
74
26
  severity: 'error',
75
27
  });
76
28
  }
77
- currentFieldLines.push(line);
78
- }
79
- else {
80
- // No segment started yet — check for free text (not fields, not blank)
81
- if (line.trim() && !FIELD_PATTERN.test(line) && !preSegmentWarned) {
82
- preSegmentWarned = true;
83
- errors.push({
84
- file,
85
- line: lineNum,
86
- message: 'Text before first segment header (####) will be ignored',
87
- suggestion: 'Move this text into a segment (e.g., #### Text with content:: field), or remove it',
88
- severity: 'warning',
89
- });
90
- }
91
- }
92
- }
93
- // Don't forget last segment
94
- if (currentSegment) {
95
- parseFieldsIntoSegment(currentSegment, currentFieldLines);
96
- segments.push(currentSegment);
97
- }
98
- return { segments, errors };
99
- }
100
- /**
101
- * Parse fields from lines into a segment, handling multiline values.
102
- * A field continues until the next field or the end of the lines.
103
- */
104
- function parseFieldsIntoSegment(segment, lines) {
105
- let currentField = null;
106
- let currentValue = [];
107
- for (const line of lines) {
108
- const match = line.match(FIELD_PATTERN);
109
- if (match) {
110
- // Save previous field if any
111
- if (currentField) {
112
- segment.fields[currentField] = currentValue.join('\n').trim();
113
- }
114
- currentField = match[1];
115
- const inlineValue = match[2].trim();
116
- currentValue = inlineValue ? [inlineValue] : [];
117
- }
118
- else if (currentField) {
119
- // Continue multiline value
120
- currentValue.push(line);
121
- }
122
- }
123
- // Save final field
124
- if (currentField) {
125
- segment.fields[currentField] = currentValue.join('\n').trim();
126
- }
127
- }
128
- /**
129
- * Convert a raw segment to a typed ParsedLensSegment.
130
- */
131
- export function convertSegment(raw, sectionType, file) {
132
- const errors = [];
133
- switch (raw.type) {
134
- case 'text': {
135
29
  const hasContentField = 'content' in raw.fields;
136
30
  const content = raw.fields.content;
137
31
  if (!hasContentField) {
@@ -173,6 +67,16 @@ export function convertSegment(raw, sectionType, file) {
173
67
  return { segment, errors };
174
68
  }
175
69
  case 'chat': {
70
+ if (raw.title) {
71
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
72
+ errors.push({
73
+ file,
74
+ line: raw.line,
75
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'`,
76
+ suggestion: `Remove the title after '${capitalized}:'`,
77
+ severity: 'error',
78
+ });
79
+ }
176
80
  const hasInstructionsField = 'instructions' in raw.fields;
177
81
  const instructions = raw.fields.instructions;
178
82
  if (!hasInstructionsField) {
@@ -199,7 +103,6 @@ export function convertSegment(raw, sectionType, file) {
199
103
  }
200
104
  const segment = {
201
105
  type: 'chat',
202
- title: raw.title,
203
106
  instructions: instructions || '',
204
107
  hidePreviousContentFromUser: raw.fields.hidePreviousContentFromUser?.toLowerCase() === 'true' ? true : undefined,
205
108
  hidePreviousContentFromTutor: raw.fields.hidePreviousContentFromTutor?.toLowerCase() === 'true' ? true : undefined,
@@ -207,38 +110,75 @@ export function convertSegment(raw, sectionType, file) {
207
110
  };
208
111
  return { segment, errors };
209
112
  }
210
- case 'article-excerpt': {
113
+ case 'article': {
114
+ if (raw.title) {
115
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
116
+ errors.push({
117
+ file,
118
+ line: raw.line,
119
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'. Set the lens title in frontmatter instead (title: ...)`,
120
+ suggestion: `Remove the title after '${capitalized}:'`,
121
+ severity: 'error',
122
+ });
123
+ }
211
124
  const fromField = raw.fields.from;
212
125
  const toField = raw.fields.to;
213
- // Both from:: and to:: are optional for article-excerpt:
214
- // - Only from:: extract from anchor to end of article
215
- // - Only to:: extract from start to anchor
216
- // - Neither extract entire article
126
+ const sourceField = raw.fields.source;
127
+ // Both from:: and to:: are optional for article:
128
+ // - Only from:: -> extract from anchor to end of article
129
+ // - Only to:: -> extract from start to anchor
130
+ // - Neither -> extract entire article
217
131
  // Strip quotes from anchor text if present
218
132
  const fromAnchor = fromField ? stripQuotes(fromField) : undefined;
219
133
  const toAnchor = toField ? stripQuotes(toField) : undefined;
134
+ // Validate source:: wikilink if present
135
+ if (sourceField) {
136
+ const wikilink = parseWikilink(sourceField);
137
+ if (wikilink && wikilink.error) {
138
+ const suggestion = wikilink.correctedPath
139
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
140
+ : 'Check the path in the wikilink';
141
+ errors.push({
142
+ file,
143
+ line: raw.line,
144
+ message: `Invalid wikilink in source:: field: ${sourceField}`,
145
+ suggestion,
146
+ severity: 'error',
147
+ });
148
+ }
149
+ else if (wikilink && !hasRelativePath(wikilink.path)) {
150
+ errors.push({
151
+ file,
152
+ line: raw.line,
153
+ message: `source:: path must be relative (contain /): ${wikilink.path}`,
154
+ suggestion: 'Use format [[../path/to/file.md|Display]] with relative path',
155
+ severity: 'error',
156
+ });
157
+ }
158
+ }
220
159
  const segment = {
221
- type: 'article-excerpt',
160
+ type: 'article',
161
+ source: sourceField,
222
162
  fromAnchor,
223
163
  toAnchor,
224
164
  optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
225
165
  };
226
166
  return { segment, errors };
227
167
  }
228
- case 'video-excerpt': {
229
- const fromField = raw.fields.from;
230
- const toField = raw.fields.to;
231
- // to:: is required, from:: defaults to "0:00"
232
- if (!toField) {
168
+ case 'video': {
169
+ if (raw.title) {
170
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
233
171
  errors.push({
234
172
  file,
235
173
  line: raw.line,
236
- message: 'Video-excerpt segment missing to:: field',
237
- suggestion: "Add 'to:: M:SS' or 'to:: H:MM:SS' to the segment",
174
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'. Set the lens title in frontmatter instead (title: ...)`,
175
+ suggestion: `Remove the title after '${capitalized}:'`,
238
176
  severity: 'error',
239
177
  });
240
- return { segment: null, errors };
241
178
  }
179
+ const fromField = raw.fields.from;
180
+ const toField = raw.fields.to;
181
+ const sourceField = raw.fields.source;
242
182
  // Validate timestamp formats at parse time for better error reporting
243
183
  const fromStr = fromField || '0:00';
244
184
  if (parseTimestamp(fromStr) === null) {
@@ -250,7 +190,7 @@ export function convertSegment(raw, sectionType, file) {
250
190
  severity: 'warning',
251
191
  });
252
192
  }
253
- if (parseTimestamp(toField) === null) {
193
+ if (toField && parseTimestamp(toField) === null) {
254
194
  errors.push({
255
195
  file,
256
196
  line: raw.line,
@@ -259,10 +199,36 @@ export function convertSegment(raw, sectionType, file) {
259
199
  severity: 'warning',
260
200
  });
261
201
  }
202
+ // Validate source:: wikilink if present
203
+ if (sourceField) {
204
+ const wikilink = parseWikilink(sourceField);
205
+ if (wikilink && wikilink.error) {
206
+ const suggestion = wikilink.correctedPath
207
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
208
+ : 'Check the path in the wikilink';
209
+ errors.push({
210
+ file,
211
+ line: raw.line,
212
+ message: `Invalid wikilink in source:: field: ${sourceField}`,
213
+ suggestion,
214
+ severity: 'error',
215
+ });
216
+ }
217
+ else if (wikilink && !hasRelativePath(wikilink.path)) {
218
+ errors.push({
219
+ file,
220
+ line: raw.line,
221
+ message: `source:: path must be relative (contain /): ${wikilink.path}`,
222
+ suggestion: 'Use format [[../path/to/file.md|Display]] with relative path',
223
+ severity: 'error',
224
+ });
225
+ }
226
+ }
262
227
  const segment = {
263
- type: 'video-excerpt',
228
+ type: 'video',
229
+ source: sourceField,
264
230
  fromTimeStr: fromField || '0:00', // Default to start of video
265
- toTimeStr: toField,
231
+ toTimeStr: toField || undefined,
266
232
  optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
267
233
  };
268
234
  return { segment, errors };
@@ -332,13 +298,14 @@ export function convertSegment(raw, sectionType, file) {
332
298
  aiInstructions,
333
299
  openingMessage: raw.fields['opening-message'] || undefined,
334
300
  assessmentInstructions: raw.fields['assessment-instructions'] || undefined,
301
+ userCustomizable: raw.fields['user-customizable']?.toLowerCase() === 'true' ? true : undefined,
335
302
  optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
336
303
  feedback: raw.fields['feedback']?.toLowerCase() === 'true' ? true : undefined,
337
304
  };
338
305
  return { segment, errors };
339
306
  }
340
307
  default:
341
- // Unknown segment type - error already reported during parseSegments
308
+ // Unknown segment type - error already reported during parseSections
342
309
  return { segment: null, errors };
343
310
  }
344
311
  }
@@ -355,13 +322,13 @@ function stripQuotes(s) {
355
322
  * Check if a segment is empty (has no meaningful fields).
356
323
  * Returns a warning if the segment is empty.
357
324
  *
358
- * Note: article-excerpt with no fields is valid (means entire article).
325
+ * Note: article with no fields is valid (means entire article, source inherited).
359
326
  */
360
327
  function checkEmptySegment(raw, file) {
361
328
  // A segment is empty if it has no fields at all
362
329
  const fieldCount = Object.keys(raw.fields).length;
363
- // article-excerpt with no fields is valid - means "include entire article"
364
- if (raw.type === 'article-excerpt' && fieldCount === 0) {
330
+ // article with no fields is valid - means "include entire article" (source inherited)
331
+ if (raw.type === 'article' && fieldCount === 0) {
365
332
  return null;
366
333
  }
367
334
  if (fieldCount === 0) {
@@ -384,19 +351,19 @@ export function stripObsidianComments(content) {
384
351
  }
385
352
  /**
386
353
  * Strip CriticMarkup from content using reject-all-changes behavior:
387
- * - {>>comments<<} removed
388
- * - {++additions++} removed
389
- * - {--deletions--} inner content kept (original preserved)
390
- * - {~~old~>new~~} old text kept
391
- * - {==highlights==} inner content kept, markers removed
354
+ * - {>>comments<<} -> removed
355
+ * - {++additions++} -> removed
356
+ * - {--deletions--} -> inner content kept (original preserved)
357
+ * - {~~old~>new~~} -> old text kept
358
+ * - {==highlights==} -> inner content kept, markers removed
392
359
  */
393
360
  export function stripCriticMarkup(content) {
394
361
  return content
395
- .replace(/\{>>.*?<<\}/gs, '') // Comments remove
396
- .replace(/\{\+\+.*?\+\+\}/gs, '') // Additions remove
397
- .replace(/\{--(?:\{[^}]*\}@@)?(.*?)--\}/gs, '$1') // Deletions keep inner (skip metadata)
398
- .replace(/\{~~(?:\{[^}]*\}@@)?(.*?)~>.*?~~\}/gs, '$1') // Substitutions keep old (skip metadata)
399
- .replace(/\{==(?:\{[^}]*\}@@)?(.*?)==\}/gs, '$1'); // Highlights keep inner (skip metadata)
362
+ .replace(/\{>>.*?<<\}/gs, '') // Comments -> remove
363
+ .replace(/\{\+\+.*?\+\+\}/gs, '') // Additions -> remove
364
+ .replace(/\{--(?:\{[^}]*\}@@)?(.*?)--\}/gs, '$1') // Deletions -> keep inner (skip metadata)
365
+ .replace(/\{~~(?:\{[^}]*\}@@)?(.*?)~>.*?~~\}/gs, '$1') // Substitutions -> keep old (skip metadata)
366
+ .replace(/\{==(?:\{[^}]*\}@@)?(.*?)==\}/gs, '$1'); // Highlights -> keep inner (skip metadata)
400
367
  }
401
368
  /**
402
369
  * Strip all authoring markup (CriticMarkup + Obsidian comments) from content.
@@ -407,12 +374,56 @@ export function stripAuthoringMarkup(content) {
407
374
  // Trim trailing whitespace left by inline markup removal
408
375
  return stripped.split('\n').map(line => line.trimEnd()).join('\n');
409
376
  }
377
+ /**
378
+ * Apply source inheritance for article and video segments.
379
+ * Each article/video segment inherits source:: from the previous segment of the same type.
380
+ * Errors if the first segment of a type has no source.
381
+ *
382
+ * Mutates segments in place. Returns any errors.
383
+ */
384
+ export function applySourceInheritance(segments, file) {
385
+ const errors = [];
386
+ let lastArticleSource;
387
+ let lastVideoSource;
388
+ for (const seg of segments) {
389
+ if (seg.type === 'article') {
390
+ if (seg.source) {
391
+ lastArticleSource = seg.source;
392
+ }
393
+ else if (lastArticleSource) {
394
+ seg.source = lastArticleSource;
395
+ }
396
+ else {
397
+ errors.push({
398
+ file,
399
+ message: 'First article segment must have a source:: field',
400
+ severity: 'error',
401
+ });
402
+ }
403
+ }
404
+ else if (seg.type === 'video') {
405
+ if (seg.source) {
406
+ lastVideoSource = seg.source;
407
+ }
408
+ else if (lastVideoSource) {
409
+ seg.source = lastVideoSource;
410
+ }
411
+ else {
412
+ errors.push({
413
+ file,
414
+ message: 'First video segment must have a source:: field',
415
+ severity: 'error',
416
+ });
417
+ }
418
+ }
419
+ }
420
+ return errors;
421
+ }
410
422
  /**
411
423
  * Parse a lens file into structured lens data.
412
424
  *
413
- * Lens files use:
414
- * - H3 (`###`) for sections: Text, Article, Video
415
- * - H4 (`####`) for segments: Text, Chat, Article-excerpt, Video-excerpt
425
+ * Lens files are flat: frontmatter + H4 segments directly.
426
+ * No H3 section headers. Source is on segments with inheritance.
416
427
  */
417
428
  export function parseLens(content, file) {
418
429
  const errors = [];
@@ -441,7 +452,12 @@ export function parseLens(content, file) {
441
452
  });
442
453
  return { lens: null, errors };
443
454
  }
455
+ const title = typeof frontmatter.title === 'string' ? frontmatter.title : undefined;
444
456
  const tldr = typeof frontmatter.tldr === 'string' ? frontmatter.tldr : undefined;
457
+ const summaryForTutor = typeof frontmatter.summary_for_tutor === 'string' ? frontmatter.summary_for_tutor : undefined;
458
+ const addToAiContext = 'add_to_ai_context' in frontmatter
459
+ ? frontmatter.add_to_ai_context
460
+ : undefined;
445
461
  if (tldr) {
446
462
  const wordCount = tldr.trim().split(/\s+/).filter(Boolean).length;
447
463
  if (wordCount > 80) {
@@ -454,136 +470,54 @@ export function parseLens(content, file) {
454
470
  });
455
471
  }
456
472
  }
457
- // Step 2: Parse H3 sections (Text, Article, Video)
458
- const sectionsResult = parseSections(body, 3, LENS_SECTION_TYPES, file);
459
- // Adjust line numbers to account for frontmatter
460
- for (const error of sectionsResult.errors) {
461
- if (error.line) {
462
- error.line += bodyStartLine - 1;
463
- }
464
- }
465
- errors.push(...sectionsResult.errors);
466
- for (const section of sectionsResult.sections) {
467
- section.line += bodyStartLine - 1;
468
- }
469
- // Step 3: Convert raw sections to ParsedLensSections with segments
470
- const parsedSections = [];
471
- for (const rawSection of sectionsResult.sections) {
472
- // Map section type: 'article' -> 'lens-article', 'video' -> 'lens-video'
473
- const outputType = LENS_OUTPUT_TYPE[rawSection.type] ?? rawSection.type;
474
- // For article/video sections, source field is required
475
- const needsSource = outputType === 'lens-article' || outputType === 'lens-video';
476
- const source = rawSection.fields.source;
477
- if (needsSource && !source) {
478
- errors.push({
479
- file,
480
- line: rawSection.line,
481
- message: `${rawSection.rawType} section missing source:: field`,
482
- suggestion: `Add 'source:: [[../path/to/file.md|Display]]' to the ${rawSection.rawType.toLowerCase()} section`,
483
- severity: 'error',
484
- });
473
+ // Step 2: Parse segments from body (flat mode — all segment headers are siblings)
474
+ const { sections: rawSegments, errors: segmentErrors } = parseSections(body, 3, // parentLevel=3 for standalone lens files → matches H4+ (backward compat)
475
+ LENS_SEGMENT_TYPES, file, true // flat=true segments are always siblings
476
+ );
477
+ errors.push(...segmentErrors);
478
+ // Step 3: Convert raw segments to typed segments + validate
479
+ const parsedSegments = [];
480
+ for (const rawSeg of rawSegments) {
481
+ // Check for empty segments
482
+ const emptyWarning = checkEmptySegment(rawSeg, file);
483
+ if (emptyWarning) {
484
+ errors.push(emptyWarning);
485
485
  }
486
- // Validate source:: path is relative (contains /)
487
- if (source) {
488
- const wikilink = parseWikilink(source);
489
- if (wikilink && wikilink.error) {
490
- const suggestion = wikilink.correctedPath
491
- ? `Did you mean '[[${wikilink.correctedPath}]]'?`
492
- : 'Check the path in the wikilink';
493
- errors.push({
494
- file,
495
- line: rawSection.line,
496
- message: `Invalid wikilink in source:: field: ${source}`,
497
- suggestion,
498
- severity: 'error',
499
- });
500
- }
501
- else if (wikilink && !hasRelativePath(wikilink.path)) {
502
- errors.push({
503
- file,
504
- line: rawSection.line,
505
- message: `source:: path must be relative (contain /): ${wikilink.path}`,
506
- suggestion: 'Use format [[../path/to/file.md|Display]] with relative path',
507
- severity: 'error',
508
- });
509
- }
486
+ // Validate that fields are appropriate for this segment type
487
+ const fieldWarnings = validateSegmentFields(rawSeg.type, rawSeg.fields, file, rawSeg.line);
488
+ errors.push(...fieldWarnings);
489
+ // Validate field values (e.g., boolean fields should have 'true' or 'false')
490
+ const valueWarnings = validateFieldValues(rawSeg.fields, file, rawSeg.line);
491
+ errors.push(...valueWarnings);
492
+ // Detect likely typos in field names
493
+ const typoWarnings = detectFieldTypos(rawSeg.fields, file, rawSeg.line);
494
+ errors.push(...typoWarnings);
495
+ const { segment, errors: conversionErrors } = convertSegment(rawSeg, 'lens', file);
496
+ errors.push(...conversionErrors);
497
+ if (segment) {
498
+ parsedSegments.push(segment);
510
499
  }
511
- // Parse H4 segments within this section
512
- const { segments: rawSegments, errors: segmentErrors } = parseSegments(rawSection.body, rawSection.line + 1, // Segments start after the section header
513
- file);
514
- errors.push(...segmentErrors);
515
- // Convert raw segments to typed segments
516
- const segments = [];
517
- for (const rawSeg of rawSegments) {
518
- // Check for empty segments
519
- const emptyWarning = checkEmptySegment(rawSeg, file);
520
- if (emptyWarning) {
521
- errors.push(emptyWarning);
522
- }
523
- // Validate that fields are appropriate for this segment type
524
- const fieldWarnings = validateSegmentFields(rawSeg.type, rawSeg.fields, file, rawSeg.line);
525
- errors.push(...fieldWarnings);
526
- // Validate field values (e.g., boolean fields should have 'true' or 'false')
527
- const valueWarnings = validateFieldValues(rawSeg.fields, file, rawSeg.line);
528
- errors.push(...valueWarnings);
529
- // Detect likely typos in field names
530
- const typoWarnings = detectFieldTypos(rawSeg.fields, file, rawSeg.line);
531
- errors.push(...typoWarnings);
532
- // Check segment/section type compatibility
533
- const validSegs = VALID_SEGMENTS_PER_SECTION[outputType];
534
- if (validSegs && !validSegs.has(rawSeg.type)) {
535
- errors.push({
536
- file,
537
- line: rawSeg.line,
538
- message: `Segment type '${rawSeg.type}' is not valid in a ${rawSection.rawType} section`,
539
- suggestion: `Valid segment types for ${rawSection.rawType}: ${[...(validSegs)].join(', ')}`,
540
- severity: 'warning',
541
- });
542
- }
543
- const { segment, errors: conversionErrors } = convertSegment(rawSeg, outputType, file);
544
- errors.push(...conversionErrors);
545
- if (segment) {
546
- segments.push(segment);
547
- }
548
- }
549
- // Validate Chat segments are preceded by Text segments
550
- const chatErrors = validateChatPrecedence(rawSegments, file);
551
- errors.push(...chatErrors);
552
- // Warn if section has no segments
553
- if (segments.length === 0) {
554
- errors.push({
555
- file,
556
- line: rawSection.line,
557
- message: `${rawSection.rawType} section has no segments`,
558
- suggestion: `Add at least one segment (#### Text, #### Chat, etc.) to the ${rawSection.rawType.toLowerCase()} section`,
559
- severity: 'warning',
560
- });
561
- }
562
- const parsedSection = {
563
- type: outputType,
564
- title: rawSection.title,
565
- source: source,
566
- segments,
567
- line: rawSection.line,
568
- };
569
- parsedSections.push(parsedSection);
570
500
  }
571
- // Check for conflicting section types (Article + Video is nonsensical)
572
- const sourceTypes = new Set(parsedSections.map(s => s.type).filter(t => t !== 'page'));
573
- if (sourceTypes.size > 1) {
574
- const typeList = [...sourceTypes].join(', ');
501
+ // Step 4: Source inheritance for article and video segments
502
+ const inheritanceErrors = applySourceInheritance(parsedSegments, file);
503
+ errors.push(...inheritanceErrors);
504
+ // Warn if lens has no segments
505
+ if (parsedSegments.length === 0) {
575
506
  errors.push({
576
507
  file,
577
- line: parsedSections[0]?.line ?? bodyStartLine,
578
- message: `Lens has conflicting section types: ${typeList}. Each lens should use a single source type.`,
579
- suggestion: 'Split into separate lens files, one per source type',
508
+ line: bodyStartLine,
509
+ message: 'Lens has no segments',
510
+ suggestion: 'Add at least one segment (#### Text, #### Chat, etc.)',
580
511
  severity: 'warning',
581
512
  });
582
513
  }
583
514
  const lens = {
584
515
  id: frontmatter.id,
516
+ title,
585
517
  tldr,
586
- sections: parsedSections,
518
+ summaryForTutor,
519
+ addToAiContext,
520
+ segments: parsedSegments,
587
521
  };
588
522
  return { lens, errors };
589
523
  }