lens-content-processor 0.4.0 → 0.6.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 +71 -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 +507 -745
  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 +53 -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 +40 -41
  37. package/dist/parser/lens.js +365 -349
  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,129 +1,60 @@
1
- import { ALL_KNOWN_FIELDS } from '../content-schema.js';
2
- import { parseFrontmatter } from './frontmatter.js';
3
- import { parseSections, LENS_SECTION_TYPES, LENS_OUTPUT_TYPE } from './sections.js';
4
- import { validateSegmentFields } from '../validator/segment-fields.js';
5
- import { validateFieldValues } from '../validator/field-values.js';
6
- import { detectFieldTypos } from '../validator/field-typos.js';
7
- import { validateFrontmatter } from '../validator/validate-frontmatter.js';
8
- import { validateChatPrecedence } from '../validator/chat-precedence.js';
9
- import { detectDirectivesInNonArticle } from '../validator/directives.js';
10
- import { parseWikilink, hasRelativePath } from './wikilink.js';
11
- import { parseTimestamp } from '../bundler/video.js';
1
+ import { parseFrontmatter } from "./frontmatter.js";
2
+ import { validateSegmentFields } from "../validator/segment-fields.js";
3
+ import { validateFieldValues } from "../validator/field-values.js";
4
+ import { detectFieldTypos } from "../validator/field-typos.js";
5
+ import { validateFrontmatter } from "../validator/validate-frontmatter.js";
6
+ import { detectDirectivesInNonArticle } from "../validator/directives.js";
7
+ import { parseWikilink, hasRelativePath } from "./wikilink.js";
8
+ import { parseSections } from "./sections.js";
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([
12
+ "text",
13
+ "chat",
14
+ "article",
15
+ "video",
16
+ "question",
17
+ "roleplay",
18
+ ]);
24
19
  /**
25
- * Parse H4 segments from a section body.
26
- * Segments are defined by `#### <type>` headers within a section.
20
+ * The largest plausible interaction requirement. Above this the field is
21
+ * almost certainly a typo, and every learner on the lens would be unable
22
+ * to complete it — reject it so completion falls back to the defaults.
27
23
  */
28
- export function parseSegments(sectionBody, bodyStartLine, file) {
29
- const lines = sectionBody.split('\n');
30
- const segments = [];
31
- 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])) {
69
- errors.push({
70
- 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)`,
74
- severity: 'error',
75
- });
76
- }
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
- }
24
+ const MAX_MIN_MESSAGES = 20;
100
25
  /**
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.
26
+ * Parse a min-messages:: field value as a non-negative integer.
27
+ * Returns undefined (with an error) for anything else an ignored field
28
+ * degrades to the default requirement instead of bricking completion.
103
29
  */
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
- }
30
+ function parseMinMessages(value, file, line, errors) {
31
+ if (value === undefined)
32
+ return undefined;
33
+ const trimmed = value.trim();
34
+ // Plain digits only — rejects empty values (Number("") === 0 would
35
+ // silently disable a question's default requirement), hex, exponents
36
+ if (!/^\d+$/.test(trimmed)) {
37
+ errors.push({
38
+ file,
39
+ line,
40
+ message: `Field 'min-messages' must be a non-negative integer, got '${value}'`,
41
+ suggestion: "Use a number like 'min-messages:: 2', or remove the field",
42
+ severity: "error",
43
+ });
44
+ return undefined;
122
45
  }
123
- // Save final field
124
- if (currentField) {
125
- segment.fields[currentField] = currentValue.join('\n').trim();
46
+ const parsed = parseInt(trimmed, 10);
47
+ if (parsed > MAX_MIN_MESSAGES) {
48
+ errors.push({
49
+ file,
50
+ line,
51
+ message: `Field 'min-messages' is ${parsed}, which would make the lens practically uncompletable (max ${MAX_MIN_MESSAGES})`,
52
+ suggestion: `Use a value up to ${MAX_MIN_MESSAGES}`,
53
+ severity: "error",
54
+ });
55
+ return undefined;
126
56
  }
57
+ return parsed;
127
58
  }
128
59
  /**
129
60
  * Convert a raw segment to a typed ParsedLensSegment.
@@ -131,34 +62,44 @@ function parseFieldsIntoSegment(segment, lines) {
131
62
  export function convertSegment(raw, sectionType, file) {
132
63
  const errors = [];
133
64
  switch (raw.type) {
134
- case 'text': {
135
- const hasContentField = 'content' in raw.fields;
65
+ case "text": {
66
+ if (raw.title) {
67
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
68
+ errors.push({
69
+ file,
70
+ line: raw.line,
71
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'`,
72
+ suggestion: `Remove the title after '${capitalized}:'`,
73
+ severity: "error",
74
+ });
75
+ }
76
+ const hasContentField = "content" in raw.fields;
136
77
  const content = raw.fields.content;
