lens-content-processor 0.10.0 → 0.12.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.
@@ -65,6 +65,16 @@ export const SEGMENT_SCHEMAS = {
65
65
  "optional",
66
66
  "feedback",
67
67
  ], ["user-customizable", "optional", "feedback"]),
68
+ embed: segmentSchema([], [
69
+ "source",
70
+ "from",
71
+ "to",
72
+ "height",
73
+ "width",
74
+ "aspect-ratio",
75
+ "sandbox",
76
+ "optional",
77
+ ], ["optional"]),
68
78
  };
69
79
  /**
70
80
  * Valid fields per segment type, derived from SEGMENT_SCHEMAS.
@@ -83,6 +93,8 @@ const SECTION_LEVEL_FIELDS = [
83
93
  "learningOutcomeId",
84
94
  "sourceUrl",
85
95
  "hide",
96
+ "notes",
97
+ "add_to_ai_context", // submodule-level (on `# Submodule:` markers)
86
98
  ];
87
99
  const SECTION_LEVEL_BOOLEAN_FIELDS = ["hide"];
88
100
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"content-schema.js","sourceRoot":"","sources":["../src/content-schema.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"content-schema.js","sourceRoot":"","sources":["../src/content-schema.ts"],"names":[],"mappings":"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"}
@@ -10,6 +10,7 @@ export type BoundaryMarker = {
10
10
  __boundary: true;
11
11
  title: string;
12
12
  customSlug?: string;
13
+ addToAiContext?: string[];
13
14
  };
14
15
  export type FlatItem = Section | BoundaryMarker;
15
16
  export declare function isBoundary(item: FlatItem): item is BoundaryMarker;
@@ -21,6 +22,7 @@ interface VirtualModuleGroup {
21
22
  parentTitle: string;
22
23
  sections: Section[];
23
24
  contentId: string | null;
25
+ addToAiContext?: string[];
24
26
  }
25
27
  export declare function splitAtBoundaries(items: FlatItem[], parentSlug: string, parentTitle: string): VirtualModuleGroup[];
26
28
  /**
@@ -86,6 +86,36 @@ function toKebabCase(str) {
86
86
  .replace(/[^a-z0-9]+/g, "-")
87
87
  .replace(/(^-|-$)/g, "");
88
88
  }
89
+ /**
90
+ * Parse a `# Submodule:` marker's `add_to_ai_context::` field into raw wikilink
91
+ * strings (the same `[[...]]` form the backend resolves for every level).
92
+ * Returns undefined if the field is absent, [] if present with no wikilinks.
93
+ */
94
+ function parseSubmoduleAiContext(fields) {
95
+ if (!("add_to_ai_context" in fields))
96
+ return undefined;
97
+ const raw = fields.add_to_ai_context ?? "";
98
+ return raw.match(/\[\[[^\]]+\]\]/g) ?? [];
99
+ }
100
+ /**
101
+ * Merge a module's add_to_ai_context with a submodule's own, deduped by exact
102
+ * wikilink string (first occurrence wins), module entries first. The backend
103
+ * treats this combined list as the section's module tier.
104
+ */
105
+ function mergeAiContext(moduleCtx, submoduleCtx) {
106
+ const seen = new Set();
107
+ const out = [];
108
+ for (const entry of [
109
+ ...(Array.isArray(moduleCtx) ? moduleCtx : []),
110
+ ...submoduleCtx,
111
+ ]) {
112
+ if (seen.has(entry))
113
+ continue;
114
+ seen.add(entry);
115
+ out.push(entry);
116
+ }
117
+ return out;
118
+ }
89
119
  export function validateBoundaries(items, file) {
90
120
  const errors = [];
91
121
  const hasBoundaries = items.some(isBoundary);
@@ -163,6 +193,7 @@ export function splitAtBoundaries(items, parentSlug, parentTitle) {
163
193
  parentTitle,
164
194
  sections: [],
165
195
  contentId: null,
196
+ addToAiContext: item.addToAiContext,
166
197
  };
167
198
  }
