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/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,269 @@ 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
|
+
// optional:: / hide:: only count as lens-level when they appear BEFORE the
|
|
634
|
+
// first #### segment header. Otherwise a #### Chat (or any segment) with
|
|
635
|
+
// optional:: true would leak its field up to section.fields and wrongly
|
|
636
|
+
// mark the whole inline lens optional. (Same discipline as source:: below.)
|
|
637
|
+
const effectiveOptional = hasFieldBeforeSegmentHeaders(section.body, "optional", section.level) &&
|
|
638
|
+
section.fields.optional?.toLowerCase() === "true";
|
|
639
|
+
const effectiveHide = hasFieldBeforeSegmentHeaders(section.body, "hide", section.level) &&
|
|
640
|
+
section.fields.hide?.toLowerCase() === "true";
|
|
641
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
642
|
+
if (hideConflict)
|
|
643
|
+
errors.push(hideConflict);
|
|
644
|
+
const resultSection = {
|
|
645
|
+
type: "lens",
|
|
646
|
+
meta: { title: inlineLens.title || section.title },
|
|
647
|
+
segments,
|
|
648
|
+
sourcePath: modulePath,
|
|
649
|
+
optional: effectiveOptional,
|
|
650
|
+
...(effectiveHide && { hide: true }),
|
|
651
|
+
learningOutcomeId: null,
|
|
652
|
+
learningOutcomeName: null,
|
|
653
|
+
contentId: inlineLens.id ?? null,
|
|
654
|
+
tldr: inlineLens.tldr,
|
|
655
|
+
summaryForTutor: inlineLens.summaryForTutor,
|
|
656
|
+
addToAiContext: inlineLens.addToAiContext,
|
|
657
|
+
...computeSectionStats(segments),
|
|
658
|
+
displayType: computeDisplayType(segments),
|
|
659
|
+
};
|
|
660
|
+
sections.push(resultSection);
|
|
661
|
+
return { sections, errors };
|
|
662
|
+
}
|
|
663
|
+
// Referenced lens: has source:: at section level (not from segment fields)
|
|
664
|
+
const hasSectionSource = hasFieldBeforeSegmentHeaders(section.body, "source", section.level);
|
|
665
|
+
const source = hasSectionSource ? section.fields.source : undefined;
|
|
666
|
+
if (!source) {
|
|
712
667
|
errors.push({
|
|
713
668
|
file: modulePath,
|
|
714
669
|
line: section.line,
|
|
715
|
-
message:
|
|
716
|
-
suggestion: "Add '
|
|
717
|
-
severity:
|
|
670
|
+
message: "Lens section missing source:: field (or id:: for inline lens)",
|
|
671
|
+
suggestion: "Add 'source:: [[../Lenses/filename.md|Display]]' or 'id:: <uuid>' with #### segments",
|
|
672
|
+
severity: "error",
|
|
718
673
|
});
|
|
719
674
|
return { sections: [], errors };
|
|
720
675
|
}
|
|
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;
|
|
676
|
+
// Parse and resolve the wikilink
|
|
677
|
+
const wikilink = parseWikilink(source);
|
|
678
|
+
if (!wikilink || wikilink.error) {
|
|
679
|
+
const suggestion = wikilink?.correctedPath
|
|
680
|
+
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
681
|
+
: "Use format [[../Lenses/filename.md|Display Text]]";
|
|
682
|
+
errors.push({
|
|
683
|
+
file: modulePath,
|
|
684
|
+
line: section.line,
|
|
685
|
+
message: wikilink?.error
|
|
686
|
+
? `${wikilink.error}: ${source}`
|
|
687
|
+
: `Invalid wikilink format: ${source}`,
|
|
688
|
+
suggestion,
|
|
689
|
+
severity: "error",
|
|
690
|
+
});
|
|
691
|
+
return { sections: [], errors };
|
|
692
|
+
}
|
|
693
|
+
const lensPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
|
|
694
|
+
const lensPath = findFileWithExtension(lensPathResolved, files);
|
|
695
|
+
if (!lensPath) {
|
|
696
|
+
const similarFiles = findSimilarFiles(lensPathResolved, files, "Lenses");
|
|
697
|
+
const suggestion = formatSuggestion(similarFiles, modulePath) ??
|
|
698
|
+
"Check the file path in the wiki-link";
|
|
699
|
+
errors.push({
|
|
700
|
+
file: modulePath,
|
|
701
|
+
message: `Referenced lens file not found: ${lensPathResolved}`,
|
|
702
|
+
suggestion,
|
|
703
|
+
severity: "error",
|
|
704
|
+
});
|
|
705
|
+
return { sections: [], errors };
|
|
706
|
+
}
|
|
707
|
+
// Check tier violation
|
|
708
|
+
if (tierMap) {
|
|
709
|
+
const parentTier = tierMap.get(modulePath) ?? "production";
|
|
710
|
+
const childTier = tierMap.get(lensPath) ?? "production";
|
|
711
|
+
const violation = checkTierViolation(modulePath, parentTier, lensPath, childTier, "lens");
|
|
712
|
+
if (violation) {
|
|
713
|
+
errors.push(violation);
|
|
764
714
|
}
|
|
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);
|
|
715
|
+
if (childTier === "ignored") {
|
|
716
|
+
return { sections: [], errors };
|
|
849
717
|
}
|
|
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
718
|
}
|
|
719
|
+
// Check for circular reference
|
|
720
|
+
if (visitedPaths.has(lensPath)) {
|
|
721
|
+
errors.push({
|
|
722
|
+
file: modulePath,
|
|
723
|
+
message: `Circular reference detected: ${lensPath}`,
|
|
724
|
+
severity: "error",
|
|
725
|
+
});
|
|
726
|
+
return { sections: [], errors };
|
|
727
|
+
}
|
|
728
|
+
visitedPaths.add(lensPath);
|
|
729
|
+
const lensContent = files.get(lensPath);
|
|
730
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
731
|
+
errors.push(...lensResult.errors);
|
|
732
|
+
if (!lensResult.lens) {
|
|
733
|
+
return { sections: [], errors };
|
|
734
|
+
}
|
|
735
|
+
const lens = lensResult.lens;
|
|
736
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
737
|
+
errors.push(...flattenErrors);
|
|
738
|
+
const effectiveOptional = section.fields.optional?.toLowerCase() === "true";
|
|
739
|
+
const effectiveHide = section.fields.hide?.toLowerCase() === "true";
|
|
740
|
+
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
741
|
+
if (hideConflict)
|
|
742
|
+
errors.push(hideConflict);
|
|
743
|
+
const resultSection = {
|
|
744
|
+
type: "lens",
|
|
745
|
+
meta: { title: lens.title || section.title },
|
|
746
|
+
segments,
|
|
747
|
+
sourcePath: lensPath,
|
|
748
|
+
optional: effectiveOptional,
|
|
749
|
+
...(effectiveHide && { hide: true }),
|
|
750
|
+
learningOutcomeId: null,
|
|
751
|
+
learningOutcomeName: null,
|
|
752
|
+
contentId: lens.id ?? null,
|
|
753
|
+
tldr: lens.tldr,
|
|
754
|
+
summaryForTutor: lens.summaryForTutor,
|
|
755
|
+
addToAiContext: lens.addToAiContext,
|
|
756
|
+
...computeSectionStats(segments),
|
|
757
|
+
displayType: computeDisplayType(segments),
|
|
758
|
+
};
|
|
759
|
+
sections.push(resultSection);
|
|
865
760
|
return { sections, errors };
|
|
866
761
|
}
|
|
762
|
+
// ─── Collapsed content and segment conversion ───
|
|
867
763
|
/**
|
|
868
|
-
*
|
|
869
|
-
*
|
|
764
|
+
* After converting segments, apply collapsed_before/collapsed_after to article segments.
|
|
765
|
+
* Groups article segments by their resolved source path, then calls bundleArticleWithCollapsed
|
|
766
|
+
* for each group.
|
|
870
767
|
*/
|
|
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 = [];
|
|
768
|
+
function applyCollapsedContent(segments, parsedSegments, lensPath, files) {
|
|
769
|
+
// Build groups of article segments by resolved source path
|
|
770
|
+
const sourceGroups = new Map();
|
|
771
|
+
// Map output article segments back to parsed segments for source info
|
|
772
|
+
let articleOutputIdx = 0;
|
|
773
|
+
for (let i = 0; i < parsedSegments.length; i++) {
|
|
774
|
+
const ps = parsedSegments[i];
|
|
775
|
+
if (ps.type !== "article")
|
|
908
776
|
continue;
|
|
777
|
+
// Find corresponding output segment
|
|
778
|
+
while (articleOutputIdx < segments.length &&
|
|
779
|
+
segments[articleOutputIdx].type !== "article") {
|
|
780
|
+
articleOutputIdx++;
|
|
909
781
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
782
|
+
if (articleOutputIdx >= segments.length)
|
|
783
|
+
break;
|
|
784
|
+
if (ps.source) {
|
|
785
|
+
const wikilink = parseWikilink(ps.source);
|
|
786
|
+
if (wikilink && !wikilink.error) {
|
|
787
|
+
const resolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
788
|
+
const actualPath = findFileWithExtension(resolved, files);
|
|
789
|
+
if (actualPath) {
|
|
790
|
+
if (!sourceGroups.has(actualPath)) {
|
|
791
|
+
sourceGroups.set(actualPath, []);
|
|
792
|
+
}
|
|
793
|
+
sourceGroups.get(actualPath).push({
|
|
794
|
+
segmentIndex: articleOutputIdx,
|
|
795
|
+
fromAnchor: ps.fromAnchor,
|
|
796
|
+
toAnchor: ps.toAnchor,
|
|
924
797
|
});
|
|
925
798
|
}
|
|
926
799
|
}
|
|
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
800
|
}
|
|
801
|
+
articleOutputIdx++;
|
|
957
802
|
}
|
|
958
|
-
//
|
|
959
|
-
|
|
960
|
-
if
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
803
|
+
// For each source group, apply collapsed content
|
|
804
|
+
for (const [articlePath, excerptInfos] of sourceGroups) {
|
|
805
|
+
// Only apply collapsed if at least one excerpt has anchors
|
|
806
|
+
const hasAnchors = excerptInfos.some((e) => e.fromAnchor || e.toAnchor);
|
|
807
|
+
if (!hasAnchors)
|
|
808
|
+
continue;
|
|
809
|
+
const articleContent = files.get(articlePath);
|
|
810
|
+
if (!articleContent)
|
|
811
|
+
continue;
|
|
812
|
+
const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map((e) => ({ from: e.fromAnchor, to: e.toAnchor })), articlePath);
|
|
813
|
+
for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
|
|
814
|
+
const segIdx = excerptInfos[i].segmentIndex;
|
|
815
|
+
const collapsed = collapsedResults[i];
|
|
816
|
+
const segment = segments[segIdx];
|
|
817
|
+
if (collapsed.collapsed_before) {
|
|
818
|
+
segment.collapsed_before = collapsed.collapsed_before;
|
|
819
|
+
}
|
|
820
|
+
if (collapsed.collapsed_after) {
|
|
821
|
+
segment.collapsed_after = collapsed.collapsed_after;
|
|
972
822
|
}
|
|
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
823
|
}
|
|
1028
824
|
}
|
|
1029
825
|
}
|
|
1030
826
|
/**
|
|
1031
827
|
* Convert a parsed lens segment into a final flattened segment.
|
|
1032
|
-
* For article
|
|
828
|
+
* For article and video, this involves extracting content from source files.
|
|
829
|
+
* Source comes from the parsed segment itself (set via inheritance in parseLens).
|
|
1033
830
|
*/
|
|
1034
|
-
function convertSegment(parsedSegment,
|
|
831
|
+
function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
1035
832
|
const errors = [];
|
|
1036
833
|
switch (parsedSegment.type) {
|
|
1037
|
-
case
|
|
834
|
+
case "text": {
|
|
835
|
+
const resolved = resolveTextLinks(parsedSegment.content, lensPath, files);
|
|
836
|
+
errors.push(...resolved.errors);
|
|
1038
837
|
const segment = {
|
|
1039
|
-
type:
|
|
1040
|
-
content:
|
|
838
|
+
type: "text",
|
|
839
|
+
content: resolved.content,
|
|
1041
840
|
};
|
|
1042
841
|
if (parsedSegment.optional) {
|
|
1043
842
|
segment.optional = true;
|
|
1044
843
|
}
|
|
1045
844
|
return { segment, errors };
|
|
1046
845
|
}
|
|
1047
|
-
case
|
|
846
|
+
case "chat": {
|
|
1048
847
|
const segment = {
|
|
1049
|
-
type:
|
|
848
|
+
type: "chat",
|
|
1050
849
|
};
|
|
1051
850
|
if (parsedSegment.instructions) {
|
|
1052
851
|
segment.instructions = parsedSegment.instructions;
|
|
@@ -1057,73 +856,72 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1057
856
|
if (parsedSegment.hidePreviousContentFromTutor) {
|
|
1058
857
|
segment.hidePreviousContentFromTutor = true;
|
|
1059
858
|
}
|
|
1060
|
-
if (parsedSegment.
|
|
1061
|
-
segment.
|
|
859
|
+
if (parsedSegment.minMessages !== undefined) {
|
|
860
|
+
segment.minMessages = parsedSegment.minMessages;
|
|
1062
861
|
}
|
|
1063
862
|
return { segment, errors };
|
|
1064
863
|
}
|
|
1065
|
-
case
|
|
1066
|
-
//
|
|
1067
|
-
if (!
|
|
864
|
+
case "article": {
|
|
865
|
+
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
866
|
+
if (!parsedSegment.source) {
|
|
1068
867
|
errors.push({
|
|
1069
868
|
file: lensPath,
|
|
1070
|
-
message:
|
|
1071
|
-
severity:
|
|
869
|
+
message: "Article segment missing source (should have been set via inheritance)",
|
|
870
|
+
severity: "error",
|
|
1072
871
|
});
|
|
1073
872
|
return { segment: null, errors };
|
|
1074
873
|
}
|
|
1075
|
-
const wikilink = parseWikilink(
|
|
874
|
+
const wikilink = parseWikilink(parsedSegment.source);
|
|
1076
875
|
if (!wikilink || wikilink.error) {
|
|
1077
876
|
const suggestion = wikilink?.correctedPath
|
|
1078
877
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
1079
878
|
: undefined;
|
|
1080
879
|
errors.push({
|
|
1081
880
|
file: lensPath,
|
|
1082
|
-
message: `Invalid wikilink in article source: ${
|
|
881
|
+
message: `Invalid wikilink in article source: ${parsedSegment.source}`,
|
|
1083
882
|
suggestion,
|
|
1084
|
-
severity:
|
|
883
|
+
severity: "error",
|
|
1085
884
|
});
|
|
1086
885
|
return { segment: null, errors };
|
|
1087
886
|
}
|
|
1088
887
|
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1089
888
|
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1090
889
|
if (!articlePath) {
|
|
1091
|
-
|
|
1092
|
-
const
|
|
1093
|
-
|
|
890
|
+
const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
|
|
891
|
+
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
892
|
+
"Check the file path in the wiki-link";
|
|
1094
893
|
errors.push({
|
|
1095
894
|
file: lensPath,
|
|
1096
895
|
message: `Referenced article file not found: ${articlePathResolved}`,
|
|
1097
896
|
suggestion,
|
|
1098
|
-
severity:
|
|
897
|
+
severity: "error",
|
|
1099
898
|
});
|
|
1100
899
|
return { segment: null, errors };
|
|
1101
900
|
}
|
|
1102
|
-
// Check tier violation (Lens
|
|
901
|
+
// Check tier violation (Lens -> Article)
|
|
1103
902
|
if (tierMap) {
|
|
1104
|
-
const parentTier = tierMap.get(lensPath) ??
|
|
1105
|
-
const childTier = tierMap.get(articlePath) ??
|
|
1106
|
-
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier,
|
|
903
|
+
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
904
|
+
const childTier = tierMap.get(articlePath) ?? "production";
|
|
905
|
+
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
|
|
1107
906
|
if (violation) {
|
|
1108
907
|
errors.push(violation);
|
|
1109
908
|
}
|
|
1110
|
-
if (childTier ===
|
|
909
|
+
if (childTier === "ignored") {
|
|
1111
910
|
return { segment: null, errors };
|
|
1112
911
|
}
|
|
1113
912
|
}
|
|
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
913
|
if (visitedPaths.has(articlePath)) {
|
|
1119
914
|
errors.push({
|
|
1120
915
|
file: lensPath,
|
|
1121
916
|
message: `Circular reference detected: ${articlePath}`,
|
|
1122
|
-
severity:
|
|
917
|
+
severity: "error",
|
|
1123
918
|
});
|
|
1124
919
|
return { segment: null, errors };
|
|
1125
920
|
}
|
|
1126
921
|
const articleContent = files.get(articlePath);
|
|
922
|
+
// Extract article metadata from frontmatter
|
|
923
|
+
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
924
|
+
const fm = articleFrontmatter.frontmatter;
|
|
1127
925
|
// Extract the excerpt
|
|
1128
926
|
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
1129
927
|
if (excerptResult.error) {
|
|
@@ -1131,88 +929,93 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1131
929
|
return { segment: null, errors };
|
|
1132
930
|
}
|
|
1133
931
|
const segment = {
|
|
1134
|
-
type:
|
|
932
|
+
type: "article",
|
|
1135
933
|
content: excerptResult.content,
|
|
1136
934
|
};
|
|
935
|
+
// Populate metadata from article frontmatter
|
|
936
|
+
if (fm.title)
|
|
937
|
+
segment.title = fm.title;
|
|
938
|
+
if (fm.author) {
|
|
939
|
+
const raw = fm.author;
|
|
940
|
+
segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
|
|
941
|
+
}
|
|
942
|
+
if (fm.source_url)
|
|
943
|
+
segment.sourceUrl = fm.source_url;
|
|
944
|
+
if (fm.published)
|
|
945
|
+
segment.published = String(fm.published);
|
|
946
|
+
segment.sourcePath = articlePath;
|
|
1137
947
|
if (parsedSegment.optional) {
|
|
1138
948
|
segment.optional = true;
|
|
1139
949
|
}
|
|
1140
950
|
return { segment, errors };
|
|
1141
951
|
}
|
|
1142
|
-
case
|
|
1143
|
-
//
|
|
1144
|
-
if (!
|
|
952
|
+
case "video": {
|
|
953
|
+
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
954
|
+
if (!parsedSegment.source) {
|
|
1145
955
|
errors.push({
|
|
1146
956
|
file: lensPath,
|
|
1147
|
-
message:
|
|
1148
|
-
severity:
|
|
957
|
+
message: "Video segment missing source (should have been set via inheritance)",
|
|
958
|
+
severity: "error",
|
|
1149
959
|
});
|
|
1150
960
|
return { segment: null, errors };
|
|
1151
961
|
}
|
|
1152
|
-
const wikilink = parseWikilink(
|
|
962
|
+
const wikilink = parseWikilink(parsedSegment.source);
|
|
1153
963
|
if (!wikilink || wikilink.error) {
|
|
1154
964
|
const suggestion = wikilink?.correctedPath
|
|
1155
965
|
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
1156
966
|
: undefined;
|
|
1157
967
|
errors.push({
|
|
1158
968
|
file: lensPath,
|
|
1159
|
-
message: `Invalid wikilink in video source: ${
|
|
969
|
+
message: `Invalid wikilink in video source: ${parsedSegment.source}`,
|
|
1160
970
|
suggestion,
|
|
1161
|
-
severity:
|
|
971
|
+
severity: "error",
|
|
1162
972
|
});
|
|
1163
973
|
return { segment: null, errors };
|
|
1164
974
|
}
|
|
1165
975
|
const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1166
976
|
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
1167
977
|
if (!videoPath) {
|
|
1168
|
-
|
|
1169
|
-
const
|
|
1170
|
-
|
|
978
|
+
const similarFiles = findSimilarFiles(videoPathResolved, files, "video_transcripts");
|
|
979
|
+
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
980
|
+
"Check the file path in the wiki-link";
|
|
1171
981
|
errors.push({
|
|
1172
982
|
file: lensPath,
|
|
1173
983
|
message: `Referenced video transcript file not found: ${videoPathResolved}`,
|
|
1174
984
|
suggestion,
|
|
1175
|
-
severity:
|
|
985
|
+
severity: "error",
|
|
1176
986
|
});
|
|
1177
987
|
return { segment: null, errors };
|
|
1178
988
|
}
|
|
1179
|
-
// Check tier violation (Lens
|
|
989
|
+
// Check tier violation (Lens -> Video)
|
|
1180
990
|
if (tierMap) {
|
|
1181
|
-
const parentTier = tierMap.get(lensPath) ??
|
|
1182
|
-
const childTier = tierMap.get(videoPath) ??
|
|
1183
|
-
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier,
|
|
991
|
+
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
992
|
+
const childTier = tierMap.get(videoPath) ?? "production";
|
|
993
|
+
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, "video transcript");
|
|
1184
994
|
if (violation) {
|
|
1185
995
|
errors.push(violation);
|
|
1186
996
|
}
|
|
1187
|
-
if (childTier ===
|
|
997
|
+
if (childTier === "ignored") {
|
|
1188
998
|
return { segment: null, errors };
|
|
1189
999
|
}
|
|
1190
1000
|
}
|
|
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
1001
|
if (visitedPaths.has(videoPath)) {
|
|
1196
1002
|
errors.push({
|
|
1197
1003
|
file: lensPath,
|
|
1198
1004
|
message: `Circular reference detected: ${videoPath}`,
|
|
1199
|
-
severity:
|
|
1005
|
+
severity: "error",
|
|
1200
1006
|
});
|
|
1201
1007
|
return { segment: null, errors };
|
|
1202
1008
|
}
|
|
1203
1009
|
const transcriptContent = files.get(videoPath);
|
|
1010
|
+
// Extract video metadata from frontmatter
|
|
1011
|
+
const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
|
|
1012
|
+
const vfm = videoFrontmatter.frontmatter;
|
|
1204
1013
|
// Look for corresponding .timestamps.json file
|
|
1205
|
-
|
|
1206
|
-
const timestampsPath = videoPath.replace(/\.md$/, '.timestamps.json');
|
|
1014
|
+
const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
|
|
1207
1015
|
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
1016
|
if (files.has(timestampsPath)) {
|
|
1213
1017
|
try {
|
|
1214
1018
|
timestamps = JSON.parse(files.get(timestampsPath));
|
|
1215
|
-
// console.log('DEBUG loaded timestamps count:', timestamps.length);
|
|
1216
1019
|
}
|
|
1217
1020
|
catch {
|
|
1218
1021
|
// JSON parse error - will fall back to inline timestamps
|
|
@@ -1225,19 +1028,32 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1225
1028
|
return { segment: null, errors };
|
|
1226
1029
|
}
|
|
1227
1030
|
const segment = {
|
|
1228
|
-
type:
|
|
1031
|
+
type: "video",
|
|
1229
1032
|
from: excerptResult.from,
|
|
1230
1033
|
to: excerptResult.to,
|
|
1231
1034
|
transcript: excerptResult.transcript,
|
|
1232
1035
|
};
|
|
1036
|
+
// Populate metadata from video transcript frontmatter
|
|
1037
|
+
if (vfm.title)
|
|
1038
|
+
segment.title = vfm.title;
|
|
1039
|
+
if (vfm.channel)
|
|
1040
|
+
segment.channel = vfm.channel;
|
|
1041
|
+
if (vfm.url) {
|
|
1042
|
+
const extracted = extractVideoFromUrl(vfm.url);
|
|
1043
|
+
if (extracted) {
|
|
1044
|
+
segment.videoId = extracted.videoId;
|
|
1045
|
+
if (extracted.isShort)
|
|
1046
|
+
segment.isShort = true;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1233
1049
|
if (parsedSegment.optional) {
|
|
1234
1050
|
segment.optional = true;
|
|
1235
1051
|
}
|
|
1236
1052
|
return { segment, errors };
|
|
1237
1053
|
}
|
|
1238
|
-
case
|
|
1054
|
+
case "question": {
|
|
1239
1055
|
const segment = {
|
|
1240
|
-
type:
|
|
1056
|
+
type: "question",
|
|
1241
1057
|
content: parsedSegment.content,
|
|
1242
1058
|
};
|
|
1243
1059
|
if (parsedSegment.assessmentInstructions)
|
|
@@ -1248,15 +1064,15 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1248
1064
|
segment.maxChars = parsedSegment.maxChars;
|
|
1249
1065
|
if (parsedSegment.enforceVoice)
|
|
1250
1066
|
segment.enforceVoice = true;
|
|
1251
|
-
if (parsedSegment.optional)
|
|
1252
|
-
segment.optional = true;
|
|
1253
1067
|
if (parsedSegment.feedback)
|
|
1254
1068
|
segment.feedback = true;
|
|
1069
|
+
if (parsedSegment.minMessages !== undefined)
|
|
1070
|
+
segment.minMessages = parsedSegment.minMessages;
|
|
1255
1071
|
return { segment, errors };
|
|
1256
1072
|
}
|
|
1257
|
-
case
|
|
1073
|
+
case "roleplay": {
|
|
1258
1074
|
const segment = {
|
|
1259
|
-
type:
|
|
1075
|
+
type: "roleplay",
|
|
1260
1076
|
id: parsedSegment.id,
|
|
1261
1077
|
content: parsedSegment.content,
|
|
1262
1078
|
aiInstructions: parsedSegment.aiInstructions,
|
|
@@ -1265,6 +1081,8 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1265
1081
|
segment.openingMessage = parsedSegment.openingMessage;
|
|
1266
1082
|
if (parsedSegment.assessmentInstructions)
|
|
1267
1083
|
segment.assessmentInstructions = parsedSegment.assessmentInstructions;
|
|
1084
|
+
if (parsedSegment.userCustomizable)
|
|
1085
|
+
segment.userCustomizable = true;
|
|
1268
1086
|
if (parsedSegment.optional)
|
|
1269
1087
|
segment.optional = true;
|
|
1270
1088
|
if (parsedSegment.feedback)
|
|
@@ -1277,25 +1095,11 @@ function convertSegment(parsedSegment, lensSection, lensPath, files, visitedPath
|
|
|
1277
1095
|
}
|
|
1278
1096
|
/**
|
|
1279
1097
|
* 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
1098
|
*/
|
|
1295
1099
|
export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
1296
1100
|
const errors = [];
|
|
1297
1101
|
// Skip ignored lenses
|
|
1298
|
-
if (tierMap?.get(lensPath) ===
|
|
1102
|
+
if (tierMap?.get(lensPath) === "ignored") {
|
|
1299
1103
|
return { module: null, modules: [], errors };
|
|
1300
1104
|
}
|
|
1301
1105
|
const lensContent = files.get(lensPath);
|
|
@@ -1303,109 +1107,67 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
1303
1107
|
errors.push({
|
|
1304
1108
|
file: lensPath,
|
|
1305
1109
|
message: `Lens file not found: ${lensPath}`,
|
|
1306
|
-
severity:
|
|
1110
|
+
severity: "error",
|
|
1307
1111
|
});
|
|
1308
1112
|
return { module: null, modules: [], errors };
|
|
1309
1113
|
}
|
|
1310
1114
|
// Use pre-parsed lens if provided, otherwise parse
|
|
1311
|
-
const lens = preParsedLens ??
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1115
|
+
const lens = preParsedLens ??
|
|
1116
|
+
(() => {
|
|
1117
|
+
const lensResult = parseLens(lensContent, lensPath);
|
|
1118
|
+
errors.push(...lensResult.errors);
|
|
1119
|
+
return lensResult.lens;
|
|
1120
|
+
})();
|
|
1316
1121
|
if (!lens) {
|
|
1317
1122
|
return { module: null, modules: [], errors };
|
|
1318
1123
|
}
|
|
1319
1124
|
const visitedPaths = new Set([lensPath]);
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1125
|
+
// Flatten lens segments using shared helper
|
|
1126
|
+
const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
|
|
1127
|
+
errors.push(...flattenErrors);
|
|
1128
|
+
// Derive title: frontmatter > first article/video segment > filename
|
|
1322
1129
|
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
|
-
}
|
|
1130
|
+
if (lens.title) {
|
|
1131
|
+
meta.title = lens.title;
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
for (const seg of segments) {
|
|
1135
|
+
if (seg.type === "article" && seg.title) {
|
|
1136
|
+
meta.title = seg.title;
|
|
1137
|
+
break;
|
|
1347
1138
|
}
|
|
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
|
-
}
|
|
1139
|
+
else if (seg.type === "video" && seg.title) {
|
|
1140
|
+
meta.title = seg.title;
|
|
1141
|
+
break;
|
|
1376
1142
|
}
|
|
1377
1143
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
if (segmentResult.segment)
|
|
1383
|
-
segments.push(segmentResult.segment);
|
|
1144
|
+
if (!meta.title) {
|
|
1145
|
+
meta.title = fileNameToSlug(lensPath)
|
|
1146
|
+
.replace(/-/g, " ")
|
|
1147
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1384
1148
|
}
|
|
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
1149
|
}
|
|
1392
1150
|
const section = {
|
|
1393
|
-
type:
|
|
1151
|
+
type: "lens",
|
|
1394
1152
|
meta,
|
|
1395
1153
|
segments,
|
|
1154
|
+
sourcePath: lensPath,
|
|
1396
1155
|
optional: false,
|
|
1397
1156
|
learningOutcomeId: null,
|
|
1398
1157
|
learningOutcomeName: null,
|
|
1399
1158
|
contentId: lens.id ?? null,
|
|
1400
1159
|
tldr: lens.tldr,
|
|
1401
|
-
|
|
1160
|
+
summaryForTutor: lens.summaryForTutor,
|
|
1161
|
+
addToAiContext: lens.addToAiContext,
|
|
1402
1162
|
...computeSectionStats(segments),
|
|
1163
|
+
displayType: computeDisplayType(segments),
|
|
1403
1164
|
};
|
|
1404
1165
|
const flattenedModule = {
|
|
1405
|
-
slug:
|
|
1166
|
+
slug: "lens/" + fileNameToSlug(lensPath),
|
|
1406
1167
|
title: meta.title ?? fileNameToSlug(lensPath),
|
|
1407
1168
|
contentId: lens.id ?? null,
|
|
1408
1169
|
sections: [section],
|
|
1170
|
+
sourcePath: lensPath,
|
|
1409
1171
|
};
|
|
1410
1172
|
return { module: flattenedModule, modules: [flattenedModule], errors };
|
|
1411
1173
|
}
|