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.
Files changed (63) hide show
  1. package/dist/authoring-markup.d.ts +36 -0
  2. package/dist/authoring-markup.js +103 -0
  3. package/dist/authoring-markup.js.map +1 -1
  4. package/dist/bundler/article.d.ts +69 -9
  5. package/dist/bundler/article.js +159 -58
  6. package/dist/bundler/article.js.map +1 -1
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +6 -2
  9. package/dist/cli.js.map +1 -1
  10. package/dist/content-schema.js +5 -0
  11. package/dist/content-schema.js.map +1 -1
  12. package/dist/flattener/index.js +381 -252
  13. package/dist/flattener/index.js.map +1 -1
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +13 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/parser/article.js +42 -32
  18. package/dist/parser/article.js.map +1 -1
  19. package/dist/parser/course.d.ts +0 -10
  20. package/dist/parser/course.js +19 -4
  21. package/dist/parser/course.js.map +1 -1
  22. package/dist/parser/learning-outcome.js +20 -4
  23. package/dist/parser/learning-outcome.js.map +1 -1
  24. package/dist/parser/lens.d.ts +1 -6
  25. package/dist/parser/lens.js +64 -18
  26. package/dist/parser/lens.js.map +1 -1
  27. package/dist/parser/module.js +28 -17
  28. package/dist/parser/module.js.map +1 -1
  29. package/dist/parser/sections.d.ts +2 -1
  30. package/dist/parser/sections.js +5 -3
  31. package/dist/parser/sections.js.map +1 -1
  32. package/dist/parser/survey.js +19 -4
  33. package/dist/parser/survey.js.map +1 -1
  34. package/dist/source-location.d.ts +2 -0
  35. package/dist/source-location.js +12 -0
  36. package/dist/source-location.js.map +1 -0
  37. package/dist/validator/article-review-provenance.d.ts +5 -0
  38. package/dist/validator/article-review-provenance.js +57 -0
  39. package/dist/validator/article-review-provenance.js.map +1 -0
  40. package/dist/validator/article-structure.d.ts +8 -0
  41. package/dist/validator/article-structure.js +660 -0
  42. package/dist/validator/article-structure.js.map +1 -0
  43. package/dist/validator/directives.js +20 -0
  44. package/dist/validator/directives.js.map +1 -1
  45. package/dist/validator/emphasis.js +104 -1
  46. package/dist/validator/emphasis.js.map +1 -1
  47. package/dist/validator/markdown-parse.d.ts +3 -0
  48. package/dist/validator/markdown-parse.js +22 -0
  49. package/dist/validator/markdown-parse.js.map +1 -0
  50. package/dist/validator/math.js +66 -9
  51. package/dist/validator/math.js.map +1 -1
  52. package/dist/validator/output-integrity.js +16 -16
  53. package/dist/validator/output-integrity.js.map +1 -1
  54. package/dist/validator/same-lens-links.d.ts +4 -0
  55. package/dist/validator/same-lens-links.js +297 -0
  56. package/dist/validator/same-lens-links.js.map +1 -0
  57. package/dist/validator/suppressions.d.ts +42 -0
  58. package/dist/validator/suppressions.js +97 -0
  59. package/dist/validator/suppressions.js.map +1 -0
  60. package/dist/validator/video-imports.d.ts +35 -0
  61. package/dist/validator/video-imports.js +183 -0
  62. package/dist/validator/video-imports.js.map +1 -0
  63. package/package.json +9 -1
@@ -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, bundleArticleWithCollapsed, } from "../bundler/article.js";
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
- minChatMessages: lens.minChatMessages,
89
- durationMinutes: lens.durationMinutes,
90
- readingMinutes: lens.readingMinutes,
91
- tutorMinutes: lens.tutorMinutes,
92
- addToAiContext: lens.addToAiContext,
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
- for (const parsedSegment of lens.segments) {
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
- ...(g.addToAiContext !== undefined
509
- ? {
510
- addToAiContext: mergeAiContext(parsedModule.addToAiContext, g.addToAiContext),
511
- }
512
- : { addToAiContext: parsedModule.addToAiContext }),
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
- for (const parsedSegment of testRef.segments) {
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
- * After converting segments, apply collapsed_before/collapsed_after to article segments.
792
- * Groups article segments by their resolved source path, then calls bundleArticleWithCollapsed
793
- * for each group.
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 applyCollapsedContent(segments, parsedSegments, lensPath, files) {
796
- // Build groups of article segments by resolved source path
797
- const sourceGroups = new Map();
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 ps = parsedSegments[i];
802
- if (ps.type !== "article")
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
- if (articleOutputIdx >= segments.length)
810
- break;
811
- if (ps.source) {
812
- const wikilink = parseWikilink(ps.source);
813
- if (wikilink && !wikilink.error) {
814
- const resolved = resolveWikilinkPath(wikilink.path, lensPath);
815
- const actualPath = findFileWithExtension(resolved, files);
816
- if (actualPath) {
817
- if (!sourceGroups.has(actualPath)) {
818
- sourceGroups.set(actualPath, []);
819
- }
820
- sourceGroups.get(actualPath).push({
821
- segmentIndex: articleOutputIdx,
822
- fromAnchor: ps.fromAnchor,
823
- toAnchor: ps.toAnchor,
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
- articleOutputIdx++;
829
- }
830
- // For each source group, apply collapsed content
831
- for (const [articlePath, excerptInfos] of sourceGroups) {
832
- // Only apply collapsed if at least one excerpt has anchors
833
- const hasAnchors = excerptInfos.some((e) => e.fromAnchor || e.toAnchor);
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
- if (!articleContent)
838
- continue;
839
- const collapsedResults = bundleArticleWithCollapsed(articleContent, excerptInfos.map((e) => ({ from: e.fromAnchor, to: e.toAnchor })), articlePath);
840
- for (let i = 0; i < excerptInfos.length && i < collapsedResults.length; i++) {
841
- const segIdx = excerptInfos[i].segmentIndex;
842
- const collapsed = collapsedResults[i];
843
- const segment = segments[segIdx];
844
- if (collapsed.collapsed_before) {
845
- segment.collapsed_before = collapsed.collapsed_before;
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
- if (collapsed.collapsed_after) {
848
- segment.collapsed_after = collapsed.collapsed_after;
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 { segment, errors };
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 { segment, errors };
1037
- }
1038
- case "article": {
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 wikilink = parseWikilink(parsedSegment.source);
1137
- if (!wikilink || wikilink.error) {
1138
- const suggestion = wikilink?.correctedPath
1139
- ? `Did you mean '[[${wikilink.correctedPath}]]'?`
1140
- : undefined;
1141
- errors.push({
1142
- file: lensPath,
1143
- message: `Invalid wikilink in video source: ${parsedSegment.source}`,
1144
- suggestion,
1145
- severity: "error",
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 { segment, errors };
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 { segment, errors };
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 { segment, errors };
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 { segment, errors };
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 { segment, errors };
1541
+ return {
1542
+ segment: attachSourceLine(segment, getSourceLine(parsedSegment)),
1543
+ errors,
1544
+ };
1416
1545
  }
1417
1546
  default:
1418
1547
  return { segment: null, errors };