168
199
  else if (currentGroup) {
@@ -261,6 +292,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
261
292
  __boundary: true,
262
293
  title: section.title,
263
294
  customSlug: section.fields.slug,
295
+ addToAiContext: parseSubmoduleAiContext(section.fields),
264
296
  });
265
297
  // Process children
266
298
  if (section.children) {
@@ -271,14 +303,6 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
271
303
  }
272
304
  }
273
305
  else {
274
- // Check if this is an LO with submodules
275
- if (section.type === "learning-outcome") {
276
- const loSections = processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors);
277
- if (loSections) {
278
- flatItems.push(...loSections);
279
- continue;
280
- }
281
- }
282
306
  const sections = processSection(section);
283
307
  flatItems.push(...sections);
284
308
  }
@@ -297,9 +321,14 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
297
321
  ? { parentSlug: g.parentSlug, parentTitle: g.parentTitle }
298
322
  : {}),
299
323
  ...(moduleError ? { error: moduleError } : {}),
300
- ...("addToAiContext" in parsedModule
301
- ? { addToAiContext: parsedModule.addToAiContext }
302
- : {}),
324
+ // Module-level add_to_ai_context, with any submodule-level entries merged in
325
+ // (module first, deduped). When the submodule has no field, this is exactly
326
+ // the module's own value.
327
+ ...(g.addToAiContext !== undefined
328
+ ? {
329
+ addToAiContext: mergeAiContext(parsedModule.addToAiContext, g.addToAiContext),
330
+ }
331
+ : { addToAiContext: parsedModule.addToAiContext }),
303
332
  }));
304
333
  // Enrich ::card links in text segments with computed metadata (duration, attribution, displayType)
305
334
  for (const mod of resultModules) {
@@ -317,7 +346,6 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
317
346
  }
318
347
  /**
319
348
  * Build a test Section from a ParsedTestRef with inline segments.
320
- * Shared by both processLOWithSubmodules() and flattenLearningOutcomeSection().
321
349
  */
322
350
  function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, tierMap, errors) {
323
351
  if (!testRef.segments.length)
@@ -346,123 +374,27 @@ function buildTestSection(testRef, loId, loName, loPath, files, visitedPaths, ti
346
374
  };
347
375
  }
