lens-content-processor 0.34.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 +36 -0
- package/dist/authoring-markup.js +103 -0
- 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/cli.d.ts +1 -0
- package/dist/cli.js +6 -2
- package/dist/cli.js.map +1 -1
- package/dist/content-schema.js +5 -0
- package/dist/content-schema.js.map +1 -1
- package/dist/flattener/index.js +381 -252
- package/dist/flattener/index.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/parser/article.js +42 -32
- package/dist/parser/article.js.map +1 -1
- package/dist/parser/course.d.ts +0 -10
- package/dist/parser/course.js +19 -4
- package/dist/parser/course.js.map +1 -1
- package/dist/parser/learning-outcome.js +20 -4
- package/dist/parser/learning-outcome.js.map +1 -1
- package/dist/parser/lens.d.ts +1 -6
- package/dist/parser/lens.js +64 -18
- package/dist/parser/lens.js.map +1 -1
- package/dist/parser/module.js +28 -17
- package/dist/parser/module.js.map +1 -1
- package/dist/parser/sections.d.ts +2 -1
- package/dist/parser/sections.js +5 -3
- package/dist/parser/sections.js.map +1 -1
- package/dist/parser/survey.js +19 -4
- package/dist/parser/survey.js.map +1 -1
- package/dist/source-location.d.ts +2 -0
- package/dist/source-location.js +12 -0
- package/dist/source-location.js.map +1 -0
- package/dist/validator/article-review-provenance.d.ts +5 -0
- package/dist/validator/article-review-provenance.js +57 -0
- package/dist/validator/article-review-provenance.js.map +1 -0
- package/dist/validator/article-structure.d.ts +8 -0
- package/dist/validator/article-structure.js +660 -0
- package/dist/validator/article-structure.js.map +1 -0
- package/dist/validator/directives.js +20 -0
- package/dist/validator/directives.js.map +1 -1
- package/dist/validator/emphasis.js +104 -1
- package/dist/validator/emphasis.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/math.js +66 -9
- package/dist/validator/math.js.map +1 -1
- package/dist/validator/output-integrity.js +16 -16
- package/dist/validator/output-integrity.js.map +1 -1
- package/dist/validator/same-lens-links.d.ts +4 -0
- package/dist/validator/same-lens-links.js +297 -0
- package/dist/validator/same-lens-links.js.map +1 -0
- package/dist/validator/suppressions.d.ts +42 -0
- package/dist/validator/suppressions.js +97 -0
- package/dist/validator/suppressions.js.map +1 -0
- 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 +9 -1
package/dist/flattener/index.js
CHANGED
|
@@ -8,8 +8,9 @@ import { parsePrompt, isPromptPath } from "../parser/prompt.js";
|
|
|
8
8
|
import { toFlattenedResponseSegment } from "../parser/response-segments.js";
|
|
9
9
|
import { parseFrontmatter } from "../parser/frontmatter.js";
|
|
10
10
|
import { fileNameToSlug } from "../utils/slug.js";
|
|
11
|
-
import { extractArticleExcerpt,
|
|
11
|
+
import { extractArticleExcerpt, resolveArticleSource, } from "../bundler/article.js";
|
|
12
12
|
import { extractVideoExcerpt, resolveVideoRange, } from "../bundler/video.js";
|
|
13
|
+
import { attachSourceLine, getSourceLine } from "../source-location.js";
|
|
13
14
|
function countWords(text) {
|
|
14
15
|
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
15
16
|
}
|
|
@@ -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) {
|
|
@@ -684,14 +709,14 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
|
|
|
684
709
|
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
685
710
|
if (hideConflict)
|
|
686
711
|
errors.push(hideConflict);
|
|
687
|
-
const resultSection = {
|
|
712
|
+
const resultSection = attachSourceLine({
|
|
688
713
|
type: "lens",
|
|
689
714
|
meta: { title: inlineLens.title || section.title },
|
|
690
715
|
sourcePath: modulePath,
|
|
691
716
|
optional: effectiveOptional,
|
|
692
717
|
...(effectiveHide && { hide: true }),
|
|
693
718
|
...lensSectionFields(inlineLens, segments, modulePath, section.line, errors),
|
|
694
|
-
};
|
|
719
|
+
}, section.line);
|
|
695
720
|
sections.push(resultSection);
|
|
696
721
|
return { sections, errors };
|
|
697
722
|
}
|
|
@@ -775,80 +800,323 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
|
|
|
775
800
|
const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, modulePath, section.line);
|
|
776
801
|
if (hideConflict)
|
|
777
802
|
errors.push(hideConflict);
|
|
778
|
-
const resultSection = {
|
|
803
|
+
const resultSection = attachSourceLine({
|
|
779
804
|
type: "lens",
|
|
780
805
|
meta: { title: lens.title || section.title },
|
|
781
806
|
sourcePath: lensPath,
|
|
782
807
|
optional: effectiveOptional,
|
|
783
808
|
...(effectiveHide && { hide: true }),
|
|
784
809
|
...lensSectionFields(lens, segments, lensPath, section.line, errors),
|
|
785
|
-
};
|
|
810
|
+
}, getSourceLine(lens));
|
|
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;
|
|
846
1033
|
}
|
|
847
|
-
|
|
848
|
-
segment
|
|
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;
|
|
849
1073
|
}
|
|
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;
|
|
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
|
|
@@ -1009,7 +1277,10 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1009
1277
|
if (parsedSegment.optional) {
|
|
1010
1278
|
segment.optional = true;
|
|
1011
1279
|
}
|
|
1012
|
-
return {
|
|
1280
|
+
return {
|
|
1281
|
+
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1282
|
+
errors,
|
|
1283
|
+
};
|
|
1013
1284
|
}
|
|
1014
1285
|
case "chat": {
|
|
1015
1286
|
let instructions = parsedSegment.instructions;
|
|
@@ -1033,96 +1304,16 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1033
1304
|
if (parsedSegment.hidePreviousContentFromTutor) {
|
|
1034
1305
|
segment.hidePreviousContentFromTutor = true;
|
|
1035
1306
|
}
|
|
1036
|
-
return {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
1040
|
-
if (!parsedSegment.source) {
|
|
1041
|
-
errors.push({
|
|
1042
|
-
file: lensPath,
|
|
1043
|
-
message: "Article segment missing source (should have been set via inheritance)",
|
|
1044
|
-
severity: "error",
|
|
1045
|
-
});
|
|
1046
|
-
return { segment: null, errors };
|
|
1047
|
-
}
|
|
1048
|
-
const wikilink = parseWikilink(parsedSegment.source);
|
|
1049
|
-
if (!wikilink || wikilink.error) {
|
|
1050
|
-
const suggestion = wikilink?.correctedPath
|
|
1051
|
-
? `Did you mean '[[${wikilink.correctedPath}]]'?`
|
|
1052
|
-
: undefined;
|
|
1053
|
-
errors.push({
|
|
1054
|
-
file: lensPath,
|
|
1055
|
-
message: `Invalid wikilink in article source: ${parsedSegment.source}`,
|
|
1056
|
-
suggestion,
|
|
1057
|
-
severity: "error",
|
|
1058
|
-
});
|
|
1059
|
-
return { segment: null, errors };
|
|
1060
|
-
}
|
|
1061
|
-
const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1062
|
-
const articlePath = findFileWithExtension(articlePathResolved, files);
|
|
1063
|
-
if (!articlePath) {
|
|
1064
|
-
const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
|
|
1065
|
-
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
1066
|
-
"Check the file path in the wiki-link";
|
|
1067
|
-
errors.push({
|
|
1068
|
-
file: lensPath,
|
|
1069
|
-
message: `Referenced article file not found: ${articlePathResolved}`,
|
|
1070
|
-
suggestion,
|
|
1071
|
-
severity: "error",
|
|
1072
|
-
});
|
|
1073
|
-
return { segment: null, errors };
|
|
1074
|
-
}
|
|
1075
|
-
// Check tier violation (Lens -> Article)
|
|
1076
|
-
if (tierMap) {
|
|
1077
|
-
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
1078
|
-
const childTier = tierMap.get(articlePath) ?? "production";
|
|
1079
|
-
const violation = checkTierViolation(lensPath, parentTier, articlePath, childTier, "article");
|
|
1080
|
-
if (violation) {
|
|
1081
|
-
errors.push(violation);
|
|
1082
|
-
}
|
|
1083
|
-
if (childTier === "ignored") {
|
|
1084
|
-
return { segment: null, errors };
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
if (visitedPaths.has(articlePath)) {
|
|
1088
|
-
errors.push({
|
|
1089
|
-
file: lensPath,
|
|
1090
|
-
message: `Circular reference detected: ${articlePath}`,
|
|
1091
|
-
severity: "error",
|
|
1092
|
-
});
|
|
1093
|
-
return { segment: null, errors };
|
|
1094
|
-
}
|
|
1095
|
-
const articleContent = files.get(articlePath);
|
|
1096
|
-
// Extract article metadata from frontmatter
|
|
1097
|
-
const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
|
|
1098
|
-
const fm = articleFrontmatter.frontmatter;
|
|
1099
|
-
// Extract the excerpt
|
|
1100
|
-
const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
|
|
1101
|
-
if (excerptResult.error) {
|
|
1102
|
-
errors.push({ ...excerptResult.error, file: lensPath });
|
|
1103
|
-
return { segment: null, errors };
|
|
1104
|
-
}
|
|
1105
|
-
const segment = {
|
|
1106
|
-
type: "article",
|
|
1107
|
-
content: excerptResult.content,
|
|
1307
|
+
return {
|
|
1308
|
+
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1309
|
+
errors,
|
|
1108
1310
|
};
|
|
1109
|
-
// Populate metadata from article frontmatter
|
|
1110
|
-
if (fm.title)
|
|
1111
|
-
segment.title = fm.title;
|
|
1112
|
-
if (fm.author) {
|
|
1113
|
-
const raw = fm.author;
|
|
1114
|
-
segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
|
|
1115
|
-
}
|
|
1116
|
-
if (fm.source_url)
|
|
1117
|
-
segment.sourceUrl = fm.source_url;
|
|
1118
|
-
if (fm.published)
|
|
1119
|
-
segment.published = String(fm.published);
|
|
1120
|
-
segment.sourcePath = articlePath;
|
|
1121
|
-
if (parsedSegment.optional) {
|
|
1122
|
-
segment.optional = true;
|
|
1123
|
-
}
|
|
1124
|
-
return { segment, errors };
|
|
1125
1311
|
}
|
|
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 };
|
|
1126
1317
|
case "video": {
|
|
1127
1318
|
// Source comes from the parsed segment (set via inheritance in parseLens)
|
|
1128
1319
|
if (!parsedSegment.source) {
|
|
@@ -1133,99 +1324,25 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1133
1324
|
});
|
|
1134
1325
|
return { segment: null, errors };
|
|
1135
1326
|
}
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
});
|
|
1147
|
-
return { segment: null, errors };
|
|
1148
|
-
}
|
|
1149
|
-
const videoPathResolved = resolveWikilinkPath(wikilink.path, lensPath);
|
|
1150
|
-
const videoPath = findFileWithExtension(videoPathResolved, files);
|
|
1151
|
-
if (!videoPath) {
|
|
1152
|
-
const similarFiles = findSimilarFiles(videoPathResolved, files, "video_transcripts");
|
|
1153
|
-
const suggestion = formatSuggestion(similarFiles, lensPath) ??
|
|
1154
|
-
"Check the file path in the wiki-link";
|
|
1155
|
-
errors.push({
|
|
1156
|
-
file: lensPath,
|
|
1157
|
-
message: `Referenced video transcript file not found: ${videoPathResolved}`,
|
|
1158
|
-
suggestion,
|
|
1159
|
-
severity: "error",
|
|
1160
|
-
});
|
|
1161
|
-
return { segment: null, errors };
|
|
1162
|
-
}
|
|
1163
|
-
// Check tier violation (Lens -> Video)
|
|
1164
|
-
if (tierMap) {
|
|
1165
|
-
const parentTier = tierMap.get(lensPath) ?? "production";
|
|
1166
|
-
const childTier = tierMap.get(videoPath) ?? "production";
|
|
1167
|
-
const violation = checkTierViolation(lensPath, parentTier, videoPath, childTier, "video transcript");
|
|
1168
|
-
if (violation) {
|
|
1169
|
-
errors.push(violation);
|
|
1170
|
-
}
|
|
1171
|
-
if (childTier === "ignored") {
|
|
1172
|
-
return { segment: null, errors };
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
if (visitedPaths.has(videoPath)) {
|
|
1176
|
-
errors.push({
|
|
1177
|
-
file: lensPath,
|
|
1178
|
-
message: `Circular reference detected: ${videoPath}`,
|
|
1179
|
-
severity: "error",
|
|
1180
|
-
});
|
|
1181
|
-
return { segment: null, errors };
|
|
1182
|
-
}
|
|
1183
|
-
const transcriptContent = files.get(videoPath);
|
|
1184
|
-
// Extract video metadata from frontmatter
|
|
1185
|
-
const videoFrontmatter = parseFrontmatter(transcriptContent, videoPath);
|
|
1186
|
-
const vfm = videoFrontmatter.frontmatter;
|
|
1187
|
-
// Look for corresponding .timestamps.json file
|
|
1188
|
-
const timestampsPath = videoPath.replace(/\.md$/, ".timestamps.json");
|
|
1189
|
-
let timestamps;
|
|
1190
|
-
if (files.has(timestampsPath)) {
|
|
1191
|
-
try {
|
|
1192
|
-
timestamps = JSON.parse(files.get(timestampsPath));
|
|
1193
|
-
}
|
|
1194
|
-
catch {
|
|
1195
|
-
// JSON parse error - will fall back to inline timestamps
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
// Extract the video excerpt. Lens from::/to:: win over the
|
|
1199
|
-
// transcript's frontmatter defaults (which win over 0:00/open-ended).
|
|
1200
|
-
const { fromTime, toTime } = resolveVideoRange(parsedSegment.fromTimeStr, parsedSegment.toTimeStr, vfm);
|
|
1201
|
-
const excerptResult = extractVideoExcerpt(transcriptContent, fromTime, toTime, videoPath, timestamps);
|
|
1202
|
-
if (excerptResult.error) {
|
|
1203
|
-
errors.push({ ...excerptResult.error, file: lensPath });
|
|
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) {
|
|
1204
1337
|
return { segment: null, errors };
|
|
1205
1338
|
}
|
|
1206
|
-
const segment = {
|
|
1207
|
-
type: "video",
|
|
1208
|
-
from: excerptResult.from,
|
|
1209
|
-
to: excerptResult.to,
|
|
1210
|
-
transcript: excerptResult.transcript,
|
|
1211
|
-
};
|
|
1212
|
-
// Populate metadata from video transcript frontmatter
|
|
1213
|
-
if (vfm.title)
|
|
1214
|
-
segment.title = vfm.title;
|
|
1215
|
-
if (vfm.channel)
|
|
1216
|
-
segment.channel = vfm.channel;
|
|
1217
|
-
if (vfm.url) {
|
|
1218
|
-
const extracted = extractVideoFromUrl(vfm.url);
|
|
1219
|
-
if (extracted) {
|
|
1220
|
-
segment.videoId = extracted.videoId;
|
|
1221
|
-
if (extracted.isShort)
|
|
1222
|
-
segment.isShort = true;
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
1339
|
if (parsedSegment.optional) {
|
|
1226
|
-
segment.optional = true;
|
|
1340
|
+
result.segment.optional = true;
|
|
1227
1341
|
}
|
|
1228
|
-
return {
|
|
1342
|
+
return {
|
|
1343
|
+
segment: attachSourceLine(result.segment, getSourceLine(parsedSegment)),
|
|
1344
|
+
errors,
|
|
1345
|
+
};
|
|
1229
1346
|
}
|
|
1230
1347
|
case "question": {
|
|
1231
1348
|
let assessmentInstructions = parsedSegment.assessmentInstructions;
|
|
@@ -1256,7 +1373,10 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1256
1373
|
segment.assessmentInstructions = assessmentInstructions;
|
|
1257
1374
|
if (feedbackInstructions)
|
|
1258
1375
|
segment.feedbackInstructions = feedbackInstructions;
|
|
1259
|
-
return {
|
|
1376
|
+
return {
|
|
1377
|
+
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1378
|
+
errors,
|
|
1379
|
+
};
|
|
1260
1380
|
}
|
|
1261
1381
|
const segment = {
|
|
1262
1382
|
type: "question",
|
|
@@ -1276,7 +1396,10 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1276
1396
|
segment.feedback = parsedSegment.feedback;
|
|
1277
1397
|
if (parsedSegment.optional)
|
|
1278
1398
|
segment.optional = true;
|
|
1279
|
-
return {
|
|
1399
|
+
return {
|
|
1400
|
+
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1401
|
+
errors,
|
|
1402
|
+
};
|
|
1280
1403
|
}
|
|
1281
1404
|
case "roleplay": {
|
|
1282
1405
|
let aiInstructions = parsedSegment.aiInstructions;
|
|
@@ -1313,7 +1436,10 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1313
1436
|
segment.optional = true;
|
|
1314
1437
|
if (parsedSegment.feedback)
|
|
1315
1438
|
segment.feedback = true;
|
|
1316
|
-
return {
|
|
1439
|
+
return {
|
|
1440
|
+
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1441
|
+
errors,
|
|
1442
|
+
};
|
|
1317
1443
|
}
|
|
1318
1444
|
case "embed": {
|
|
1319
1445
|
if (!parsedSegment.source) {
|
|
@@ -1412,7 +1538,10 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
|
|
|
1412
1538
|
segment.sandbox = parsedSegment.sandbox;
|
|
1413
1539
|
if (parsedSegment.optional)
|
|
1414
1540
|
segment.optional = true;
|
|
1415
|
-
return {
|
|
1541
|
+
return {
|
|
1542
|
+
segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
|
|
1543
|
+
errors,
|
|
1544
|
+
};
|
|
1416
1545
|
}
|
|
1417
1546
|
default:
|
|
1418
1547
|
return { segment: null, errors };
|