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.
- 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 +71 -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 +507 -745
- 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 +53 -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 +40 -41
- package/dist/parser/lens.js +365 -349
- 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,129 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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([
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
|
102
|
-
*
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
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
|
|
135
|
-
|
|
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:
|
|
83
|
+
message: "Text segment missing content:: field",
|
|
143
84
|
suggestion: "Add 'content:: Your text here' to the text segment",
|
|
144
|
-
severity:
|
|
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:
|
|
154
|
-
suggestion:
|
|
155
|
-
severity:
|
|
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:
|
|
160
|
-
content:
|
|
161
|
-
optional: raw.fields.optional?.toLowerCase() ===
|
|
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:
|
|
110
|
+
type: "text",
|
|
170
111
|
content,
|
|
171
|
-
optional: raw.fields.optional?.toLowerCase() ===
|
|
112
|
+
optional: raw.fields.optional?.toLowerCase() === "true" ? true : undefined,
|
|
172
113
|
};
|
|
173
114
|
return { segment, errors };
|
|
174
115
|
}
|
|
175
|
-
case
|
|
176
|
-
|
|
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:
|
|
134
|
+
message: "Chat segment missing instructions:: field",
|
|
184
135
|
suggestion: "Add 'instructions:: Your instructions here' to the chat segment",
|
|
185
|
-
severity:
|
|
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:
|
|
195
|
-
suggestion:
|
|
196
|
-
severity:
|
|
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:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
//
|
|
215
|
-
// - Only
|
|
216
|
-
// -
|
|
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:
|
|
211
|
+
type: "article",
|
|
212
|
+
source: sourceField,
|
|
222
213
|
fromAnchor,
|
|
223
214
|
toAnchor,
|
|
224
|
-
optional: raw.fields.optional?.toLowerCase() ===
|
|
215
|
+
optional: raw.fields.optional?.toLowerCase() === "true" ? true : undefined,
|
|
225
216
|
};
|
|
226
217
|
return { segment, errors };
|
|
227
218
|
}
|
|
228
|
-
case
|
|
229
|
-
|
|
230
|
-
|
|
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: '
|
|
237
|
-
suggestion:
|
|
238
|
-
severity:
|
|
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 ||
|
|
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:
|
|
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:
|
|
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:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
271
|
-
const content = raw.fields[
|
|
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:
|
|
293
|
+
message: "Question segment missing content:: field",
|
|
277
294
|
suggestion: "Add 'content:: Your question here'",
|
|
278
|
-
severity:
|
|
295
|
+
severity: "error",
|
|
279
296
|
});
|
|
280
297
|
return { segment: null, errors };
|
|
281
298
|
}
|
|
282
299
|
const segment = {
|
|
283
|
-
type:
|
|
300
|
+
type: "question",
|
|
284
301
|
content,
|
|
285
|
-
assessmentInstructions: raw.fields[
|
|
286
|
-
maxTime: raw.fields[
|
|
287
|
-
maxChars: raw.fields[
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
295
|
-
const id = raw.fields[
|
|
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:
|
|
336
|
+
message: "Roleplay segment missing id:: field",
|
|
301
337
|
suggestion: "Add 'id:: <uuid>' to the roleplay segment",
|
|
302
|
-
severity:
|
|
338
|
+
severity: "error",
|
|
303
339
|
});
|
|
304
340
|
}
|
|
305
|
-
const content = raw.fields[
|
|
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:
|
|
346
|
+
message: "Roleplay segment missing content:: field",
|
|
311
347
|
suggestion: "Add 'content:: Your scenario briefing here'",
|
|
312
|
-
severity:
|
|
348
|
+
severity: "error",
|
|
313
349
|
});
|
|
314
350
|
}
|
|
315
|
-
const aiInstructions = raw.fields[
|
|
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:
|
|
356
|
+
message: "Roleplay segment missing ai-instructions:: field",
|
|
321
357
|
suggestion: "Add 'ai-instructions:: Character behavior description'",
|
|
322
|
-
severity:
|
|
358
|
+
severity: "error",
|
|
323
359
|
});
|
|
324
360
|
}
|
|
325
|
-
if (!id ||
|
|
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:
|
|
370
|
+
type: "roleplay",
|
|
330
371
|
id,
|
|
331
372
|
content,
|
|
332
373
|
aiInstructions,
|
|
333
|
-
openingMessage: raw.fields[
|
|
334
|
-
assessmentInstructions: raw.fields[
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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('"')) ||
|
|
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
|
|
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
|
|
364
|
-
if (raw.type ===
|
|
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:
|
|
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<<}
|
|
388
|
-
* - {++additions++}
|
|
389
|
-
* - {--deletions--}
|
|
390
|
-
* - {~~old~>new~~}
|
|
391
|
-
* - {==highlights==}
|
|
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,
|
|
396
|
-
.replace(/\{\+\+.*?\+\+\}/gs,
|
|
397
|
-
.replace(/\{--(?:\{[^}]*\}@@)?(.*?)--\}/gs,
|
|
398
|
-
.replace(/\{~~(?:\{[^}]*\}@@)?(.*?)~>.*?~~\}/gs,
|
|
399
|
-
.replace(/\{==(?:\{[^}]*\}@@)?(.*?)==\}/gs,
|
|
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
|
|
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
|
|
414
|
-
*
|
|
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,
|
|
520
|
+
const frontmatterErrors = validateFrontmatter(frontmatter, "lens", file);
|
|
429
521
|
errors.push(...frontmatterErrors);
|
|
430
|
-
if (frontmatterErrors.some(e => e.severity ===
|
|
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 !==
|
|
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:
|
|
532
|
+
severity: "error",
|
|
441
533
|
});
|
|
442
534
|
return { lens: null, errors };
|
|
443
535
|
}
|
|
444
|
-
const
|
|
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:
|
|
453
|
-
severity:
|
|
551
|
+
suggestion: "Shorten the tldr to 80 words or fewer",
|
|
552
|
+
severity: "error",
|
|
454
553
|
});
|
|
455
554
|
}
|
|
456
555
|
}
|
|
457
|
-
// Step 2: Parse
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
//
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
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:
|
|
578
|
-
message:
|
|
579
|
-
suggestion:
|
|
580
|
-
severity:
|
|
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
|
-
|
|
600
|
+
summaryForTutor,
|
|
601
|
+
addToAiContext,
|
|
602
|
+
segments: parsedSegments,
|
|
587
603
|
};
|
|
588
604
|
return { lens, errors };
|
|
589
605
|
}
|