lens-content-processor 0.3.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 (78) hide show
  1. package/dist/bundler/article.d.ts +7 -2
  2. package/dist/bundler/article.js +19 -8
  3. package/dist/bundler/article.js.map +1 -1
  4. package/dist/bundler/video.d.ts +12 -6
  5. package/dist/bundler/video.js +57 -34
  6. package/dist/bundler/video.js.map +1 -1
  7. package/dist/cli.d.ts +11 -2
  8. package/dist/cli.js +36 -19
  9. package/dist/cli.js.map +1 -1
  10. package/dist/content-schema.js +64 -15
  11. package/dist/content-schema.js.map +1 -1
  12. package/dist/dedupe-errors.d.ts +14 -0
  13. package/dist/dedupe-errors.js +33 -0
  14. package/dist/dedupe-errors.js.map +1 -0
  15. package/dist/flattener/index.d.ts +22 -28
  16. package/dist/flattener/index.js +773 -613
  17. package/dist/flattener/index.js.map +1 -1
  18. package/dist/flattener/resolve-text-links.d.ts +25 -0
  19. package/dist/flattener/resolve-text-links.js +248 -0
  20. package/dist/flattener/resolve-text-links.js.map +1 -0
  21. package/dist/flattener/test-helpers.d.ts +23 -0
  22. package/dist/flattener/test-helpers.js +56 -0
  23. package/dist/flattener/test-helpers.js.map +1 -0
  24. package/dist/index.d.ts +66 -20
  25. package/dist/index.js +327 -152
  26. package/dist/index.js.map +1 -1
  27. package/dist/parser/article.d.ts +3 -2
  28. package/dist/parser/article.js +80 -12
  29. package/dist/parser/article.js.map +1 -1
  30. package/dist/parser/course.d.ts +1 -1
  31. package/dist/parser/course.js +88 -28
  32. package/dist/parser/course.js.map +1 -1
  33. package/dist/parser/frontmatter.d.ts +1 -1
  34. package/dist/parser/frontmatter.js +38 -12
  35. package/dist/parser/frontmatter.js.map +1 -1
  36. package/dist/parser/learning-outcome.d.ts +12 -2
  37. package/dist/parser/learning-outcome.js +177 -96
  38. package/dist/parser/learning-outcome.js.map +1 -1
  39. package/dist/parser/lens.d.ts +52 -28
  40. package/dist/parser/lens.js +283 -260
  41. package/dist/parser/lens.js.map +1 -1
  42. package/dist/parser/module.d.ts +7 -15
  43. package/dist/parser/module.js +139 -170
  44. package/dist/parser/module.js.map +1 -1
  45. package/dist/parser/sections.d.ts +14 -1
  46. package/dist/parser/sections.js +94 -81
  47. package/dist/parser/sections.js.map +1 -1
  48. package/dist/parser/video-transcript.d.ts +2 -1
  49. package/dist/parser/video-transcript.js +10 -6
  50. package/dist/parser/video-transcript.js.map +1 -1
  51. package/dist/reference-graph.d.ts +8 -0
  52. package/dist/reference-graph.js +105 -0
  53. package/dist/reference-graph.js.map +1 -0
  54. package/dist/validator/card-module-membership.d.ts +2 -0
  55. package/dist/validator/card-module-membership.js +80 -0
  56. package/dist/validator/card-module-membership.js.map +1 -0
  57. package/dist/validator/directives.d.ts +12 -0
  58. package/dist/validator/directives.js +428 -0
  59. package/dist/validator/directives.js.map +1 -0
  60. package/dist/validator/field-values.js +14 -0
  61. package/dist/validator/field-values.js.map +1 -1
  62. package/dist/validator/output-integrity.js +5 -5
  63. package/dist/validator/output-integrity.js.map +1 -1
  64. package/dist/validator/segment-fields.d.ts +1 -1
  65. package/dist/validator/segment-fields.js +2 -2
  66. package/dist/validator/segment-fields.js.map +1 -1
  67. package/dist/validator/test-segments.d.ts +12 -0
  68. package/dist/validator/test-segments.js +40 -0
  69. package/dist/validator/test-segments.js.map +1 -0
  70. package/dist/validator/url-reachability.d.ts +2 -1
  71. package/dist/validator/url-reachability.js +42 -20
  72. package/dist/validator/url-reachability.js.map +1 -1
  73. package/dist/validator/uuid.js +9 -6
  74. package/dist/validator/uuid.js.map +1 -1
  75. package/package.json +1 -1
  76. package/dist/validator/chat-precedence.d.ts +0 -9
  77. package/dist/validator/chat-precedence.js +0 -32
  78. package/dist/validator/chat-precedence.js.map +0 -1
