lens-content-processor 0.79.0 → 0.80.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 (130) hide show
  1. package/dist/authoring-markup.d.ts +22 -0
  2. package/dist/authoring-markup.js +24 -0
  3. package/dist/authoring-markup.js.map +1 -1
  4. package/dist/cache.d.ts +10 -0
  5. package/dist/cache.js +6 -5
  6. package/dist/cache.js.map +1 -1
  7. package/dist/cli-steps.d.ts +37 -0
  8. package/dist/cli-steps.js +80 -0
  9. package/dist/cli-steps.js.map +1 -0
  10. package/dist/cli.d.ts +7 -0
  11. package/dist/cli.js +52 -70
  12. package/dist/cli.js.map +1 -1
  13. package/dist/course-graph.d.ts +2 -2
  14. package/dist/course-graph.js +7 -2
  15. package/dist/course-graph.js.map +1 -1
  16. package/dist/criticmarkup-forms.d.ts +8 -6
  17. package/dist/criticmarkup-forms.js +22 -19
  18. package/dist/criticmarkup-forms.js.map +1 -1
  19. package/dist/dedupe-errors.d.ts +16 -0
  20. package/dist/dedupe-errors.js +23 -9
  21. package/dist/dedupe-errors.js.map +1 -1
  22. package/dist/dependency-cache.d.ts +15 -5
  23. package/dist/dependency-cache.js +33 -74
  24. package/dist/dependency-cache.js.map +1 -1
  25. package/dist/dropped.d.ts +114 -0
  26. package/dist/dropped.js +253 -0
  27. package/dist/dropped.js.map +1 -0
  28. package/dist/engine/assemble.d.ts +44 -0
  29. package/dist/engine/assemble.js +132 -0
  30. package/dist/engine/assemble.js.map +1 -0
  31. package/dist/engine/build.d.ts +8 -0
  32. package/dist/engine/build.js +558 -0
  33. package/dist/engine/build.js.map +1 -0
  34. package/dist/engine/file-facts.d.ts +26 -0
  35. package/dist/engine/file-facts.js +63 -0
  36. package/dist/engine/file-facts.js.map +1 -0
  37. package/dist/engine/git-source.d.ts +35 -0
  38. package/dist/engine/git-source.js +242 -0
  39. package/dist/engine/git-source.js.map +1 -0
  40. package/dist/engine/overlay.d.ts +43 -0
  41. package/dist/engine/overlay.js +167 -0
  42. package/dist/engine/overlay.js.map +1 -0
  43. package/dist/engine/protocol.d.ts +146 -0
  44. package/dist/engine/protocol.js +40 -0
  45. package/dist/engine/protocol.js.map +1 -0
  46. package/dist/engine/reader.d.ts +133 -0
  47. package/dist/engine/reader.js +298 -0
  48. package/dist/engine/reader.js.map +1 -0
  49. package/dist/engine/sources.d.ts +39 -0
  50. package/dist/engine/sources.js +137 -0
  51. package/dist/engine/sources.js.map +1 -0
  52. package/dist/engine/sqlite-store.d.ts +99 -0
  53. package/dist/engine/sqlite-store.js +452 -0
  54. package/dist/engine/sqlite-store.js.map +1 -0
  55. package/dist/engine/store.d.ts +85 -0
  56. package/dist/engine/store.js +31 -0
  57. package/dist/engine/store.js.map +1 -0
  58. package/dist/engine/tables.d.ts +15 -0
  59. package/dist/engine/tables.js +70 -0
  60. package/dist/engine/tables.js.map +1 -0
  61. package/dist/engine/units.d.ts +58 -0
  62. package/dist/engine/units.js +186 -0
  63. package/dist/engine/units.js.map +1 -0
  64. package/dist/engine/version.d.ts +6 -0
  65. package/dist/engine/version.js +21 -0
  66. package/dist/engine/version.js.map +1 -0
  67. package/dist/engine/worker.d.ts +1 -0
  68. package/dist/engine/worker.js +220 -0
  69. package/dist/engine/worker.js.map +1 -0
  70. package/dist/flattener/index.d.ts +34 -17
  71. package/dist/flattener/index.js +245 -114
  72. package/dist/flattener/index.js.map +1 -1
  73. package/dist/flattener/resolve-text-links.d.ts +15 -14
  74. package/dist/flattener/resolve-text-links.js +24 -25
  75. package/dist/flattener/resolve-text-links.js.map +1 -1
  76. package/dist/glossary.d.ts +2 -2
  77. package/dist/glossary.js.map +1 -1
  78. package/dist/index.d.ts +168 -2
  79. package/dist/index.js +883 -639
  80. package/dist/index.js.map +1 -1
  81. package/dist/markdown-parse.d.ts +6 -0
  82. package/dist/markdown-parse.js +14 -0
  83. package/dist/markdown-parse.js.map +1 -1
  84. package/dist/parser/learning-outcome.js +11 -2
  85. package/dist/parser/learning-outcome.js.map +1 -1
  86. package/dist/parser/lens.d.ts +5 -1
  87. package/dist/parser/lens.js +22 -7
  88. package/dist/parser/lens.js.map +1 -1
  89. package/dist/parser/meeting-doc.d.ts +2 -1
  90. package/dist/parser/meeting-doc.js +2 -1
  91. package/dist/parser/meeting-doc.js.map +1 -1
  92. package/dist/parser/module.d.ts +3 -1
  93. package/dist/parser/module.js +7 -5
  94. package/dist/parser/module.js.map +1 -1
  95. package/dist/parser/question-embeds.d.ts +4 -3
  96. package/dist/parser/question-embeds.js.map +1 -1
  97. package/dist/parser/sections.d.ts +10 -0
  98. package/dist/parser/sections.js +8 -3
  99. package/dist/parser/sections.js.map +1 -1
  100. package/dist/parser/widget.d.ts +2 -1
  101. package/dist/parser/widget.js.map +1 -1
  102. package/dist/parser/wikilink.d.ts +13 -3
  103. package/dist/parser/wikilink.js +55 -34
  104. package/dist/parser/wikilink.js.map +1 -1
  105. package/dist/reference-graph.d.ts +21 -3
  106. package/dist/reference-graph.js +35 -12
  107. package/dist/reference-graph.js.map +1 -1
  108. package/dist/same-page-links.d.ts +11 -1
  109. package/dist/same-page-links.js +9 -0
  110. package/dist/same-page-links.js.map +1 -1
  111. package/dist/skill-tree.d.ts +2 -1
  112. package/dist/skill-tree.js.map +1 -1
  113. package/dist/source-location.d.ts +0 -4
  114. package/dist/source-location.js +0 -43
  115. package/dist/source-location.js.map +1 -1
  116. package/dist/validator/card-module-membership.d.ts +17 -1
  117. package/dist/validator/card-module-membership.js +60 -56
  118. package/dist/validator/card-module-membership.js.map +1 -1
  119. package/dist/validator/criticmarkup.js +1 -1
  120. package/dist/validator/criticmarkup.js.map +1 -1
  121. package/dist/validator/field-typos.d.ts +1 -1
  122. package/dist/validator/field-typos.js +21 -21
  123. package/dist/validator/field-typos.js.map +1 -1
  124. package/dist/validator/output-integrity.d.ts +9 -5
  125. package/dist/validator/output-integrity.js +43 -45
  126. package/dist/validator/output-integrity.js.map +1 -1
  127. package/dist/validator/tier.d.ts +2 -2
  128. package/dist/validator/tier.js.map +1 -1
  129. package/dist/validator/uuid.d.ts +1 -1
  130. package/package.json +2 -1
