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/flattener/index.js
CHANGED
|
@@ -1,24 +1,43 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
1
|
+
import { resolveTextLinks, enrichCardLinks } from "./resolve-text-links.js";
|
|
2
|
+
import { checkTierViolation } from "../validator/tier.js";
|
|
3
|
+
import { parseModule, hasFieldBeforeSegmentHeaders, } from "../parser/module.js";
|
|
4
|
+
import { parseLearningOutcome, } from "../parser/learning-outcome.js";
|
|
5
|
+
import { parseLens, } from "../parser/lens.js";
|
|
6
|
+
import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion, } from "../parser/wikilink.js";
|
|
7
|
+
import { parseFrontmatter } from "../parser/frontmatter.js";
|
|
8
|
+
import { fileNameToSlug } from "../utils/slug.js";
|
|
9
|
+
import { extractArticleExcerpt, bundleArticleWithCollapsed, } from "../bundler/article.js";
|
|
10
|
+
import { extractVideoExcerpt } from "../bundler/video.js";
|
|
10
11
|
function countWords(text) {
|
|
11
12
|
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A lens that is hidden (hide:: true) but not optional is a contradiction:
|
|
16
|
+
* users never see it, yet it counts as required to finish the section, so the
|
|
17
|
+
* section can't be completed. Flag it so editors either make it optional or
|
|
18
|
+
* unhide it.
|
|
19
|
+
*/
|
|
20
|
+
function checkHideOptionalConflict(optional, hide, file, line) {
|
|
21
|
+
if (hide && !optional) {
|
|
22
|
+
return {
|
|
23
|
+
file,
|
|
24
|
+
line,
|
|
25
|
+
message: "Lens is hidden (hide:: true) but required (optional is not true)",
|
|
26
|
+
suggestion: "A hidden lens cannot be completed by users — set optional:: true, or remove hide:: true to show it",
|
|
27
|
+
severity: "error",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
13
32
|
/** Compute word count and video duration from a section's segments. */
|
|
14
33
|
function computeSectionStats(segments) {
|
|
15
34
|
let words = 0;
|
|
16
35
|
let videoSeconds = 0;
|
|
17
36
|
for (const seg of segments) {
|
|
18
|
-
if (seg.type ===
|
|
37
|
+
if (seg.type === "text" || seg.type === "article") {
|
|
19
38
|
words += countWords(seg.content);
|
|
20
39
|
}
|
|
21
|
-
else if (seg.type ===
|
|
40
|
+
else if (seg.type === "video") {
|
|
22
41
|
if (seg.to != null)
|
|
23
42
|
videoSeconds += seg.to - seg.from;
|
|
24
43
|
}
|
|
@@ -30,26 +49,42 @@ function computeSectionStats(segments) {
|
|
|
30
49
|
stats.videoDurationSeconds = videoSeconds;
|
|
31
50
|
return stats;
|
|
32
51
|
}
|
|
52
|
+
/** Derive display type from segment content for UI icons/labels. */
|
|
53
|
+
function computeDisplayType(segments) {
|
|
54
|
+
let hasArticle = false;
|
|
55
|
+
let hasVideo = false;
|
|
56
|
+
for (const seg of segments) {
|
|
57
|
+
if (seg.type === "article")
|
|
58
|
+
hasArticle = true;
|
|
59
|
+
if (seg.type === "video")
|
|
60
|
+
hasVideo = true;
|
|
61
|
+
}
|
|
62
|
+
if (hasArticle && hasVideo)
|
|
63
|
+
return "lens-mixed";
|
|
64
|
+
if (hasArticle)
|
|
65
|
+
return "lens-article";
|
|
66
|
+
if (hasVideo)
|
|
67
|
+
return "lens-video";
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
33
70
|
/**
|
|
34
|
-
* Extract YouTube video
|
|
35
|
-
*
|
|
36
|
-
* Supported formats:
|
|
37
|
-
* - https://www.youtube.com/watch?v=VIDEO_ID
|
|
38
|
-
* - https://youtube.com/watch?v=VIDEO_ID
|
|
39
|
-
* - https://youtu.be/VIDEO_ID
|
|
40
|
-
* - https://www.youtube.com/embed/VIDEO_ID
|
|
41
|
-
*
|
|
42
|
-
* @returns Video ID string, or null if URL format is not recognized
|
|
71
|
+
* Extract YouTube video metadata from a URL. Returns the video ID and whether
|
|
72
|
+
* the URL is a YouTube Short (`/shorts/<id>`), so callers can pick a 9:16 layout.
|
|
43
73
|
*/
|
|
44
|
-
function
|
|
45
|
-
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
|
|
46
|
-
|
|
74
|
+
function extractVideoFromUrl(url) {
|
|
75
|
+
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/(shorts)\/)([a-zA-Z0-9_-]+)/);
|
|
76
|
+
if (!match)
|
|
77
|
+
return null;
|
|
78
|
+
return { videoId: match[2], isShort: match[1] === "shorts" };
|
|
47
79
|
}
|
|
48
80
|
export function isBoundary(item) {
|
|
49
|
-
return
|
|
81
|
+
return "__boundary" in item && item.__boundary === true;
|
|
50
82
|
}
|
|
51
83
|
function toKebabCase(str) {
|
|
52
|
-
return str
|
|
84
|
+
return str
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
87
|
+
.replace(/(^-|-$)/g, "");
|
|
53
88
|
}
|
|
54
89
|
export function validateBoundaries(items, file) {
|
|
55
90
|
const errors = [];
|
|
@@ -58,12 +93,14 @@ export function validateBoundaries(items, file) {
|
|
|
58
93
|
return errors;
|
|
59
94
|
// Check: sections before first boundary
|
|
60
95
|
const firstBoundaryIdx = items.findIndex(isBoundary);
|
|
61
|
-
const sectionsBefore = items
|
|
96
|
+
const sectionsBefore = items
|
|
97
|
+
.slice(0, firstBoundaryIdx)
|
|
98
|
+
.filter((i) => !isBoundary(i));
|
|
62
99
|
if (sectionsBefore.length > 0) {
|
|
63
100
|
errors.push({
|
|
64
101
|
file,
|
|
65
|
-
message:
|
|
66
|
-
severity:
|
|
102
|
+
message: "Content found outside submodule boundaries — all content must be inside a submodule",
|
|
103
|
+
severity: "error",
|
|
67
104
|
});
|
|
68
105
|
}
|
|
69
106
|
// Check: consecutive boundaries and trailing boundary
|
|
@@ -71,25 +108,24 @@ export function validateBoundaries(items, file) {
|
|
|
71
108
|
if (isBoundary(items[i])) {
|
|
72
109
|
// Find next non-boundary item
|
|
73
110
|
let nextNonBoundary = i + 1;
|
|
74
|
-
while (nextNonBoundary < items.length &&
|
|
111
|
+
while (nextNonBoundary < items.length &&
|
|
112
|
+
isBoundary(items[nextNonBoundary])) {
|
|
75
113
|
nextNonBoundary++;
|
|
76
114
|
}
|
|
77
115
|
// If this boundary has no sections before next boundary or end
|
|
78
116
|
if (nextNonBoundary > i + 1 || nextNonBoundary >= items.length) {
|
|
79
|
-
// nextNonBoundary > i + 1 means consecutive boundaries
|
|
80
|
-
// nextNonBoundary >= items.length means trailing boundary with no content
|
|
81
117
|
if (nextNonBoundary > i + 1) {
|
|
82
118
|
errors.push({
|
|
83
119
|
file,
|
|
84
120
|
message: `Submodule "${items[i].title}" is empty — no content before next submodule`,
|
|
85
|
-
severity:
|
|
121
|
+
severity: "error",
|
|
86
122
|
});
|
|
87
123
|
}
|
|
88
124
|
else if (nextNonBoundary >= items.length) {
|
|
89
125
|
errors.push({
|
|
90
126
|
file,
|
|
91
127
|
message: `Submodule "${items[i].title}" is empty — no content after this marker`,
|
|
92
|
-
severity:
|
|
128
|
+
severity: "error",
|
|
93
129
|
});
|
|
94
130
|
}
|
|
95
131
|
}
|
|
@@ -101,14 +137,16 @@ export function splitAtBoundaries(items, parentSlug, parentTitle) {
|
|
|
101
137
|
const hasBoundaries = items.some(isBoundary);
|
|
102
138
|
if (!hasBoundaries) {
|
|
103
139
|
// No split — return single group without parentSlug
|
|
104
|
-
return [
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
105
142
|
slug: parentSlug,
|
|
106
143
|
title: parentTitle,
|
|
107
144
|
parentSlug: undefined,
|
|
108
145
|
parentTitle: undefined,
|
|
109
|
-
sections: items.filter(i => !isBoundary(i)),
|
|
146
|
+
sections: items.filter((i) => !isBoundary(i)),
|
|
110
147
|
contentId: null,
|
|
111
|
-
}
|
|
148
|
+
},
|
|
149
|
+
];
|
|
112
150
|
}
|
|
113
151
|
const groups = [];
|
|
114
152
|
let currentGroup = null;
|
|
@@ -138,18 +176,29 @@ export function splitAtBoundaries(items, parentSlug, parentTitle) {
|
|
|
138
176
|
return groups;
|
|
139
177
|
}
|
|
140
178
|
/**
|
|
141
|
-
* Flatten a
|
|
142
|
-
*
|
|
143
|
-
* This function:
|
|
144
|
-
* 1. Parses the module file
|
|
145
|
-
* 2. For each Learning Outcome section, resolves the LO file
|
|
146
|
-
* 3. For each Lens in the LO, resolves the lens file
|
|
147
|
-
* 4. For each segment, extracts content from articles/videos as needed
|
|
179
|
+
* Flatten a single ParsedLens into output Segments.
|
|
180
|
+
* This is the shared core used by all lens-processing functions.
|
|
148
181
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
|
|
152
|
-
|
|
182
|
+
* For each segment in the lens, calls convertSegment to resolve article/video content.
|
|
183
|
+
* Then applies collapsed content for article segments from the same source.
|
|
184
|
+
*/
|
|
185
|
+
function flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap) {
|
|
186
|
+
const errors = [];
|
|
187
|
+
const segments = [];
|
|
188
|
+
for (const parsedSegment of lens.segments) {
|
|
189
|
+
const segmentResult = convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap);
|
|
190
|
+
errors.push(...segmentResult.errors);
|
|
191
|
+
if (segmentResult.segment) {
|
|
192
|
+
segments.push(segmentResult.segment);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Apply collapsed content for article segments
|
|
196
|
+
applyCollapsedContent(segments, lens.segments, lensPath, files);
|
|
197
|
+
return { segments, errors };
|
|
198
|
+
}
|
|
199
|
+
// ─── Module flattening ───
|
|
200
|
+
/**
|
|
201
|
+
* Flatten a module by resolving all references to Learning Outcomes, Lenses, and content.
|
|
153
202
|
*/
|
|
154
203
|
export function flattenModule(modulePath, files, visitedPaths = new Set(), tierMap) {
|
|
155
204
|
// Check for circular reference
|
|
@@ -157,11 +206,13 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
157
206
|
return {
|
|
158
207
|
module: null,
|
|
159
208
|
modules: [],
|
|
160
|
-
errors: [
|
|
209
|
+
errors: [
|
|
210
|
+
{
|
|
161
211
|
file: modulePath,
|
|
162
212
|
message: `Circular reference detected: ${modulePath}`,
|
|
163
|
-
severity:
|
|
164
|
-
}
|
|
213
|
+
severity: "error",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
165
216
|
};
|
|
166
217
|
}
|
|
167
218
|
visitedPaths.add(modulePath);
|
|
@@ -173,7 +224,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
173
224
|
errors.push({
|
|
174
225
|
file: modulePath,
|
|
175
226
|
message: `Module file not found: ${modulePath}`,
|
|
176
|
-
severity:
|
|
227
|
+
severity: "error",
|
|
177
228
|
});
|
|
178
229
|
return { module: null, modules: [], errors };
|
|
179
230
|
}
|
|
@@ -185,33 +236,18 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
185
236
|
}
|
|
186
237
|
const parsedModule = moduleResult.module;
|
|
187
238
|
const flatItems = [];
|
|
188
|
-
// Helper: process a section (LO
|
|
189
|
-
function processSection(section,
|
|
190
|
-
if (section.type ===
|
|
239
|
+
// Helper: process a section (LO or Lens) and return Section[]
|
|
240
|
+
function processSection(section, _subsectionLevel) {
|
|
241
|
+
if (section.type === "learning-outcome") {
|
|
191
242
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
192
243
|
const result = flattenLearningOutcomeSection(section, modulePath, files, sectionVisitedPaths, tierMap);
|
|
193
244
|
errors.push(...result.errors);
|
|
194
245
|
return result.sections;
|
|
195
246
|
}
|
|
196
|
-
else if (section.type ===
|
|
197
|
-
|
|
198
|
-
const textResult = parsePageSegments(section.body, modulePath, section.line, level);
|
|
199
|
-
errors.push(...textResult.errors);
|
|
200
|
-
return [{
|
|
201
|
-
type: 'page',
|
|
202
|
-
meta: { title: section.title },
|
|
203
|
-
segments: textResult.segments,
|
|
204
|
-
optional: section.fields.optional?.toLowerCase() === 'true',
|
|
205
|
-
contentId: section.fields.id ?? null,
|
|
206
|
-
learningOutcomeId: null,
|
|
207
|
-
learningOutcomeName: null,
|
|
208
|
-
videoId: null,
|
|
209
|
-
...computeSectionStats(textResult.segments),
|
|
210
|
-
}];
|
|
211
|
-
}
|
|
212
|
-
else if (section.type === 'uncategorized') {
|
|
247
|
+
else if (section.type === "lens") {
|
|
248
|
+
// Referenced lens (has source::) or inline lens
|
|
213
249
|
const sectionVisitedPaths = new Set(visitedPaths);
|
|
214
|
-
const result =
|
|
250
|
+
const result = flattenLensSection(section, modulePath, files, sectionVisitedPaths, tierMap, section.inlineLens);
|
|
215
251
|
errors.push(...result.errors);
|
|
216
252
|
return result.sections;
|
|
217
253
|
}
|
|
@@ -219,14 +255,14 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
219
255
|
}
|
|
220
256
|
// Process each section in the module
|
|
221
257
|
for (const section of parsedModule.sections) {
|
|
222
|
-
if (section.type ===
|
|
258
|
+
if (section.type === "submodule") {
|
|
223
259
|
// Emit boundary marker
|
|
224
260
|
flatItems.push({
|
|
225
261
|
__boundary: true,
|
|
226
262
|
title: section.title,
|
|
227
263
|
customSlug: section.fields.slug,
|
|
228
264
|
});
|
|
229
|
-
// Process children
|
|
265
|
+
// Process children
|
|
230
266
|
if (section.children) {
|
|
231
267
|
for (const child of section.children) {
|
|
232
268
|
const childSections = processSection(child, child.level + 1);
|
|
@@ -236,7 +272,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
236
272
|
}
|
|
237
273
|
else {
|
|
238
274
|
// Check if this is an LO with submodules
|
|
239
|
-
if (section.type ===
|
|
275
|
+
if (section.type === "learning-outcome") {
|
|
240
276
|
const loSections = processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors);
|
|
241
277
|
if (loSections) {
|
|
242
278
|
flatItems.push(...loSections);
|
|
@@ -251,17 +287,33 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
251
287
|
const boundaryErrors = validateBoundaries(flatItems, modulePath);
|
|
252
288
|
errors.push(...boundaryErrors);
|
|
253
289
|
const groups = splitAtBoundaries(flatItems, parsedModule.slug, parsedModule.title);
|
|
254
|
-
const resultModules = groups.map(g => ({
|
|
290
|
+
const resultModules = groups.map((g) => ({
|
|
255
291
|
slug: g.slug,
|
|
256
292
|
title: g.title,
|
|
257
293
|
contentId: parsedModule.contentId,
|
|
258
294
|
sections: g.sections,
|
|
259
|
-
|
|
295
|
+
sourcePath: modulePath,
|
|
296
|
+
...(g.parentSlug
|
|
297
|
+
? { parentSlug: g.parentSlug, parentTitle: g.parentTitle }
|
|
298
|
+
: {}),
|
|
260
299
|
...(moduleError ? { error: moduleError } : {}),
|
|
300
|
+
...("addToAiContext" in parsedModule
|
|
301
|
+
? { addToAiContext: parsedModule.addToAiContext }
|
|
302
|
+
: {}),
|
|
261
303
|
}));
|
|
304
|
+
// Enrich ::card links in text segments with computed metadata (duration, attribution, displayType)
|
|
305
|
+
for (const mod of resultModules) {
|
|
306
|
+
enrichCardLinks(mod.sections);
|
|
307
|
+
}
|
|
262
308
|
// Backwards compat: module is the first result or a merged single module
|
|
263
309
|
const primaryModule = resultModules.length > 0 ? resultModules[0] : null;
|
|
264
|
-
return {
|
|
310
|
+
return {
|
|
311
|
+
module: resultModules.length === 1 && !resultModules[0].parentSlug
|
|
312
|
+
? resultModules[0]
|
|
313
|
+
: primaryModule,
|
|
314
|
+
modules: resultModules,
|
|
315
|
+
errors,
|
|
316
|
+
};
|
|
265
317
|
}
|
|
266
318
|
/**
|
|
267
319
|
* Build a test Section from a ParsedTestRef with inline segments.
|
|
@@ -271,14 +323,8 @@ function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, ti
|
|
|
271
323
|
if (!testRef.segments.length)
|
|
272
324
|
return null;
|
|
273
325
|
const testSegments = [];
|
|
274
|
-
const stubLensSection = {
|
|
275
|
-
type: 'page',
|
|
276
|
-
title: 'Test',
|
|
277
|
-
segments: [],
|
|
278
|
-
line: 0,
|
|
279
|
-
};
|
|
280
326
|
for (const parsedSegment of testRef.segments) {
|
|
281
|
-
const segmentResult = convertSegment(parsedSegment,
|
|
327
|
+
const segmentResult = convertSegment(parsedSegment, loPath, files, new Set(visitedPaths), tierMap);
|
|
282
328
|
errors.push(...segmentResult.errors);
|
|
283
329
|
if (segmentResult.segment) {
|
|
284
330
|
testSegments.push(segmentResult.segment);
|
|
@@ -286,17 +332,17 @@ function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, ti
|
|
|
286
332
|
}
|
|
287
333
|
if (testSegments.length === 0)
|
|
288
334
|
return null;
|
|
289
|
-
const hasFeedback = testSegments.some((s) => s.type ===
|
|
335
|
+
const hasFeedback = testSegments.some((s) => s.type === "question" && s.feedback);
|
|
290
336
|
return {
|
|
291
|
-
type:
|
|
292
|
-
meta: { title:
|
|
337
|
+
type: "test",
|
|
338
|
+
meta: { title: "Test" },
|
|
293
339
|
segments: testSegments,
|
|
340
|
+
sourcePath: loPath,
|
|
294
341
|
optional: false,
|
|
295
342
|
...(hasFeedback && { feedback: true }),
|
|
296
|
-
contentId:
|
|
343
|
+
contentId: testRef.id,
|
|
297
344
|
learningOutcomeId: loId,
|
|
298
345
|
learningOutcomeName: loName,
|
|
299
|
-
videoId: null,
|
|
300
346
|
};
|
|
301
347
|
}
|
|
302
348
|
/**
|
|
@@ -333,32 +379,33 @@ function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierM
|
|
|
333
379
|
for (const lensRef of sub.lenses) {
|
|
334
380
|
const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
|
|
335
381
|
if (!lensPath) {
|
|
336
|
-
const similarFiles = findSimilarFiles(lensRef.resolvedPath, files,
|
|
337
|
-
const suggestion = formatSuggestion(similarFiles, loPath) ??
|
|
382
|
+
const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
|
|
383
|
+
const suggestion = formatSuggestion(similarFiles, loPath) ??
|
|
384
|
+
"Check the file path in the wiki-link";
|
|
338
385
|
errors.push({
|
|
339
386
|
file: loPath,
|
|
340
387
|
message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
|
|
341
388
|
suggestion,
|
|
342
|
-
severity:
|
|
389
|
+
severity: "error",
|
|
343
390
|
});
|
|
344
391
|
continue;
|
|
345
392
|
}
|
|
346
393
|
// Check tier violation
|
|
347
394
|
if (tierMap) {
|
|
348
|
-
const parentTier = tierMap.get(loPath) ??
|
|
349
|
-
const childTier = tierMap.get(lensPath) ??
|
|
350
|
-
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier,
|
|
395
|
+
const parentTier = tierMap.get(loPath) ?? "production";
|
|
396
|
+
const childTier = tierMap.get(lensPath) ?? "production";
|
|
397
|
+
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
|
|
351
398
|
if (violation) {
|
|
352
399
|
errors.push(violation);
|
|
353
400
|
}
|
|
354
|
-
if (childTier ===
|
|
401
|
+
if (childTier === "ignored")
|
|
355
402
|
continue;
|
|
356
403
|
}
|
|
357
404
|
if (visitedPaths.has(lensPath)) {
|
|
358
405
|
errors.push({
|
|
359
406
|
file: loPath,
|
|
360
407
|
message: `Circular reference detected: ${lensPath}`,
|
|
361
|
-
severity:
|
|
408
|
+
severity: "error",
|
|
362
409
|
});
|
|
363
410
|
continue;
|
|
364
411
|
}
|
|
@@ -370,92 +417,41 @@ function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierM
|
|
|
370
417
|
if (!lensResult.lens)
|
|
371
418
|
continue;
|
|
372
419
|
const lens = lensResult.lens;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const segments = [];
|
|
376
|
-
let videoId;
|
|
377
|
-
for (const lensSection of lens.sections) {
|
|
378
|
-
if (lensSection.type === 'lens-article') {
|
|
379
|
-
sectionType = 'lens-article';
|
|
380
|
-
if (lensSection.source) {
|
|
381
|
-
const articleWikilink = parseWikilink(lensSection.source);
|
|
382
|
-
if (articleWikilink && !articleWikilink.error) {
|
|
383
|
-
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
384
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
385
|
-
if (articlePath) {
|
|
386
|
-
const articleContent = files.get(articlePath);
|
|
387
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
388
|
-
if (articleFrontmatter.frontmatter.title)
|
|
389
|
-
meta.title = articleFrontmatter.frontmatter.title;
|
|
390
|
-
if (articleFrontmatter.frontmatter.author) {
|
|
391
|
-
const raw = articleFrontmatter.frontmatter.author;
|
|
392
|
-
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
393
|
-
}
|
|
394
|
-
if (articleFrontmatter.frontmatter.source_url)
|
|
395
|
-
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
else if (lensSection.type === 'page') {
|
|
401
|
-
sectionType = 'page';
|
|
402
|
-
if (lensSection.title)
|
|
403
|
-
meta.title = lensSection.title;
|
|
404
|
-
}
|
|
405
|
-
else if (lensSection.type === 'lens-video') {
|
|
406
|
-
sectionType = 'lens-video';
|
|
407
|
-
if (lensSection.source) {
|
|
408
|
-
const videoWikilink = parseWikilink(lensSection.source);
|
|
409
|
-
if (videoWikilink && !videoWikilink.error) {
|
|
410
|
-
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
411
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
412
|
-
if (videoPath) {
|
|
413
|
-
const videoContent = files.get(videoPath);
|
|
414
|
-
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
415
|
-
if (videoFrontmatter.frontmatter.title)
|
|
416
|
-
meta.title = videoFrontmatter.frontmatter.title;
|
|
417
|
-
if (videoFrontmatter.frontmatter.channel)
|
|
418
|
-
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
419
|
-
if (videoFrontmatter.frontmatter.url) {
|
|
420
|
-
const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
421
|
-
if (extractedVideoId)
|
|
422
|
-
videoId = extractedVideoId;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
for (const parsedSegment of lensSection.segments) {
|
|
429
|
-
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, sectionVisitedPaths, tierMap);
|
|
430
|
-
errors.push(...segmentResult.errors);
|
|
431
|
-
if (segmentResult.segment)
|
|
432
|
-
segments.push(segmentResult.segment);
|
|
433
|
-
}
|
|
434
|
-
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
435
|
-
}
|
|
420
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, sectionVisitedPaths, tierMap);
|
|
421
|
+
errors.push(...flattenErrors);
|
|
436
422
|
const lo = loResult.learningOutcome;
|
|
423
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true" || lensRef.optional;
|
|
424
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true" || lensRef.hide;
|
|
425
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, loPath, section.line);
|
|
426
|
+
if (hideConflict)
|
|
427
|
+
errors.push(hideConflict);
|
|
437
428
|
items.push({
|
|
438
|
-
type:
|
|
439
|
-
meta,
|
|
429
|
+
type: "lens",
|
|
430
|
+
meta: { title: lens.title || section.title },
|
|
440
431
|
segments,
|
|
441
|
-
|
|
432
|
+
sourcePath: lensPath,
|
|
433
|
+
optional: effectiveOptional,
|
|
434
|
+
...(effectiveHide && { hide: true }),
|
|
442
435
|
learningOutcomeId: lo.id ?? null,
|
|
443
|
-
learningOutcomeName: loPath.split(
|
|
436
|
+
learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
|
|
444
437
|
contentId: lens.id ?? null,
|
|
445
438
|
tldr: lens.tldr,
|
|
446
|
-
|
|
439
|
+
summaryForTutor: lens.summaryForTutor,
|
|
440
|
+
addToAiContext: lens.addToAiContext,
|
|
441
|
+
addToAiContextLo: lo.addToAiContext,
|
|
447
442
|
...computeSectionStats(segments),
|
|
443
|
+
displayType: computeDisplayType(segments),
|
|
448
444
|
});
|
|
449
445
|
}
|
|
450
446
|
// Process test section if present in this submodule
|
|
451
447
|
if (sub.test) {
|
|
452
448
|
const lo = loResult.learningOutcome;
|
|
453
|
-
const testSection = buildTestSection(sub.test, lo.id ?? null, loPath.split(
|
|
449
|
+
const testSection = buildTestSection(sub.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
|
|
454
450
|
if (testSection)
|
|
455
451
|
items.push(testSection);
|
|
456
452
|
}
|
|
457
453
|
}
|
|
458
|
-
// Also emit LO parse errors
|
|
454
|
+
// Also emit LO parse errors
|
|
459
455
|
errors.push(...loResult.errors);
|
|
460
456
|
return items;
|
|
461
457
|
}
|
|
@@ -472,9 +468,9 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
472
468
|
errors.push({
|
|
473
469
|
file: modulePath,
|
|
474
470
|
line: section.line,
|
|
475
|
-
message:
|
|
471
|
+
message: "Learning Outcome section missing source:: field",
|
|
476
472
|
suggestion: "Add 'source:: [[../Learning Outcomes/filename.md|Display]]'",
|
|
477
|
-
severity:
|
|
473
|
+
severity: "error",
|
|
478
474
|
});
|
|
479
475
|
return { sections: [], errors };
|
|
480
476
|
}
|
|
@@ -483,7 +479,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
483
479
|
if (!wikilink || wikilink.error) {
|
|
484
480
|
const suggestion = wikilink?.correctedPath
|
|
485
481
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
486
|
-
:
|
|
482
|
+
: "Use format [[../Learning Outcomes/filename.md|Display Text]]";
|
|
487
483
|
errors.push({
|
|
488
484
|
file: modulePath,
|
|
489
485
|
line: section.line,
|
|
@@ -491,7 +487,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
491
487
|
? `${wikilink.error}: ${source}`
|
|
492
488
|
: `Invalid wikilink format: ${source}`,
|
|
493
489
|
suggestion,
|
|
494
|
-
severity:
|
|
490
|
+
severity: "error",
|
|
495
491
|
});
|
|
496
492
|
return { sections: [], errors };
|
|
497
493
|
}
|
|
@@ -499,28 +495,27 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
499
495
|
const loPath = findFileWithExtension(loPathResolved, files);
|
|
500
496
|
// Get the LO file content
|
|
501
497
|
if (!loPath) {
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
498
|
+
const similarFiles = findSimilarFiles(loPathResolved, files, "Learning Outcomes");
|
|
499
|
+
const suggestion = formatSuggestion(similarFiles, modulePath) ??
|
|
500
|
+
"Check the file path in the wiki-link";
|
|
505
501
|
errors.push({
|
|
506
502
|
file: modulePath,
|
|
507
503
|
line: section.line,
|
|
508
504
|
message: `Referenced file not found: ${loPathResolved}`,
|
|
509
505
|
suggestion,
|
|
510
|
-
severity:
|
|
506
|
+
severity: "error",
|
|
511
507
|
});
|
|
512
508
|
return { sections: [], errors };
|
|
513
509
|
}
|
|
514
|
-
// Check tier violation (module
|
|
510
|
+
// Check tier violation (module -> LO)
|
|
515
511
|
if (tierMap) {
|
|
516
|
-
const parentTier = tierMap.get(modulePath) ??
|
|
517
|
-
const childTier = tierMap.get(loPath) ??
|
|
518
|
-
const violation = checkTierViolation(modulePath, parentTier, loPath, childTier,
|
|
512
|
+
const parentTier = tierMap.get(modulePath) ?? "production";
|
|
513
|
+
const childTier = tierMap.get(loPath) ?? "production";
|
|
514
|
+
const violation = checkTierViolation(modulePath, parentTier, loPath, childTier, "learning outcome", section.line);
|
|
519
515
|
if (violation) {
|
|
520
516
|
errors.push(violation);
|
|
521
517
|
}
|
|
522
|
-
|
|
523
|
-
if (childTier === 'ignored') {
|
|
518
|
+
if (childTier === "ignored") {
|
|
524
519
|
return { sections: [], errors };
|
|
525
520
|
}
|
|
526
521
|
}
|
|
@@ -530,7 +525,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
530
525
|
file: modulePath,
|
|
531
526
|
line: section.line,
|
|
532
527
|
message: `Circular reference detected: ${loPath}`,
|
|
533
|
-
severity:
|
|
528
|
+
severity: "error",
|
|
534
529
|
});
|
|
535
530
|
return { sections: [], errors };
|
|
536
531
|
}
|
|
@@ -547,26 +542,26 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
547
542
|
for (const lensRef of lo.lenses) {
|
|
548
543
|
const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
|
|
549
544
|
if (!lensPath) {
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
545
|
+
const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
|
|
546
|
+
const suggestion = formatSuggestion(similarFiles, loPath) ??
|
|
547
|
+
"Check the file path in the wiki-link";
|
|
553
548
|
errors.push({
|
|
554
549
|
file: loPath,
|
|
555
550
|
message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
|
|
556
551
|
suggestion,
|
|
557
|
-
severity:
|
|
552
|
+
severity: "error",
|
|
558
553
|
});
|
|
559
554
|
continue;
|
|
560
555
|
}
|
|
561
|
-
// Check tier violation (LO
|
|
556
|
+
// Check tier violation (LO -> Lens)
|
|
562
557
|
if (tierMap) {
|
|
563
|
-
const parentTier = tierMap.get(loPath) ??
|
|
564
|
-
const childTier = tierMap.get(lensPath) ??
|
|
565
|
-
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier,
|
|
558
|
+
const parentTier = tierMap.get(loPath) ?? "production";
|
|
559
|
+
const childTier = tierMap.get(lensPath) ?? "production";
|
|
560
|
+
const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
|
|
566
561
|
if (violation) {
|
|
567
562
|
errors.push(violation);
|
|
568
563
|
}
|
|
569
|
-
if (childTier ===
|
|
564
|
+
if (childTier === "ignored") {
|
|
570
565
|
continue;
|
|
571
566
|
}
|
|
572
567
|
}
|
|
@@ -575,7 +570,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
575
570
|
errors.push({
|
|
576
571
|
file: loPath,
|
|
577
572
|
message: `Circular reference detected: ${lensPath}`,
|
|
578
|
-
severity:
|
|
573
|
+
severity: "error",
|
|
579
574
|
});
|
|
580
575
|
continue;
|
|
581
576
|
}
|
|
@@ -588,465 +583,263 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
|
|
|
588
583
|
continue;
|
|
589
584
|
}
|
|
590
585
|
const lens = lensResult.lens;
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const segments = [];
|
|
595
|
-
let videoId;
|
|
596
|
-
// Process each section in the lens
|
|
597
|
-
for (const lensSection of lens.sections) {
|
|
598
|
-
// Determine section type from lens section
|
|
599
|
-
if (lensSection.type === 'lens-article') {
|
|
600
|
-
sectionType = 'lens-article';
|
|
601
|
-
// Extract article metadata from the article file's frontmatter
|
|
602
|
-
if (lensSection.source) {
|
|
603
|
-
const articleWikilink = parseWikilink(lensSection.source);
|
|
604
|
-
if (articleWikilink && !articleWikilink.error) {
|
|
605
|
-
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
606
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
607
|
-
if (articlePath) {
|
|
608
|
-
const articleContent = files.get(articlePath);
|
|
609
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
610
|
-
// Extract metadata fields
|
|
611
|
-
if (articleFrontmatter.frontmatter.title) {
|
|
612
|
-
meta.title = articleFrontmatter.frontmatter.title;
|
|
613
|
-
}
|
|
614
|
-
if (articleFrontmatter.frontmatter.author) {
|
|
615
|
-
const raw = articleFrontmatter.frontmatter.author;
|
|
616
|
-
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
617
|
-
}
|
|
618
|
-
if (articleFrontmatter.frontmatter.source_url) {
|
|
619
|
-
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
620
|
-
}
|
|
621
|
-
if (articleFrontmatter.frontmatter.published) {
|
|
622
|
-
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
else if (lensSection.type === 'page') {
|
|
629
|
-
sectionType = 'page';
|
|
630
|
-
// For page sections, use the title from the ### Page: header
|
|
631
|
-
if (lensSection.title) {
|
|
632
|
-
meta.title = lensSection.title;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
else if (lensSection.type === 'lens-video') {
|
|
636
|
-
sectionType = 'lens-video';
|
|
637
|
-
// Extract video metadata from the video transcript file's frontmatter
|
|
638
|
-
if (lensSection.source) {
|
|
639
|
-
const videoWikilink = parseWikilink(lensSection.source);
|
|
640
|
-
if (videoWikilink && !videoWikilink.error) {
|
|
641
|
-
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
642
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
643
|
-
if (videoPath) {
|
|
644
|
-
const videoContent = files.get(videoPath);
|
|
645
|
-
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
646
|
-
// Extract metadata fields
|
|
647
|
-
if (videoFrontmatter.frontmatter.title) {
|
|
648
|
-
meta.title = videoFrontmatter.frontmatter.title;
|
|
649
|
-
}
|
|
650
|
-
if (videoFrontmatter.frontmatter.channel) {
|
|
651
|
-
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
652
|
-
}
|
|
653
|
-
// Extract video ID from YouTube URL
|
|
654
|
-
if (videoFrontmatter.frontmatter.url) {
|
|
655
|
-
const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
656
|
-
if (extractedVideoId) {
|
|
657
|
-
videoId = extractedVideoId;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
// Process segments
|
|
665
|
-
for (const parsedSegment of lensSection.segments) {
|
|
666
|
-
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
|
|
667
|
-
errors.push(...segmentResult.errors);
|
|
668
|
-
if (segmentResult.segment) {
|
|
669
|
-
segments.push(segmentResult.segment);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
// Apply collapsed content to article-excerpt segments
|
|
673
|
-
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
674
|
-
}
|
|
586
|
+
// Flatten lens segments using shared helper
|
|
587
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
588
|
+
errors.push(...flattenErrors);
|
|
675
589
|
// Create a section for this lens
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
590
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true" || lensRef.optional;
|
|
591
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true" || lensRef.hide;
|
|
592
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, loPath, section.line);
|
|
593
|
+
if (hideConflict)
|
|
594
|
+
errors.push(hideConflict);
|
|
679
595
|
const resultSection = {
|
|
680
|
-
type:
|
|
681
|
-
meta,
|
|
596
|
+
type: "lens",
|
|
597
|
+
meta: { title: lens.title || section.title },
|
|
682
598
|
segments,
|
|
683
|
-
|
|
599
|
+
sourcePath: lensPath,
|
|
600
|
+
optional: effectiveOptional,
|
|
601
|
+
...(effectiveHide && { hide: true }),
|
|
684
602
|
learningOutcomeId: lo.id ?? null,
|
|
685
|
-
learningOutcomeName: loPath.split(
|
|
603
|
+
learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
|
|
686
604
|
contentId: lens.id ?? null,
|
|
687
605
|
tldr: lens.tldr,
|
|
688
|
-
|
|
606
|
+
summaryForTutor: lens.summaryForTutor,
|
|
607
|
+
addToAiContext: lens.addToAiContext,
|
|
608
|
+
addToAiContextLo: lo.addToAiContext,
|
|
689
609
|
...computeSectionStats(segments),
|
|
610
|
+
displayType: computeDisplayType(segments),
|
|
690
611
|
};
|
|
691
612
|
sections.push(resultSection);
|
|
692
613
|
}
|
|
693
614
|
// After lens processing, add test section if present with inline segments
|
|
694
615
|
if (lo.test) {
|
|
695
|
-
const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split(
|
|
616
|
+
const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
|
|
696
617
|
if (testSection)
|
|
697
618
|
sections.push(testSection);
|
|
698
619
|
}
|
|
699
620
|
return { sections, errors };
|
|
700
621
|
}
|
|
701
622
|
/**
|
|
702
|
-
* Flatten
|
|
703
|
-
*
|
|
623
|
+
* Flatten a # Lens: section from a module file.
|
|
624
|
+
* Handles both referenced lenses (with source:: wikilink) and inline lenses (with id:: + segments).
|
|
704
625
|
*/
|
|
705
|
-
function
|
|
626
|
+
function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, inlineLens) {
|
|
706
627
|
const errors = [];
|
|
707
628
|
const sections = [];
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
629
|
+
// Inline lens: has id:: + segments (no source:: at section level)
|
|
630
|
+
if (inlineLens) {
|
|
631
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(inlineLens, modulePath, files, visitedPaths, tierMap);
|
|
632
|
+
errors.push(...flattenErrors);
|
|
633
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true";
|
|
634
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true";
|
|
635
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
636
|
+
if (hideConflict)
|
|
637
|
+
errors.push(hideConflict);
|
|
638
|
+
const resultSection = {
|
|
639
|
+
type: "lens",
|
|
640
|
+
meta: { title: inlineLens.title || section.title },
|
|
641
|
+
segments,
|
|
642
|
+
sourcePath: modulePath,
|
|
643
|
+
optional: effectiveOptional,
|
|
644
|
+
...(effectiveHide && { hide: true }),
|
|
645
|
+
learningOutcomeId: null,
|
|
646
|
+
learningOutcomeName: null,
|
|
647
|
+
contentId: inlineLens.id ?? null,
|
|
648
|
+
tldr: inlineLens.tldr,
|
|
649
|
+
summaryForTutor: inlineLens.summaryForTutor,
|
|
650
|
+
addToAiContext: inlineLens.addToAiContext,
|
|
651
|
+
...computeSectionStats(segments),
|
|
652
|
+
displayType: computeDisplayType(segments),
|
|
653
|
+
};
|
|
654
|
+
sections.push(resultSection);
|
|
655
|
+
return { sections, errors };
|
|
656
|
+
}
|
|
657
|
+
// Referenced lens: has source:: at section level (not from segment fields)
|
|
658
|
+
const hasSectionSource = hasFieldBeforeSegmentHeaders(section.body, "source", section.level);
|
|
659
|
+
const source = hasSectionSource ? section.fields.source : undefined;
|
|
660
|
+
if (!source) {
|
|
712
661
|
errors.push({
|
|
713
662
|
file: modulePath,
|
|
714
663
|
line: section.line,
|
|
715
|
-
message:
|
|
716
|
-
suggestion: "Add '
|
|
717
|
-
severity:
|
|
664
|
+
message: "Lens section missing source:: field (or id:: for inline lens)",
|
|
665
|
+
suggestion: "Add 'source:: [[../Lenses/filename.md|Display]]' or 'id:: <uuid>' with #### segments",
|
|
666
|
+
severity: "error",
|
|
718
667
|
});
|
|
719
668
|
return { sections: [], errors };
|
|
720
669
|
}
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
// Parse the lens
|
|
760
|
-
const lensResult = parseLens(lensContent, lensPath);
|
|
761
|
-
errors.push(...lensResult.errors);
|
|
762
|
-
if (!lensResult.lens) {
|
|
763
|
-
continue;
|
|
670
|
+
// Parse and resolve the wikilink
|
|
671
|
+
const wikilink = parseWikilink(source);
|
|
672
|
+
if (!wikilink || wikilink.error) {
|
|
673
|
+
const suggestion = wikilink?.correctedPath
|
|
674
|
+
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
675
|
+
: "Use format [[../Lenses/filename.md|Display Text]]";
|
|
676
|
+
errors.push({
|
|
677
|
+
file: modulePath,
|
|
678
|
+
line: section.line,
|
|
679
|
+
message: wikilink?.error
|
|
680
|
+
? `${wikilink.error}: ${source}`
|
|
681
|
+
: `Invalid wikilink format: ${source}`,
|
|
682
|
+
suggestion,
|
|
683
|
+
severity: "error",
|
|
684
|
+
});
|
|
685
|
+
return { sections: [], errors };
|
|
686
|
+
}
|
|
687
|
+
const lensPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
|
|
688
|
+
const lensPath = findFileWithExtension(lensPathResolved, files);
|
|
689
|
+
if (!lensPath) {
|
|
690
|
+
const similarFiles = findSimilarFiles(lensPathResolved, files, "Lenses");
|
|
691
|
+
const suggestion = formatSuggestion(similarFiles, modulePath) ??
|
|
692
|
+
"Check the file path in the wiki-link";
|
|
693
|
+
errors.push({
|
|
694
|
+
file: modulePath,
|
|
695
|
+
message: `Referenced lens file not found: ${lensPathResolved}`,
|
|
696
|
+
suggestion,
|
|
697
|
+
severity: "error",
|
|
698
|
+
});
|
|
699
|
+
return { sections: [], errors };
|
|
700
|
+
}
|
|
701
|
+
// Check tier violation
|
|
702
|
+
if (tierMap) {
|
|
703
|
+
const parentTier = tierMap.get(modulePath) ?? "production";
|
|
704
|
+
const childTier = tierMap.get(lensPath) ?? "production";
|
|
705
|
+
const violation = checkTierViolation(modulePath, parentTier, lensPath, childTier, "lens");
|
|
706
|
+
if (violation) {
|
|
707
|
+
errors.push(violation);
|
|
764
708
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
let sectionType = 'page';
|
|
768
|
-
const meta = { title: section.title };
|
|
769
|
-
const segments = [];
|
|
770
|
-
let videoId;
|
|
771
|
-
// Process each section in the lens
|
|
772
|
-
for (const lensSection of lens.sections) {
|
|
773
|
-
// Determine section type from lens section
|
|
774
|
-
if (lensSection.type === 'lens-article') {
|
|
775
|
-
sectionType = 'lens-article';
|
|
776
|
-
// Extract article metadata from the article file's frontmatter
|
|
777
|
-
if (lensSection.source) {
|
|
778
|
-
const articleWikilink = parseWikilink(lensSection.source);
|
|
779
|
-
if (articleWikilink && !articleWikilink.error) {
|
|
780
|
-
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
781
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
782
|
-
if (articlePath) {
|
|
783
|
-
const articleContent = files.get(articlePath);
|
|
784
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
785
|
-
// Extract metadata fields
|
|
786
|
-
if (articleFrontmatter.frontmatter.title) {
|
|
787
|
-
meta.title = articleFrontmatter.frontmatter.title;
|
|
788
|
-
}
|
|
789
|
-
if (articleFrontmatter.frontmatter.author) {
|
|
790
|
-
const raw = articleFrontmatter.frontmatter.author;
|
|
791
|
-
meta.author = Array.isArray(raw) ? raw.join(', ') : String(raw);
|
|
792
|
-
}
|
|
793
|
-
if (articleFrontmatter.frontmatter.source_url) {
|
|
794
|
-
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
795
|
-
}
|
|
796
|
-
if (articleFrontmatter.frontmatter.published) {
|
|
797
|
-
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
else if (lensSection.type === 'page') {
|
|
804
|
-
sectionType = 'page';
|
|
805
|
-
// For page sections, use the title from the ### Page: header
|
|
806
|
-
if (lensSection.title) {
|
|
807
|
-
meta.title = lensSection.title;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
else if (lensSection.type === 'lens-video') {
|
|
811
|
-
sectionType = 'lens-video';
|
|
812
|
-
// Extract video metadata from the video transcript file's frontmatter
|
|
813
|
-
if (lensSection.source) {
|
|
814
|
-
const videoWikilink = parseWikilink(lensSection.source);
|
|
815
|
-
if (videoWikilink && !videoWikilink.error) {
|
|
816
|
-
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
817
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
818
|
-
if (videoPath) {
|
|
819
|
-
const videoContent = files.get(videoPath);
|
|
820
|
-
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
821
|
-
// Extract metadata fields
|
|
822
|
-
if (videoFrontmatter.frontmatter.title) {
|
|
823
|
-
meta.title = videoFrontmatter.frontmatter.title;
|
|
824
|
-
}
|
|
825
|
-
if (videoFrontmatter.frontmatter.channel) {
|
|
826
|
-
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
827
|
-
}
|
|
828
|
-
// Extract video ID from YouTube URL
|
|
829
|
-
if (videoFrontmatter.frontmatter.url) {
|
|
830
|
-
const extractedVideoId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
831
|
-
if (extractedVideoId) {
|
|
832
|
-
videoId = extractedVideoId;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
// Process segments
|
|
840
|
-
for (const parsedSegment of lensSection.segments) {
|
|
841
|
-
const segmentResult = convertSegment(parsedSegment, lensSection, lensPath, files, visitedPaths, tierMap);
|
|
842
|
-
errors.push(...segmentResult.errors);
|
|
843
|
-
if (segmentResult.segment) {
|
|
844
|
-
segments.push(segmentResult.segment);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
// Apply collapsed content to article-excerpt segments
|
|
848
|
-
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
709
|
+
if (childTier === "ignored") {
|
|
710
|
+
return { sections: [], errors };
|
|
849
711
|
}
|
|
850
|
-
// Create a section for this lens
|
|
851
|
-
const resultSection = {
|
|
852
|
-
type: sectionType,
|
|
853
|
-
meta,
|
|
854
|
-
segments,
|
|
855
|
-
optional: lensRef.optional,
|
|
856
|
-
learningOutcomeId: null,
|
|
857
|
-
learningOutcomeName: null,
|
|
858
|
-
contentId: lens.id ?? null,
|
|
859
|
-
tldr: lens.tldr,
|
|
860
|
-
videoId: videoId ?? null,
|
|
861
|
-
...computeSectionStats(segments),
|
|
862
|
-
};
|
|
863
|
-
sections.push(resultSection);
|
|
864
712
|
}
|
|
713
|
+
// Check for circular reference
|
|
714
|
+
if (visitedPaths.has(lensPath)) {
|
|
715
|
+
errors.push({
|
|
716
|
+
file: modulePath,
|
|
717
|
+
message: `Circular reference detected: ${lensPath}`,
|
|
718
|
+
severity: "error",
|
|
719
|
+
});
|
|
720
|
+
return { sections: [], errors };
|
|
721
|
+
}
|
|
722
|
+
visitedPaths.add(lensPath);
|
|
723
|
+
const lensContent = files.get(lensPath);
|
|
724
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
725
|
+
errors.push(...lensResult.errors);
|
|
726
|
+
if (!lensResult.lens) {
|
|
727
|
+
return { sections: [], errors };
|
|
728
|
+
}
|
|
729
|
+
const lens = lensResult.lens;
|
|
730
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
731
|
+
errors.push(...flattenErrors);
|
|
732
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true";
|
|
733
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true";
|
|
734
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
735
|
+
if (hideConflict)
|
|
736
|
+
errors.push(hideConflict);
|
|
737
|
+
const resultSection = {
|
|
738
|
+
type: "lens",
|
|
739
|
+
meta: { title: lens.title || section.title },
|
|
740
|
+
segments,
|
|
741
|
+
sourcePath: lensPath,
|
|
742
|
+
optional: effectiveOptional,
|
|
743
|
+
...(effectiveHide && { hide: true }),
|
|
744
|
+
learningOutcomeId: null,
|
|
745
|
+
learningOutcomeName: null,
|
|
746
|
+
contentId: lens.id ?? null,
|
|
747
|
+
tldr: lens.tldr,
|
|
748
|
+
summaryForTutor: lens.summaryForTutor,
|
|
749
|
+
addToAiContext: lens.addToAiContext,
|
|
750
|
+
...computeSectionStats(segments),
|
|
751
|
+
displayType: computeDisplayType(segments),
|
|
752
|
+
};
|
|
753
|
+
sections.push(resultSection);
|
|
865
754
|
return { sections, errors };
|
|
866
755
|
}
|
|
756
|
+
// ─── Collapsed content and segment conversion ───
|
|
867
757
|
/**
|
|
868
|
-
*
|
|
869
|
-
*
|
|
758
|
+
* After converting segments, apply collapsed_before/collapsed_after to article segments.
|
|
759
|
+
* Groups article segments by their resolved source path, then calls bundleArticleWithCollapsed
|
|
760
|
+
* for each group.
|
|
870
761
|
*/
|
|
871
|
-
function
|
|
872
|
-
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
let
|
|
876
|
-
let
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const FIELD_PATTERN = /^(\w+)::\s*(.*)$/;
|
|
880
|
-
for (let i = 0; i < lines.length; i++) {
|
|
881
|
-
const line = lines[i];
|
|
882
|
-
// Check for ## Lens: header
|
|
883
|
-
const lensMatch = line.match(LENS_HEADER_PATTERN);
|
|
884
|
-
if (lensMatch) {
|
|
885
|
-
// Save previous lens if we were in one
|
|
886
|
-
if (inLensSection) {
|
|
887
|
-
// First, finalize current field if we were collecting one
|
|
888
|
-
// This must happen BEFORE checking currentFields.source
|
|
889
|
-
if (currentField) {
|
|
890
|
-
currentFields[currentField] = currentValue.join('\n').trim();
|
|
891
|
-
}
|
|
892
|
-
if (currentFields.source) {
|
|
893
|
-
const wikilink = parseWikilink(currentFields.source);
|
|
894
|
-
if (wikilink) {
|
|
895
|
-
const resolvedPath = resolveWikilinkPath(wikilink.path, parentPath);
|
|
896
|
-
lensRefs.push({
|
|
897
|
-
source: currentFields.source,
|
|
898
|
-
resolvedPath,
|
|
899
|
-
optional: currentFields.optional?.toLowerCase() === 'true',
|
|
900
|
-
});
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
inLensSection = true;
|
|
905
|
-
currentFields = {};
|
|
906
|
-
currentField = null;
|
|
907
|
-
currentValue = [];
|
|
762
|
+
function applyCollapsedContent(segments, parsedSegments, lensPath, files) {
|
|
763
|
+
// Build groups of article segments by resolved source path
|
|
764
|
+
const sourceGroups = new Map();
|
|
765
|
+
// Map output article segments back to parsed segments for source info
|
|
766
|
+
let articleOutputIdx = 0;
|
|
767
|
+
for (let i = 0; i < parsedSegments.length; i++) {
|
|
768
|
+
const ps = parsedSegments[i];
|
|
769
|
+
if (ps.type !== "article")
|
|
908
770
|
continue;
|
|
771
|
+
// Find corresponding output segment
|
|
772
|
+
while (articleOutputIdx < segments.length &&
|
|
773
|
+
segments[articleOutputIdx].type !== "article") {
|
|
774
|
+
articleOutputIdx++;
|
|
909
775
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
776
|
+
if (articleOutputIdx >= segments.length)
|
|
777
|
+
break;
|
|
778
|
+
if (ps.source) {
|
|
779
|
+
const wikilink = parseWikilink(ps.source);
|
|
780
|
+
if (wikilink && !wikilink.error) {
|
|
781
|
+
const resolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
782
|
+
const actualPath = findFileWithExtension(resolved, files);
|
|
783
|
+
if (actualPath) {
|
|
784
|
+
if (!sourceGroups.has(actualPath)) {
|
|
785
|
+
sourceGroups.set(actualPath, []);
|
|
786
|
+
}
|
|
787
|
+
sourceGroups.get(actualPath).push({
|
|
788
|
+
segmentIndex: articleOutputIdx,
|
|
789
|
+
fromAnchor: ps.fromAnchor,
|
|
790
|
+
toAnchor: ps.toAnchor,
|
|
924
791
|
});
|
|
925
792
|
}
|
|
926
793
|
}
|
|
927
|
-
inLensSection = false;
|
|
928
|
-
currentFields = {};
|
|
929
|
-
currentField = null;
|
|
930
|
-
currentValue = [];
|
|
931
|
-
continue;
|
|
932
|
-
}
|
|
933
|
-
if (inLensSection) {
|
|
934
|
-
// Parse fields
|
|
935
|
-
const fieldMatch = line.match(FIELD_PATTERN);
|
|
936
|
-
if (fieldMatch) {
|
|
937
|
-
// Save previous field
|
|
938
|
-
if (currentField) {
|
|
939
|
-
currentFields[currentField] = currentValue.join('\n').trim();
|
|
940
|
-
}
|
|
941
|
-
currentField = fieldMatch[1];
|
|
942
|
-
const inlineValue = fieldMatch[2].trim();
|
|
943
|
-
currentValue = inlineValue ? [inlineValue] : [];
|
|
944
|
-
}
|
|
945
|
-
else if (currentField) {
|
|
946
|
-
// Check if line starts a new section header
|
|
947
|
-
if (line.match(/^#/)) {
|
|
948
|
-
currentFields[currentField] = currentValue.join('\n').trim();
|
|
949
|
-
currentField = null;
|
|
950
|
-
currentValue = [];
|
|
951
|
-
}
|
|
952
|
-
else {
|
|
953
|
-
currentValue.push(line);
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
794
|
}
|
|
795
|
+
articleOutputIdx++;
|
|
957
796
|
}
|
|
958
|
-
//
|
|
959
|
-
|
|
960
|
-
if
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
797
|
+
// For each source group, apply collapsed content
|
|
798
|
+
for (const [articlePath, excerptInfos] of sourceGroups) {
|
|
799
|
+
// Only apply collapsed if at least one excerpt has anchors
|
|
800
|
+
const hasAnchors = excerptInfos.some((e) => e.fromAnchor || e.toAnchor);
|
|
801
|
+
if (!hasAnchors)
|
|
802
|
+
continue;
|
|
803
|
+
const articleContent = files.get(articlePath);
|
|
804
|
+
if (!articleContent)
|
|
805
|
+
continue;
|
|
806
|
+
const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map((e) => ({ from: e.fromAnchor, to: e.toAnchor })), articlePath);
|
|
807
|
+
for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
|
|
808
|
+
const segIdx = excerptInfos[i].segmentIndex;
|
|
809
|
+
const collapsed = collapsedResults[i];
|
|
810
|
+
const segment = segments[segIdx];
|
|
811
|
+
if (collapsed.collapsed_before) {
|
|
812
|
+
segment.collapsed_before = collapsed.collapsed_before;
|
|
813
|
+
}
|
|
814
|
+
if (collapsed.collapsed_after) {
|
|
815
|
+
segment.collapsed_after = collapsed.collapsed_after;
|
|
972
816
|
}
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return lensRefs;
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* After converting segments, apply collapsed_before/collapsed_after to article-excerpt segments.
|
|
979
|
-
* This resolves the article source once and calls bundleArticleWithCollapsed to compute
|
|
980
|
-
* which parts of the article are outside the excerpted ranges.
|
|
981
|
-
*/
|
|
982
|
-
function applyCollapsedContent(segments, parsedSegments, lensSection, lensPath, files) {
|
|
983
|
-
// Only process lens-article sections with article-excerpt segments
|
|
984
|
-
if (lensSection.type !== 'lens-article' || !lensSection.source)
|
|
985
|
-
return;
|
|
986
|
-
// Collect article-excerpt segment indices
|
|
987
|
-
const excerptIndices = [];
|
|
988
|
-
for (let i = 0; i < segments.length; i++) {
|
|
989
|
-
if (segments[i].type === 'article-excerpt') {
|
|
990
|
-
excerptIndices.push(i);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
if (excerptIndices.length === 0)
|
|
994
|
-
return;
|
|
995
|
-
// Map to parsed segments to get anchors (order-preserving)
|
|
996
|
-
const parsedExcerpts = parsedSegments.filter(ps => ps.type === 'article-excerpt');
|
|
997
|
-
const excerptInfos = excerptIndices.map((segIdx, i) => ({
|
|
998
|
-
index: segIdx,
|
|
999
|
-
from: parsedExcerpts[i]?.fromAnchor,
|
|
1000
|
-
to: parsedExcerpts[i]?.toAnchor,
|
|
1001
|
-
}));
|
|
1002
|
-
// Only apply collapsed if at least one excerpt has anchors
|
|
1003
|
-
const hasAnchors = excerptInfos.some(e => e.from || e.to);
|
|
1004
|
-
if (!hasAnchors)
|
|
1005
|
-
return;
|
|
1006
|
-
// Resolve article path
|
|
1007
|
-
const wikilink = parseWikilink(lensSection.source);
|
|
1008
|
-
if (!wikilink || wikilink.error)
|
|
1009
|
-
return;
|
|
1010
|
-
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1011
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1012
|
-
if (!articlePath)
|
|
1013
|
-
return;
|
|
1014
|
-
const articleContent = files.get(articlePath);
|
|
1015
|
-
// Call bundleArticleWithCollapsed
|
|
1016
|
-
const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map(e => ({ from: e.from, to: e.to })), articlePath);
|
|
1017
|
-
// Overlay collapsed_before/collapsed_after onto segments
|
|
1018
|
-
for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
|
|
1019
|
-
const segIdx = excerptInfos[i].index;
|
|
1020
|
-
const collapsed = collapsedResults[i];
|
|
1021
|
-
const segment = segments[segIdx];
|
|
1022
|
-
if (collapsed.collapsed_before) {
|
|
1023
|
-
segment.collapsed_before = collapsed.collapsed_before;
|
|
1024
|
-
}
|
|
1025
|
-
if (collapsed.collapsed_after) {
|
|
1026
|
-
segment.collapsed_after = collapsed.collapsed_after;
|
|
1027
817
|
}
|
|
1028
818
|
}
|
|
1029
819
|
}
|
|
1030
820
|
/**
|
|
1031
821
|
* Convert a parsed lens segment into a final flattened segment.
|
|
1032
|
-
* For article
|
|
822
|
+
* For article and video, this involves extracting content from source files.
|
|
823
|
+
* Source comes from the parsed segment itself (set via inheritance in parseLens).
|
|
1033
824
|
*/
|
|
1034
|
-
function convertSegment(parsedSegment,
|
|
825
|
+
function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
1035
826
|
const errors = [];
|
|
1036
827
|
switch (parsedSegment.type) {
|
|
1037
|
-
case
|
|
828
|
+
case "text": {
|
|
829
|
+
const resolved = resolveTextLinks(parsedSegment.content, lensPath, files);
|
|
830
|
+
errors.push(...resolved.errors);
|
|
1038
831
|
const segment = {
|
|
1039
|
-
type:
|
|
1040
|
-
content:
|
|
832
|
+
type: "text",
|
|
833
|
+
content: resolved.content,
|
|
1041
834
|
};
|
|
1042
835
|
if (parsedSegment.optional) {
|
|
1043
836
|
segment.optional = true;
|
|
1044
837
|
}
|
|
1045
838
|
return { segment, errors };
|
|
1046
839
|
}
|
|
1047
|
-
case
|
|
840
|
+
case "chat": {
|
|
1048
841
|
const segment = {
|
|
1049
|
-
type:
|
|
842
|
+
type: "chat",
|
|
1050
843
|
};
|
|
1051
844
|
if (parsedSegment.instructions) {
|
|
1052
845
|
segment.instructions = parsedSegment.instructions;
|
|
@@ -1062,68 +855,67 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1062
855
|
}
|
|
1063
856
|
return { segment, errors };
|
|
1064
857
|
}
|
|
1065
|
-
case
|
|
1066
|
-
//
|
|
1067
|
-
if (!
|
|
858
|
+
case "article": {
|
|
859
|
+
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
860
|
+
if (!parsedSegment.source) {
|
|
1068
861
|
errors.push({
|
|
1069
862
|
file: lensPath,
|
|
1070
|
-
message:
|
|
1071
|
-
severity:
|
|
863
|
+
message: "Article segment missing source (should have been set via inheritance)",
|
|
864
|
+
severity: "error",
|
|
1072
865
|
});
|
|
1073
866
|
return { segment: null, errors };
|
|
1074
867
|
}
|
|
1075
|
-
const wikilink = parseWikilink(
|
|
868
|
+
const wikilink = parseWikilink(parsedSegment.source);
|
|
1076
869
|
if (!wikilink || wikilink.error) {
|
|
1077
870
|
const suggestion = wikilink?.correctedPath
|
|
1078
871
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
1079
872
|
: undefined;
|
|
1080
873
|
errors.push({
|
|
1081
874
|
file: lensPath,
|
|
1082
|
-
message: `Invalid wikilink in article source: ${
|
|
875
|
+
message: `Invalid wikilink in article source: ${parsedSegment.source}`,
|
|
1083
876
|
suggestion,
|
|
1084
|
-
severity:
|
|
877
|
+
severity: "error",
|
|
1085
878
|
});
|
|
1086
879
|
return { segment: null, errors };
|
|
1087
880
|
}
|
|
1088
881
|
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1089
882
|
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1090
883
|
if (!articlePath) {
|
|
1091
|
-
|
|
1092
|
-
const
|
|
1093
|
-
|
|
884
|
+
const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
|
|
885
|
+
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
886
|
+
"Check the file path in the wiki-link";
|
|
1094
887
|
errors.push({
|
|
1095
888
|
file: lensPath,
|
|
1096
889
|
message: `Referenced article file not found: ${articlePathResolved}`,
|
|
1097
890
|
suggestion,
|
|
1098
|
-
severity:
|
|
891
|
+
severity: "error",
|
|
1099
892
|
});
|
|
1100
893
|
return { segment: null, errors };
|
|
1101
894
|
}
|
|
1102
|
-
// Check tier violation (Lens
|
|
895
|
+
// Check tier violation (Lens -> Article)
|
|
1103
896
|
if (tierMap) {
|
|
1104
|
-
const parentTier = tierMap.get(lensPath) ??
|
|
1105
|
-
const childTier = tierMap.get(articlePath) ??
|
|
1106
|
-
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier,
|
|
897
|
+
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
898
|
+
const childTier = tierMap.get(articlePath) ?? "production";
|
|
899
|
+
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
|
|
1107
900
|
if (violation) {
|
|
1108
901
|
errors.push(violation);
|
|
1109
902
|
}
|
|
1110
|
-
if (childTier ===
|
|
903
|
+
if (childTier === "ignored") {
|
|
1111
904
|
return { segment: null, errors };
|
|
1112
905
|
}
|
|
1113
906
|
}
|
|
1114
|
-
// Check if the article path points back to an already-visited structural file
|
|
1115
|
-
// This would indicate a circular reference (e.g., a lens source pointing back to an LO)
|
|
1116
|
-
// Note: We only check, we don't add article paths to visitedPaths since
|
|
1117
|
-
// multiple segments can legitimately reference the same article
|
|
1118
907
|
if (visitedPaths.has(articlePath)) {
|
|
1119
908
|
errors.push({
|
|
1120
909
|
file: lensPath,
|
|
1121
910
|
message: `Circular reference detected: ${articlePath}`,
|
|
1122
|
-
severity:
|
|
911
|
+
severity: "error",
|
|
1123
912
|
});
|
|
1124
913
|
return { segment: null, errors };
|
|
1125
914
|
}
|
|
1126
915
|
const articleContent = files.get(articlePath);
|
|
916
|
+
// Extract article metadata from frontmatter
|
|
917
|
+
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
918
|
+
const fm = articleFrontmatter.frontmatter;
|
|
1127
919
|
// Extract the excerpt
|
|
1128
920
|
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
1129
921
|
if (excerptResult.error) {
|
|
@@ -1131,88 +923,93 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1131
923
|
return { segment: null, errors };
|
|
1132
924
|
}
|
|
1133
925
|
const segment = {
|
|
1134
|
-
type:
|
|
926
|
+
type: "article",
|
|
1135
927
|
content: excerptResult.content,
|
|
1136
928
|
};
|
|
929
|
+
// Populate metadata from article frontmatter
|
|
930
|
+
if (fm.title)
|
|
931
|
+
segment.title = fm.title;
|
|
932
|
+
if (fm.author) {
|
|
933
|
+
const raw = fm.author;
|
|
934
|
+
segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
|
|
935
|
+
}
|
|
936
|
+
if (fm.source_url)
|
|
937
|
+
segment.sourceUrl = fm.source_url;
|
|
938
|
+
if (fm.published)
|
|
939
|
+
segment.published = String(fm.published);
|
|
940
|
+
segment.sourcePath = articlePath;
|
|
1137
941
|
if (parsedSegment.optional) {
|
|
1138
942
|
segment.optional = true;
|
|
1139
943
|
}
|
|
1140
944
|
return { segment, errors };
|
|
1141
945
|
}
|
|
1142
|
-
case
|
|
1143
|
-
//
|
|
1144
|
-
if (!
|
|
946
|
+
case "video": {
|
|
947
|
+
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
948
|
+
if (!parsedSegment.source) {
|
|
1145
949
|
errors.push({
|
|
1146
950
|
file: lensPath,
|
|
1147
|
-
message:
|
|
1148
|
-
severity:
|
|
951
|
+
message: "Video segment missing source (should have been set via inheritance)",
|
|
952
|
+
severity: "error",
|
|
1149
953
|
});
|
|
1150
954
|
return { segment: null, errors };
|
|
1151
955
|
}
|
|
1152
|
-
const wikilink = parseWikilink(
|
|
956
|
+
const wikilink = parseWikilink(parsedSegment.source);
|
|
1153
957
|
if (!wikilink || wikilink.error) {
|
|
1154
958
|
const suggestion = wikilink?.correctedPath
|
|
1155
959
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
1156
960
|
: undefined;
|
|
1157
961
|
errors.push({
|
|
1158
962
|
file: lensPath,
|
|
1159
|
-
message: `Invalid wikilink in video source: ${
|
|
963
|
+
message: `Invalid wikilink in video source: ${parsedSegment.source}`,
|
|
1160
964
|
suggestion,
|
|
1161
|
-
severity:
|
|
965
|
+
severity: "error",
|
|
1162
966
|
});
|
|
1163
967
|
return { segment: null, errors };
|
|
1164
968
|
}
|
|
1165
969
|
const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1166
970
|
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
1167
971
|
if (!videoPath) {
|
|
1168
|
-
|
|
1169
|
-
const
|
|
1170
|
-
|
|
972
|
+
const similarFiles = findSimilarFiles(videoPathResolved, files, "video_transcripts");
|
|
973
|
+
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
974
|
+
"Check the file path in the wiki-link";
|
|
1171
975
|
errors.push({
|
|
1172
976
|
file: lensPath,
|
|
1173
977
|
message: `Referenced video transcript file not found: ${videoPathResolved}`,
|
|
1174
978
|
suggestion,
|
|
1175
|
-
severity:
|
|
979
|
+
severity: "error",
|
|
1176
980
|
});
|
|
1177
981
|
return { segment: null, errors };
|
|
1178
982
|
}
|
|
1179
|
-
// Check tier violation (Lens
|
|
983
|
+
// Check tier violation (Lens -> Video)
|
|
1180
984
|
if (tierMap) {
|
|
1181
|
-
const parentTier = tierMap.get(lensPath) ??
|
|
1182
|
-
const childTier = tierMap.get(videoPath) ??
|
|
1183
|
-
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier,
|
|
985
|
+
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
986
|
+
const childTier = tierMap.get(videoPath) ?? "production";
|
|
987
|
+
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, "video transcript");
|
|
1184
988
|
if (violation) {
|
|
1185
989
|
errors.push(violation);
|
|
1186
990
|
}
|
|
1187
|
-
if (childTier ===
|
|
991
|
+
if (childTier === "ignored") {
|
|
1188
992
|
return { segment: null, errors };
|
|
1189
993
|
}
|
|
1190
994
|
}
|
|
1191
|
-
// Check if the video path points back to an already-visited structural file
|
|
1192
|
-
// This would indicate a circular reference
|
|
1193
|
-
// Note: We only check, we don't add video paths to visitedPaths since
|
|
1194
|
-
// multiple segments can legitimately reference the same video
|
|
1195
995
|
if (visitedPaths.has(videoPath)) {
|
|
1196
996
|
errors.push({
|
|
1197
997
|
file: lensPath,
|
|
1198
998
|
message: `Circular reference detected: ${videoPath}`,
|
|
1199
|
-
severity:
|
|
999
|
+
severity: "error",
|
|
1200
1000
|
});
|
|
1201
1001
|
return { segment: null, errors };
|
|
1202
1002
|
}
|
|
1203
1003
|
const transcriptContent = files.get(videoPath);
|
|
1004
|
+
// Extract video metadata from frontmatter
|
|
1005
|
+
const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
|
|
1006
|
+
const vfm = videoFrontmatter.frontmatter;
|
|
1204
1007
|
// Look for corresponding .timestamps.json file
|
|
1205
|
-
|
|
1206
|
-
const timestampsPath = videoPath.replace(/\.md$/, '.timestamps.json');
|
|
1008
|
+
const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
|
|
1207
1009
|
let timestamps;
|
|
1208
|
-
// Debug: Log path resolution (uncomment to debug)
|
|
1209
|
-
// console.log('DEBUG videoPath:', videoPath);
|
|
1210
|
-
// console.log('DEBUG timestampsPath:', timestampsPath);
|
|
1211
|
-
// console.log('DEBUG files has timestamps:', files.has(timestampsPath));
|
|
1212
1010
|
if (files.has(timestampsPath)) {
|
|
1213
1011
|
try {
|
|
1214
1012
|
timestamps = JSON.parse(files.get(timestampsPath));
|
|
1215
|
-
// console.log('DEBUG loaded timestamps count:', timestamps.length);
|
|
1216
1013
|
}
|
|
1217
1014
|
catch {
|
|
1218
1015
|
// JSON parse error - will fall back to inline timestamps
|
|
@@ -1225,19 +1022,32 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1225
1022
|
return { segment: null, errors };
|
|
1226
1023
|
}
|
|
1227
1024
|
const segment = {
|
|
1228
|
-
type:
|
|
1025
|
+
type: "video",
|
|
1229
1026
|
from: excerptResult.from,
|
|
1230
1027
|
to: excerptResult.to,
|
|
1231
1028
|
transcript: excerptResult.transcript,
|
|
1232
1029
|
};
|
|
1030
|
+
// Populate metadata from video transcript frontmatter
|
|
1031
|
+
if (vfm.title)
|
|
1032
|
+
segment.title = vfm.title;
|
|
1033
|
+
if (vfm.channel)
|
|
1034
|
+
segment.channel = vfm.channel;
|
|
1035
|
+
if (vfm.url) {
|
|
1036
|
+
const extracted = extractVideoFromUrl(vfm.url);
|
|
1037
|
+
if (extracted) {
|
|
1038
|
+
segment.videoId = extracted.videoId;
|
|
1039
|
+
if (extracted.isShort)
|
|
1040
|
+
segment.isShort = true;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1233
1043
|
if (parsedSegment.optional) {
|
|
1234
1044
|
segment.optional = true;
|
|
1235
1045
|
}
|
|
1236
1046
|
return { segment, errors };
|
|
1237
1047
|
}
|
|
1238
|
-
case
|
|
1048
|
+
case "question": {
|
|
1239
1049
|
const segment = {
|
|
1240
|
-
type:
|
|
1050
|
+
type: "question",
|
|
1241
1051
|
content: parsedSegment.content,
|
|
1242
1052
|
};
|
|
1243
1053
|
if (parsedSegment.assessmentInstructions)
|
|
@@ -1254,9 +1064,9 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1254
1064
|
segment.feedback = true;
|
|
1255
1065
|
return { segment, errors };
|
|
1256
1066
|
}
|
|
1257
|
-
case
|
|
1067
|
+
case "roleplay": {
|
|
1258
1068
|
const segment = {
|
|
1259
|
-
type:
|
|
1069
|
+
type: "roleplay",
|
|
1260
1070
|
id: parsedSegment.id,
|
|
1261
1071
|
content: parsedSegment.content,
|
|
1262
1072
|
aiInstructions: parsedSegment.aiInstructions,
|
|
@@ -1265,6 +1075,8 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1265
1075
|
segment.openingMessage = parsedSegment.openingMessage;
|
|
1266
1076
|
if (parsedSegment.assessmentInstructions)
|
|
1267
1077
|
segment.assessmentInstructions = parsedSegment.assessmentInstructions;
|
|
1078
|
+
if (parsedSegment.userCustomizable)
|
|
1079
|
+
segment.userCustomizable = true;
|
|
1268
1080
|
if (parsedSegment.optional)
|
|
1269
1081
|
segment.optional = true;
|
|
1270
1082
|
if (parsedSegment.feedback)
|
|
@@ -1277,25 +1089,11 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1277
1089
|
}
|
|
1278
1090
|
/**
|
|
1279
1091
|
* Flatten a single Lens file into a FlattenedModule.
|
|
1280
|
-
*
|
|
1281
|
-
* This wraps a standalone Lens as a single-section module so it can be
|
|
1282
|
-
* rendered by the frontend using the existing Module.tsx component.
|
|
1283
|
-
*
|
|
1284
|
-
* The resulting module has:
|
|
1285
|
-
* - slug: 'lens/' + fileNameToSlug(lensPath)
|
|
1286
|
-
* - title: extracted from source metadata or lens header
|
|
1287
|
-
* - sections: exactly one section (lens-article, lens-video, or page)
|
|
1288
|
-
*
|
|
1289
|
-
* @param lensPath - Path to the lens file within the files Map
|
|
1290
|
-
* @param files - Map of all file paths to their content
|
|
1291
|
-
* @param tierMap - Optional tier map for filtering ignored content
|
|
1292
|
-
* @param preParsedLens - Optional pre-parsed lens to avoid re-parsing
|
|
1293
|
-
* @returns Flattened module with a single section, plus any errors
|
|
1294
1092
|
*/
|
|
1295
1093
|
export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
1296
1094
|
const errors = [];
|
|
1297
1095
|
// Skip ignored lenses
|
|
1298
|
-
if (tierMap?.get(lensPath) ===
|
|
1096
|
+
if (tierMap?.get(lensPath) === "ignored") {
|
|
1299
1097
|
return { module: null, modules: [], errors };
|
|
1300
1098
|
}
|
|
1301
1099
|
const lensContent = files.get(lensPath);
|
|
@@ -1303,109 +1101,67 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
1303
1101
|
errors.push({
|
|
1304
1102
|
file: lensPath,
|
|
1305
1103
|
message: `Lens file not found: ${lensPath}`,
|
|
1306
|
-
severity:
|
|
1104
|
+
severity: "error",
|
|
1307
1105
|
});
|
|
1308
1106
|
return { module: null, modules: [], errors };
|
|
1309
1107
|
}
|
|
1310
1108
|
// Use pre-parsed lens if provided, otherwise parse
|
|
1311
|
-
const lens = preParsedLens ??
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1109
|
+
const lens = preParsedLens ??
|
|
1110
|
+
(() => {
|
|
1111
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
1112
|
+
errors.push(...lensResult.errors);
|
|
1113
|
+
return lensResult.lens;
|
|
1114
|
+
})();
|
|
1316
1115
|
if (!lens) {
|
|
1317
1116
|
return { module: null, modules: [], errors };
|
|
1318
1117
|
}
|
|
1319
1118
|
const visitedPaths = new Set([lensPath]);
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1119
|
+
// Flatten lens segments using shared helper
|
|
1120
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
1121
|
+
errors.push(...flattenErrors);
|
|
1122
|
+
// Derive title: frontmatter > first article/video segment > filename
|
|
1322
1123
|
const meta = {};
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
if (articleWikilink && !articleWikilink.error) {
|
|
1332
|
-
const articlePathResolved = resolveWikilinkPath(articleWikilink.path, lensPath);
|
|
1333
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1334
|
-
if (articlePath) {
|
|
1335
|
-
const articleContent = files.get(articlePath);
|
|
1336
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
1337
|
-
if (articleFrontmatter.frontmatter.title)
|
|
1338
|
-
meta.title = articleFrontmatter.frontmatter.title;
|
|
1339
|
-
if (articleFrontmatter.frontmatter.author)
|
|
1340
|
-
meta.author = articleFrontmatter.frontmatter.author;
|
|
1341
|
-
if (articleFrontmatter.frontmatter.source_url)
|
|
1342
|
-
meta.sourceUrl = articleFrontmatter.frontmatter.source_url;
|
|
1343
|
-
if (articleFrontmatter.frontmatter.published)
|
|
1344
|
-
meta.published = String(articleFrontmatter.frontmatter.published);
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1124
|
+
if (lens.title) {
|
|
1125
|
+
meta.title = lens.title;
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
for (const seg of segments) {
|
|
1129
|
+
if (seg.type === "article" && seg.title) {
|
|
1130
|
+
meta.title = seg.title;
|
|
1131
|
+
break;
|
|
1347
1132
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
if (lensSection.title)
|
|
1352
|
-
meta.title = lensSection.title;
|
|
1353
|
-
}
|
|
1354
|
-
else if (lensSection.type === 'lens-video') {
|
|
1355
|
-
sectionType = 'lens-video';
|
|
1356
|
-
// Extract video metadata from the video transcript file's frontmatter
|
|
1357
|
-
if (lensSection.source) {
|
|
1358
|
-
const videoWikilink = parseWikilink(lensSection.source);
|
|
1359
|
-
if (videoWikilink && !videoWikilink.error) {
|
|
1360
|
-
const videoPathResolved = resolveWikilinkPath(videoWikilink.path, lensPath);
|
|
1361
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
1362
|
-
if (videoPath) {
|
|
1363
|
-
const videoContent = files.get(videoPath);
|
|
1364
|
-
const videoFrontmatter = parseFrontmatter(videoContent, videoPath);
|
|
1365
|
-
if (videoFrontmatter.frontmatter.title)
|
|
1366
|
-
meta.title = videoFrontmatter.frontmatter.title;
|
|
1367
|
-
if (videoFrontmatter.frontmatter.channel)
|
|
1368
|
-
meta.channel = videoFrontmatter.frontmatter.channel;
|
|
1369
|
-
if (videoFrontmatter.frontmatter.url) {
|
|
1370
|
-
const extractedId = extractVideoIdFromUrl(videoFrontmatter.frontmatter.url);
|
|
1371
|
-
if (extractedId)
|
|
1372
|
-
videoId = extractedId;
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1133
|
+
else if (seg.type === "video" && seg.title) {
|
|
1134
|
+
meta.title = seg.title;
|
|
1135
|
+
break;
|
|
1376
1136
|
}
|
|
1377
1137
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
if (segmentResult.segment)
|
|
1383
|
-
segments.push(segmentResult.segment);
|
|
1138
|
+
if (!meta.title) {
|
|
1139
|
+
meta.title = fileNameToSlug(lensPath)
|
|
1140
|
+
.replace(/-/g, " ")
|
|
1141
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1384
1142
|
}
|
|
1385
|
-
// Apply collapsed content to article-excerpt segments
|
|
1386
|
-
applyCollapsedContent(segments, lensSection.segments, lensSection, lensPath, files);
|
|
1387
|
-
}
|
|
1388
|
-
// Fallback title from filename if not extracted from source metadata
|
|
1389
|
-
if (!meta.title) {
|
|
1390
|
-
meta.title = fileNameToSlug(lensPath).replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
1391
1143
|
}
|
|
1392
1144
|
const section = {
|
|
1393
|
-
type:
|
|
1145
|
+
type: "lens",
|
|
1394
1146
|
meta,
|
|
1395
1147
|
segments,
|
|
1148
|
+
sourcePath: lensPath,
|
|
1396
1149
|
optional: false,
|
|
1397
1150
|
learningOutcomeId: null,
|
|
1398
1151
|
learningOutcomeName: null,
|
|
1399
1152
|
contentId: lens.id ?? null,
|
|
1400
1153
|
tldr: lens.tldr,
|
|
1401
|
-
|
|
1154
|
+
summaryForTutor: lens.summaryForTutor,
|
|
1155
|
+
addToAiContext: lens.addToAiContext,
|
|
1402
1156
|
...computeSectionStats(segments),
|
|
1157
|
+
displayType: computeDisplayType(segments),
|
|
1403
1158
|
};
|
|
1404
1159
|
const flattenedModule = {
|
|
1405
|
-
slug:
|
|
1160
|
+
slug: "lens/" + fileNameToSlug(lensPath),
|
|
1406
1161
|
title: meta.title ?? fileNameToSlug(lensPath),
|
|
1407
1162
|
contentId: lens.id ?? null,
|
|
1408
1163
|
sections: [section],
|
|
1164
|
+
sourcePath: lensPath,
|
|
1409
1165
|
};
|
|
1410
1166
|
return { module: flattenedModule, modules: [flattenedModule], errors };
|
|
1411
1167
|
}
|