@@ -1,136 +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';
6
+ import { detectDirectivesInNonArticle } from '../validator/directives.js';
9
7
  import { parseWikilink, hasRelativePath } from './wikilink.js';
8
+ import { parseSections } from './sections.js';
10
9
  import { parseTimestamp } from '../bundler/video.js';
11
10
  // Valid segment types for lens H4 headers
12
- const LENS_SEGMENT_TYPES = new Set(['text', 'chat', 'article-excerpt', 'video-excerpt', 'question']);
13
- // Valid segment types per section output type
14
- const VALID_SEGMENTS_PER_SECTION = {
15
- 'page': new Set(['text', 'chat', 'question']),
16
- 'lens-article': new Set(['text', 'chat', 'article-excerpt', 'question']),
17
- 'lens-video': new Set(['text', 'chat', 'video-excerpt', 'question']),
18
- };
19
- // H4 segment header pattern: #### <type> or #### <type>: <title>
20
- const SEGMENT_HEADER_PATTERN = /^####\s+([^:\s]+)(?::\s*(.*))?$/i;
21
- // Field pattern: fieldname:: value
22
- const FIELD_PATTERN = /^([\w-]+)::\s*(.*)$/;
11
+ export const LENS_SEGMENT_TYPES = new Set(['text', 'chat', 'article', 'video', 'question', 'roleplay']);
23
12
  /**
24
- * Parse H4 segments from a section body.
25
- * Segments are defined by `#### <type>` headers within a section.
13
+ * Convert a raw segment to a typed ParsedLensSegment.
26
14
  */