348
376
  /**
349
- * Check if a Learning Outcome section resolves to an LO with submodules.
350
- * If so, emit boundary markers + sections. Returns null if no submodules.
351
- */
352
- function processLOWithSubmodules(section, modulePath, files, visitedPaths, tierMap, errors) {
353
- // Resolve the LO file to check for submodules
354
- const source = section.fields.source;
355
- if (!source)
356
- return null;
357
- const wikilink = parseWikilink(source);
358
- if (!wikilink || wikilink.error)
359
- return null;
360
- const loPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
361
- const loPath = findFileWithExtension(loPathResolved, files);
362
- if (!loPath)
363
- return null;
364
- const loContent = files.get(loPath);
365
- if (!loContent)
366
- return null;
367
- const loResult = parseLearningOutcome(loContent, loPath);
368
- if (!loResult.learningOutcome?.submodules)
369
- return null;
370
- // This LO has submodules — emit boundaries and resolve lenses
371
- const items = [];
372
- for (const sub of loResult.learningOutcome.submodules) {
373
- items.push({
374
- __boundary: true,
375
- title: sub.title,
376
- customSlug: sub.customSlug,
377
- });
378
- // Resolve each lens in this submodule group
379
- for (const lensRef of sub.lenses) {
380
- const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
381
- if (!lensPath) {
382
- const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
383
- const suggestion = formatSuggestion(similarFiles, loPath) ??
384
- "Check the file path in the wiki-link";
385
- errors.push({
386
- file: loPath,
387
- message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
388
- suggestion,
389
- severity: "error",
390
- });
391
- continue;
392
- }
393
- // Check tier violation
394
- if (tierMap) {
395
- const parentTier = tierMap.get(loPath) ?? "production";
396
- const childTier = tierMap.get(lensPath) ?? "production";
397
- const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
398
- if (violation) {
399
- errors.push(violation);
400
- }
401
- if (childTier === "ignored")
402
- continue;
403
- }
404
- if (visitedPaths.has(lensPath)) {
405
- errors.push({
406
- file: loPath,
407
- message: `Circular reference detected: ${lensPath}`,
408
- severity: "error",
409
- });
410
- continue;
411
- }
412
- const sectionVisitedPaths = new Set(visitedPaths);
413
- sectionVisitedPaths.add(lensPath);
414
- const lensContent = files.get(lensPath);
415
- const lensResult = parseLens(lensContent, lensPath);
416
- errors.push(...lensResult.errors);
417
- if (!lensResult.lens)
418
- continue;
419
- const lens = lensResult.lens;
420
- const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, sectionVisitedPaths, tierMap);
421
- errors.push(...flattenErrors);
422
- const lo = loResult.learningOutcome;
423
- const effectiveOptional = section.fields.optional?.toLowerCase() === "true" || lensRef.optional;
424
- const effectiveHide = section.fields.hide?.toLowerCase() === "true" || lensRef.hide;
425
- const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, loPath, section.line);
426
- if (hideConflict)
427
- errors.push(hideConflict);
428
- items.push({
429
- type: "lens",
430
- meta: { title: lens.title || section.title },
431
- segments,
432
- sourcePath: lensPath,
433
- optional: effectiveOptional,
434
- ...(effectiveHide && { hide: true }),
435
- learningOutcomeId: lo.id ?? null,
436
- learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
437
- contentId: lens.id ?? null,
438
- tldr: lens.tldr,
439
- summaryForTutor: lens.summaryForTutor,
440
- minChatMessages: lens.minChatMessages,
441
- addToAiContext: lens.addToAiContext,
442
- addToAiContextLo: lo.addToAiContext,
443
- ...computeSectionStats(segments),
444
- displayType: computeDisplayType(segments),
445
- });
446
- }
447
- // Process test section if present in this submodule
448
- if (sub.test) {
449
- const lo = loResult.learningOutcome;
450
- const testSection = buildTestSection(sub.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
451
- if (testSection)
452
- items.push(testSection);
453
- }
454
- }
455
- // Also emit LO parse errors
456
- errors.push(...loResult.errors);
457
- return items;
458
- }
459
- /**
460
- * Flatten a Learning Outcome section by resolving its LO file and all referenced lenses.
461
- * Each lens in the LO becomes its own section.
377
+ * Flatten a Learning Outcome section from a module file.
378
+ * In the current format an LO's lenses are suggestions only (imported
379
+ * explicitly by the module), so importing an LO yields just its test section.
462
380
  */
