lens-content-processor 0.33.0 → 0.35.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/authoring-markup.d.ts +26 -1
- package/dist/authoring-markup.js +67 -16
- package/dist/authoring-markup.js.map +1 -1
- package/dist/bundler/article.d.ts +69 -9
- package/dist/bundler/article.js +159 -58
- package/dist/bundler/article.js.map +1 -1
- package/dist/content-schema.d.ts +3 -1
- package/dist/content-schema.js +51 -12
- package/dist/content-schema.js.map +1 -1
- package/dist/dedupe-errors.js +1 -7
- package/dist/dedupe-errors.js.map +1 -1
- package/dist/flattener/index.d.ts +23 -0
- package/dist/flattener/index.js +379 -262
- package/dist/flattener/index.js.map +1 -1
- package/dist/index.d.ts +29 -2
- package/dist/index.js +84 -15
- package/dist/index.js.map +1 -1
- package/dist/parser/article.js +13 -5
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.js +19 -6
- package/dist/parser/course.js.map +1 -1
- package/dist/parser/learning-outcome.d.ts +0 -1
- package/dist/parser/learning-outcome.js +55 -18
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +6 -1
- package/dist/parser/lens.js +47 -41
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.js +9 -5
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/response-segments.d.ts +54 -0
- package/dist/parser/response-segments.js +499 -0
- package/dist/parser/response-segments.js.map +1 -0
- package/dist/parser/survey.js +56 -15
- package/dist/parser/survey.js.map +1 -1
- package/dist/validator/article-structure.js +4 -18
- package/dist/validator/article-structure.js.map +1 -1
- package/dist/validator/directives.js +20 -42
- package/dist/validator/directives.js.map +1 -1
- package/dist/validator/emphasis.js +34 -55
- package/dist/validator/emphasis.js.map +1 -1
- package/dist/validator/html-tags.js +111 -103
- package/dist/validator/html-tags.js.map +1 -1
- package/dist/validator/markdown-parse.d.ts +3 -0
- package/dist/validator/markdown-parse.js +22 -0
- package/dist/validator/markdown-parse.js.map +1 -0
- package/dist/validator/output-integrity.d.ts +1 -1
- package/dist/validator/output-integrity.js +17 -23
- package/dist/validator/output-integrity.js.map +1 -1
- package/dist/validator/segment-fields.d.ts +4 -2
- package/dist/validator/segment-fields.js +27 -5
- package/dist/validator/segment-fields.js.map +1 -1
- package/dist/validator/test-segments.d.ts +11 -0
- package/dist/validator/test-segments.js +45 -0
- package/dist/validator/test-segments.js.map +1 -1
- package/dist/validator/uuid.d.ts +1 -2
- package/dist/validator/uuid.js +4 -6
- package/dist/validator/uuid.js.map +1 -1
- package/dist/validator/video-imports.d.ts +35 -0
- package/dist/validator/video-imports.js +183 -0
- package/dist/validator/video-imports.js.map +1 -0
- package/package.json +1 -1
package/dist/flattener/index.js
CHANGED
|
@@ -5,9 +5,10 @@ import { parseLearningOutcome, } from "../parser/learning-outcome.js";
|
|
|
5
5
|
import { parseLens, } from "../parser/lens.js";
|
|
6
6
|
import { parseWikilink, resolveWikilinkPath, findFileWithExtension, findSimilarFiles, formatSuggestion, isSoleWikilink, findWikilinks, findBrokenPromptImports, } from "../parser/wikilink.js";
|
|
7
7
|
import { parsePrompt, isPromptPath } from "../parser/prompt.js";
|
|
8
|
+
import { toFlattenedResponseSegment } from "../parser/response-segments.js";
|
|
8
9
|
import { parseFrontmatter } from "../parser/frontmatter.js";
|
|
9
10
|
import { fileNameToSlug } from "../utils/slug.js";
|
|
10
|
-
import { extractArticleExcerpt,
|
|
11
|
+
import { extractArticleExcerpt, resolveArticleSource, } from "../bundler/article.js";
|
|
11
12
|
import { extractVideoExcerpt, resolveVideoRange, } from "../bundler/video.js";
|
|
12
13
|
import { attachSourceLine, getSourceLine } from "../source-location.js";
|
|
13
14
|
function countWords(text) {
|
|
@@ -85,15 +86,21 @@ function lensSectionFields(lens, segments, file, line, errors) {
|
|
|
85
86
|
contentId: lens.id ?? null,
|
|
86
87
|
tldr: lens.tldr,
|
|
87
88
|
summaryForTutor: lens.summaryForTutor,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
...definedProperties({
|
|
90
|
+
minChatMessages: lens.minChatMessages,
|
|
91
|
+
durationMinutes: lens.durationMinutes,
|
|
92
|
+
readingMinutes: lens.readingMinutes,
|
|
93
|
+
tutorMinutes: lens.tutorMinutes,
|
|
94
|
+
addToAiContext: lens.addToAiContext,
|
|
95
|
+
displayType: computeDisplayType(segments),
|
|
96
|
+
}),
|
|
93
97
|
...stats,
|
|
94
|
-
displayType: computeDisplayType(segments),
|
|
95
98
|
};
|
|
96
99
|
}
|
|
100
|
+
/** Keep optional output fields absent instead of materializing `undefined`. */
|
|
101
|
+
function definedProperties(value) {
|
|
102
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
103
|
+
}
|
|
97
104
|
/** Compute word count and video duration from a section's segments. */
|
|
98
105
|
function computeSectionStats(segments) {
|
|
99
106
|
let words = 0;
|
|
@@ -280,15 +287,24 @@ export function splitAtBoundaries(items, parentSlug, parentTitle) {
|
|
|
280
287
|
function flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap) {
|
|
281
288
|
const errors = [];
|
|
282
289
|
const segments = [];
|
|
283
|
-
|
|
290
|
+
// Article segments are resolved up front, grouped per source file (one
|
|
291
|
+
// parsed segment can expand to several output segments when the article
|
|
292
|
+
// declares inline videos).
|
|
293
|
+
const articlePlan = planLensArticles(lens.segments, lensPath, files, visitedPaths, tierMap, { collapsed: true });
|
|
294
|
+
for (let i = 0; i < lens.segments.length; i++) {
|
|
295
|
+
const parsedSegment = lens.segments[i];
|
|
296
|
+
const planned = articlePlan.get(i);
|
|
297
|
+
if (planned) {
|
|
298
|
+
errors.push(...planned.errors);
|
|
299
|
+
segments.push(...planned.segments);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
284
302
|
const segmentResult = convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap);
|
|
285
303
|
errors.push(...segmentResult.errors);
|
|
286
304
|
if (segmentResult.segment) {
|
|
287
305
|
segments.push(segmentResult.segment);
|
|
288
306
|
}
|
|
289
307
|
}
|
|
290
|
-
// Apply collapsed content for article segments
|
|
291
|
-
applyCollapsedContent(segments, lens.segments, lensPath, files);
|
|
292
308
|
return { segments, errors };
|
|
293
309
|
}
|
|
294
310
|
// ─── Module flattening ───
|
|
@@ -505,11 +521,11 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
|
|
|
505
521
|
// Module-level add_to_ai_context, with any submodule-level entries merged in
|
|
506
522
|
// (module first, deduped). When the submodule has no field, this is exactly
|
|
507
523
|
// the module's own value.
|
|
508
|
-
...(
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
524
|
+
...definedProperties({
|
|
525
|
+
addToAiContext: g.addToAiContext !== undefined
|
|
526
|
+
? mergeAiContext(parsedModule.addToAiContext, g.addToAiContext)
|
|
527
|
+
: parsedModule.addToAiContext,
|
|
528
|
+
}),
|
|
513
529
|
}));
|
|
514
530
|
// Enrich ::card links in text segments with computed metadata (duration, attribution, displayType)
|
|
515
531
|
for (const mod of resultModules) {
|
|
@@ -532,7 +548,16 @@ function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, ti
|
|
|
532
548
|
if (!testRef.segments.length)
|
|
533
549
|
return null;
|
|
534
550
|
const testSegments = [];
|
|
535
|
-
|
|
551
|
+
// collapsed: false — test sections never carried collapsed_* fields
|
|
552
|
+
const articlePlan = planLensArticles(testRef.segments, loPath, files, new Set(visitedPaths), tierMap, { collapsed: false });
|
|
553
|
+
for (let i = 0; i < testRef.segments.length; i++) {
|
|
554
|
+
const parsedSegment = testRef.segments[i];
|
|
555
|
+
const planned = articlePlan.get(i);
|
|
556
|
+
if (planned) {
|
|
557
|
+
errors.push(...planned.errors);
|
|
558
|
+
testSegments.push(...planned.segments);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
536
561
|
const segmentResult = convertSegment(parsedSegment, loPath, files, new Set(visitedPaths), tierMap);
|
|
537
562
|
errors.push(...segmentResult.errors);
|
|
538
563
|
if (segmentResult.segment) {
|
|
@@ -786,69 +811,312 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
|
|
|
786
811
|
sections.push(resultSection);
|
|
787
812
|
return { sections, errors };
|
|
788
813
|
}
|
|
789
|
-
// ─── Collapsed content and segment conversion ───
|
|
790
814
|
/**
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
815
|
+
* Resolve a video source wikilink into a VideoSegment: transcript lookup,
|
|
816
|
+
* tier/circular checks, `.timestamps.json` sidecar, from/to range resolution,
|
|
817
|
+
* and transcript excerpt extraction. Shared by lens `#### Video` segments and
|
|
818
|
+
* inline `::video` article imports — only error attribution differs (ctx).
|
|
819
|
+
*/
|
|
820
|
+
function resolveVideoSegment(source, fromTimeStr, toTimeStr, ctx) {
|
|
821
|
+
const errors = [];
|
|
822
|
+
const { files, visitedPaths, tierMap } = ctx;
|
|
823
|
+
const withLine = (error) => ctx.errorLine !== undefined ? { ...error, line: ctx.errorLine } : error;
|
|
824
|
+
const wikilink = parseWikilink(source);
|
|
825
|
+
if (!wikilink || wikilink.error) {
|
|
826
|
+
const suggestion = wikilink?.correctedPath
|
|
827
|
+
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
828
|
+
: undefined;
|
|
829
|
+
errors.push(withLine({
|
|
830
|
+
file: ctx.errorFile,
|
|
831
|
+
message: `Invalid wikilink in video source: ${source}`,
|
|
832
|
+
suggestion,
|
|
833
|
+
severity: "error",
|
|
834
|
+
}));
|
|
835
|
+
return { segment: null, errors };
|
|
836
|
+
}
|
|
837
|
+
const videoPathResolved = resolveWikilinkPath(wikilink.path, ctx.resolveFrom);
|
|
838
|
+
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
839
|
+
if (!videoPath) {
|
|
840
|
+
const similarFiles = findSimilarFiles(videoPathResolved, files, "video_transcripts");
|
|
841
|
+
const suggestion = formatSuggestion(similarFiles, ctx.errorFile) ??
|
|
842
|
+
"Check the file path in the wiki-link";
|
|
843
|
+
const error = withLine({
|
|
844
|
+
file: ctx.errorFile,
|
|
845
|
+
message: `Referenced video transcript file not found: ${videoPathResolved}`,
|
|
846
|
+
suggestion,
|
|
847
|
+
severity: "error",
|
|
848
|
+
});
|
|
849
|
+
if (ctx.notFoundCode)
|
|
850
|
+
error.code = ctx.notFoundCode;
|
|
851
|
+
errors.push(error);
|
|
852
|
+
return { segment: null, errors };
|
|
853
|
+
}
|
|
854
|
+
// Check tier violation (referencing file -> video transcript)
|
|
855
|
+
if (tierMap) {
|
|
856
|
+
const parentTier = tierMap.get(ctx.resolveFrom) ?? "production";
|
|
857
|
+
const childTier = tierMap.get(videoPath) ?? "production";
|
|
858
|
+
const violation = checkTierViolation(ctx.resolveFrom, parentTier, videoPath, childTier, "video transcript");
|
|
859
|
+
if (violation) {
|
|
860
|
+
errors.push(violation);
|
|
861
|
+
}
|
|
862
|
+
if (childTier === "ignored") {
|
|
863
|
+
return { segment: null, errors };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (visitedPaths.has(videoPath)) {
|
|
867
|
+
errors.push(withLine({
|
|
868
|
+
file: ctx.errorFile,
|
|
869
|
+
message: `Circular reference detected: ${videoPath}`,
|
|
870
|
+
severity: "error",
|
|
871
|
+
}));
|
|
872
|
+
return { segment: null, errors };
|
|
873
|
+
}
|
|
874
|
+
const transcriptContent = files.get(videoPath);
|
|
875
|
+
// Extract video metadata from frontmatter
|
|
876
|
+
const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
|
|
877
|
+
const vfm = videoFrontmatter.frontmatter;
|
|
878
|
+
// Look for corresponding .timestamps.json file
|
|
879
|
+
const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
|
|
880
|
+
let timestamps;
|
|
881
|
+
if (files.has(timestampsPath)) {
|
|
882
|
+
try {
|
|
883
|
+
timestamps = JSON.parse(files.get(timestampsPath));
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
// JSON parse error - will fall back to inline timestamps
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// Extract the video excerpt. Caller from/to (lens fields or ::video attrs)
|
|
890
|
+
// win over the transcript's frontmatter defaults (which win over
|
|
891
|
+
// 0:00/open-ended).
|
|
892
|
+
const { fromTime, toTime } = resolveVideoRange(fromTimeStr, toTimeStr, vfm);
|
|
893
|
+
const excerptResult = extractVideoExcerpt(transcriptContent, fromTime, toTime, videoPath, timestamps);
|
|
894
|
+
if (excerptResult.error) {
|
|
895
|
+
const error = { ...excerptResult.error, file: ctx.errorFile };
|
|
896
|
+
const line = ctx.errorLine ?? ctx.excerptErrorLine;
|
|
897
|
+
if (line !== undefined)
|
|
898
|
+
error.line = line;
|
|
899
|
+
errors.push(error);
|
|
900
|
+
return { segment: null, errors };
|
|
901
|
+
}
|
|
902
|
+
const segment = {
|
|
903
|
+
type: "video",
|
|
904
|
+
from: excerptResult.from,
|
|
905
|
+
to: excerptResult.to,
|
|
906
|
+
transcript: excerptResult.transcript,
|
|
907
|
+
};
|
|
908
|
+
// Populate metadata from video transcript frontmatter
|
|
909
|
+
if (vfm.title)
|
|
910
|
+
segment.title = vfm.title;
|
|
911
|
+
if (vfm.channel)
|
|
912
|
+
segment.channel = vfm.channel;
|
|
913
|
+
if (vfm.url) {
|
|
914
|
+
const extracted = extractVideoFromUrl(vfm.url);
|
|
915
|
+
if (extracted) {
|
|
916
|
+
segment.videoId = extracted.videoId;
|
|
917
|
+
if (extracted.isShort)
|
|
918
|
+
segment.isShort = true;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
segment.sourcePath = videoPath;
|
|
922
|
+
return { segment, errors };
|
|
923
|
+
}
|
|
924
|
+
/** 1-based line of an inline import's source line in the raw article file. */
|
|
925
|
+
function findImportLine(rawContent, rawLine) {
|
|
926
|
+
const lines = rawContent.split("\n");
|
|
927
|
+
for (let i = 0; i < lines.length; i++) {
|
|
928
|
+
if (lines[i].trim() === rawLine)
|
|
929
|
+
return i + 1;
|
|
930
|
+
}
|
|
931
|
+
return undefined;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Resolve every article segment of a lens (or LO test) up front, grouped by
|
|
935
|
+
* source file so each source is read once: excerpt extraction, collapsed
|
|
936
|
+
* (elided) text at excerpt boundaries, and splitting around inline `::video`
|
|
937
|
+
* imports all happen in resolveArticleSource. The result is keyed by the
|
|
938
|
+
* parsed segment's index, so one parsed article segment can expand to several
|
|
939
|
+
* output segments (article part / video / article part) without any
|
|
940
|
+
* positional re-pairing.
|
|
941
|
+
*
|
|
942
|
+
* `collapsed: false` (LO test sections) skips collapsed enrichment, matching
|
|
943
|
+
* the previous pipeline where only lens sections received collapsed fields.
|
|
794
944
|
*/
|
|
795
|
-
function
|
|
796
|
-
|
|
797
|
-
const
|
|
798
|
-
// Map output article segments back to parsed segments for source info
|
|
799
|
-
let articleOutputIdx = 0;
|
|
945
|
+
function planLensArticles(parsedSegments, lensPath, files, visitedPaths, tierMap, options) {
|
|
946
|
+
const plan = new Map();
|
|
947
|
+
const groups = new Map();
|
|
800
948
|
for (let i = 0; i < parsedSegments.length; i++) {
|
|
801
|
-
const
|
|
802
|
-
if (
|
|
949
|
+
const parsedSegment = parsedSegments[i];
|
|
950
|
+
if (parsedSegment.type !== "article")
|
|
951
|
+
continue;
|
|
952
|
+
const entry = { segments: [], errors: [] };
|
|
953
|
+
plan.set(i, entry);
|
|
954
|
+
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
955
|
+
if (!parsedSegment.source) {
|
|
956
|
+
entry.errors.push({
|
|
957
|
+
file: lensPath,
|
|
958
|
+
message: "Article segment missing source (should have been set via inheritance)",
|
|
959
|
+
severity: "error",
|
|
960
|
+
});
|
|
803
961
|
continue;
|
|
804
|
-
// Find corresponding output segment
|
|
805
|
-
while (articleOutputIdx < segments.length &&
|
|
806
|
-
segments[articleOutputIdx].type !== "article") {
|
|
807
|
-
articleOutputIdx++;
|
|
808
962
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
963
|
+
const wikilink = parseWikilink(parsedSegment.source);
|
|
964
|
+
if (!wikilink || wikilink.error) {
|
|
965
|
+
const suggestion = wikilink?.correctedPath
|
|
966
|
+
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
967
|
+
: undefined;
|
|
968
|
+
entry.errors.push({
|
|
969
|
+
file: lensPath,
|
|
970
|
+
message: `Invalid wikilink in article source: ${parsedSegment.source}`,
|
|
971
|
+
suggestion,
|
|
972
|
+
severity: "error",
|
|
973
|
+
});
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
977
|
+
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
978
|
+
if (!articlePath) {
|
|
979
|
+
const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
|
|
980
|
+
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
981
|
+
"Check the file path in the wiki-link";
|
|
982
|
+
entry.errors.push({
|
|
983
|
+
file: lensPath,
|
|
984
|
+
message: `Referenced article file not found: ${articlePathResolved}`,
|
|
985
|
+
suggestion,
|
|
986
|
+
severity: "error",
|
|
987
|
+
});
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
// Check tier violation (Lens -> Article)
|
|
991
|
+
if (tierMap) {
|
|
992
|
+
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
993
|
+
const childTier = tierMap.get(articlePath) ?? "production";
|
|
994
|
+
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
|
|
995
|
+
if (violation) {
|
|
996
|
+
entry.errors.push(violation);
|
|
997
|
+
}
|
|
998
|
+
if (childTier === "ignored") {
|
|
999
|
+
continue;
|
|
826
1000
|
}
|
|
827
1001
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
if (!hasAnchors)
|
|
1002
|
+
if (visitedPaths.has(articlePath)) {
|
|
1003
|
+
entry.errors.push({
|
|
1004
|
+
file: lensPath,
|
|
1005
|
+
message: `Circular reference detected: ${articlePath}`,
|
|
1006
|
+
severity: "error",
|
|
1007
|
+
});
|
|
835
1008
|
continue;
|
|
1009
|
+
}
|
|
1010
|
+
let group = groups.get(articlePath);
|
|
1011
|
+
if (!group) {
|
|
1012
|
+
group = { indices: [], segments: [] };
|
|
1013
|
+
groups.set(articlePath, group);
|
|
1014
|
+
}
|
|
1015
|
+
group.indices.push(i);
|
|
1016
|
+
group.segments.push(parsedSegment);
|
|
1017
|
+
}
|
|
1018
|
+
for (const [articlePath, group] of groups) {
|
|
836
1019
|
const articleContent = files.get(articlePath);
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
const
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1020
|
+
const fm = parseFrontmatter(articleContent, articlePath).frontmatter;
|
|
1021
|
+
const resolved = resolveArticleSource(articleContent, group.segments.map((ps) => ({ from: ps.fromAnchor, to: ps.toAnchor })), articlePath);
|
|
1022
|
+
for (let k = 0; k < group.segments.length; k++) {
|
|
1023
|
+
const parsedSegment = group.segments[k];
|
|
1024
|
+
const entry = plan.get(group.indices[k]);
|
|
1025
|
+
const excerpt = resolved[k];
|
|
1026
|
+
if (excerpt.error) {
|
|
1027
|
+
entry.errors.push({
|
|
1028
|
+
...excerpt.error,
|
|
1029
|
+
file: lensPath,
|
|
1030
|
+
line: getSourceLine(parsedSegment),
|
|
1031
|
+
});
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const makeArticleSegment = (content) => {
|
|
1035
|
+
const segment = { type: "article", content };
|
|
1036
|
+
if (fm.title)
|
|
1037
|
+
segment.title = fm.title;
|
|
1038
|
+
if (fm.author) {
|
|
1039
|
+
const raw = fm.author;
|
|
1040
|
+
segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
|
|
1041
|
+
}
|
|
1042
|
+
if (fm.source_url)
|
|
1043
|
+
segment.sourceUrl = fm.source_url;
|
|
1044
|
+
if (fm.published)
|
|
1045
|
+
segment.published = String(fm.published);
|
|
1046
|
+
segment.sourcePath = articlePath;
|
|
1047
|
+
if (parsedSegment.optional)
|
|
1048
|
+
segment.optional = true;
|
|
1049
|
+
return attachSourceLine(segment, getSourceLine(parsedSegment));
|
|
1050
|
+
};
|
|
1051
|
+
const applyCollapsed = (segments) => {
|
|
1052
|
+
if (!options.collapsed)
|
|
1053
|
+
return;
|
|
1054
|
+
// collapsed_* describe the excerpt's outer boundaries, so they go on
|
|
1055
|
+
// the first/last article part; a video at the very edge has no prose
|
|
1056
|
+
// to carry them, and the (rare) all-video excerpt drops them.
|
|
1057
|
+
const articleParts = segments.filter((s) => s.type === "article");
|
|
1058
|
+
if (articleParts.length === 0)
|
|
1059
|
+
return;
|
|
1060
|
+
if (excerpt.collapsed_before) {
|
|
1061
|
+
articleParts[0].collapsed_before = excerpt.collapsed_before;
|
|
1062
|
+
}
|
|
1063
|
+
if (excerpt.collapsed_after) {
|
|
1064
|
+
articleParts[articleParts.length - 1].collapsed_after =
|
|
1065
|
+
excerpt.collapsed_after;
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
if (!excerpt.parts) {
|
|
1069
|
+
const segment = makeArticleSegment(excerpt.content);
|
|
1070
|
+
applyCollapsed([segment]);
|
|
1071
|
+
entry.segments.push(segment);
|
|
1072
|
+
continue;
|
|
846
1073
|
}
|
|
847
|
-
|
|
848
|
-
|
|
1074
|
+
// The excerpt contains inline videos: emit article/video/article parts.
|
|
1075
|
+
const partSegments = [];
|
|
1076
|
+
let videoFailed = false;
|
|
1077
|
+
for (const part of excerpt.parts) {
|
|
1078
|
+
if (part.kind === "prose") {
|
|
1079
|
+
const segment = makeArticleSegment(part.content);
|
|
1080
|
+
if (part.contiguousWithPrev)
|
|
1081
|
+
segment.skipStartMarker = true;
|
|
1082
|
+
if (part.contiguousWithNext)
|
|
1083
|
+
segment.skipEndMarker = true;
|
|
1084
|
+
partSegments.push(segment);
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
const videoResult = resolveVideoSegment(`[[${part.target}]]`, part.from, part.to, {
|
|
1088
|
+
resolveFrom: articlePath,
|
|
1089
|
+
errorFile: articlePath,
|
|
1090
|
+
errorLine: findImportLine(articleContent, part.rawLine),
|
|
1091
|
+
notFoundCode: "article.video-import-not-found",
|
|
1092
|
+
files,
|
|
1093
|
+
visitedPaths,
|
|
1094
|
+
tierMap,
|
|
1095
|
+
});
|
|
1096
|
+
entry.errors.push(...videoResult.errors);
|
|
1097
|
+
if (!videoResult.segment) {
|
|
1098
|
+
videoFailed = true;
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
videoResult.segment.partOfArticle = true;
|
|
1102
|
+
if (parsedSegment.optional)
|
|
1103
|
+
videoResult.segment.optional = true;
|
|
1104
|
+
partSegments.push(attachSourceLine(videoResult.segment, getSourceLine(parsedSegment)));
|
|
1105
|
+
}
|
|
1106
|
+
if (videoFailed) {
|
|
1107
|
+
// A broken import must not leave a half-split article with dangling
|
|
1108
|
+
// seam flags: fall back to the un-split excerpt (error already
|
|
1109
|
+
// recorded above).
|
|
1110
|
+
const segment = makeArticleSegment(excerpt.content);
|
|
1111
|
+
applyCollapsed([segment]);
|
|
1112
|
+
entry.segments.push(segment);
|
|
1113
|
+
continue;
|
|
849
1114
|
}
|
|
1115
|
+
applyCollapsed(partSegments);
|
|
1116
|
+
entry.segments.push(...partSegments);
|
|
850
1117
|
}
|
|
851
1118
|
}
|
|
1119
|
+
return plan;
|
|
852
1120
|
}
|
|
853
1121
|
/**
|
|
854
1122
|
* Resolve a single prompt-import wikilink into the referenced prompt file's
|
|
@@ -938,7 +1206,7 @@ function resolvePromptImport(value, fieldName, lensPath, files, tierMap) {
|
|
|
938
1206
|
* doesn't resolve is an error, and the segment is dropped like a failed
|
|
939
1207
|
* sole import.
|
|
940
1208
|
*/
|
|
941
|
-
function expandPromptImports(value, fieldName, lensPath, files, tierMap) {
|
|
1209
|
+
export function expandPromptImports(value, fieldName, lensPath, files, tierMap) {
|
|
942
1210
|
// Sole wikilink — the whole field is the import (including recognizably
|
|
943
1211
|
// malformed single wikilinks, which must fail loudly).
|
|
944
1212
|
if (isSoleWikilink(value)) {
|
|
@@ -1041,101 +1309,11 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1041
1309
|
errors,
|
|
1042
1310
|
};
|
|
1043
1311
|
}
|
|
1044
|
-
case "article":
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
message: "Article segment missing source (should have been set via inheritance)",
|
|
1050
|
-
severity: "error",
|
|
1051
|
-
});
|
|
1052
|
-
return { segment: null, errors };
|
|
1053
|
-
}
|
|
1054
|
-
const wikilink = parseWikilink(parsedSegment.source);
|
|
1055
|
-
if (!wikilink || wikilink.error) {
|
|
1056
|
-
const suggestion = wikilink?.correctedPath
|
|
1057
|
-
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
1058
|
-
: undefined;
|
|
1059
|
-
errors.push({
|
|
1060
|
-
file: lensPath,
|
|
1061
|
-
message: `Invalid wikilink in article source: ${parsedSegment.source}`,
|
|
1062
|
-
suggestion,
|
|
1063
|
-
severity: "error",
|
|
1064
|
-
});
|
|
1065
|
-
return { segment: null, errors };
|
|
1066
|
-
}
|
|
1067
|
-
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1068
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1069
|
-
if (!articlePath) {
|
|
1070
|
-
const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
|
|
1071
|
-
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
1072
|
-
"Check the file path in the wiki-link";
|
|
1073
|
-
errors.push({
|
|
1074
|
-
file: lensPath,
|
|
1075
|
-
message: `Referenced article file not found: ${articlePathResolved}`,
|
|
1076
|
-
suggestion,
|
|
1077
|
-
severity: "error",
|
|
1078
|
-
});
|
|
1079
|
-
return { segment: null, errors };
|
|
1080
|
-
}
|
|
1081
|
-
// Check tier violation (Lens -> Article)
|
|
1082
|
-
if (tierMap) {
|
|
1083
|
-
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
1084
|
-
const childTier = tierMap.get(articlePath) ?? "production";
|
|
1085
|
-
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
|
|
1086
|
-
if (violation) {
|
|
1087
|
-
errors.push(violation);
|
|
1088
|
-
}
|
|
1089
|
-
if (childTier === "ignored") {
|
|
1090
|
-
return { segment: null, errors };
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
if (visitedPaths.has(articlePath)) {
|
|
1094
|
-
errors.push({
|
|
1095
|
-
file: lensPath,
|
|
1096
|
-
message: `Circular reference detected: ${articlePath}`,
|
|
1097
|
-
severity: "error",
|
|
1098
|
-
});
|
|
1099
|
-
return { segment: null, errors };
|
|
1100
|
-
}
|
|
1101
|
-
const articleContent = files.get(articlePath);
|
|
1102
|
-
// Extract article metadata from frontmatter
|
|
1103
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
1104
|
-
const fm = articleFrontmatter.frontmatter;
|
|
1105
|
-
// Extract the excerpt
|
|
1106
|
-
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
1107
|
-
if (excerptResult.error) {
|
|
1108
|
-
errors.push({
|
|
1109
|
-
...excerptResult.error,
|
|
1110
|
-
file: lensPath,
|
|
1111
|
-
line: getSourceLine(parsedSegment),
|
|
1112
|
-
});
|
|
1113
|
-
return { segment: null, errors };
|
|
1114
|
-
}
|
|
1115
|
-
const segment = {
|
|
1116
|
-
type: "article",
|
|
1117
|
-
content: excerptResult.content,
|
|
1118
|
-
};
|
|
1119
|
-
// Populate metadata from article frontmatter
|
|
1120
|
-
if (fm.title)
|
|
1121
|
-
segment.title = fm.title;
|
|
1122
|
-
if (fm.author) {
|
|
1123
|
-
const raw = fm.author;
|
|
1124
|
-
segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
|
|
1125
|
-
}
|
|
1126
|
-
if (fm.source_url)
|
|
1127
|
-
segment.sourceUrl = fm.source_url;
|
|
1128
|
-
if (fm.published)
|
|
1129
|
-
segment.published = String(fm.published);
|
|
1130
|
-
segment.sourcePath = articlePath;
|
|
1131
|
-
if (parsedSegment.optional) {
|
|
1132
|
-
segment.optional = true;
|
|
1133
|
-
}
|
|
1134
|
-
return {
|
|
1135
|
-
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1136
|
-
errors,
|
|
1137
|
-
};
|
|
1138
|
-
}
|
|
1312
|
+
case "article":
|
|
1313
|
+
// Article segments never reach convertSegment: planLensArticles
|
|
1314
|
+
// resolves them, grouped per source file, so excerpts, collapsed text,
|
|
1315
|
+
// and inline-video splits are computed in one pass.
|
|
1316
|
+
return { segment: null, errors };
|
|
1139
1317
|
case "video": {
|
|
1140
1318
|
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
1141
1319
|
if (!parsedSegment.source) {
|
|
@@ -1146,104 +1324,23 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1146
1324
|
});
|
|
1147
1325
|
return { segment: null, errors };
|
|
1148
1326
|
}
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
});
|
|
1160
|
-
return { segment: null, errors };
|
|
1161
|
-
}
|
|
1162
|
-
const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1163
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
1164
|
-
if (!videoPath) {
|
|
1165
|
-
const similarFiles = findSimilarFiles(videoPathResolved, files, "video_transcripts");
|
|
1166
|
-
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
1167
|
-
"Check the file path in the wiki-link";
|
|
1168
|
-
errors.push({
|
|
1169
|
-
file: lensPath,
|
|
1170
|
-
message: `Referenced video transcript file not found: ${videoPathResolved}`,
|
|
1171
|
-
suggestion,
|
|
1172
|
-
severity: "error",
|
|
1173
|
-
});
|
|
1174
|
-
return { segment: null, errors };
|
|
1175
|
-
}
|
|
1176
|
-
// Check tier violation (Lens -> Video)
|
|
1177
|
-
if (tierMap) {
|
|
1178
|
-
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
1179
|
-
const childTier = tierMap.get(videoPath) ?? "production";
|
|
1180
|
-
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, "video transcript");
|
|
1181
|
-
if (violation) {
|
|
1182
|
-
errors.push(violation);
|
|
1183
|
-
}
|
|
1184
|
-
if (childTier === "ignored") {
|
|
1185
|
-
return { segment: null, errors };
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
if (visitedPaths.has(videoPath)) {
|
|
1189
|
-
errors.push({
|
|
1190
|
-
file: lensPath,
|
|
1191
|
-
message: `Circular reference detected: ${videoPath}`,
|
|
1192
|
-
severity: "error",
|
|
1193
|
-
});
|
|
1194
|
-
return { segment: null, errors };
|
|
1195
|
-
}
|
|
1196
|
-
const transcriptContent = files.get(videoPath);
|
|
1197
|
-
// Extract video metadata from frontmatter
|
|
1198
|
-
const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
|
|
1199
|
-
const vfm = videoFrontmatter.frontmatter;
|
|
1200
|
-
// Look for corresponding .timestamps.json file
|
|
1201
|
-
const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
|
|
1202
|
-
let timestamps;
|
|
1203
|
-
if (files.has(timestampsPath)) {
|
|
1204
|
-
try {
|
|
1205
|
-
timestamps = JSON.parse(files.get(timestampsPath));
|
|
1206
|
-
}
|
|
1207
|
-
catch {
|
|
1208
|
-
// JSON parse error - will fall back to inline timestamps
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
// Extract the video excerpt. Lens from::/to:: win over the
|
|
1212
|
-
// transcript's frontmatter defaults (which win over 0:00/open-ended).
|
|
1213
|
-
const { fromTime, toTime } = resolveVideoRange(parsedSegment.fromTimeStr, parsedSegment.toTimeStr, vfm);
|
|
1214
|
-
const excerptResult = extractVideoExcerpt(transcriptContent, fromTime, toTime, videoPath, timestamps);
|
|
1215
|
-
if (excerptResult.error) {
|
|
1216
|
-
errors.push({
|
|
1217
|
-
...excerptResult.error,
|
|
1218
|
-
file: lensPath,
|
|
1219
|
-
line: getSourceLine(parsedSegment),
|
|
1220
|
-
});
|
|
1327
|
+
const result = resolveVideoSegment(parsedSegment.source, parsedSegment.fromTimeStr, parsedSegment.toTimeStr, {
|
|
1328
|
+
resolveFrom: lensPath,
|
|
1329
|
+
errorFile: lensPath,
|
|
1330
|
+
excerptErrorLine: getSourceLine(parsedSegment),
|
|
1331
|
+
files,
|
|
1332
|
+
visitedPaths,
|
|
1333
|
+
tierMap,
|
|
1334
|
+
});
|
|
1335
|
+
errors.push(...result.errors);
|
|
1336
|
+
if (!result.segment) {
|
|
1221
1337
|
return { segment: null, errors };
|
|
1222
1338
|
}
|
|
1223
|
-
const segment = {
|
|
1224
|
-
type: "video",
|
|
1225
|
-
from: excerptResult.from,
|
|
1226
|
-
to: excerptResult.to,
|
|
1227
|
-
transcript: excerptResult.transcript,
|
|
1228
|
-
};
|
|
1229
|
-
// Populate metadata from video transcript frontmatter
|
|
1230
|
-
if (vfm.title)
|
|
1231
|
-
segment.title = vfm.title;
|
|
1232
|
-
if (vfm.channel)
|
|
1233
|
-
segment.channel = vfm.channel;
|
|
1234
|
-
if (vfm.url) {
|
|
1235
|
-
const extracted = extractVideoFromUrl(vfm.url);
|
|
1236
|
-
if (extracted) {
|
|
1237
|
-
segment.videoId = extracted.videoId;
|
|
1238
|
-
if (extracted.isShort)
|
|
1239
|
-
segment.isShort = true;
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
1339
|
if (parsedSegment.optional) {
|
|
1243
|
-
segment.optional = true;
|
|
1340
|
+
result.segment.optional = true;
|
|
1244
1341
|
}
|
|
1245
1342
|
return {
|
|
1246
|
-
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1343
|
+
segment: attachSourceLine(result.segment, getSourceLine(parsedSegment)),
|
|
1247
1344
|
errors,
|
|
1248
1345
|
};
|
|
1249
1346
|
}
|
|
@@ -1257,6 +1354,30 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1257
1354
|
}
|
|
1258
1355
|
assessmentInstructions = resolved.value;
|
|
1259
1356
|
}
|
|
1357
|
+
// New-style response segment (`#### Question: <Subtype>`): pass the
|
|
1358
|
+
// parsed shape through, with prompt imports expanded in both
|
|
1359
|
+
// instruction fields. Answers (correctOptions, blank expectations)
|
|
1360
|
+
// stay in the cache JSON — the web API strips them before serving.
|
|
1361
|
+
if ("questionType" in parsedSegment) {
|
|
1362
|
+
let feedbackInstructions = parsedSegment.feedbackInstructions;
|
|
1363
|
+
if (feedbackInstructions) {
|
|
1364
|
+
const resolved = expandPromptImports(feedbackInstructions, "feedback-instructions", lensPath, files, tierMap);
|
|
1365
|
+
errors.push(...resolved.errors);
|
|
1366
|
+
if (resolved.value === null) {
|
|
1367
|
+
return { segment: null, errors };
|
|
1368
|
+
}
|
|
1369
|
+
feedbackInstructions = resolved.value;
|
|
1370
|
+
}
|
|
1371
|
+
const segment = toFlattenedResponseSegment(parsedSegment);
|
|
1372
|
+
if (assessmentInstructions)
|
|
1373
|
+
segment.assessmentInstructions = assessmentInstructions;
|
|
1374
|
+
if (feedbackInstructions)
|
|
1375
|
+
segment.feedbackInstructions = feedbackInstructions;
|
|
1376
|
+
return {
|
|
1377
|
+
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1378
|
+
errors,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1260
1381
|
const segment = {
|
|
1261
1382
|
type: "question",
|
|
1262
1383
|
content: parsedSegment.content,
|
|
@@ -1389,11 +1510,7 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1389
1510
|
}
|
|
1390
1511
|
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
1391
1512
|
if (excerptResult.error) {
|
|
1392
|
-
errors.push({
|
|
1393
|
-
...excerptResult.error,
|
|
1394
|
-
file: lensPath,
|
|
1395
|
-
line: getSourceLine(parsedSegment),
|
|
1396
|
-
});
|
|
1513
|
+
errors.push({ ...excerptResult.error, file: lensPath });
|
|
1397
1514
|
return { segment: null, errors };
|
|
1398
1515
|
}
|
|
1399
1516
|
const segment = {
|
|
@@ -1484,13 +1601,13 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
|
|
|
1484
1601
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1485
1602
|
}
|
|
1486
1603
|
}
|
|
1487
|
-
const section =
|
|
1604
|
+
const section = {
|
|
1488
1605
|
type: "lens",
|
|
1489
1606
|
meta,
|
|
1490
1607
|
sourcePath: lensPath,
|
|
1491
1608
|
optional: false,
|
|
1492
1609
|
...lensSectionFields(lens, segments, lensPath, 1, errors),
|
|
1493
|
-
}
|
|
1610
|
+
};
|
|
1494
1611
|
const flattenedModule = {
|
|
1495
1612
|
slug: "lens/" + fileNameToSlug(lensPath),
|
|
1496
1613
|
title: meta.title ?? fileNameToSlug(lensPath),
|