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.
- package/dist/bundler/video.d.ts +12 -6
- package/dist/bundler/video.js +57 -34
- package/dist/bundler/video.js.map +1 -1
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +36 -19
- package/dist/cli.js.map +1 -1
- package/dist/content-schema.js +64 -16
- package/dist/content-schema.js.map +1 -1
- package/dist/dedupe-errors.d.ts +14 -0
- package/dist/dedupe-errors.js +33 -0
- package/dist/dedupe-errors.js.map +1 -0
- package/dist/flattener/index.d.ts +3 -28
- package/dist/flattener/index.js +497 -741
- package/dist/flattener/index.js.map +1 -1
- package/dist/flattener/resolve-text-links.d.ts +25 -0
- package/dist/flattener/resolve-text-links.js +248 -0
- package/dist/flattener/resolve-text-links.js.map +1 -0
- package/dist/flattener/test-helpers.d.ts +1 -1
- package/dist/flattener/test-helpers.js +1 -3
- package/dist/flattener/test-helpers.js.map +1 -1
- package/dist/index.d.ts +51 -21
- package/dist/index.js +285 -140
- package/dist/index.js.map +1 -1
- package/dist/parser/article.d.ts +2 -1
- package/dist/parser/article.js +75 -14
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.d.ts +1 -1
- package/dist/parser/course.js +79 -22
- package/dist/parser/course.js.map +1 -1
- package/dist/parser/frontmatter.d.ts +1 -1
- package/dist/parser/frontmatter.js +38 -12
- package/dist/parser/frontmatter.js.map +1 -1
- package/dist/parser/learning-outcome.d.ts +5 -2
- package/dist/parser/learning-outcome.js +75 -37
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +33 -34
- package/dist/parser/lens.js +205 -271
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.d.ts +7 -15
- package/dist/parser/module.js +135 -171
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/sections.d.ts +12 -1
- package/dist/parser/sections.js +74 -95
- package/dist/parser/sections.js.map +1 -1
- package/dist/parser/video-transcript.d.ts +2 -1
- package/dist/parser/video-transcript.js +8 -7
- package/dist/parser/video-transcript.js.map +1 -1
- package/dist/reference-graph.d.ts +8 -0
- package/dist/reference-graph.js +105 -0
- package/dist/reference-graph.js.map +1 -0
- package/dist/validator/card-module-membership.d.ts +2 -0
- package/dist/validator/card-module-membership.js +80 -0
- package/dist/validator/card-module-membership.js.map +1 -0
- package/dist/validator/output-integrity.js +5 -5
- package/dist/validator/output-integrity.js.map +1 -1
- package/dist/validator/segment-fields.d.ts +1 -1
- package/dist/validator/segment-fields.js +2 -2
- package/dist/validator/segment-fields.js.map +1 -1
- package/dist/validator/test-segments.d.ts +12 -0
- package/dist/validator/test-segments.js +40 -0
- package/dist/validator/test-segments.js.map +1 -0
- package/dist/validator/url-reachability.d.ts +2 -1
- package/dist/validator/url-reachability.js +40 -20
- package/dist/validator/url-reachability.js.map +1 -1
- package/package.json +1 -1
- package/dist/validator/chat-precedence.d.ts +0 -9
- package/dist/validator/chat-precedence.js +0 -32
- package/dist/validator/chat-precedence.js.map +0 -1
package/dist/parser/lens.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
26
|
-
* Segments are defined by `#### <type>` headers within a section.
|
|
13
|
+
* Convert a raw segment to a typed ParsedLensSegment.
|
|
27
14
|
*/
|
|
28
|
-
export function
|
|
29
|
-
const lines = sectionBody.split('\n');
|
|
30
|
-
const segments = [];
|
|
15
|
+
export function convertSegment(raw, sectionType, file) {
|
|
31
16
|
const errors = [];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
72
|
-
message: `
|
|
73
|
-
suggestion: `
|
|
23
|
+
line: raw.line,
|
|
24
|
+
message: `Titles are not supported for ${capitalized} segments — use 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
|
|
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
|
-
|
|
214
|
-
//
|
|
215
|
-
// - Only
|
|
216
|
-
// -
|
|
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
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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: '
|
|
237
|
-
suggestion:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
364
|
-
if (raw.type === 'article
|
|
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<<}
|
|
388
|
-
* - {++additions++}
|
|
389
|
-
* - {--deletions--}
|
|
390
|
-
* - {~~old~>new~~}
|
|
391
|
-
* - {==highlights==}
|
|
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
|
|
396
|
-
.replace(/\{\+\+.*?\+\+\}/gs, '') // Additions
|
|
397
|
-
.replace(/\{--(?:\{[^}]*\}@@)?(.*?)--\}/gs, '$1') // Deletions
|
|
398
|
-
.replace(/\{~~(?:\{[^}]*\}@@)?(.*?)~>.*?~~\}/gs, '$1') // Substitutions
|
|
399
|
-
.replace(/\{==(?:\{[^}]*\}@@)?(.*?)==\}/gs, '$1'); // Highlights
|
|
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
|
|
414
|
-
*
|
|
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
|
|
458
|
-
const
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
//
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
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:
|
|
578
|
-
message:
|
|
579
|
-
suggestion: '
|
|
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
|
-
|
|
518
|
+
summaryForTutor,
|
|
519
|
+
addToAiContext,
|
|
520
|
+
segments: parsedSegments,
|
|
587
521
|
};
|
|
588
522
|
return { lens, errors };
|
|
589
523
|
}
|