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.
Files changed (61) hide show
  1. package/dist/authoring-markup.d.ts +26 -1
  2. package/dist/authoring-markup.js +67 -16
  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/content-schema.d.ts +3 -1
  8. package/dist/content-schema.js +51 -12
  9. package/dist/content-schema.js.map +1 -1
  10. package/dist/dedupe-errors.js +1 -7
  11. package/dist/dedupe-errors.js.map +1 -1
  12. package/dist/flattener/index.d.ts +23 -0
  13. package/dist/flattener/index.js +379 -262
  14. package/dist/flattener/index.js.map +1 -1
  15. package/dist/index.d.ts +29 -2
  16. package/dist/index.js +84 -15
  17. package/dist/index.js.map +1 -1
  18. package/dist/parser/article.js +13 -5
  19. package/dist/parser/article.js.map +1 -1
  20. package/dist/parser/course.js +19 -6
  21. package/dist/parser/course.js.map +1 -1
  22. package/dist/parser/learning-outcome.d.ts +0 -1
  23. package/dist/parser/learning-outcome.js +55 -18
  24. package/dist/parser/learning-outcome.js.map +1 -1
  25. package/dist/parser/lens.d.ts +6 -1
  26. package/dist/parser/lens.js +47 -41
  27. package/dist/parser/lens.js.map +1 -1
  28. package/dist/parser/module.js +9 -5
  29. package/dist/parser/module.js.map +1 -1
  30. package/dist/parser/response-segments.d.ts +54 -0
  31. package/dist/parser/response-segments.js +499 -0
  32. package/dist/parser/response-segments.js.map +1 -0
  33. package/dist/parser/survey.js +56 -15
  34. package/dist/parser/survey.js.map +1 -1
  35. package/dist/validator/article-structure.js +4 -18
  36. package/dist/validator/article-structure.js.map +1 -1
  37. package/dist/validator/directives.js +20 -42
  38. package/dist/validator/directives.js.map +1 -1
  39. package/dist/validator/emphasis.js +34 -55
  40. package/dist/validator/emphasis.js.map +1 -1
  41. package/dist/validator/html-tags.js +111 -103
  42. package/dist/validator/html-tags.js.map +1 -1
  43. package/dist/validator/markdown-parse.d.ts +3 -0
  44. package/dist/validator/markdown-parse.js +22 -0
  45. package/dist/validator/markdown-parse.js.map +1 -0
  46. package/dist/validator/output-integrity.d.ts +1 -1
  47. package/dist/validator/output-integrity.js +17 -23
  48. package/dist/validator/output-integrity.js.map +1 -1
  49. package/dist/validator/segment-fields.d.ts +4 -2
  50. package/dist/validator/segment-fields.js +27 -5
  51. package/dist/validator/segment-fields.js.map +1 -1
  52. package/dist/validator/test-segments.d.ts +11 -0
  53. package/dist/validator/test-segments.js +45 -0
  54. package/dist/validator/test-segments.js.map +1 -1
  55. package/dist/validator/uuid.d.ts +1 -2
  56. package/dist/validator/uuid.js +4 -6
  57. package/dist/validator/uuid.js.map +1 -1
  58. package/dist/validator/video-imports.d.ts +35 -0
  59. package/dist/validator/video-imports.js +183 -0
  60. package/dist/validator/video-imports.js.map +1 -0
  61. package/package.json +1 -1
@@ -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, bundleArticleWithCollapsed, } from "../bundler/article.js";
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
- 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) {
@@ -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
- * 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;
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
- if (collapsed.collapsed_after) {
848
- segment.collapsed_after = collapsed.collapsed_after;
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
- // Source comes from the parsed segment (set via inheritance in parseLens)
1046
- if (!parsedSegment.source) {
1047
- errors.push({
1048
- file: lensPath,
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 wikilink = parseWikilink(parsedSegment.source);
1150
- if (!wikilink || wikilink.error) {
1151
- const suggestion = wikilink?.correctedPath
1152
- ? `Did you mean '[[${wikilink.correctedPath}]]'?`
1153
- : undefined;
1154
- errors.push({
1155
- file: lensPath,
1156
- message: `Invalid wikilink in video source: ${parsedSegment.source}`,
1157
- suggestion,
1158
- severity: "error",
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 = attachSourceLine({
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
- }, getSourceLine(lens));
1610
+ };
1494
1611
  const flattenedModule = {
1495
1612
  slug: "lens/" + fileNameToSlug(lensPath),
1496
1613
  title: meta.title ?? fileNameToSlug(lensPath),