463
381
  function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths, tierMap) {
464
382
  const errors = [];
465
383
  const sections = [];
384
+ // The old format cascaded these onto the LO's auto-imported lenses; the
385
+ // import now contributes only the test (always required), so flag them
386
+ // instead of silently ignoring them.
387
+ for (const deadField of ["optional", "hide"]) {
388
+ if (section.fields[deadField] !== undefined) {
389
+ errors.push({
390
+ file: modulePath,
391
+ line: section.line,
392
+ message: `'${deadField}::' on a Learning Outcome import has no effect — importing an outcome contributes only its test, which is always required`,
393
+ suggestion: "Remove the field here; set it on the individual lens imports instead",
394
+ severity: "warning",
395
+ });
396
+ }
397
+ }
466
398
  // Get the source wikilink
467
399
  const source = section.fields.source;
468
400
  if (!source) {
@@ -539,81 +471,9 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
539
471
  return { sections: [], errors };
540
472
  }
541
473
  const lo = loResult.learningOutcome;
542
- // Each lens becomes its own section
543
- for (const lensRef of lo.lenses) {
544
- const lensPath = findFileWithExtension(lensRef.resolvedPath, files);
545
- if (!lensPath) {
546
- const similarFiles = findSimilarFiles(lensRef.resolvedPath, files, "Lenses");
547
- const suggestion = formatSuggestion(similarFiles, loPath) ??
548
- "Check the file path in the wiki-link";
549
- errors.push({
550
- file: loPath,
551
- message: `Referenced lens file not found: ${lensRef.resolvedPath}`,
552
- suggestion,
553
- severity: "error",
554
- });
555
- continue;
556
- }
557
- // Check tier violation (LO -> Lens)
558
- if (tierMap) {
559
- const parentTier = tierMap.get(loPath) ?? "production";
560
- const childTier = tierMap.get(lensPath) ?? "production";
561
- const violation = checkTierViolation(loPath, parentTier, lensPath, childTier, "lens");
562
- if (violation) {
563
- errors.push(violation);
564
- }
565
- if (childTier === "ignored") {
566
- continue;
567
- }
568
- }
569
- // Check for circular reference
570
- if (visitedPaths.has(lensPath)) {
571
- errors.push({
572
- file: loPath,
573
- message: `Circular reference detected: ${lensPath}`,
574
- severity: "error",
575
- });
576
- continue;
577
- }
578
- visitedPaths.add(lensPath);
579
- const lensContent = files.get(lensPath);
580
- // Parse the lens
581
- const lensResult = parseLens(lensContent, lensPath);
582
- errors.push(...lensResult.errors);
583
- if (!lensResult.lens) {
584
- continue;
585
- }
586
- const lens = lensResult.lens;
587
- // Flatten lens segments using shared helper
588
- const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
589
- errors.push(...flattenErrors);
590
- // Create a section for this lens
591
- const effectiveOptional = section.fields.optional?.toLowerCase() === "true" || lensRef.optional;
592
- const effectiveHide = section.fields.hide?.toLowerCase() === "true" || lensRef.hide;
593
- const hideConflict = checkHideOptionalConflict(effectiveOptional, effectiveHide, loPath, section.line);
594
- if (hideConflict)
595
- errors.push(hideConflict);
596
- const resultSection = {
597
- type: "lens",
598
- meta: { title: lens.title || section.title },
599
- segments,
600
- sourcePath: lensPath,
601
- optional: effectiveOptional,
602
- ...(effectiveHide && { hide: true }),
603
- learningOutcomeId: lo.id ?? null,
604
- learningOutcomeName: loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null,
605
- contentId: lens.id ?? null,
606
- tldr: lens.tldr,
607
- summaryForTutor: lens.summaryForTutor,
608
- minChatMessages: lens.minChatMessages,
609
- addToAiContext: lens.addToAiContext,
610
- addToAiContextLo: lo.addToAiContext,
611
- ...computeSectionStats(segments),
612
- displayType: computeDisplayType(segments),
613
- };
614
- sections.push(resultSection);
615
- }
616
- // After lens processing, add test section if present with inline segments
474
+ // Suggested lenses are intentionally NOT resolved here: they are
475
+ // suggestions for course creators, validated standalone in processContent.
476
+ // Emit the test section if the LO has one with inline segments
617
477
  if (lo.test) {
618
478
  const testSection = buildTestSection(lo.test, lo.id ?? null, loPath.split("/").pop()?.replace(/\.md$/i, "") ?? null, loPath, files, visitedPaths, tierMap, errors);
619
479
  if (testSection)
@@ -1092,6 +952,105 @@ function convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap) {
1092
952
  segment.feedback = true;
1093
953
  return { segment, errors };
1094
954
  }
955
+ case "embed": {
956
+ if (!parsedSegment.source) {
957
+ errors.push({
958
+ file: lensPath,
959
+ message: "Embed segment missing source (should have been set via inheritance)",
960
+ severity: "error",
961
+ });
962
+ return { segment: null, errors };
963
+ }
964
+ const wikilink = parseWikilink(parsedSegment.source);
965
+ if (!wikilink || wikilink.error) {
966
+ const suggestion = wikilink?.correctedPath
967
+ ? `Did you mean '[[${wikilink.correctedPath}]]'?`
968
+ : undefined;
969
+ errors.push({
970
+ file: lensPath,
971
+ message: `Invalid wikilink in embed source: ${parsedSegment.source}`,
972
+ suggestion,
973
+ severity: "error",
974
+ });
975
+ return { segment: null, errors };
976
+ }
977
+ const articlePathResolved = resolveWikilinkPath(wikilink.path, lensPath);
978
+ const articlePath = findFileWithExtension(articlePathResolved, files);
979
+ if (!articlePath) {
980
+ const similarFiles = findSimilarFiles(articlePathResolved, files, "articles");
981
+ const suggestion = formatSuggestion(similarFiles, lensPath) ??
982
+ "Check the file path in the wiki-link";
983
+ errors.push({
984
+ file: lensPath,
985
+ message: `Referenced article file not found: ${articlePathResolved}`,
986
+ suggestion,
987
+ severity: "error",
988
+ });
989
+ return { segment: null, errors };
990
+ }
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
+ errors.push(violation);
997
+ }
998
+ if (childTier === "ignored") {
999
+ return { segment: null, errors };
1000
+ }
1001
+ }
1002
+ if (visitedPaths.has(articlePath)) {
1003
+ errors.push({
1004
+ file: lensPath,
1005
+ message: `Circular reference detected: ${articlePath}`,
1006
+ severity: "error",
1007
+ });
1008
+ return { segment: null, errors };
1009
+ }
1010
+ const articleContent = files.get(articlePath);
1011
+ const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
1012
+ const fm = articleFrontmatter.frontmatter;
1013
+ if (!fm.source_url) {
1014
+ errors.push({
1015
+ file: lensPath,
1016
+ message: `Embed source article missing source_url: ${articlePath}`,
1017
+ suggestion: "Add source_url to the article frontmatter; embeds use it as the iframe URL",
1018
+ severity: "error",
1019
+ });
1020
+ return { segment: null, errors };
1021
+ }
1022
+ const excerptResult = extractArticleExcerpt(articleContent, parsedSegment.fromAnchor, parsedSegment.toAnchor, articlePath);
1023
+ if (excerptResult.error) {
1024
+ errors.push({ ...excerptResult.error, file: lensPath });
1025
+ return { segment: null, errors };
1026
+ }
1027
+ const segment = {
1028
+ type: "embed",
1029
+ url: String(fm.source_url),
1030
+ content: excerptResult.content,
1031
+ sourceUrl: String(fm.source_url),
1032
+ sourcePath: articlePath,
1033
+ };
1034
+ if (fm.title)
1035
+ segment.title = fm.title;
1036
+ if (fm.author) {
1037
+ const raw = fm.author;
1038
+ segment.author = Array.isArray(raw) ? raw.join(", ") : String(raw);
1039
+ }
1040
+ if (fm.published)
1041
+ segment.published = String(fm.published);
1042
+ if (parsedSegment.height)
1043
+ segment.height = parsedSegment.height;
1044
+ if (parsedSegment.width)
1045
+ segment.width = parsedSegment.width;
1046
+ if (parsedSegment.aspectRatio)
1047
+ segment.aspectRatio = parsedSegment.aspectRatio;
1048
+ if (parsedSegment.sandbox)
1049
+ segment.sandbox = parsedSegment.sandbox;
1050
+ if (parsedSegment.optional)
1051
+ segment.optional = true;
1052
+ return { segment, errors };
1053
+ }
1095
1054
  default:
1096
1055
  return { segment: null, errors };
1097
1056
  }