137
78
  if (!hasContentField) {
138
79
  // Field completely missing - error
139
80
  errors.push({
140
81
  file,
141
82
  line: raw.line,
142
- message: 'Text segment missing content:: field',
83
+ message: "Text segment missing content:: field",
143
84
  suggestion: "Add 'content:: Your text here' to the text segment",
144
- severity: 'error',
85
+ severity: "error",
145
86
  });
146
87
  return { segment: null, errors };
147
88
  }
148
- if (!content || content.trim() === '') {
89
+ if (!content || content.trim() === "") {
149
90
  // Field present but empty - warning
150
91
  errors.push({
151
92
  file,
152
93
  line: raw.line,
153
- message: 'Text segment has empty content:: field',
154
- suggestion: 'Add text content after content::',
155
- severity: 'warning',
94
+ message: "Text segment has empty content:: field",
95
+ suggestion: "Add text content after content::",
96
+ severity: "warning",
156
97
  });
157
98
  // Still create the segment, just with empty content
158
99
  const segment = {
159
- type: 'text',
160
- content: '',
161
- optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
100
+ type: "text",
101
+ content: "",
102
+ optional: raw.fields.optional?.toLowerCase() === "true" ? true : undefined,
162
103
  };
163
104
  return { segment, errors };
164
105
  }
@@ -166,179 +107,282 @@ export function convertSegment(raw, sectionType, file) {
166
107
  const directiveWarnings = detectDirectivesInNonArticle(content, file, raw.line);
167
108
  errors.push(...directiveWarnings);
168
109
  const segment = {
169
- type: 'text',
110
+ type: "text",
170
111
  content,
171
- optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
112
+ optional: raw.fields.optional?.toLowerCase() === "true" ? true : undefined,
172
113
  };
173
114
  return { segment, errors };
174
115
  }
175
- case 'chat': {
176
- const hasInstructionsField = 'instructions' in raw.fields;
116
+ case "chat": {
117
+ if (raw.title) {
118
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
119
+ errors.push({
120
+ file,
121
+ line: raw.line,
122
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'`,
123
+ suggestion: `Remove the title after '${capitalized}:'`,
124
+ severity: "error",
125
+ });
126
+ }
127
+ const hasInstructionsField = "instructions" in raw.fields;
177
128
  const instructions = raw.fields.instructions;
178
129
  if (!hasInstructionsField) {
179
130
  // Field completely missing - error
180
131
  errors.push({
181
132
  file,
182
133
  line: raw.line,
183
- message: 'Chat segment missing instructions:: field',
134
+ message: "Chat segment missing instructions:: field",
184
135
  suggestion: "Add 'instructions:: Your instructions here' to the chat segment",
185
- severity: 'error',
136
+ severity: "error",
186
137
  });
187
138
  return { segment: null, errors };
188
139
  }
189
- if (!instructions || instructions.trim() === '') {
140
+ if (!instructions || instructions.trim() === "") {
190
141
  // Field present but empty - warning
191
142
  errors.push({
192
143
  file,
193
144
  line: raw.line,
194
- message: 'Chat segment has empty instructions:: field',
195
- suggestion: 'Add instructions text after instructions::',
196
- severity: 'warning',
145
+ message: "Chat segment has empty instructions:: field",
146
+ suggestion: "Add instructions text after instructions::",
147
+ severity: "warning",
197
148
  });
198
149
  // Still create the segment with empty instructions
199
150
  }
200
151
  const segment = {
201
- type: 'chat',
202
- title: raw.title,
203
- instructions: instructions || '',
204
- hidePreviousContentFromUser: raw.fields.hidePreviousContentFromUser?.toLowerCase() === 'true' ? true : undefined,
205
- hidePreviousContentFromTutor: raw.fields.hidePreviousContentFromTutor?.toLowerCase() === 'true' ? true : undefined,
206
- optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
152
+ type: "chat",
153
+ instructions: instructions || "",
154
+ hidePreviousContentFromUser: raw.fields.hidePreviousContentFromUser?.toLowerCase() === "true"
155
+ ? true
156
+ : undefined,
157
+ hidePreviousContentFromTutor: raw.fields.hidePreviousContentFromTutor?.toLowerCase() === "true"
158
+ ? true
159
+ : undefined,
160
+ minMessages: parseMinMessages(raw.fields["min-messages"], file, raw.line, errors),
207
161
  };
208
162
  return { segment, errors };
209
163
  }
210
- case 'article-excerpt': {
164
+ case "article": {
165
+ if (raw.title) {
166
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
167
+ errors.push({
168
+ file,
169
+ line: raw.line,
170
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'. Set the lens title in frontmatter instead (title: ...)`,
171
+ suggestion: `Remove the title after '${capitalized}:'`,
172
+ severity: "error",
173
+ });
174
+ }
211
175
  const fromField = raw.fields.from;
212
176
  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
177
+ const sourceField = raw.fields.source;
178
+ // Both from:: and to:: are optional for article:
179
+ // - Only from:: -> extract from anchor to end of article
180
+ // - Only to:: -> extract from start to anchor
181
+ // - Neither -> extract entire article
217
182
  // Strip quotes from anchor text if present
218
183
  const fromAnchor = fromField ? stripQuotes(fromField) : undefined;
219
184
  const toAnchor = toField ? stripQuotes(toField) : undefined;
185
+ // Validate source:: wikilink if present
186
+ if (sourceField) {
187
+ const wikilink = parseWikilink(sourceField);
188
+ if (wikilink && wikilink.error) {
189
+ const suggestion = wikilink.correctedPath
190
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
191
+ : "Check the path in the wikilink";
192
+ errors.push({
193
+ file,
194
+ line: raw.line,
195
+ message: `Invalid wikilink in source:: field: ${sourceField}`,
196
+ suggestion,
197
+ severity: "error",
198
+ });
199
+ }
200
+ else if (wikilink && !hasRelativePath(wikilink.path)) {
201
+ errors.push({
202
+ file,
203
+ line: raw.line,
204
+ message: `source:: path must be relative (contain /): ${wikilink.path}`,
205
+ suggestion: "Use format [[../path/to/file.md|Display]] with relative path",
206
+ severity: "error",
207
+ });
208
+ }
209
+ }
220
210
  const segment = {
221
- type: 'article-excerpt',
211
+ type: "article",
212
+ source: sourceField,
222
213
  fromAnchor,
223
214
  toAnchor,
224
- optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
215
+ optional: raw.fields.optional?.toLowerCase() === "true" ? true : undefined,
225
216
  };
226
217
  return { segment, errors };
227
218
  }
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) {
219
+ case "video": {
220
+ if (raw.title) {
221
+ const capitalized = raw.type[0].toUpperCase() + raw.type.slice(1);
233
222
  errors.push({
234
223
  file,
235
224
  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",
238
- severity: 'error',
225
+ message: `Titles are not supported for ${capitalized} segments — use just '#### ${capitalized}'. Set the lens title in frontmatter instead (title: ...)`,
226
+ suggestion: `Remove the title after '${capitalized}:'`,
227
+ severity: "error",
239
228
  });
240
- return { segment: null, errors };
241
229
  }
230
+ const fromField = raw.fields.from;
231
+ const toField = raw.fields.to;
232
+ const sourceField = raw.fields.source;
242
233
  // Validate timestamp formats at parse time for better error reporting
243
- const fromStr = fromField || '0:00';
234
+ const fromStr = fromField || "0:00";
244
235
  if (parseTimestamp(fromStr) === null) {
245
236
  errors.push({
246
237
  file,
247
238
  line: raw.line,
248
239
  message: `Invalid timestamp format in from:: field: '${fromStr}'`,
249
240
  suggestion: "Expected format: M:SS (e.g., 1:30) or H:MM:SS (e.g., 1:30:00)",
250
- severity: 'warning',
241
+ severity: "warning",
251
242
  });
252
243
  }
253
- if (parseTimestamp(toField) === null) {
244
+ if (toField && parseTimestamp(toField) === null) {
254
245
  errors.push({
255
246
  file,
256
247
  line: raw.line,
257
248
  message: `Invalid timestamp format in to:: field: '${toField}'`,
258
249
  suggestion: "Expected format: M:SS (e.g., 5:45) or H:MM:SS (e.g., 1:30:00)",
259
- severity: 'warning',
250
+ severity: "warning",
260
251
  });
261
252
  }
253
+ // Validate source:: wikilink if present
254
+ if (sourceField) {
255
+ const wikilink = parseWikilink(sourceField);
256
+ if (wikilink && wikilink.error) {
257
+ const suggestion = wikilink.correctedPath
258
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
259
+ : "Check the path in the wikilink";
260
+ errors.push({
261
+ file,
262
+ line: raw.line,
263
+ message: `Invalid wikilink in source:: field: ${sourceField}`,
264
+ suggestion,
265
+ severity: "error",
266
+ });
267
+ }
268
+ else if (wikilink && !hasRelativePath(wikilink.path)) {
269
+ errors.push({
270
+ file,
271
+ line: raw.line,
272
+ message: `source:: path must be relative (contain /): ${wikilink.path}`,
273
+ suggestion: "Use format [[../path/to/file.md|Display]] with relative path",
274
+ severity: "error",
275
+ });
276
+ }
277
+ }
262
278
  const segment = {
263
- type: 'video-excerpt',
264
- fromTimeStr: fromField || '0:00', // Default to start of video
265
- toTimeStr: toField,
266
- optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
279
+ type: "video",
280
+ source: sourceField,
281
+ fromTimeStr: fromField || "0:00", // Default to start of video
282
+ toTimeStr: toField || undefined,
283
+ optional: raw.fields.optional?.toLowerCase() === "true" ? true : undefined,
267
284
  };
268
285
  return { segment, errors };
269
286
  }
270
- case 'question': {
271
- const content = raw.fields['content'];
272
- if (!content || content.trim() === '') {
287
+ case "question": {
288
+ const content = raw.fields["content"];
289
+ if (!content || content.trim() === "") {
273
290
  errors.push({
274
291
  file,
275
292
  line: raw.line,
276
- message: 'Question segment missing content:: field',
293
+ message: "Question segment missing content:: field",
277
294
  suggestion: "Add 'content:: Your question here'",
278
- severity: 'error',
295
+ severity: "error",
279
296
  });
280
297
  return { segment: null, errors };
281
298
  }
282
299
  const segment = {
283
- type: 'question',
300
+ type: "question",
284
301
  content,
285
- assessmentInstructions: raw.fields['assessment-instructions'] || undefined,
286
- maxTime: raw.fields['max-time'] || undefined,
287
- maxChars: raw.fields['max-chars'] ? parseInt(raw.fields['max-chars'], 10) : undefined,
288
- enforceVoice: raw.fields['enforce-voice']?.toLowerCase() === 'true' ? true : undefined,
289
- optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
290
- feedback: raw.fields['feedback']?.toLowerCase() === 'true' ? true : undefined,
302
+ assessmentInstructions: raw.fields["assessment-instructions"] || undefined,
303
+ maxTime: raw.fields["max-time"] || undefined,
304
+ maxChars: raw.fields["max-chars"]
305
+ ? parseInt(raw.fields["max-chars"], 10)
306
+ : undefined,
307
+ enforceVoice: raw.fields["enforce-voice"]?.toLowerCase() === "true"
308
+ ? true
309
+ : undefined,
310
+ feedback: raw.fields["feedback"]?.toLowerCase() === "true" ? true : undefined,
311
+ minMessages: parseMinMessages(raw.fields["min-messages"], file, raw.line, errors),
291
312
  };
313
+ // min-messages N on a question means 1 answer + N-1 replies in the
314
+ // feedback chat — without feedback:: true there is no chat to reply
315
+ // in, so the requirement would be unsatisfiable and every learner on
316
+ // the lens would be blocked. Error, and drop the field so completion
317
+ // degrades to the default (one answer) if it ships anyway.
318
+ if ((segment.minMessages ?? 0) > 1 && !segment.feedback) {
319
+ errors.push({
320
+ file,
321
+ line: raw.line,
322
+ message: "Question with min-messages > 1 needs feedback:: true — the extra messages are replies in the feedback chat",
323
+ suggestion: "Add 'feedback:: true' or lower min-messages to 1",
324
+ severity: "error",
325
+ });
326
+ delete segment.minMessages;
327
+ }
292
328
  return { segment, errors };
293
329
  }
294
- case 'roleplay': {
295
- const id = raw.fields['id'];
296
- if (!id || id.trim() === '') {
330
+ case "roleplay": {
331
+ const id = raw.fields["id"];
332
+ if (!id || id.trim() === "") {
297
333
  errors.push({
298
334
  file,
299
335
  line: raw.line,
300
- message: 'Roleplay segment missing id:: field',
336
+ message: "Roleplay segment missing id:: field",
301
337
  suggestion: "Add 'id:: <uuid>' to the roleplay segment",
302
- severity: 'error',
338
+ severity: "error",
303
339
  });
304
340
  }
305
- const content = raw.fields['content'];
306
- if (!content || content.trim() === '') {
341
+ const content = raw.fields["content"];
342
+ if (!content || content.trim() === "") {
307
343
  errors.push({
308
344
  file,
309
345
  line: raw.line,
310
- message: 'Roleplay segment missing content:: field',
346
+ message: "Roleplay segment missing content:: field",
311
347
  suggestion: "Add 'content:: Your scenario briefing here'",
312
- severity: 'error',
348
+ severity: "error",
313
349
  });
314
350
  }
315
- const aiInstructions = raw.fields['ai-instructions'];
316
- if (!aiInstructions || aiInstructions.trim() === '') {
351
+ const aiInstructions = raw.fields["ai-instructions"];
352
+ if (!aiInstructions || aiInstructions.trim() === "") {
317
353
  errors.push({
318
354
  file,
319
355
  line: raw.line,
320
- message: 'Roleplay segment missing ai-instructions:: field',
356
+ message: "Roleplay segment missing ai-instructions:: field",
321
357
  suggestion: "Add 'ai-instructions:: Character behavior description'",
322
- severity: 'error',
358
+ severity: "error",
323
359
  });
324
360
  }
325
- if (!id || id.trim() === '' || !content || content.trim() === '' || !aiInstructions || aiInstructions.trim() === '') {
361
+ if (!id ||
362
+ id.trim() === "" ||
363
+ !content ||
364
+ content.trim() === "" ||
365
+ !aiInstructions ||
366
+ aiInstructions.trim() === "") {
326
367
  return { segment: null, errors };
327
368
  }
328
369
  const segment = {
329
- type: 'roleplay',
370
+ type: "roleplay",
330
371
  id,
331
372
  content,
332
373
  aiInstructions,
333
- openingMessage: raw.fields['opening-message'] || undefined,
334
- assessmentInstructions: raw.fields['assessment-instructions'] || undefined,
335
- optional: raw.fields.optional?.toLowerCase() === 'true' ? true : undefined,
336
- feedback: raw.fields['feedback']?.toLowerCase() === 'true' ? true : undefined,
374
+ openingMessage: raw.fields["opening-message"] || undefined,
375
+ assessmentInstructions: raw.fields["assessment-instructions"] || undefined,
376
+ userCustomizable: raw.fields["user-customizable"]?.toLowerCase() === "true"
377
+ ? true
378
+ : undefined,
379
+ optional: raw.fields.optional?.toLowerCase() === "true" ? true : undefined,
380
+ feedback: raw.fields["feedback"]?.toLowerCase() === "true" ? true : undefined,
337
381
  };
338
382
  return { segment, errors };
339
383
  }
340
384
  default:
341
- // Unknown segment type - error already reported during parseSegments
385
+ // Unknown segment type - error already reported during parseSections
342
386
  return { segment: null, errors };
343
387
  }
344
388
  }
@@ -346,7 +390,8 @@ export function convertSegment(raw, sectionType, file) {
346
390
  * Strip surrounding quotes from a string if present.
347
391
  */
348
392
  function stripQuotes(s) {
349
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
393
+ if ((s.startsWith('"') && s.endsWith('"')) ||
394
+ (s.startsWith("'") && s.endsWith("'"))) {
350
395
  return s.slice(1, -1);
351
396
  }
352
397
  return s;
@@ -355,13 +400,13 @@ function stripQuotes(s) {
355
400
  * Check if a segment is empty (has no meaningful fields).
356
401
  * Returns a warning if the segment is empty.
357
402
  *
358
- * Note: article-excerpt with no fields is valid (means entire article).
403
+ * Note: article with no fields is valid (means entire article, source inherited).
359
404
  */
360
405
  function checkEmptySegment(raw, file) {
361
406
  // A segment is empty if it has no fields at all
362
407
  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) {
408
+ // article with no fields is valid - means "include entire article" (source inherited)
409
+ if (raw.type === "article" && fieldCount === 0) {
365
410
  return null;
366
411
  }
367
412
  if (fieldCount === 0) {
@@ -370,7 +415,7 @@ function checkEmptySegment(raw, file) {
370
415
  line: raw.line,
371
416
  message: `Empty ${raw.type} segment has no fields`,
372
417
  suggestion: `Add required fields to the ${raw.type} segment`,
373
- severity: 'warning',
418
+ severity: "warning",
374
419
  };
375
420
  }
376
421
  return null;
@@ -380,23 +425,23 @@ function checkEmptySegment(raw, file) {
380
425
  * Handles both inline (%% ... %% on same line) and block (multiline) comments.
381
426
  */
382
427
  export function stripObsidianComments(content) {
383
- return content.replace(/%%.*?%%/gs, '');
428
+ return content.replace(/%%.*?%%/gs, "");
384
429
  }
385
430
  /**
386
431
  * 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
432
+ * - {>>comments<<} -> removed
433
+ * - {++additions++} -> removed
434
+ * - {--deletions--} -> inner content kept (original preserved)
435
+ * - {~~old~>new~~} -> old text kept
436
+ * - {==highlights==} -> inner content kept, markers removed
392
437
  */
393
438
  export function stripCriticMarkup(content) {
394
439
  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)
440
+ .replace(/\{>>.*?<<\}/gs, "") // Comments -> remove
441
+ .replace(/\{\+\+.*?\+\+\}/gs, "") // Additions -> remove
442
+ .replace(/\{--(?:\{[^}]*\}@@)?(.*?)--\}/gs, "$1") // Deletions -> keep inner (skip metadata)
443
+ .replace(/\{~~(?:\{[^}]*\}@@)?(.*?)~>.*?~~\}/gs, "$1") // Substitutions -> keep old (skip metadata)
444
+ .replace(/\{==(?:\{[^}]*\}@@)?(.*?)==\}/gs, "$1"); // Highlights -> keep inner (skip metadata)
400
445
  }
401
446
  /**
402
447
  * Strip all authoring markup (CriticMarkup + Obsidian comments) from content.
@@ -405,14 +450,61 @@ export function stripCriticMarkup(content) {
405
450
  export function stripAuthoringMarkup(content) {
406
451
  const stripped = stripObsidianComments(stripCriticMarkup(content));
407
452
  // Trim trailing whitespace left by inline markup removal
408
- return stripped.split('\n').map(line => line.trimEnd()).join('\n');
453
+ return stripped
454
+ .split("\n")
455
+ .map((line) => line.trimEnd())
456
+ .join("\n");
457
+ }
458
+ /**
459
+ * Apply source inheritance for article and video segments.
460
+ * Each article/video segment inherits source:: from the previous segment of the same type.
461
+ * Errors if the first segment of a type has no source.
462
+ *
463
+ * Mutates segments in place. Returns any errors.
464
+ */
465
+ export function applySourceInheritance(segments, file) {
466
+ const errors = [];
467
+ let lastArticleSource;
468
+ let lastVideoSource;
469
+ for (const seg of segments) {
470
+ if (seg.type === "article") {
471
+ if (seg.source) {
472
+ lastArticleSource = seg.source;
473
+ }
474
+ else if (lastArticleSource) {
475
+ seg.source = lastArticleSource;
476
+ }
477
+ else {
478
+ errors.push({
479
+ file,
480
+ message: "First article segment must have a source:: field",
481
+ severity: "error",
482
+ });
483
+ }
484
+ }
485
+ else if (seg.type === "video") {
486
+ if (seg.source) {
487
+ lastVideoSource = seg.source;
488
+ }
489
+ else if (lastVideoSource) {
490
+ seg.source = lastVideoSource;
491
+ }
492
+ else {
493
+ errors.push({
494
+ file,
495
+ message: "First video segment must have a source:: field",
496
+ severity: "error",
497
+ });
498
+ }
499
+ }
500
+ }
501
+ return errors;
409
502
  }
410
503
  /**
411
504
  * Parse a lens file into structured lens data.
412
505
  *
413
- * Lens files use:
414
- * - H3 (`###`) for sections: Text, Article, Video
415
- * - H4 (`####`) for segments: Text, Chat, Article-excerpt, Video-excerpt
506
+ * Lens files are flat: frontmatter + H4 segments directly.
507
+ * No H3 section headers. Source is on segments with inheritance.
416
508
  */
417
509
  export function parseLens(content, file) {
418
510
  const errors = [];
@@ -425,23 +517,30 @@ export function parseLens(content, file) {
425
517
  return { lens: null, errors };
426
518
  }
427
519
  const { frontmatter, body, bodyStartLine } = frontmatterResult;
428
- const frontmatterErrors = validateFrontmatter(frontmatter, 'lens', file);
520
+ const frontmatterErrors = validateFrontmatter(frontmatter, "lens", file);
429
521
  errors.push(...frontmatterErrors);
430
- if (frontmatterErrors.some(e => e.severity === 'error')) {
522
+ if (frontmatterErrors.some((e) => e.severity === "error")) {
431
523
  return { lens: null, errors };
432
524
  }
433
525
  // Lens-specific: id must be a string (YAML might parse UUIDs as numbers)
434
- if (typeof frontmatter.id !== 'string') {
526
+ if (typeof frontmatter.id !== "string") {
435
527
  errors.push({
436
528
  file,
437
529
  line: 2,
438
530
  message: `Field 'id' must be a string, got ${typeof frontmatter.id}`,
439
531
  suggestion: "Use quotes: id: '12345'",
440
- severity: 'error',
532
+ severity: "error",
441
533
  });
442
534
  return { lens: null, errors };
443
535
  }
444
- const tldr = typeof frontmatter.tldr === 'string' ? frontmatter.tldr : undefined;
536
+ const title = typeof frontmatter.title === "string" ? frontmatter.title : undefined;
537
+ const tldr = typeof frontmatter.tldr === "string" ? frontmatter.tldr : undefined;
538
+ const summaryForTutor = typeof frontmatter.summary_for_tutor === "string"
539
+ ? frontmatter.summary_for_tutor
540
+ : undefined;
541
+ const addToAiContext = "add_to_ai_context" in frontmatter
542
+ ? frontmatter.add_to_ai_context
543
+ : undefined;
445
544
  if (tldr) {
446
545
  const wordCount = tldr.trim().split(/\s+/).filter(Boolean).length;
447
546
  if (wordCount > 80) {
@@ -449,141 +548,58 @@ export function parseLens(content, file) {
449
548
  file,
450
549
  line: 2,
451
550
  message: `tldr exceeds 80 words (has ${wordCount})`,
452
- suggestion: 'Shorten the tldr to 80 words or fewer',
453
- severity: 'error',
551
+ suggestion: "Shorten the tldr to 80 words or fewer",
552
+ severity: "error",
454
553
  });
455
554
  }
456
555
  }
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;
556
+ // Step 2: Parse segments from body (flat mode — all segment headers are siblings)
557
+ const { sections: rawSegments, errors: segmentErrors } = parseSections(body, 3, // parentLevel=3 for standalone lens files → matches H4+ (backward compat)
558
+ LENS_SEGMENT_TYPES, file, true);
559
+ errors.push(...segmentErrors);
560
+ // Step 3: Convert raw segments to typed segments + validate
561
+ const parsedSegments = [];
562
+ for (const rawSeg of rawSegments) {
563
+ // Check for empty segments
564
+ const emptyWarning = checkEmptySegment(rawSeg, file);
565
+ if (emptyWarning) {
566
+ errors.push(emptyWarning);
463
567
  }
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
- });
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
- }
568
+ // Validate that fields are appropriate for this segment type
569
+ const fieldWarnings = validateSegmentFields(rawSeg.type, rawSeg.fields, file, rawSeg.line);
570
+ errors.push(...fieldWarnings);
571
+ // Validate field values (e.g., boolean fields should have 'true' or 'false')
572
+ const valueWarnings = validateFieldValues(rawSeg.fields, file, rawSeg.line);
573
+ errors.push(...valueWarnings);
574
+ // Detect likely typos in field names
575
+ const typoWarnings = detectFieldTypos(rawSeg.fields, file, rawSeg.line);
576
+ errors.push(...typoWarnings);
577
+ const { segment, errors: conversionErrors } = convertSegment(rawSeg, "lens", file);
578
+ errors.push(...conversionErrors);
579
+ if (segment) {
580
+ parsedSegments.push(segment);
510
581
  }
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
582
  }
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(', ');
583
+ // Step 4: Source inheritance for article and video segments
584
+ const inheritanceErrors = applySourceInheritance(parsedSegments, file);
585
+ errors.push(...inheritanceErrors);
586
+ // Warn if lens has no segments
587
+ if (parsedSegments.length === 0) {
575
588
  errors.push({
576
589
  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',
580
- severity: 'warning',
590
+ line: bodyStartLine,
591
+ message: "Lens has no segments",
592
+ suggestion: "Add at least one segment (#### Text, #### Chat, etc.)",
593
+ severity: "warning",
581
594
  });
582
595
  }
583
596
  const lens = {
584
597
  id: frontmatter.id,
598
+ title,
585
599
  tldr,
586
- sections: parsedSections,
600
+ summaryForTutor,
601
+ addToAiContext,
602
+ segments: parsedSegments,
587
603
  };
588
604
  return { lens, errors };
589
605
  }