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.
- package/dist/bundler/article.d.ts +7 -2
- package/dist/bundler/article.js +19 -8
- package/dist/bundler/article.js.map +1 -1
- 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 -15
- 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 +22 -28
- package/dist/flattener/index.js +773 -613
- 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 +23 -0
- package/dist/flattener/test-helpers.js +56 -0
- package/dist/flattener/test-helpers.js.map +1 -0
- package/dist/index.d.ts +66 -20
- package/dist/index.js +327 -152
- package/dist/index.js.map +1 -1
- package/dist/parser/article.d.ts +3 -2
- package/dist/parser/article.js +80 -12
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.d.ts +1 -1
- package/dist/parser/course.js +88 -28
- 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 +12 -2
- package/dist/parser/learning-outcome.js +177 -96
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +52 -28
- package/dist/parser/lens.js +283 -260
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.d.ts +7 -15
- package/dist/parser/module.js +139 -170
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/sections.d.ts +14 -1
- package/dist/parser/sections.js +94 -81
- package/dist/parser/sections.js.map +1 -1
- package/dist/parser/video-transcript.d.ts +2 -1
- package/dist/parser/video-transcript.js +10 -6
- 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/directives.d.ts +12 -0
- package/dist/validator/directives.js +428 -0
- package/dist/validator/directives.js.map +1 -0
- package/dist/validator/field-values.js +14 -0
- package/dist/validator/field-values.js.map +1 -1
- 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 +42 -20
- package/dist/validator/url-reachability.js.map +1 -1
- package/dist/validator/uuid.js +9 -6
- package/dist/validator/uuid.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,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 {
|
|
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
|
|
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
|
-
*
|
|
25
|
-
* Segments are defined by `#### <type>` headers within a section.
|
|
13
|
+
* Convert a raw segment to a typed ParsedLensSegment.
|
|
26
14
|
*/
|
|
27
|
-
export function
|
|
28
|
-
const lines = sectionBody.split('\n');
|
|
29
|
-
const segments = [];
|
|
15
|
+
export function convertSegment(raw, sectionType, file) {
|
|
30
16
|
const errors = [];
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
51
|
-
message: `
|
|
52
|
-
suggestion: `
|
|
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
|
|
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
|
-
|
|
210
|
-
//
|
|
211
|
-
// - Only
|
|
212
|
-
// -
|
|
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
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
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: '
|
|
233
|
-
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}:'`,
|
|
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
|
|
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']
|
|
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'] ||
|
|
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
|
|
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
|
|
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
|
|
314
|
-
if (raw.type === 'article
|
|
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
|
|
339
|
-
*
|
|
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 =
|
|
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
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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:
|
|
393
|
-
message:
|
|
394
|
-
suggestion:
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
462
|
-
const
|
|
463
|
-
errors.push(...
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
//
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
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:
|
|
490
|
-
message:
|
|
491
|
-
suggestion: '
|
|
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
|
-
|
|
516
|
+
title,
|
|
517
|
+
tldr,
|
|
518
|
+
summaryForTutor,
|
|
519
|
+
addToAiContext,
|
|
520
|
+
segments: parsedSegments,
|
|
498
521
|
};
|
|
499
522
|
return { lens, errors };
|
|
500
523
|
}
|