27
- export function parseSegments(sectionBody, bodyStartLine, file) {
28
- const lines = sectionBody.split('\n');
29
- const segments = [];
15
+ export function convertSegment(raw, sectionType, file) {
30
16
  const errors = [];
31
- let currentSegment = null;
32
- let currentFieldLines = [];
33
- let preSegmentWarned = false;
34
- for (let i = 0; i < lines.length; i++) {
35
- const line = lines[i];
36
- const lineNum = bodyStartLine + i;
37
- const headerMatch = line.match(SEGMENT_HEADER_PATTERN);
38
- if (headerMatch) {
39
- // Save previous segment
40
- if (currentSegment) {
41
- parseFieldsIntoSegment(currentSegment, currentFieldLines);
42
- segments.push(currentSegment);
43
- }
44
- const rawType = headerMatch[1].trim();
45
- const normalizedType = rawType.toLowerCase();
46
- const title = headerMatch[2]?.trim() || undefined;
47
- if (!LENS_SEGMENT_TYPES.has(normalizedType)) {
17
+ switch (raw.type) {
18
+ case 'text': {
19
+ if (raw.title) {
20
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
48
21
  errors.push({
49
22
  file,
50
- line: lineNum,
51
- message: `Unknown segment type: ${rawType}`,
52
- suggestion: `Valid types: ${[...LENS_SEGMENT_TYPES].join(', ')}`,
23
+ line: raw.line,
24
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'`,
25
+ suggestion: `Remove the title after '${capitalized}:'`,
53
26
  severity: 'error',
54
27
  });
55
28
  }
56
- currentSegment = {
57
- type: normalizedType,
58
- title,
59
- fields: {},
60
- line: lineNum,
61
- };
62
- currentFieldLines = [];
63
- }
64
- else if (currentSegment) {
65
- // Check for single-colon field that should be double-colon
66
- const singleColonMatch = line.match(/^([\w-]+):\s+(.*)$/);
67
- if (singleColonMatch && !line.match(/^https?:/) && !FIELD_PATTERN.test(line) && ALL_KNOWN_FIELDS.includes(singleColonMatch[1])) {
68
- errors.push({
69
- file,
70
- line: lineNum,
71
- message: `Found '${singleColonMatch[1]}:' with single colon — did you mean '${singleColonMatch[1]}::'?`,
72
- suggestion: `Change '${singleColonMatch[1]}:' to '${singleColonMatch[1]}::' (double colon)`,
73
- severity: 'warning',
74
- });
75
- }
76
- currentFieldLines.push(line);
77
- }
78
- else {
79
- // No segment started yet — check for free text (not fields, not blank)
80
- if (line.trim() && !FIELD_PATTERN.test(line) && !preSegmentWarned) {
81
- preSegmentWarned = true;
82
- errors.push({
83
- file,
84
- line: lineNum,
85
- message: 'Text before first segment header (####) will be ignored',
86
- suggestion: 'Move this text into a segment (e.g., #### Text with content:: field), or remove it',
87
- severity: 'warning',
88
- });
89
- }
90
- }
91
- }
92
- // Don't forget last segment
93
- if (currentSegment) {
94
- parseFieldsIntoSegment(currentSegment, currentFieldLines);
95
- segments.push(currentSegment);
96
- }
97
- return { segments, errors };
98
- }
99
- /**
100
- * Parse fields from lines into a segment, handling multiline values.
101
- * A field continues until the next field or the end of the lines.
102
- */
103
- function parseFieldsIntoSegment(segment, lines) {
104
- let currentField = null;
105
- let currentValue = [];
106
- for (const line of lines) {
107
- const match = line.match(FIELD_PATTERN);
108
- if (match) {
109
- // Save previous field if any
110
- if (currentField) {
111
- segment.fields[currentField] = currentValue.join('\n').trim();
112
- }
113
- currentField = match[1];
114
- const inlineValue = match[2].trim();
115
- currentValue = inlineValue ? [inlineValue] : [];
116
- }
117
- else if (currentField) {
118
- // Continue multiline value
119
- currentValue.push(line);
120
- }
121
- }
122
- // Save final field
123
- if (currentField) {
124
- segment.fields[currentField] = currentValue.join('\n').trim();
125
- }
126
- }
127
- /**
128
- * Convert a raw segment to a typed ParsedLensSegment.
129
- */
130
- export function convertSegment(raw, sectionType, file) {
131
- const errors = [];
132
- switch (raw.type) {
133
- case 'text': {
134
29
  const hasContentField = 'content' in raw.fields;
135
30
  const content = raw.fields.content;
136
31
  if (!hasContentField) {
@@ -161,6 +56,9 @@ export function convertSegment(raw, sectionType, file) {
161
56
  };
162
57
  return { segment, errors };
163
58
  }
59
+ // Warn if directives are used in lens text segments (they only render in articles)
60
+ const directiveWarnings = detectDirectivesInNonArticle(content, file, raw.line);
61
+ errors.push(...directiveWarnings);
164
62
  const segment = {
165
63
  type: 'text',
166
64
  content,
@@ -169,6 +67,16 @@ export function convertSegment(raw, sectionType, file) {
169
67
  return { segment, errors };
170
68
  }
171
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
+ }
172
80
  const hasInstructionsField = 'instructions' in raw.fields;
173
81
  const instructions = raw.fields.instructions;
174
82
  if (!hasInstructionsField) {
@@ -195,7 +103,6 @@ export function convertSegment(raw, sectionType, file) {
195
103
  }
196
104
  const segment = {
197
105
  type: 'chat',
198
- title: raw.title,
199
106
  instructions: instructions || '',
200
107
  hidePreviousContentFromUser: raw.fields.hidePreviousContentFromUser?.toLowerCase() === 'true' ? true : undefined,
201
108
  hidePreviousContentFromTutor: raw.fields.hidePreviousContentFromTutor?.toLowerCase() === 'true' ? true : undefined,
@@ -203,38 +110,75 @@ export function convertSegment(raw, sectionType, file) {
203
110
  };
204
111
  return { segment, errors };
205
112
  }
206
- 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
+ }
207
124
  const fromField = raw.fields.from;
208
125
  const toField = raw.fields.to;
209
- // Both from:: and to:: are optional for article-excerpt:
210
- // - Only from:: extract from anchor to end of article
211
- // - Only to:: extract from start to anchor
212
- // - 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
213
131
  // Strip quotes from anchor text if present
214
132
  const fromAnchor = fromField ? stripQuotes(fromField) : undefined;
215
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
+ }
216
159
  const segment = {
217
- type: 'article-excerpt',
160
+ type: 'article',
161
+ source: sourceField,
218
162
  fromAnchor,
219
163
  toAnchor,
220
164
  optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
221
165
  };
222
166
  return { segment, errors };
223
167
  }
224
- case 'video-excerpt': {
225
- const fromField = raw.fields.from;
226
- const toField = raw.fields.to;
227
- // to:: is required, from:: defaults to "0:00"
228
- if (!toField) {
168
+ case 'video': {
169
+ if (raw.title) {
170
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
229
171
  errors.push({
230
172
  file,
231
173
  line: raw.line,
232
- message: 'Video-excerpt segment missing to:: field',
233
- 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}:'`,
234
176
  severity: 'error',
235
177
  });
236
- return { segment: null, errors };
237
178
  }
179
+ const fromField = raw.fields.from;
180
+ const toField = raw.fields.to;
181
+ const sourceField = raw.fields.source;
238
182
  // Validate timestamp formats at parse time for better error reporting
239
183
  const fromStr = fromField || '0:00';
240
184
  if (parseTimestamp(fromStr) === null) {
@@ -246,7 +190,7 @@ export function convertSegment(raw, sectionType, file) {
246
190
  severity: 'warning',
247
191
  });
248
192
  }
249
- if (parseTimestamp(toField) === null) {
193
+ if (toField && parseTimestamp(toField) === null) {
250
194
  errors.push({
251
195
  file,
252
196
  line: raw.line,
@@ -255,16 +199,42 @@ export function convertSegment(raw, sectionType, file) {
255
199
  severity: 'warning',
256
200
  });
257
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
+ }
258
227
  const segment = {
259
- type: 'video-excerpt',
228
+ type: 'video',
229
+ source: sourceField,
260
230
  fromTimeStr: fromField || '0:00', // Default to start of video
261
- toTimeStr: toField,
231
+ toTimeStr: toField || undefined,
262
232
  optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
263
233
  };
264
234
  return { segment, errors };
265
235
  }
266
236
  case 'question': {
267
- const content = raw.fields['content'] || raw.fields['user-instruction'];
237
+ const content = raw.fields['content'];
268
238
  if (!content || content.trim() === '') {
269
239
  errors.push({
270
240
  file,
@@ -278,7 +248,7 @@ export function convertSegment(raw, sectionType, file) {
278
248
  const segment = {
279
249
  type: 'question',
280
250
  content,
281
- assessmentInstructions: raw.fields['assessment-instructions'] || raw.fields['assessment-prompt'] || undefined,
251
+ assessmentInstructions: raw.fields['assessment-instructions'] || undefined,
282
252
  maxTime: raw.fields['max-time'] || undefined,
283
253
  maxChars: raw.fields['max-chars'] ? parseInt(raw.fields['max-chars'], 10) : undefined,
284
254
  enforceVoice: raw.fields['enforce-voice']?.toLowerCase() === 'true' ? true : undefined,
@@ -287,8 +257,55 @@ export function convertSegment(raw, sectionType, file) {
287
257
  };
288
258
  return { segment, errors };
289
259
  }
260
+ case 'roleplay': {
261
+ const id = raw.fields['id'];
262
+ if (!id || id.trim() === '') {
263
+ errors.push({
264
+ file,
265
+ line: raw.line,
266
+ message: 'Roleplay segment missing id:: field',
267
+ suggestion: "Add 'id:: <uuid>' to the roleplay segment",
268
+ severity: 'error',
269
+ });
270
+ }
271
+ const content = raw.fields['content'];
272
+ if (!content || content.trim() === '') {
273
+ errors.push({
274
+ file,
275
+ line: raw.line,
276
+ message: 'Roleplay segment missing content:: field',
277
+ suggestion: "Add 'content:: Your scenario briefing here'",
278
+ severity: 'error',
279
+ });
280
+ }
281
+ const aiInstructions = raw.fields['ai-instructions'];
282
+ if (!aiInstructions || aiInstructions.trim() === '') {
283
+ errors.push({
284
+ file,
285
+ line: raw.line,
286
+ message: 'Roleplay segment missing ai-instructions:: field',
287
+ suggestion: "Add 'ai-instructions:: Character behavior description'",
288
+ severity: 'error',
289
+ });
290
+ }
291
+ if (!id || id.trim() === '' || !content || content.trim() === '' || !aiInstructions || aiInstructions.trim() === '') {
292
+ return { segment: null, errors };
293
+ }
294
+ const segment = {
295
+ type: 'roleplay',
296
+ id,
297
+ content,
298
+ aiInstructions,
299
+ openingMessage: raw.fields['opening-message'] || undefined,
300
+ assessmentInstructions: raw.fields['assessment-instructions'] || undefined,
301
+ userCustomizable: raw.fields['user-customizable']?.toLowerCase() === 'true' ? true : undefined,
302
+ optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
303
+ feedback: raw.fields['feedback']?.toLowerCase() === 'true' ? true : undefined,
304
+ };
305
+ return { segment, errors };
306
+ }
290
307
  default:
291
- // Unknown segment type - error already reported during parseSegments
308
+ // Unknown segment type - error already reported during parseSections
292
309
  return { segment: null, errors };
293
310
  }
294
311
  }
@@ -305,13 +322,13 @@ function stripQuotes(s) {
305
322
  * Check if a segment is empty (has no meaningful fields).
306
323
  * Returns a warning if the segment is empty.
307
324
  *
308
- * Note: article-excerpt with no fields is valid (means entire article).
325
+ * Note: article with no fields is valid (means entire article, source inherited).
309
326
  */
310
327
  function checkEmptySegment(raw, file) {
311
328
  // A segment is empty if it has no fields at all
312
329
  const fieldCount = Object.keys(raw.fields).length;
313
- // article-excerpt with no fields is valid - means "include entire article"
314
- 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) {
315
332
  return null;
316
333
  }
317
334
  if (fieldCount === 0) {
@@ -332,17 +349,86 @@ function checkEmptySegment(raw, file) {
332
349
  export function stripObsidianComments(content) {
333
350
  return content.replace(/%%.*?%%/gs, '');
334
351
  }
352
+ /**
353
+ * Strip CriticMarkup from content using reject-all-changes behavior:
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
359
+ */
360
+ export function stripCriticMarkup(content) {
361
+ return content
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)
367
+ }
368
+ /**
369
+ * Strip all authoring markup (CriticMarkup + Obsidian comments) from content.
370
+ * Call at the top of each parser before any processing.
371
+ */
372
+ export function stripAuthoringMarkup(content) {
373
+ const stripped = stripObsidianComments(stripCriticMarkup(content));
374
+ // Trim trailing whitespace left by inline markup removal
375
+ return stripped.split('\n').map(line => line.trimEnd()).join('\n');
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
+ }
335
422
  /**
336
423
  * Parse a lens file into structured lens data.
337
424
  *
338
- * Lens files use:
339
- * - H3 (`###`) for sections: Text, Article, Video
340
- * - 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.
341
427
  */
342
428
  export function parseLens(content, file) {
343
429
  const errors = [];
344
- // Strip Obsidian comments before parsing
345
- content = stripObsidianComments(content);
430
+ // Strip authoring markup (CriticMarkup + Obsidian comments) before parsing
431
+ content = stripAuthoringMarkup(content);
346
432
  // Step 1: Parse frontmatter and validate id field
347
433
  const frontmatterResult = parseFrontmatter(content, file);
348
434
  if (frontmatterResult.error) {
@@ -366,135 +452,72 @@ export function parseLens(content, file) {
366
452
  });
367
453
  return { lens: null, errors };
368
454
  }
369
- // Step 2: Parse H3 sections (Text, Article, Video)
370
- const sectionsResult = parseSections(body, 3, LENS_SECTION_TYPES, file);
371
- // Adjust line numbers to account for frontmatter
372
- for (const error of sectionsResult.errors) {
373
- if (error.line) {
374
- error.line += bodyStartLine - 1;
375
- }
376
- }
377
- errors.push(...sectionsResult.errors);
378
- for (const section of sectionsResult.sections) {
379
- section.line += bodyStartLine - 1;
380
- }
381
- // Step 3: Convert raw sections to ParsedLensSections with segments
382
- const parsedSections = [];
383
- for (const rawSection of sectionsResult.sections) {
384
- // Map section type: 'article' -> 'lens-article', 'video' -> 'lens-video'
385
- const outputType = LENS_OUTPUT_TYPE[rawSection.type] ?? rawSection.type;
386
- // For article/video sections, source field is required
387
- const needsSource = outputType === 'lens-article' || outputType === 'lens-video';
388
- const source = rawSection.fields.source;
389
- if (needsSource && !source) {
455
+ const title = typeof frontmatter.title === 'string' ? frontmatter.title : undefined;
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;
461
+ if (tldr) {
462
+ const wordCount = tldr.trim().split(/\s+/).filter(Boolean).length;
463
+ if (wordCount > 80) {
390
464
  errors.push({
391
465
  file,
392
- line: rawSection.line,
393
- message: `${rawSection.rawType} section missing source:: field`,
394
- suggestion: `Add 'source:: [[../path/to/file.md|Display]]' to the ${rawSection.rawType.toLowerCase()} section`,
466
+ line: 2,
467
+ message: `tldr exceeds 80 words (has ${wordCount})`,
468
+ suggestion: 'Shorten the tldr to 80 words or fewer',
395
469
  severity: 'error',
396
470
  });
397
471
  }
398
- // Validate source:: path is relative (contains /)
399
- if (source) {
400
- const wikilink = parseWikilink(source);
401
- if (wikilink && wikilink.error) {
402
- const suggestion = wikilink.correctedPath
403
- ? `Did you mean '[[${wikilink.correctedPath}]]'?`
404
- : 'Check the path in the wikilink';
405
- errors.push({
406
- file,
407
- line: rawSection.line,
408
- message: `Invalid wikilink in source:: field: ${source}`,
409
- suggestion,
410
- severity: 'error',
411
- });
412
- }
413
- else if (wikilink && !hasRelativePath(wikilink.path)) {
414
- errors.push({
415
- file,
416
- line: rawSection.line,
417
- message: `source:: path must be relative (contain /): ${wikilink.path}`,
418
- suggestion: 'Use format [[../path/to/file.md|Display]] with relative path',
419
- severity: 'error',
420
- });
421
- }
422
- }
423
- // Parse H4 segments within this section
424
- const { segments: rawSegments, errors: segmentErrors } = parseSegments(rawSection.body, rawSection.line + 1, // Segments start after the section header
425
- file);
426
- errors.push(...segmentErrors);
427
- // Convert raw segments to typed segments
428
- const segments = [];
429
- for (const rawSeg of rawSegments) {
430
- // Check for empty segments
431
- const emptyWarning = checkEmptySegment(rawSeg, file);
432
- if (emptyWarning) {
433
- errors.push(emptyWarning);
434
- }
435
- // Validate that fields are appropriate for this segment type
436
- const fieldWarnings = validateSegmentFields(rawSeg.type, rawSeg.fields, file, rawSeg.line);
437
- errors.push(...fieldWarnings);
438
- // Validate field values (e.g., boolean fields should have 'true' or 'false')
439
- const valueWarnings = validateFieldValues(rawSeg.fields, file, rawSeg.line);
440
- errors.push(...valueWarnings);
441
- // Detect likely typos in field names
442
- const typoWarnings = detectFieldTypos(rawSeg.fields, file, rawSeg.line);
443
- errors.push(...typoWarnings);
444
- // Check segment/section type compatibility
445
- const validSegs = VALID_SEGMENTS_PER_SECTION[outputType];
446
- if (validSegs && !validSegs.has(rawSeg.type)) {
447
- errors.push({
448
- file,
449
- line: rawSeg.line,
450
- message: `Segment type '${rawSeg.type}' is not valid in a ${rawSection.rawType} section`,
451
- suggestion: `Valid segment types for ${rawSection.rawType}: ${[...(validSegs)].join(', ')}`,
452
- severity: 'warning',
453
- });
454
- }
455
- const { segment, errors: conversionErrors } = convertSegment(rawSeg, outputType, file);
456
- errors.push(...conversionErrors);
457
- if (segment) {
458
- segments.push(segment);
459
- }
472
+ }
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);
460
485
  }
461
- // Validate Chat segments are preceded by Text segments
462
- const chatErrors = validateChatPrecedence(rawSegments, file);
463
- errors.push(...chatErrors);
464
- // Warn if section has no segments
465
- if (segments.length === 0) {
466
- errors.push({
467
- file,
468
- line: rawSection.line,
469
- message: `${rawSection.rawType} section has no segments`,
470
- suggestion: `Add at least one segment (#### Text, #### Chat, etc.) to the ${rawSection.rawType.toLowerCase()} section`,
471
- severity: 'warning',
472
- });
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);
473
499
  }
474
- const parsedSection = {
475
- type: outputType,
476
- title: rawSection.title,
477
- source: source,
478
- segments,
479
- line: rawSection.line,
480
- };
481
- parsedSections.push(parsedSection);
482
500
  }
483
- // Check for conflicting section types (Article + Video is nonsensical)
484
- const sourceTypes = new Set(parsedSections.map(s => s.type).filter(t => t !== 'page'));
485
- if (sourceTypes.size > 1) {
486
- 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) {
487
506
  errors.push({
488
507
  file,
489
- line: parsedSections[0]?.line ?? bodyStartLine,
490
- message: `Lens has conflicting section types: ${typeList}. Each lens should use a single source type.`,
491
- 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.)',
492
511
  severity: 'warning',
493
512
  });
494
513
  }
495
514
  const lens = {
496
515
  id: frontmatter.id,
497
- sections: parsedSections,
516
+ title,
517
+ tldr,
518
+ summaryForTutor,
519
+ addToAiContext,
520
+ segments: parsedSegments,
498
521
  };
499
522
  return { lens, errors };
500
523
  }