@@ -15,7 +15,8 @@ import { fileNameToSlug } from "../utils/slug.js";
15
15
  import { extractArticleExcerpt, resolveArticleSource, } from "../bundler/article.js";
16
16
  import { extractVideoExcerpt, resolveVideoRange, } from "../bundler/video.js";
17
17
  import { attachSourceLine, getSourceLine } from "../source-location.js";
18
- import { collectBlockTargets, collectBlockTargetsFrom, resolveSamePageLinks, } from "../same-page-links.js";
18
+ import { collectBlockTargets, collectBlockTargetsFrom, lazyBlockTargets, resolveSamePageLinks, } from "../same-page-links.js";
19
+ import { attachDroppedSegments, dropCause, droppedContent, droppedSection, droppedSegment, droppedSegmentsOf, fileStem, isBuilt, placeDropped, placedAfter, } from "../dropped.js";
19
20
  function countWords(text) {
20
21
  return text.trim().split(/\s+/).filter(Boolean).length;
21
22
  }
@@ -242,6 +243,8 @@ function mergeAiContext(moduleCtx, submoduleCtx) {
242
243
  }
243
244
  return out;
244
245
  }
246
+ /** The error for sections before the first `# Submodule:`, which no page holds. */
247
+ const OUTSIDE_SUBMODULES = "Content found outside submodule boundaries — all content must be inside a submodule";
245
248
  export function validateBoundaries(items, file) {
246
249
  const errors = [];
247
250
  const hasBoundaries = items.some(isBoundary);
@@ -255,7 +258,7 @@ export function validateBoundaries(items, file) {
255
258
  if (sectionsBefore.length > 0) {
256
259
  errors.push({
257
260
  file,
258
- message: "Content found outside submodule boundaries — all content must be inside a submodule",
261
+ message: OUTSIDE_SUBMODULES,
259
262
  severity: "error",
260
263
  });
261
264
  }
@@ -333,39 +336,50 @@ export function splitAtBoundaries(items, parentSlug, parentTitle) {
333
336
  return groups;
334
337
  }
335
338
  /**
336
- * Flatten a single ParsedLens into output Segments.
337
- * This is the shared core used by all lens-processing functions.
339
+ * Flatten the parsed segments of a lens or of an outcome's test into output
340
+ * Segments: the shared core of every lens and test path.
338
341
  *
339
- * For each segment in the lens, calls convertSegment to resolve article/video content.
340
- * Then applies collapsed content for article segments from the same source.
342
+ * Article segments are resolved up front, grouped per source file (one parsed
343
+ * segment can expand to several output segments when the article declares
344
+ * inline videos); every other segment goes through convertSegment.
345
+ * `collapsed: false` (test sections) skips the collapsed text at excerpt
346
+ * boundaries. `dropped` starts with the segments the parser refused.
341
347
  */
342
- function flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap) {
348
+ function flattenSegments(parsed, path, files, visitedPaths, tierMap, options) {
349
+ const parsedSegments = parsed.segments;
343
350
  const errors = [];
344
351
  const segments = [];
345
- const blockTargets = collectBlockTargetsFrom(lens.segments.flatMap((segment) => "content" in segment && typeof segment.content === "string"
352
+ const dropped = [...droppedSegmentsOf(parsed)];
353
+ const blockTargets = lazyBlockTargets(() => collectBlockTargetsFrom(parsedSegments.flatMap((segment) => "content" in segment && typeof segment.content === "string"
346
354
  ? [segment.content]
347
- : []));
348
- // Article segments are resolved up front, grouped per source file (one
349
- // parsed segment can expand to several output segments when the article
350
- // declares inline videos).
351
- const articlePlan = planLensArticles(lens.segments, lensPath, files, visitedPaths, tierMap, { collapsed: true });
352
- for (let i = 0; i < lens.segments.length; i++) {
353
- const parsedSegment = lens.segments[i];
355
+ : [])));
356
+ const articlePlan = planLensArticles(parsedSegments, path, files, visitedPaths, tierMap, options);
357
+ for (let i = 0; i < parsedSegments.length; i++) {
358
+ const parsedSegment = parsedSegments[i];
354
359
  const planned = articlePlan.get(i);
355
360
  if (planned) {
356
361
  errors.push(...planned.errors);
362
+ dropped.push(...planned.dropped);
357
363
  for (const segment of planned.segments) {
358
364
  segments.push(prependFrames(segment, parsedSegment.frames));
359
365
  }
360
366
  continue;
361
367
  }
362
- const segmentResult = convertSegment(parsedSegment, lensPath, files, visitedPaths, tierMap, blockTargets);
368
+ const segmentResult = convertSegment(parsedSegment, path, files, visitedPaths, tierMap, blockTargets);
363
369
  errors.push(...segmentResult.errors);
364
370
  if (segmentResult.segment) {
365
371
  segments.push(prependFrames(segmentResult.segment, parsedSegment.frames));
366
372
  }
373
+ else {
374
+ dropped.push(...droppedSegment(parsedSegment.type, sourceOf(parsedSegment), path, segmentResult.errors));
375
+ }
367
376
  }
368
- return { segments, errors };
377
+ return { segments, errors, dropped };
378
+ }
379
+ /** The `source::` wikilink of a parsed segment, when its type has one. */
380
+ function sourceOf(segment) {
381
+ const source = segment.source;
382
+ return typeof source === "string" ? source : undefined;
369
383
  }
370
384
  // ─── Module flattening ───
371
385
  /**
@@ -384,11 +398,11 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
384
398
  severity: "error",
385
399
  },
386
400
  ],
401
+ dropped: [],
387
402
  };
388
403
  }
389
404
  visitedPaths.add(modulePath);
390
405
  const errors = [];
391
- let moduleError;
392
406
  // Get module content
393
407
  const moduleContent = files.get(modulePath);
394
408
  if (!moduleContent) {
@@ -397,16 +411,27 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
397
411
  message: `Module file not found: ${modulePath}`,
398
412
  severity: "error",
399
413
  });
400
- return { module: null, modules: [], errors };
414
+ return { module: null, modules: [], errors, dropped: [] };
401
415
  }
402
416
  // Parse the module
403
417
  const moduleResult = parseModule(moduleContent, modulePath);
404
418
  errors.push(...moduleResult.errors);
405
419
  if (!moduleResult.module) {
406
- return { module: null, modules: [], errors };
420
+ return { module: null, modules: [], errors, dropped: [] };
407
421
  }
408
422
  const parsedModule = moduleResult.module;
409
423
  const flatItems = [];
424
+ // A header the parser refused (an unknown type, such as "# Lense:") left out
425
+ // the section it would have started: a placeholder stands where the header
426
+ // is, and keeps its place as a lens does (src/dropped.ts).
427
+ const refused = [...moduleResult.refused];
428
+ const placeRefused = (beforeLine = Infinity) => {
429
+ while (refused.length > 0 && (refused[0].error.line ?? 0) < beforeLine) {
430
+ const { title, error } = refused.shift();
431
+ const drop = droppedContent("section", title, modulePath, error);
432
+ flatItems.push(droppedSection("lens", drop));
433
+ }
434
+ };
410
435
  // Helper: process a section (LO or Lens). An LO import also yields the
411
436
  // outcome itself, so lenses can be tagged with it below.
412
437
  function processSection(section, _subsectionLevel) {
@@ -476,9 +501,10 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
476
501
  const { sections, outcome } = processSection(section);
477
502
  recordOutcome(outcome, true);
478
503
  moduleLevelTestSections.push(...sections);
479
- return sections.length;
504
+ return sections.filter(isBuilt).length;
480
505
  }
481
506
  for (const section of parsedModule.sections) {
507
+ placeRefused(section.line);
482
508
  if (section.type === "submodule") {
483
509
  if (seenSubmodule)
484
510
  groupIdx++;
@@ -493,6 +519,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
493
519
  // Process children
494
520
  if (section.children) {
495
521
  for (const child of section.children) {
522
+ placeRefused(child.line);
496
523
  const { sections, outcome } = processSection(child, child.level + 1);
497
524
  recordOutcome(outcome, false);
498
525
  flatItems.push(...sections);
@@ -537,13 +564,31 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
537
564
  flatItems.push(...sections);
538
565
  }
539
566
  }
567
+ placeRefused();
540
568
  // Flat modules: append top-declared LO tests at the end of the section list.
541
569
  if (!hasSubmodules) {
542
570
  flatItems.push(...moduleLevelTestSections);
543
571
  }
544
572
  // Validate and split at boundaries
545
- const boundaryErrors = validateBoundaries(flatItems, modulePath);
573
+ const boundaryErrors = validateBoundaries(flatItems.filter(isBuilt), modulePath);
546
574
  errors.push(...boundaryErrors);
575
+ // Sections before the first `# Submodule:` are on no page. Each is recorded
576
+ // before the first page's sections: a built one with the error that says
577
+ // so, a placeholder with its own (src/dropped.ts).
578
+ const firstBoundary = flatItems.findIndex(isBoundary);
579
+ const outsideItems = flatItems.slice(0, Math.max(firstBoundary, 0));
580
+ const outside = boundaryErrors.find((e) => e.message === OUTSIDE_SUBMODULES);
581
+ const orphans = [];
582
+ for (const section of outsideItems) {
583
+ if (!isBuilt(section))
584
+ orphans.push(...placeDropped([section]).items);
585
+ else if (outside) {
586
+ const sourcePath = section.sourcePath ?? modulePath;
587
+ const title = section.meta.title || fileStem(sourcePath);
588
+ const drop = droppedContent("section", title, sourcePath, outside);
589
+ orphans.push(placedAfter(-1, [], drop));
590
+ }
591
+ }
547
592
  const groups = splitAtBoundaries(flatItems, parsedModule.slug, parsedModule.title);
548
593
  // groups[i] is the i-th `# Submodule:` (the whole module when flat), the
549
594
  // same order recordOutcome counted in.
@@ -572,7 +617,7 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
572
617
  }
573
618
  }
574
619
  // Module-level LOs: append the synthesized "Test Your Understanding" entry.
575
- if (hasSubmodules && moduleLevelTestSections.length > 0) {
620
+ if (hasSubmodules && moduleLevelTestSections.some(isBuilt)) {
576
621
  const testSlug = `${parsedModule.slug}/test-your-understanding`;
577
622
  const collision = groups.find((g) => g.slug === testSlug);
578
623
  if (collision) {
@@ -596,16 +641,22 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
596
641
  });
597
642
  }
598
643
  }
599
- const resultModules = groups.map((g) => ({
644
+ else if (hasSubmodules && moduleLevelTestSections.length > 0) {
645
+ // Every module-level test failed, so there is no test page: their
646
+ // records follow the last page.
647
+ groups[groups.length - 1].sections.push(...moduleLevelTestSections);
648
+ }
649
+ const placed = groups.map((g) => placeDropped(g.sections));
650
+ placed[0].items.unshift(...orphans);
651
+ const resultModules = groups.map((g, i) => ({
600
652
  slug: g.slug,
601
653
  title: g.title,
602
654
  contentId: parsedModule.contentId,
603
- sections: g.sections,
655
+ sections: placed[i].sections,
604
656
  sourcePath: modulePath,
605
657
  ...(g.parentSlug
606
658
  ? { parentSlug: g.parentSlug, parentTitle: g.parentTitle }
607
659
  : {}),
608
- ...(moduleError ? { error: moduleError } : {}),
609
660
  // Module-level add_to_ai_context, with any submodule-level entries merged in
610
661
  // (module first, deduped). When the submodule has no field, this is exactly
611
662
  // the module's own value.
@@ -627,42 +678,80 @@ export function flattenModule(modulePath, files, visitedPaths = new Set(), tierM
627
678
  : primaryModule,
628
679
  modules: resultModules,
629
680
  errors,
681
+ dropped: placed.map((p) => p.items),
630
682
  };
631
683
  }
684
+ /**
685
+ * The sections a module declares, for the cross-check in src/dropped.ts:
686
+ * every lens import, and every outcome import except one whose outcome
687
+ * declares no test segment (that builds nothing, on purpose). A section's page
688
+ * is the index of its `# Submodule:` (0 outside any). Paths resolve as
689
+ * flattenLensSection and flattenLearningOutcomeSection resolve them, so a
690
+ * section and its record name the same file.
691
+ */
692
+ export function declaredSections(parsedModule, modulePath, files) {
693
+ const declared = [];
694
+ const pathOf = (source) => {
695
+ const link = source ? parseWikilink(source, modulePath) : null;
696
+ if (!link || link.error)
697
+ return modulePath;
698
+ const resolved = resolveWikilinkPath(link.path, modulePath);
699
+ return findFileWithExtension(resolved, files) ?? resolved;
700
+ };
701
+ const declare = (section, page) => {
702
+ const sourcePath = pathOf(section.fields.source);
703
+ const own = sourcePath === modulePath;
704
+ if (section.type === "lens") {
705
+ const title = section.title.trim() || (own ? "Lens" : fileStem(sourcePath));
706
+ declared.push({ sourcePath, title, line: section.line, page });
707
+ }
708
+ else if (section.type === "learning-outcome") {
709
+ if (!own && files.has(sourcePath)) {
710
+ const text = files.get(sourcePath);
711
+ const outcome = parseLearningOutcome(text, sourcePath).learningOutcome;
712
+ const test = outcome?.test;
713
+ const declaresSegments = !!test && test.segments.length + droppedSegmentsOf(test).length > 0;
714
+ if (outcome && !declaresSegments)
715
+ return;
716
+ }
717
+ const title = own ? "Test" : `Test: ${fileStem(sourcePath)}`;
718
+ declared.push({ sourcePath, title, line: section.line, page });
719
+ }
720
+ };
721
+ // A section after the k-th `# Submodule:` marker, nested or not, is on
722
+ // page k.
723
+ let page = 0;
724
+ let submodules = 0;
725
+ for (const section of parsedModule.sections) {
726
+ if (section.type !== "submodule")
727
+ declare(section, page);
728
+ else {
729
+ page = submodules++;
730
+ for (const child of section.children ?? [])
731
+ declare(child, page);
732
+ }
733
+ }
734
+ return declared;
735
+ }
632
736
  /** Minutes a learning-outcome test takes to write when its outcome sets no
633
737
  * `writing_minutes`: one figure per test, whatever its question count. */
634
738
  const TEST_WRITING_MINUTES = 7;
635
739
  /**
636
740
  * Build a test Section from a ParsedTestRef with inline segments. Its time is
637
741
  * writing time, carried as durationMinutes (a total with no tutor share).
742
+ * `dropped` are the segments that errors left out; when they were all of
743
+ * them, there is no section, and they say why.
638
744
  */
639
745
  function buildTestSection(testRef, writingMinutes, outcome, loPath, files, visitedPaths, tierMap, errors) {
640
- if (!testRef.segments.length)
641
- return null;
642
- const testSegments = [];
643
- const blockTargets = collectBlockTargetsFrom(testRef.segments.flatMap((segment) => "content" in segment && typeof segment.content === "string"
644
- ? [segment.content]
645
- : []));
646
746
  // collapsed: false — test sections never carried collapsed_* fields
647
- const articlePlan = planLensArticles(testRef.segments, loPath, files, new Set(visitedPaths), tierMap, { collapsed: false });
648
- for (let i = 0; i < testRef.segments.length; i++) {
649
- const parsedSegment = testRef.segments[i];
650
- const planned = articlePlan.get(i);
651
- if (planned) {
652
- errors.push(...planned.errors);
653
- for (const segment of planned.segments) {
654
- testSegments.push(prependFrames(segment, parsedSegment.frames));
655
- }
656
- continue;
657
- }
658
- const segmentResult = convertSegment(parsedSegment, loPath, files, new Set(visitedPaths), tierMap, blockTargets);
659
- errors.push(...segmentResult.errors);
660
- if (segmentResult.segment) {
661
- testSegments.push(prependFrames(segmentResult.segment, parsedSegment.frames));
662
- }
663
- }
747
+ const flat = flattenSegments(testRef, loPath, files, visitedPaths, tierMap, {
748
+ collapsed: false,
749
+ });
750
+ errors.push(...flat.errors);
751
+ const { dropped } = flat;
752
+ const testSegments = flat.segments;
664
753
  if (testSegments.length === 0)
665
- return null;
754
+ return { section: null, dropped };
666
755
  const hasFeedback = testSegments.some((s) => s.type === "question" && s.feedback);
667
756
  // Grouped feedback is the legacy questions' feedback; force-feedback:: on
668
757
  // any of them sends it on completion instead of on "Get feedback". The
@@ -673,7 +762,7 @@ function buildTestSection(testRef, writingMinutes, outcome, loPath, files, visit
673
762
  : forced.includes(true)
674
763
  ? true
675
764
  : forced[0];
676
- return {
765
+ const section = {
677
766
  type: "test",
678
767
  // Always the outcome's name: the sidebar shows it, and the title is the
679
768
  // section's URL anchor, so two tests in one submodule must differ.
@@ -687,14 +776,15 @@ function buildTestSection(testRef, writingMinutes, outcome, loPath, files, visit
687
776
  learningOutcomes: [outcome],
688
777
  durationMinutes: writingMinutes ?? TEST_WRITING_MINUTES,
689
778
  };
779
+ return { section, dropped };
690
780
  }
691
781
  /** Tag a lens section with every outcome that suggests its file. An inline
692
782
  * lens's sourcePath is the module file, which no outcome suggests. */
693
- function tagLensOutcomes(section, outcomes) {
783
+ export function tagLensOutcomes(section, outcomes) {
694
784
  if (section.type !== "lens" || !section.sourcePath)
695
785
  return;
696
786
  for (const { ref, lensPaths } of outcomes) {
697
- if (!lensPaths.has(section.sourcePath))
787
+ if (!lensPaths.includes(section.sourcePath))
698
788
  continue;
699
789
  if (section.learningOutcomes.some((o) => o.id === ref.id))
700
790
  continue;
@@ -709,32 +799,7 @@ export function suggestedLensPaths(lo, files) {
709
799
  if (resolved)
710
800
  lensPaths.add(resolved);
711
801
  }
712
- return lensPaths;
713
- }
714
- /**
715
- * A course teaches an outcome across units but tests it once, so a lens also
716
- * carries every outcome that suggests it and is tested in any module of a
717
- * course the lens's module is in. Runs after flattening, once the course
718
- * progressions are resolved to module slugs; tags from the lens's own
719
- * submodule come first, and the ones added here are marked testedElsewhere.
720
- */
721
- export function tagCourseOutcomes(modules, courses, lensPathsOf) {
722
- const moduleBySlug = new Map(modules.map((mod) => [mod.slug, mod]));
723
- for (const course of courses) {
724
- const courseModules = course.progression.flatMap((item) => {
725
- const mod = item.slug ? moduleBySlug.get(item.slug) : undefined;
726
- return item.type === "module" && mod ? [mod] : [];
727
- });
728
- const sections = courseModules.flatMap((mod) => mod.sections);
729
- const outcomes = sections.flatMap((section) => section.type === "test" && section.sourcePath
730
- ? section.learningOutcomes.map((ref) => ({
731
- ref: { ...ref, testedElsewhere: true },
732
- lensPaths: lensPathsOf(section.sourcePath),
733
- }))
734
- : []);
735
- for (const section of sections)
736
- tagLensOutcomes(section, outcomes);
737
- }
802
+ return [...lensPaths];
738
803
  }
739
804
  /**
740
805
  * Flatten a Learning Outcome section from a module file.
@@ -771,6 +836,18 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
771
836
  });
772
837
  }
773
838
  }
839
+ // An import that fails makes no test: a placeholder stands where the test
840
+ // would have been, with the error that stopped it (src/dropped.ts).
841
+ const failed = (sourcePath, cause = errors.at(-1)) => ({
842
+ sections: cause
843
+ ? [
844
+ droppedSection("test", droppedContent("section", sourcePath === modulePath
845
+ ? "Test"
846
+ : `Test: ${fileStem(sourcePath)}`, sourcePath, cause)),
847
+ ]
848
+ : [],
849
+ errors,
850
+ });
774
851
  // Get the source wikilink
775
852
  const source = section.fields.source;
776
853
  if (!source) {
@@ -781,7 +858,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
781
858
  suggestion: "Add 'source:: [[../Learning Outcomes/filename.md|Display]]'",
782
859
  severity: "error",
783
860
  });
784
- return { sections: [], errors };
861
+ return failed(modulePath);
785
862
  }
786
863
  // Parse and resolve the wikilink
787
864
  const wikilink = parseWikilink(source, modulePath);
@@ -798,7 +875,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
798
875
  suggestion,
799
876
  severity: "error",
800
877
  });
801
- return { sections: [], errors };
878
+ return failed(modulePath);
802
879
  }
803
880
  const loPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
804
881
  const loPath = findFileWithExtension(loPathResolved, files);
@@ -814,7 +891,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
814
891
  suggestion,
815
892
  severity: "error",
816
893
  });
817
- return { sections: [], errors };
894
+ return failed(loPathResolved);
818
895
  }
819
896
  // Check tier violation (module -> LO)
820
897
  if (tierMap) {
@@ -825,7 +902,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
825
902
  errors.push(violation);
826
903
  }
827
904
  if (childTier === "ignored") {
828
- return { sections: [], errors };
905
+ return failed(loPath, violation);
829
906
  }
830
907
  }
831
908
  // Check for circular reference
@@ -836,7 +913,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
836
913
  message: `Circular reference detected: ${loPath}`,
837
914
  severity: "error",
838
915
  });
839
- return { sections: [], errors };
916
+ return failed(loPath);
840
917
  }
841
918
  visitedPaths.add(loPath);
842
919
  const loContent = files.get(loPath);
@@ -844,7 +921,7 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
844
921
  const loResult = parseLearningOutcome(loContent, loPath);
845
922
  errors.push(...loResult.errors);
846
923
  if (!loResult.learningOutcome) {
847
- return { sections: [], errors };
924
+ return failed(loPath, dropCause(loResult.errors));
848
925
  }
849
926
  const lo = loResult.learningOutcome;
850
927
  // Suggested lenses are validated standalone in processContent; here they
@@ -857,9 +934,11 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
857
934
  const lensPaths = suggestedLensPaths(lo, files);
858
935
  // Emit the test section if the LO has one with inline segments
859
936
  if (lo.test) {
860
- const testSection = buildTestSection(lo.test, lo.writingMinutes, ref, loPath, files, visitedPaths, tierMap, errors);
861
- if (testSection)
862
- sections.push(testSection);
937
+ const { section: test, dropped } = buildTestSection(lo.test, lo.writingMinutes, ref, loPath, files, visitedPaths, tierMap, errors);
938
+ if (test)
939
+ sections.push(attachDroppedSegments(test, dropped));
940
+ else if (dropped.length > 0)
941
+ sections.push(...failed(loPath, dropped[0].error).sections);
863
942
  }
864
943
  return { sections, errors, outcome: { ref, lensPaths } };
865
944
  }
@@ -870,9 +949,22 @@ function flattenLearningOutcomeSection(section, modulePath, files, visitedPaths,
870
949
  function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, inlineLens) {
871
950
  const errors = [];
872
951
  const sections = [];
952
+ // A lens that fails makes no section: a placeholder stands where it would
953
+ // have been, with the error that stopped it (src/dropped.ts).
954
+ const failed = (sourcePath, cause = errors.at(-1)) => ({
955
+ sections: cause
956
+ ? [
957
+ droppedSection("lens", droppedContent("section", section.title.trim() ||
958
+ (sourcePath === modulePath ? "Lens" : fileStem(sourcePath)), sourcePath, cause)),
959
+ ]
960
+ : [],
961
+ errors,
962
+ });
873
963
  // Inline lens: has id:: + segments (no source:: at section level)
874
964
  if (inlineLens) {
875
- const { segments, errors: flattenErrors } = flattenSingleLens(inlineLens, modulePath, files, visitedPaths, tierMap);
965
+ const { segments, errors: flattenErrors, dropped, } = flattenSegments(inlineLens, modulePath, files, visitedPaths, tierMap, {
966
+ collapsed: true,
967
+ });
876
968
  errors.push(...flattenErrors);
877
969
  const resultSection = attachSourceLine({
878
970
  type: "lens",
@@ -881,7 +973,7 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
881
973
  ...lensPlacementFields(section.fields, modulePath, section.line, errors),
882
974
  ...lensSectionFields(inlineLens, segments, modulePath, section.line, errors),
883
975
  }, section.line);
884
- sections.push(resultSection);
976
+ sections.push(attachDroppedSegments(resultSection, dropped));
885
977
  return { sections, errors };
886
978
  }
887
979
  // Referenced lens: has source:: at section level
@@ -894,7 +986,7 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
894
986
  suggestion: "Add 'source:: [[../Lenses/filename.md|Display]]' or 'id:: <uuid>' with #### segments",
895
987
  severity: "error",
896
988
  });
897
- return { sections: [], errors };
989
+ return failed(modulePath);
898
990
  }
899
991
  // Parse and resolve the wikilink
900
992
  const wikilink = parseWikilink(source, modulePath);
@@ -911,7 +1003,7 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
911
1003
  suggestion,
912
1004
  severity: "error",
913
1005
  });
914
- return { sections: [], errors };
1006
+ return failed(modulePath);
915
1007
  }
916
1008
  const lensPathResolved = resolveWikilinkPath(wikilink.path, modulePath);
917
1009
  const lensPath = findFileWithExtension(lensPathResolved, files);
@@ -925,7 +1017,7 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
925
1017
  suggestion,
926
1018
  severity: "error",
927
1019
  });
928
- return { sections: [], errors };
1020
+ return failed(lensPathResolved);
929
1021
  }
930
1022
  // Check tier violation
931
1023
  if (tierMap) {
@@ -936,7 +1028,7 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
936
1028
  errors.push(violation);
937
1029
  }
938
1030
  if (childTier === "ignored") {
939
- return { sections: [], errors };
1031
+ return failed(lensPath, violation);
940
1032
  }
941
1033
  }
942
1034
  // Check for circular reference
@@ -946,17 +1038,19 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
946
1038
  message: `Circular reference detected: ${lensPath}`,
947
1039
  severity: "error",
948
1040
  });
949
- return { sections: [], errors };
1041
+ return failed(lensPath);
950
1042
  }
951
1043
  visitedPaths.add(lensPath);
952
1044
  const lensContent = files.get(lensPath);
953
1045
  const lensResult = parseLens(lensContent, lensPath, files);
954
1046
  errors.push(...lensResult.errors);
955
1047
  if (!lensResult.lens) {
956
- return { sections: [], errors };
1048
+ return failed(lensPath, dropCause(lensResult.errors));
957
1049
  }
958
1050
  const lens = lensResult.lens;
959
- const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
1051
+ const { segments, errors: flattenErrors, dropped, } = flattenSegments(lens, lensPath, files, visitedPaths, tierMap, {
1052
+ collapsed: true,
1053
+ });
960
1054
  errors.push(...flattenErrors);
961
1055
  const resultSection = attachSourceLine({
962
1056
  type: "lens",
@@ -965,7 +1059,7 @@ function flattenLensSection(section, modulePath, files, visitedPaths, tierMap, i
965
1059
  ...lensPlacementFields(section.fields, modulePath, section.line, errors),
966
1060
  ...lensSectionFields(lens, segments, lensPath, section.line, errors),
967
1061
  }, getSourceLine(lens));
968
- sections.push(resultSection);
1062
+ sections.push(attachDroppedSegments(resultSection, dropped));
969
1063
  return { sections, errors };
970
1064
  }
971
1065
  /**
@@ -1187,7 +1281,7 @@ function planLensArticles(parsedSegments, lensPath, files, visitedPaths, tierMap
1187
1281
  const parsedSegment = parsedSegments[i];
1188
1282
  if (parsedSegment.type !== "article")
1189
1283
  continue;
1190
- const entry = { segments: [], errors: [] };
1284
+ const entry = { segments: [], errors: [], dropped: [] };
1191
1285
  plan.set(i, entry);
1192
1286
  // Source comes from the parsed segment (set via inheritance in parseLens)
1193
1287
  if (!parsedSegment.source) {
@@ -1253,22 +1347,32 @@ function planLensArticles(parsedSegments, lensPath, files, visitedPaths, tierMap
1253
1347
  group.indices.push(i);
1254
1348
  group.segments.push(parsedSegment);
1255
1349
  }
1350
+ // A segment that joined no group was left out by the error it reported.
1351
+ const grouped = new Set([...groups.values()].flatMap((g) => g.indices));
1352
+ for (const [i, entry] of plan) {
1353
+ if (grouped.has(i))
1354
+ continue;
1355
+ entry.dropped.push(...droppedSegment("article", sourceOf(parsedSegments[i]), lensPath, entry.errors));
1356
+ }
1256
1357
  for (const [articlePath, group] of groups) {
1257
1358
  const articleContent = files.get(articlePath);
1258
1359
  const articleFrontmatter = parseFrontmatter(articleContent, articlePath);
1259
1360
  const fm = articleFrontmatter.frontmatter;
1260
- const blockTargets = collectBlockTargets(articleFrontmatter.body);
1361
+ const blockTargets = lazyBlockTargets(() => collectBlockTargets(articleFrontmatter.body));
1362
+ const articleTitle = typeof fm.title === "string" ? fm.title : fileStem(articlePath);
1261
1363
  const resolved = resolveArticleSource(articleContent, group.segments.map((ps) => ({ from: ps.fromAnchor, to: ps.toAnchor })), articlePath);
1262
1364
  for (let k = 0; k < group.segments.length; k++) {
1263
1365
  const parsedSegment = group.segments[k];
1264
1366
  const entry = plan.get(group.indices[k]);
1265
1367
  const excerpt = resolved[k];
1266
1368
  if (excerpt.error) {
1267
- entry.errors.push({
1369
+ const error = {
1268
1370
  ...excerpt.error,
1269
1371
  file: lensPath,
1270
1372
  line: getSourceLine(parsedSegment),
1271
- });
1373
+ };
1374
+ entry.errors.push(error);
1375
+ entry.dropped.push(droppedContent("segment", articleTitle, articlePath, error));
1272
1376
  continue;
1273
1377
  }
1274
1378
  if (excerpt.warning) {
@@ -1330,6 +1434,8 @@ function planLensArticles(parsedSegments, lensPath, files, visitedPaths, tierMap
1330
1434
  // by the lifted callouts it sits in.
1331
1435
  const partSegments = [];
1332
1436
  let elementFailed = false;
1437
+ // Each element, with the errors of its failure when it failed.
1438
+ const elements = [];
1333
1439
  for (const part of excerpt.parts) {
1334
1440
  if (part.kind === "prose") {
1335
1441
  const segment = makeArticleSegment(part.content);
@@ -1343,9 +1449,9 @@ function planLensArticles(parsedSegments, lensPath, files, visitedPaths, tierMap
1343
1449
  // The element is the RawSegment its `####` twin would be: the same
1344
1450
  // parser and flattener conversions apply, resolved from the article.
1345
1451
  // Its field errors were reported in the article's own parse pass.
1346
- const parsed = parseRawSegment(part.raw, "lens", articlePath).segment;
1347
- const converted = parsed &&
1348
- convertSegment(parsed, articlePath, files, visitedPaths, tierMap, new Map(), {
1452
+ const parse = parseRawSegment(part.raw, "lens", articlePath);
1453
+ const converted = parse.segment &&
1454
+ convertSegment(parse.segment, articlePath, files, visitedPaths, tierMap, new Map(), {
1349
1455
  errorLine: findImportLine(articleContent, part.rawLine),
1350
1456
  notFoundCode: `article.${ARTICLE_ELEMENT_KINDS[part.raw.type].code}-not-found`,
1351
1457
  });
@@ -1353,8 +1459,13 @@ function planLensArticles(parsedSegments, lensPath, files, visitedPaths, tierMap
1353
1459
  entry.errors.push(...converted.errors);
1354
1460
  if (!converted?.segment) {
1355
1461
  elementFailed = true;
1462
+ elements.push({
1463
+ raw: part.raw,
1464
+ failure: converted ? converted.errors : parse.errors,
1465
+ });
1356
1466
  continue;
1357
1467
  }
1468
+ elements.push({ raw: part.raw });
1358
1469
  const segment = converted.segment;
1359
1470
  if (segment.type === "video" ||
1360
1471
  segment.type === "widget" ||
@@ -1369,10 +1480,23 @@ function planLensArticles(parsedSegments, lensPath, files, visitedPaths, tierMap
1369
1480
  if (elementFailed) {
1370
1481
  // A broken element must not leave a half-split article with dangling
1371
1482
  // seam flags: fall back to the un-split excerpt (error already
1372
- // recorded above).
1483
+ // recorded above). Every element of the excerpt is lost with the
1484
+ // split: a failed one by its own error, the others by the first.
1373
1485
  const segment = makeArticleSegment(excerpt.content);
1374
1486
  applyCollapsed([segment]);
1375
1487
  entry.segments.push(segment);
1488
+ const causeOf = (failure) => failure && dropCause(failure);
1489
+ const firstCause = elements
1490
+ .map((e) => causeOf(e.failure))
1491
+ .find(Boolean);
1492
+ for (const { raw, failure } of elements) {
1493
+ const cause = causeOf(failure) ?? firstCause;
1494
+ if (!cause)
1495
+ continue;
1496
+ entry.dropped.push(...droppedSegment(raw.type, raw.fields.source, articlePath, [
1497
+ cause,
1498
+ ]));
1499
+ }
1376
1500
  continue;
1377
1501
  }
1378
1502
  applyCollapsed(partSegments);
@@ -1840,7 +1964,7 @@ origin) {
1840
1964
  export function flattenStandaloneArticle(articlePath, files, tierMap) {
1841
1965
  const fileName = articlePath.slice(articlePath.lastIndexOf("/") + 1);
1842
1966
  const plan = planLensArticles([{ type: "article", source: `[[${fileName}]]` }], articlePath, files, new Set(), tierMap, { collapsed: false });
1843
- return plan.get(0) ?? { segments: [], errors: [] };
1967
+ return plan.get(0);
1844
1968
  }
1845
1969
  /**
1846
1970
  * Flatten a single Lens file into a FlattenedModule.
@@ -1849,7 +1973,7 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1849
1973
  const errors = [];
1850
1974
  // Skip ignored lenses
1851
1975
  if (tierMap?.get(lensPath) === "ignored") {
1852
- return { module: null, modules: [], errors };
1976
+ return { module: null, modules: [], errors, dropped: [] };
1853
1977
  }
1854
1978
  const lensContent = files.get(lensPath);
1855
1979
  if (!lensContent) {
@@ -1858,7 +1982,7 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1858
1982
  message: `Lens file not found: ${lensPath}`,
1859
1983
  severity: "error",
1860
1984
  });
1861
- return { module: null, modules: [], errors };
1985
+ return { module: null, modules: [], errors, dropped: [] };
1862
1986
  }
1863
1987
  // Use pre-parsed lens if provided, otherwise parse
1864
1988
  const lens = preParsedLens ??
@@ -1868,11 +1992,13 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1868
1992
  return lensResult.lens;
1869
1993
  })();
1870
1994
  if (!lens) {
1871
- return { module: null, modules: [], errors };
1995
+ return { module: null, modules: [], errors, dropped: [] };
1872
1996
  }
1873
1997
  const visitedPaths = new Set([lensPath]);
1874
1998
  // Flatten lens segments using shared helper
1875
- const { segments, errors: flattenErrors } = flattenSingleLens(lens, lensPath, files, visitedPaths, tierMap);
1999
+ const { segments, errors: flattenErrors, dropped, } = flattenSegments(lens, lensPath, files, visitedPaths, tierMap, {
2000
+ collapsed: true,
2001
+ });
1876
2002
  errors.push(...flattenErrors);
1877
2003
  // Derive title: frontmatter > first article/video segment > filename
1878
2004
  const meta = {};
@@ -1896,13 +2022,13 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1896
2022
  .replace(/\b\w/g, (c) => c.toUpperCase());
1897
2023
  }
1898
2024
  }
1899
- const section = {
2025
+ const section = attachDroppedSegments({
1900
2026
  type: "lens",
1901
2027
  meta,
1902
2028
  sourcePath: lensPath,
1903
2029
  optional: false,
1904
2030
  ...lensSectionFields(lens, segments, lensPath, 1, errors),
1905
- };
2031
+ }, dropped);
1906
2032
  const flattenedModule = {
1907
2033
  slug: "lens/" + fileNameToSlug(lensPath),
1908
2034
  title: meta.title ?? fileNameToSlug(lensPath),
@@ -1910,6 +2036,11 @@ export function flattenLens(lensPath, files, tierMap, preParsedLens) {
1910
2036
  sections: [section],
1911
2037
  sourcePath: lensPath,
1912
2038
  };
1913
- return { module: flattenedModule, modules: [flattenedModule], errors };
2039
+ return {
2040
+ module: flattenedModule,
2041
+ modules: [flattenedModule],
2042
+ errors,
2043
+ dropped: [placeDropped([section]).items],
2044
+ };
1914
2045
  }
1915
2046
  //# sourceMappingURL=index.js.map