explico 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -0
- package/README.md +67 -7
- package/dist/compiler/astro.d.ts +30 -0
- package/dist/compiler/astro.js +79 -0
- package/dist/compiler/cli.d.ts +16 -0
- package/dist/compiler/cli.js +43 -9
- package/dist/compiler/collections.js +4 -1
- package/dist/compiler/course-config.d.ts +80 -4
- package/dist/compiler/course-config.js +12 -0
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +98 -2
- package/dist/compiler/manifest.js +153 -24
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +51 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +10 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +80 -13
- package/dist/reference/equations.mjs +109 -19
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- package/dist/reference/keyed-float-validate.d.ts +73 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +64 -0
- package/dist/reference/keyed-jsx-floats.mjs +191 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/remark-notation.d.mts +61 -0
- package/dist/reference/remark-notation.mjs +344 -10
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +100 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +169 -0
- package/docs/authoring/notation.md +297 -0
- package/docs/authoring/structure.md +139 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/astro.ts +127 -0
- package/src/compiler/cli.ts +52 -7
- package/src/compiler/collections.ts +12 -3
- package/src/compiler/course-config.ts +93 -3
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +320 -26
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -0
- package/src/components/notation/NotationLayer.astro +11 -0
- package/src/components/notation/render-notation-math.ts +13 -2
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +15 -2
- package/src/components/table/Table.astro +105 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +83 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +8 -1
- package/src/reference/equations.mjs +109 -19
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/keyed-float-validate.ts +210 -0
- package/src/reference/keyed-jsx-floats.mjs +191 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/remark-notation.mjs +344 -10
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +244 -7
- package/src/components/starlight/EquationEnhancer.astro +0 -87
|
@@ -828,3 +828,95 @@ export function resolveMathGlyphs(latex, definitions) {
|
|
|
828
828
|
unresolved: unresolvedIdentifiers(latex, identifiers, bindings),
|
|
829
829
|
};
|
|
830
830
|
}
|
|
831
|
+
|
|
832
|
+
// --- named operators ------------------------------------------------
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Every named operator in one KaTeX expression: a built-in `op` node
|
|
836
|
+
* (`\exp`, `\int`, `\sum`, …) or an author-declared `\operatorname{…}` /
|
|
837
|
+
* `\mathop{…}` (`Var`, `Cov`, …). The `name` is the KaTeX command without its
|
|
838
|
+
* backslash for a built-in, and the verbatim body for a declared one — the
|
|
839
|
+
* form `BASE_OPERATORS` and `course.config.ts` `operators` are keyed by.
|
|
840
|
+
*
|
|
841
|
+
* Pure read: unlike `resolveMathGlyphs`, nothing here rewrites the source.
|
|
842
|
+
* Used by the `operator-explained` consistency check (rule N8); the glyph
|
|
843
|
+
* resolver still skips operator bodies whole.
|
|
844
|
+
*
|
|
845
|
+
* @param {string} latex
|
|
846
|
+
* @returns {Array<{ name: string, source: 'named' | 'operatorname' }>}
|
|
847
|
+
*/
|
|
848
|
+
export function collectOperators(latex) {
|
|
849
|
+
if (typeof latex !== 'string' || latex.trim() === '') return [];
|
|
850
|
+
|
|
851
|
+
let tree;
|
|
852
|
+
try {
|
|
853
|
+
tree = parseMath(latex, 'operator scan');
|
|
854
|
+
} catch (error) {
|
|
855
|
+
// A span that will not parse is the math gate's problem, not this check's.
|
|
856
|
+
// A KaTeX version-pin failure is nobody's to swallow — let it through.
|
|
857
|
+
if (error instanceof GlyphResolutionError && error.code === 'invalid-latex') {
|
|
858
|
+
return [];
|
|
859
|
+
}
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const seen = new Set();
|
|
864
|
+
const operators = [];
|
|
865
|
+
const add = (name, source) => {
|
|
866
|
+
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
867
|
+
if (trimmed === '') return;
|
|
868
|
+
const identity = `${source}:${trimmed}`;
|
|
869
|
+
if (seen.has(identity)) return;
|
|
870
|
+
seen.add(identity);
|
|
871
|
+
operators.push({ name: trimmed, source });
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
// The declared-operator name is its letters with any internal spacing
|
|
875
|
+
// dropped, so `\operatorname{arg\,max}` keys as `argmax` — `fontBodyText`
|
|
876
|
+
// alone bails on the `\,`.
|
|
877
|
+
const declaredName = (body) => {
|
|
878
|
+
const direct = fontBodyText(body);
|
|
879
|
+
if (typeof direct === 'string') return direct;
|
|
880
|
+
const letters = [];
|
|
881
|
+
const gather = (value) => {
|
|
882
|
+
if (Array.isArray(value)) {
|
|
883
|
+
value.forEach(gather);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (!isNode(value)) return;
|
|
887
|
+
if (value.type === 'mathord' || value.type === 'textord') {
|
|
888
|
+
letters.push(value.text ?? '');
|
|
889
|
+
}
|
|
890
|
+
if (value.type === 'ordgroup') gather(value.body);
|
|
891
|
+
};
|
|
892
|
+
gather(body);
|
|
893
|
+
return letters.join('');
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const visit = (value) => {
|
|
897
|
+
if (Array.isArray(value)) {
|
|
898
|
+
for (const child of value) visit(child);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (!isNode(value)) return;
|
|
902
|
+
|
|
903
|
+
if (value.type === 'operatorname') {
|
|
904
|
+
add(declaredName(value.body), 'operatorname');
|
|
905
|
+
} else if (value.type === 'op') {
|
|
906
|
+
if (typeof value.name === 'string' && value.name.length > 0) {
|
|
907
|
+
add(value.name.replace(/^\\/, ''), 'named');
|
|
908
|
+
} else if (value.body !== undefined) {
|
|
909
|
+
// `\mathop{Foo}` — a hand-rolled operator with no command name.
|
|
910
|
+
add(declaredName(value.body), 'operatorname');
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
for (const key of Object.keys(value)) {
|
|
915
|
+
if (key === 'loc') continue;
|
|
916
|
+
visit(value[key]);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
visit(tree);
|
|
921
|
+
return operators;
|
|
922
|
+
}
|
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
parseEquationRef,
|
|
43
43
|
stripEquationLabels,
|
|
44
44
|
} from './equations.mjs';
|
|
45
|
+
import {
|
|
46
|
+
collectKeyedJsxFloats,
|
|
47
|
+
keyedFloatAnchorId,
|
|
48
|
+
parseKeyedFloatRef,
|
|
49
|
+
} from './keyed-jsx-floats.mjs';
|
|
45
50
|
|
|
46
51
|
const PROSE_SKIP_TYPES = new Set([
|
|
47
52
|
'code',
|
|
@@ -340,6 +345,8 @@ function equationNode(mathNode, { key, number }) {
|
|
|
340
345
|
className: ['keyed-equation__number'],
|
|
341
346
|
'data-eq-number': number,
|
|
342
347
|
'aria-label': `Equation ${number}`,
|
|
348
|
+
// Plain hover tooltip — the number is a permalink to this equation.
|
|
349
|
+
title: 'Anchor to this equation',
|
|
343
350
|
},
|
|
344
351
|
},
|
|
345
352
|
},
|
|
@@ -347,6 +354,32 @@ function equationNode(mathNode, { key, number }) {
|
|
|
347
354
|
};
|
|
348
355
|
}
|
|
349
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Give a `<Table>`/`<Figure>` `mdxJsxFlowElement` its computed `number` and
|
|
359
|
+
* `anchorId` as real JSX attributes, so the actual Astro component (which
|
|
360
|
+
* renders the numbered anchor, unlike `equationNode`'s synthesized hast div)
|
|
361
|
+
* receives them as props. The compiler is the single source of truth for
|
|
362
|
+
* both — the component only renders what it's given.
|
|
363
|
+
*/
|
|
364
|
+
function injectFloatNumberAttributes(node, float, prefix) {
|
|
365
|
+
const anchorId = keyedFloatAnchorId({
|
|
366
|
+
key: float.key,
|
|
367
|
+
number: float.number,
|
|
368
|
+
prefix,
|
|
369
|
+
});
|
|
370
|
+
node.attributes = (node.attributes ?? []).filter(
|
|
371
|
+
(attribute) =>
|
|
372
|
+
!(
|
|
373
|
+
attribute?.type === 'mdxJsxAttribute' &&
|
|
374
|
+
(attribute.name === 'number' || attribute.name === 'anchorId')
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
node.attributes.push(
|
|
378
|
+
{ type: 'mdxJsxAttribute', name: 'number', value: float.number },
|
|
379
|
+
{ type: 'mdxJsxAttribute', name: 'anchorId', value: anchorId },
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
350
383
|
/** Resolve a `[[eq-key]]` / `[[slug#eq-key]]` reference to `{ number, href }`. */
|
|
351
384
|
function resolveEquationRef(eqRef, eqCtx, file, node) {
|
|
352
385
|
if (eqRef.lesson) {
|
|
@@ -374,7 +407,47 @@ function resolveEquationRef(eqRef, eqCtx, file, node) {
|
|
|
374
407
|
return { number, href: `#eq-${eqRef.key}` };
|
|
375
408
|
}
|
|
376
409
|
|
|
377
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Resolve a `[[<prefix>-key]]` / `[[slug#<prefix>-key]]` reference (a
|
|
412
|
+
* `<Table>`/`<Figure>` float) to `{ number, href }` — the table/figure
|
|
413
|
+
* equivalent of `resolveEquationRef`.
|
|
414
|
+
*/
|
|
415
|
+
function resolveKeyedFloatRef(floatRef, ctx, { prefix, tagName }, file, node) {
|
|
416
|
+
if (floatRef.lesson) {
|
|
417
|
+
const number = ctx.crossPage?.[floatRef.lesson]?.[floatRef.key];
|
|
418
|
+
if (number === undefined) {
|
|
419
|
+
fail(
|
|
420
|
+
file,
|
|
421
|
+
`Unknown ${tagName} reference [[${floatRef.lesson}#${prefix}-${floatRef.key}]]: lesson ${floatRef.lesson} has no <${tagName}> with id="${floatRef.key}".`,
|
|
422
|
+
node,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
number,
|
|
427
|
+
href: `${ctx.base.replace(/\/$/, '')}/${floatRef.lesson}/#${prefix}-${floatRef.key}`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const number = ctx.pageLabels.get(floatRef.key);
|
|
431
|
+
if (number === undefined) {
|
|
432
|
+
fail(
|
|
433
|
+
file,
|
|
434
|
+
`Unknown ${tagName} reference [[${prefix}-${floatRef.key}]]: no <${tagName}> on this page carries id="${floatRef.key}".`,
|
|
435
|
+
node,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return { number, href: `#${prefix}-${floatRef.key}` };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function proseNodes(
|
|
442
|
+
value,
|
|
443
|
+
resolve,
|
|
444
|
+
file,
|
|
445
|
+
node,
|
|
446
|
+
references,
|
|
447
|
+
eqCtx,
|
|
448
|
+
tableCtx,
|
|
449
|
+
figureCtx,
|
|
450
|
+
) {
|
|
378
451
|
const base = eqCtx?.base ?? '';
|
|
379
452
|
const strayExplain = firstUnescaped(value, EXPLAIN);
|
|
380
453
|
if (strayExplain !== -1) {
|
|
@@ -395,7 +468,18 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
|
|
|
395
468
|
|
|
396
469
|
const key = match[1].trim();
|
|
397
470
|
const eqRef = eqCtx ? parseEquationRef(key) : undefined;
|
|
398
|
-
|
|
471
|
+
const tableRef =
|
|
472
|
+
!eqRef && tableCtx ? parseKeyedFloatRef(key, { prefix: 'tab' }) : undefined;
|
|
473
|
+
const figureRef =
|
|
474
|
+
!eqRef && !tableRef && figureCtx
|
|
475
|
+
? parseKeyedFloatRef(key, { prefix: 'fig' })
|
|
476
|
+
: undefined;
|
|
477
|
+
if (
|
|
478
|
+
!eqRef &&
|
|
479
|
+
!tableRef &&
|
|
480
|
+
!figureRef &&
|
|
481
|
+
!NOTATION_KEY_PATTERN.test(key)
|
|
482
|
+
) {
|
|
399
483
|
fail(file, `Invalid notation key "${key}" in [[...]].`, node);
|
|
400
484
|
}
|
|
401
485
|
|
|
@@ -426,9 +510,46 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
|
|
|
426
510
|
continue;
|
|
427
511
|
}
|
|
428
512
|
|
|
513
|
+
const floatRef = tableRef ?? figureRef;
|
|
514
|
+
if (floatRef) {
|
|
515
|
+
const kind = tableRef ? 'table' : 'figure';
|
|
516
|
+
const prefix = tableRef ? 'tab' : 'fig';
|
|
517
|
+
const tagName = tableRef ? 'Table' : 'Figure';
|
|
518
|
+
const { number, href } = resolveKeyedFloatRef(
|
|
519
|
+
floatRef,
|
|
520
|
+
tableRef ? tableCtx : figureCtx,
|
|
521
|
+
{ prefix, tagName },
|
|
522
|
+
file,
|
|
523
|
+
node,
|
|
524
|
+
);
|
|
525
|
+
output.push({
|
|
526
|
+
type: 'link',
|
|
527
|
+
url: href,
|
|
528
|
+
children: [{ type: 'text', value: `${tagName} ${number}` }],
|
|
529
|
+
data: {
|
|
530
|
+
hProperties: {
|
|
531
|
+
className: [`${kind}-ref`],
|
|
532
|
+
[`data-${prefix}-ref`]: floatRef.key,
|
|
533
|
+
...(floatRef.lesson ? { [`data-${prefix}-lesson`]: floatRef.lesson } : {}),
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
references.push({
|
|
538
|
+
key: floatRef.key,
|
|
539
|
+
kind,
|
|
540
|
+
...(floatRef.lesson ? { lesson: floatRef.lesson } : {}),
|
|
541
|
+
});
|
|
542
|
+
cursor = match.index + match[0].length;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
429
546
|
const definition = resolve(key, 'prose', node);
|
|
430
|
-
|
|
431
|
-
|
|
547
|
+
// `title`/`summary` are the pre-C1b field names, kept as the first
|
|
548
|
+
// fallback for a caller still supplying them; real content authors
|
|
549
|
+
// `label`/`meaning` (see `content-config.ts`), which raw frontmatter
|
|
550
|
+
// (`manifest.notation.raw`, a page's own `notation.local`) carries as-is.
|
|
551
|
+
const title = definition?.title ?? definition?.label ?? key;
|
|
552
|
+
const summary = definition?.summary ?? definition?.meaning;
|
|
432
553
|
const href = defaultHref(definition, key, base);
|
|
433
554
|
|
|
434
555
|
const properties = {
|
|
@@ -550,17 +671,146 @@ function inspectExplainCalls(value, resolve, file, node, references) {
|
|
|
550
671
|
}
|
|
551
672
|
}
|
|
552
673
|
|
|
674
|
+
/**
|
|
675
|
+
* The numbering inputs a lesson page needs, read off one manifest.
|
|
676
|
+
*
|
|
677
|
+
* Every number on a page comes from two places that MUST agree: the page's own
|
|
678
|
+
* counters (`numbering`) and the manifest tables a cross-page `[[slug#eq-key]]`
|
|
679
|
+
* is resolved against (`equations` / `tables` / `figures`). Deriving all four
|
|
680
|
+
* from the same manifest in one step is what stops a course config from
|
|
681
|
+
* supplying one without the other — the omission that produced page-local
|
|
682
|
+
* `(2.2)` and cross-page `(4.5)` for the same equation.
|
|
683
|
+
*
|
|
684
|
+
* `numbering` maps a lesson slug to `{ part, equations, tables, figures }`:
|
|
685
|
+
* the part's ordinal (the number before the dot) and, per kind of float, how
|
|
686
|
+
* many of them the earlier lessons of that part already used.
|
|
687
|
+
*/
|
|
688
|
+
export function numberingFromManifest(manifest) {
|
|
689
|
+
if (!manifest || typeof manifest !== 'object') return {};
|
|
690
|
+
return {
|
|
691
|
+
equations: manifest.equations?.numbersBySlug ?? {},
|
|
692
|
+
tables: manifest.tables?.numbersBySlug ?? {},
|
|
693
|
+
figures: manifest.figures?.numbersBySlug ?? {},
|
|
694
|
+
numbering: Object.fromEntries(
|
|
695
|
+
(manifest.lessons ?? []).map((lesson) => [
|
|
696
|
+
lesson.slug,
|
|
697
|
+
{
|
|
698
|
+
part: lesson.part,
|
|
699
|
+
equations: lesson.numbering?.equations ?? 0,
|
|
700
|
+
tables: lesson.numbering?.tables ?? 0,
|
|
701
|
+
figures: lesson.numbering?.figures ?? 0,
|
|
702
|
+
},
|
|
703
|
+
]),
|
|
704
|
+
),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* `{ prefix, offset }` for one kind of float on one page, from whichever
|
|
710
|
+
* numbering option the caller supplied. `numbering` is the current form;
|
|
711
|
+
* `chapters` is the superseded per-lesson form (prefix = the lesson's ordinal,
|
|
712
|
+
* no offset) and is still read so an unmigrated config keeps working.
|
|
713
|
+
*/
|
|
714
|
+
function pageNumbering(options, slug, kind) {
|
|
715
|
+
if (!slug) return undefined;
|
|
716
|
+
const placement = options.numbering?.[slug];
|
|
717
|
+
if (placement?.part) {
|
|
718
|
+
return { prefix: placement.part, offset: placement[kind] ?? 0 };
|
|
719
|
+
}
|
|
720
|
+
const chapter = options.chapters?.[slug];
|
|
721
|
+
return chapter ? { prefix: chapter, offset: 0 } : undefined;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Fail when the number this page just computed for a keyed float disagrees
|
|
726
|
+
* with the number the manifest publishes for the same key (WERN — a reference
|
|
727
|
+
* that shows a different number than its target). The two are produced by the
|
|
728
|
+
* same algorithm from the same content, so any disagreement means the render
|
|
729
|
+
* path and the manifest were fed different inputs; in practice, an
|
|
730
|
+
* `astro.config.mjs` that passes the manifest number tables but not
|
|
731
|
+
* `numbering`, which silently drops the page back to the legacy `##`-section
|
|
732
|
+
* scheme.
|
|
733
|
+
*/
|
|
734
|
+
function assertManifestNumberParity({
|
|
735
|
+
file,
|
|
736
|
+
slug,
|
|
737
|
+
pageLabels,
|
|
738
|
+
crossPage,
|
|
739
|
+
placement,
|
|
740
|
+
noun,
|
|
741
|
+
syntax,
|
|
742
|
+
}) {
|
|
743
|
+
// No entry for this slug at all means the caller supplied no manifest table
|
|
744
|
+
// (hand-built test trees, non-lesson pages) — nothing to cross-check.
|
|
745
|
+
const published = crossPage?.[slug];
|
|
746
|
+
if (!published) return;
|
|
747
|
+
|
|
748
|
+
for (const [key, number] of pageLabels) {
|
|
749
|
+
const expected = published[key];
|
|
750
|
+
if (expected === undefined) {
|
|
751
|
+
fail(
|
|
752
|
+
file,
|
|
753
|
+
`Stale Explico manifest for ${slug}: this page numbers ${noun} ` +
|
|
754
|
+
`${syntax(key)} as (${number}), but the manifest lists no such ` +
|
|
755
|
+
`${noun} for the lesson. Recompile the manifest before rendering.`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
if (expected !== number) {
|
|
759
|
+
fail(
|
|
760
|
+
file,
|
|
761
|
+
`Inconsistent ${noun} numbering for ${syntax(key)} in ${slug}: this ` +
|
|
762
|
+
`page renders it as (${number}) while a cross-page reference to it ` +
|
|
763
|
+
`resolves to (${expected}). ` +
|
|
764
|
+
(placement === undefined
|
|
765
|
+
? 'The remarkNotation options carry the manifest number tables ' +
|
|
766
|
+
'but no `numbering` entry for this lesson, so the page fell ' +
|
|
767
|
+
'back to the legacy `##`-section scheme. Pass `manifest` to ' +
|
|
768
|
+
'remarkNotation so both sides use the part scheme.'
|
|
769
|
+
: 'The page counters and the manifest disagree; recompile the ' +
|
|
770
|
+
'manifest from the current content.'),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
553
776
|
/**
|
|
554
777
|
* @param {{
|
|
555
778
|
* definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
|
|
556
779
|
* resolve?: (key: string, context: object) => object | undefined,
|
|
557
780
|
* unknown?: 'error' | 'warn' | 'ignore',
|
|
558
781
|
* base?: string,
|
|
782
|
+
* manifest?: object,
|
|
559
783
|
* equations?: Record<string, Record<string, string>>,
|
|
784
|
+
* tables?: Record<string, Record<string, string>>,
|
|
785
|
+
* figures?: Record<string, Record<string, string>>,
|
|
786
|
+
* numbering?: Record<string, { part: number, equations: number, tables: number, figures: number }>,
|
|
787
|
+
* chapters?: Record<string, number>,
|
|
560
788
|
* }} [options]
|
|
789
|
+
*
|
|
790
|
+
* `manifest` is the preferred wiring: pass the compiled manifest and the four
|
|
791
|
+
* numbering inputs below are read off it together, so they cannot drift apart.
|
|
792
|
+
* They stay available individually for hand-built trees, and an explicitly
|
|
793
|
+
* supplied one still wins over the manifest.
|
|
794
|
+
*
|
|
795
|
+
* `numbering` maps a lesson slug to its **part** (the number before the dot)
|
|
796
|
+
* and, per kind of float, how many the earlier lessons of that part already
|
|
797
|
+
* used. Every display equation / `<Table>` / `<Figure>` on the page is then
|
|
798
|
+
* numbered `‹part›.‹offset + n›`, with `##` and `###` headings ignored; with no
|
|
799
|
+
* entry the legacy `##`-section scheme applies. It must agree with
|
|
800
|
+
* `manifest.lessons[]`, which is what feeds `equations`/`tables`/`figures`
|
|
801
|
+
* above — supplying the tables without `numbering` numbers the same equation
|
|
802
|
+
* twice, and `assertManifestNumberParity` turns that into a build error rather
|
|
803
|
+
* than a page whose references disagree with their targets.
|
|
804
|
+
*
|
|
805
|
+
* `chapters` is the superseded per-lesson form (slug → the lesson's course
|
|
806
|
+
* ordinal, no running offset). Still read when `numbering` has no entry, so a
|
|
807
|
+
* config written against the old option keeps building.
|
|
561
808
|
*/
|
|
562
809
|
export default function remarkNotation(options = {}) {
|
|
563
810
|
const unknown = options.unknown ?? 'error';
|
|
811
|
+
// The manifest supplies every numbering input at once; an explicit option
|
|
812
|
+
// still overrides it, so existing per-field wiring keeps working.
|
|
813
|
+
const numbering = { ...numberingFromManifest(options.manifest), ...options };
|
|
564
814
|
|
|
565
815
|
return function transform(tree, file) {
|
|
566
816
|
const suppliedDefinitions =
|
|
@@ -573,6 +823,10 @@ export default function remarkNotation(options = {}) {
|
|
|
573
823
|
);
|
|
574
824
|
const localDefinitions = localDefinitionsFromFile(file);
|
|
575
825
|
const lessonId = lessonIdFrom(file);
|
|
826
|
+
const lessonSlug = lessonId ? lessonId.replace(/\./g, '/') : undefined;
|
|
827
|
+
// `{ prefix, offset }` per kind of float — see the `numbering` option.
|
|
828
|
+
// Missing ⇒ the legacy `##`-section numbering.
|
|
829
|
+
const equationNumbering = pageNumbering(numbering, lessonSlug, 'equations');
|
|
576
830
|
const registryFile = isNotationRegistryFile(file);
|
|
577
831
|
const mathScope = pageGlyphScope(
|
|
578
832
|
localDefinitions,
|
|
@@ -582,10 +836,11 @@ export default function remarkNotation(options = {}) {
|
|
|
582
836
|
const references = [];
|
|
583
837
|
const resolved = new Map();
|
|
584
838
|
|
|
585
|
-
// Equation identity (D7): assign every `\label{eq:…}` its
|
|
586
|
-
//
|
|
587
|
-
// the
|
|
588
|
-
|
|
839
|
+
// Equation identity (D7): assign every `\label{eq:…}` its number before any
|
|
840
|
+
// math node is rewritten, so a `[[eq-key]]` earlier in the page still
|
|
841
|
+
// resolves. `equationNumbering` picks the `‹part›.‹n›` vs `##`-section
|
|
842
|
+
// scheme.
|
|
843
|
+
const equationLabels = collectEquationLabels(tree, equationNumbering);
|
|
589
844
|
const pageLabels = new Map();
|
|
590
845
|
for (const label of equationLabels) {
|
|
591
846
|
if (pageLabels.has(label.key)) {
|
|
@@ -600,17 +855,79 @@ export default function remarkNotation(options = {}) {
|
|
|
600
855
|
const eqCtx = {
|
|
601
856
|
base: options.base ?? '',
|
|
602
857
|
pageLabels,
|
|
603
|
-
crossPage:
|
|
858
|
+
crossPage: numbering.equations ?? {},
|
|
604
859
|
};
|
|
860
|
+
// WERN guard: what this page prints next to the equation must be what a
|
|
861
|
+
// reference from another page prints for it.
|
|
862
|
+
assertManifestNumberParity({
|
|
863
|
+
file,
|
|
864
|
+
slug: lessonSlug,
|
|
865
|
+
pageLabels,
|
|
866
|
+
crossPage: eqCtx.crossPage,
|
|
867
|
+
placement: equationNumbering,
|
|
868
|
+
noun: 'equation',
|
|
869
|
+
syntax: (key) => `eq:${key}`,
|
|
870
|
+
});
|
|
605
871
|
|
|
606
872
|
// Number and anchor every display equation on a lesson page, keyed or
|
|
607
873
|
// not (F3). Non-lesson pages (notation registry, stray docs) keep the
|
|
608
874
|
// keyed-only behaviour so their body math is left untouched.
|
|
609
|
-
const displayEquations = lessonId
|
|
875
|
+
const displayEquations = lessonId
|
|
876
|
+
? collectDisplayEquations(tree, equationNumbering)
|
|
877
|
+
: [];
|
|
610
878
|
const equationByNode = new Map(
|
|
611
879
|
displayEquations.map((equation) => [equation.node, equation]),
|
|
612
880
|
);
|
|
613
881
|
|
|
882
|
+
// Keyed floats (F-tables-figures): every `<Table>`/`<Figure>` on a lesson
|
|
883
|
+
// page is numbered and anchored, keyed or not — same rule as equations,
|
|
884
|
+
// via the shared engine in `keyed-jsx-floats.mjs`.
|
|
885
|
+
const makeFloatCtx = (tagName, crossPage, kind) => {
|
|
886
|
+
const floatNumbering = pageNumbering(numbering, lessonSlug, kind);
|
|
887
|
+
const floats = lessonId
|
|
888
|
+
? collectKeyedJsxFloats(tree, { tagName, numbering: floatNumbering })
|
|
889
|
+
: [];
|
|
890
|
+
const pageFloatLabels = new Map();
|
|
891
|
+
for (const float of floats) {
|
|
892
|
+
if (float.key == null) continue;
|
|
893
|
+
if (pageFloatLabels.has(float.key)) {
|
|
894
|
+
fail(
|
|
895
|
+
file,
|
|
896
|
+
`Duplicate <${tagName} id="${float.key}"> on this page; an id is unique per lesson.`,
|
|
897
|
+
float.node,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
pageFloatLabels.set(float.key, float.number);
|
|
901
|
+
}
|
|
902
|
+
assertManifestNumberParity({
|
|
903
|
+
file,
|
|
904
|
+
slug: lessonSlug,
|
|
905
|
+
pageLabels: pageFloatLabels,
|
|
906
|
+
crossPage: crossPage ?? {},
|
|
907
|
+
placement: floatNumbering,
|
|
908
|
+
noun: tagName,
|
|
909
|
+
syntax: (key) => `<${tagName} id="${key}">`,
|
|
910
|
+
});
|
|
911
|
+
return {
|
|
912
|
+
ctx: {
|
|
913
|
+
base: options.base ?? '',
|
|
914
|
+
pageLabels: pageFloatLabels,
|
|
915
|
+
crossPage: crossPage ?? {},
|
|
916
|
+
},
|
|
917
|
+
byNode: new Map(floats.map((float) => [float.node, float])),
|
|
918
|
+
};
|
|
919
|
+
};
|
|
920
|
+
const { ctx: tableCtx, byNode: tableByNode } = makeFloatCtx(
|
|
921
|
+
'Table',
|
|
922
|
+
numbering.tables,
|
|
923
|
+
'tables',
|
|
924
|
+
);
|
|
925
|
+
const { ctx: figureCtx, byNode: figureByNode } = makeFloatCtx(
|
|
926
|
+
'Figure',
|
|
927
|
+
numbering.figures,
|
|
928
|
+
'figures',
|
|
929
|
+
);
|
|
930
|
+
|
|
614
931
|
const resolve = (key, kind, node) => {
|
|
615
932
|
const context = {
|
|
616
933
|
file,
|
|
@@ -729,6 +1046,8 @@ export default function remarkNotation(options = {}) {
|
|
|
729
1046
|
child,
|
|
730
1047
|
references,
|
|
731
1048
|
eqCtx,
|
|
1049
|
+
tableCtx,
|
|
1050
|
+
figureCtx,
|
|
732
1051
|
);
|
|
733
1052
|
if (replacement) {
|
|
734
1053
|
node.children.splice(index, 1, ...replacement);
|
|
@@ -739,6 +1058,10 @@ export default function remarkNotation(options = {}) {
|
|
|
739
1058
|
visit(child, false);
|
|
740
1059
|
if (child && child.type === 'math' && equationByNode.has(child)) {
|
|
741
1060
|
node.children[index] = equationNode(child, equationByNode.get(child));
|
|
1061
|
+
} else if (child?.type === 'mdxJsxFlowElement' && tableByNode.has(child)) {
|
|
1062
|
+
injectFloatNumberAttributes(child, tableByNode.get(child), 'tab');
|
|
1063
|
+
} else if (child?.type === 'mdxJsxFlowElement' && figureByNode.has(child)) {
|
|
1064
|
+
injectFloatNumberAttributes(child, figureByNode.get(child), 'fig');
|
|
742
1065
|
}
|
|
743
1066
|
}
|
|
744
1067
|
};
|
|
@@ -755,5 +1078,16 @@ export default function remarkNotation(options = {}) {
|
|
|
755
1078
|
indexInSection,
|
|
756
1079
|
}),
|
|
757
1080
|
);
|
|
1081
|
+
const floatLabelData = (byNode) =>
|
|
1082
|
+
[...byNode.values()]
|
|
1083
|
+
.filter((float) => float.key != null)
|
|
1084
|
+
.map(({ key, number, section, indexInSection }) => ({
|
|
1085
|
+
key,
|
|
1086
|
+
number,
|
|
1087
|
+
section,
|
|
1088
|
+
indexInSection,
|
|
1089
|
+
}));
|
|
1090
|
+
file.data.tableLabels = floatLabelData(tableByNode);
|
|
1091
|
+
file.data.figureLabels = floatLabelData(figureByNode);
|
|
758
1092
|
};
|
|
759
1093
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `validateKeyedFloats` for `<Table>` — see `keyed-float-validate.ts`. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
validateKeyedFloats,
|
|
5
|
+
type KeyedFloatLabelRecord,
|
|
6
|
+
type KeyedFloatLessonInput,
|
|
7
|
+
type KeyedFloatValidation,
|
|
8
|
+
} from './keyed-float-validate';
|
|
9
|
+
|
|
10
|
+
export type TableDiagnosticCode =
|
|
11
|
+
| 'tab-invalid-key'
|
|
12
|
+
| 'tab-duplicate-key'
|
|
13
|
+
| 'tab-ref-resolves'
|
|
14
|
+
| 'tab-key-unused';
|
|
15
|
+
|
|
16
|
+
export interface TableDiagnostic {
|
|
17
|
+
readonly code: TableDiagnosticCode;
|
|
18
|
+
readonly severity: 'error' | 'warning';
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly file?: string;
|
|
21
|
+
readonly lessonId?: string;
|
|
22
|
+
readonly key?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type TableLabelRecord = KeyedFloatLabelRecord;
|
|
26
|
+
export type TableLessonInput = KeyedFloatLessonInput;
|
|
27
|
+
|
|
28
|
+
export interface TableValidation extends KeyedFloatValidation {
|
|
29
|
+
readonly diagnostics: readonly TableDiagnostic[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateTables(
|
|
33
|
+
lessons: readonly TableLessonInput[],
|
|
34
|
+
): TableValidation {
|
|
35
|
+
return validateKeyedFloats(lessons, {
|
|
36
|
+
tagName: 'Table',
|
|
37
|
+
prefix: 'tab',
|
|
38
|
+
noun: 'table',
|
|
39
|
+
}) as TableValidation;
|
|
40
|
+
}
|
|
@@ -118,6 +118,24 @@ export const UNITS_VOCAB: ReadonlySet<string> = new Set([
|
|
|
118
118
|
'quoted',
|
|
119
119
|
'explicitly',
|
|
120
120
|
'calculations',
|
|
121
|
+
// motion / physical quantities (playground corpus)
|
|
122
|
+
'meter',
|
|
123
|
+
'meters',
|
|
124
|
+
'second',
|
|
125
|
+
'seconds',
|
|
126
|
+
'speed',
|
|
127
|
+
'velocity',
|
|
128
|
+
'acceleration',
|
|
129
|
+
'gravitational',
|
|
130
|
+
'launch',
|
|
131
|
+
'angle',
|
|
132
|
+
'degrees',
|
|
133
|
+
'horizontal',
|
|
134
|
+
'vertical',
|
|
135
|
+
'level',
|
|
136
|
+
'ground',
|
|
137
|
+
'squared',
|
|
138
|
+
'above',
|
|
121
139
|
]);
|
|
122
140
|
|
|
123
141
|
/** Lowercase word tokens of a `units` string (split on whitespace and hyphens). */
|