explico 0.5.0 → 0.6.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 +116 -7
- package/dist/compiler/course-config.d.ts +31 -1
- package/dist/compiler/course-config.js +7 -0
- package/dist/compiler/manifest.d.ts +10 -2
- package/dist/compiler/manifest.js +7 -3
- package/dist/reference/gate-heading-length.d.ts +12 -0
- package/dist/reference/gate-heading-length.js +79 -0
- package/dist/reference/gate-math.d.ts +6 -2
- package/dist/reference/gate-math.js +2 -1
- package/dist/reference/math-glyphs.d.mts +12 -0
- package/dist/reference/math-glyphs.mjs +183 -42
- package/dist/reference/remark-citation.d.mts +5 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/docs/VERSIONING.md +22 -2
- package/docs/authoring/README.md +2 -0
- package/docs/authoring/components.md +4 -1
- package/docs/authoring/migrating.md +191 -8
- package/docs/authoring/notation.md +55 -0
- package/docs/authoring/structure-rules.md +69 -0
- package/docs/authoring/visualizations.md +2 -2
- package/package.json +1 -1
- package/src/compiler/course-config.ts +40 -0
- package/src/compiler/manifest.ts +14 -1
- package/src/components/diagram/Diagram.astro +0 -55
- package/src/components/notation/NotationLayer.astro +155 -75
- package/src/components/starlight/LessonFooter.astro +3 -1
- package/src/content-config.ts +16 -1
- package/src/reference/gate-heading-length.ts +131 -0
- package/src/reference/gate-math.ts +7 -2
- package/src/reference/math-glyphs.mjs +183 -42
- package/src/reference/remark-citation.mjs +0 -0
- package/src/styles/global.css +1 -1
|
@@ -42,6 +42,10 @@ export interface Props {
|
|
|
42
42
|
* links to reveal an explanation on hover.
|
|
43
43
|
*/
|
|
44
44
|
presentation?: 'full' | 'panel';
|
|
45
|
+
/** Author-chosen state for a reader with nothing saved yet. Default: `false` (closed) — see `docs/authoring` on `CourseConfig.disclosureDefaults`. */
|
|
46
|
+
defaultOpen?: boolean;
|
|
47
|
+
/** Persist the reader's open/closed choice across visits, per page. Default: `true`. */
|
|
48
|
+
rememberState?: boolean;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
import NotationMath from './NotationMath.astro';
|
|
@@ -55,6 +59,8 @@ const {
|
|
|
55
59
|
label = 'Notation used on this page',
|
|
56
60
|
glossaryHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`,
|
|
57
61
|
presentation = 'full',
|
|
62
|
+
defaultOpen = false,
|
|
63
|
+
rememberState = true,
|
|
58
64
|
} = Astro.props;
|
|
59
65
|
|
|
60
66
|
const panelOnly = presentation === 'panel';
|
|
@@ -111,16 +117,16 @@ for (const gloss of glossItems) {
|
|
|
111
117
|
class:list={['notation-layer', panelOnly && 'notation-layer--panel-only']}
|
|
112
118
|
data-notation-layer
|
|
113
119
|
data-notation-layer-id={id}
|
|
114
|
-
aria-
|
|
115
|
-
aria-label={panelOnly ? 'Notation explanations' : undefined}
|
|
120
|
+
aria-label={panelOnly ? 'Notation explanations' : label}
|
|
116
121
|
>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
<div class="notation-list-titlebar">
|
|
123
|
+
<details
|
|
124
|
+
class="notation-list"
|
|
125
|
+
id={id}
|
|
126
|
+
hidden={panelOnly}
|
|
127
|
+
data-remember-state={String(rememberState)}
|
|
128
|
+
open={defaultOpen}
|
|
129
|
+
>
|
|
124
130
|
<summary>
|
|
125
131
|
{label} <span class="notation-count">({items.length})</span>
|
|
126
132
|
</summary>
|
|
@@ -183,16 +189,6 @@ for (const gloss of glossItems) {
|
|
|
183
189
|
<strong>Units:</strong> {definition.units}
|
|
184
190
|
</p>
|
|
185
191
|
)}
|
|
186
|
-
<button
|
|
187
|
-
class="notation-pin"
|
|
188
|
-
type="button"
|
|
189
|
-
data-notation-control={definition.key}
|
|
190
|
-
aria-expanded="false"
|
|
191
|
-
aria-pressed="false"
|
|
192
|
-
hidden
|
|
193
|
-
>
|
|
194
|
-
Pin explanation
|
|
195
|
-
</button>
|
|
196
192
|
</dd>
|
|
197
193
|
</div>
|
|
198
194
|
))}
|
|
@@ -216,14 +212,32 @@ for (const gloss of glossItems) {
|
|
|
216
212
|
</div>
|
|
217
213
|
))}
|
|
218
214
|
</details>
|
|
215
|
+
{!panelOnly && (
|
|
216
|
+
<a
|
|
217
|
+
class="notation-list-anchor"
|
|
218
|
+
href={`#${id}`}
|
|
219
|
+
aria-label={`${label}, titled section`}
|
|
220
|
+
>
|
|
221
|
+
<svg
|
|
222
|
+
aria-hidden="true"
|
|
223
|
+
width="16"
|
|
224
|
+
height="16"
|
|
225
|
+
viewBox="0 0 24 24"
|
|
226
|
+
fill="currentColor"
|
|
227
|
+
><path
|
|
228
|
+
d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"
|
|
229
|
+
></path></svg
|
|
230
|
+
>
|
|
231
|
+
</a>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
219
234
|
|
|
220
235
|
{!panelOnly && (
|
|
221
236
|
<p class="notation-enhancement-help" data-notation-help hidden>
|
|
222
237
|
Point to a marked symbol for a quick explanation, or use the list
|
|
223
238
|
above — pointing at a symbol inside an open card explains that one
|
|
224
|
-
too, alongside it. Click a symbol
|
|
225
|
-
|
|
226
|
-
Escape.
|
|
239
|
+
too, alongside it. Click a symbol to keep its card open; click the
|
|
240
|
+
card (or its “×”) to close it, or press Escape.
|
|
227
241
|
</p>
|
|
228
242
|
)}
|
|
229
243
|
|
|
@@ -270,6 +284,7 @@ for (const gloss of glossItems) {
|
|
|
270
284
|
|
|
271
285
|
<script>
|
|
272
286
|
import { createPopupStack, closestElement } from '../popup-stack';
|
|
287
|
+
import { wirePersistedDetails } from '../disclosure/persisted-disclosure';
|
|
273
288
|
|
|
274
289
|
const triggerSelector = '[data-notation-key]';
|
|
275
290
|
|
|
@@ -296,10 +311,6 @@ for (const gloss of glossItems) {
|
|
|
296
311
|
);
|
|
297
312
|
if (!template) return;
|
|
298
313
|
|
|
299
|
-
const controls = Array.from(
|
|
300
|
-
root.querySelectorAll<HTMLButtonElement>('[data-notation-control]'),
|
|
301
|
-
);
|
|
302
|
-
|
|
303
314
|
// Static triggers, computed once: markers embedded in prose or an
|
|
304
315
|
// equation, and the symbol glyphs in the "Notation used on this page"
|
|
305
316
|
// list. These get the always-on `.notation-trigger` cursor/hover
|
|
@@ -324,20 +335,32 @@ for (const gloss of glossItems) {
|
|
|
324
335
|
for (const trigger of highlightTargets) {
|
|
325
336
|
trigger.classList.add('notation-trigger');
|
|
326
337
|
}
|
|
327
|
-
for (const control of controls) control.hidden = false;
|
|
328
338
|
const help = root.querySelector<HTMLElement>('[data-notation-help]');
|
|
329
339
|
if (help) help.hidden = false;
|
|
330
340
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
341
|
+
// The visible list's own symbol glyphs are the only keyboard path to
|
|
342
|
+
// pinning a card from this list (the old "Pin explanation" button was
|
|
343
|
+
// the other one, and is gone) — make them a real, labelled, Enter/Space-
|
|
344
|
+
// operable control rather than a mouse-only `<span>`. `symbolTriggers`
|
|
345
|
+
// (defined above) is already scoped to entries inside this list.
|
|
346
|
+
for (const trigger of symbolTriggers) {
|
|
347
|
+
const key = trigger.dataset.notationKey ?? '';
|
|
348
|
+
const entryLabel =
|
|
349
|
+
entries.get(key)?.querySelector<HTMLElement>('[data-notation-link]')
|
|
350
|
+
?.textContent?.trim() ?? key;
|
|
351
|
+
trigger.setAttribute('role', 'button');
|
|
352
|
+
trigger.tabIndex = 0;
|
|
353
|
+
trigger.setAttribute('aria-pressed', 'false');
|
|
354
|
+
trigger.setAttribute('aria-label', `Pin the ${entryLabel} explanation`);
|
|
355
|
+
trigger.addEventListener('keydown', (event) => {
|
|
356
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
trigger.click();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
336
361
|
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
return candidate && root.contains(candidate) ? candidate : null;
|
|
340
|
-
};
|
|
362
|
+
const keyFor = (element: HTMLElement | null) =>
|
|
363
|
+
element?.dataset.notationKey ?? element?.dataset.notationLink ?? null;
|
|
341
364
|
|
|
342
365
|
const staticLinkFrom = (node: EventTarget | null) => {
|
|
343
366
|
const candidate = closestElement(node, '[data-notation-link]');
|
|
@@ -357,7 +380,7 @@ for (const gloss of glossItems) {
|
|
|
357
380
|
};
|
|
358
381
|
|
|
359
382
|
const triggerFrom = (node: EventTarget | null) =>
|
|
360
|
-
|
|
383
|
+
staticLinkFrom(node) ?? symbolFrom(node);
|
|
361
384
|
|
|
362
385
|
const labelOf = (entry: HTMLElement, key: string) =>
|
|
363
386
|
entry
|
|
@@ -465,21 +488,6 @@ for (const gloss of glossItems) {
|
|
|
465
488
|
return el;
|
|
466
489
|
};
|
|
467
490
|
|
|
468
|
-
const updateControls = (
|
|
469
|
-
open: ReadonlySet<string>,
|
|
470
|
-
pinned: ReadonlySet<string>,
|
|
471
|
-
) => {
|
|
472
|
-
for (const control of controls) {
|
|
473
|
-
const key = control.dataset.notationControl ?? '';
|
|
474
|
-
const selected = pinned.has(key);
|
|
475
|
-
control.setAttribute('aria-pressed', String(selected));
|
|
476
|
-
control.setAttribute('aria-expanded', String(open.has(key)));
|
|
477
|
-
control.textContent = selected
|
|
478
|
-
? 'Unpin explanation'
|
|
479
|
-
: 'Pin explanation';
|
|
480
|
-
}
|
|
481
|
-
};
|
|
482
|
-
|
|
483
491
|
createPopupStack({
|
|
484
492
|
// Popups are `position: fixed` and float above everything regardless
|
|
485
493
|
// of DOM placement — *except* z-index only ever wins within its own
|
|
@@ -493,7 +501,6 @@ for (const gloss of glossItems) {
|
|
|
493
501
|
triggerFrom,
|
|
494
502
|
keyFor,
|
|
495
503
|
createPopup,
|
|
496
|
-
pinControlFrom: controlFrom,
|
|
497
504
|
onChange: (open, pinned) => {
|
|
498
505
|
for (const trigger of highlightTargets) {
|
|
499
506
|
trigger.classList.toggle(
|
|
@@ -501,15 +508,44 @@ for (const gloss of glossItems) {
|
|
|
501
508
|
open.has(trigger.dataset.notationKey ?? ''),
|
|
502
509
|
);
|
|
503
510
|
}
|
|
504
|
-
|
|
511
|
+
for (const trigger of symbolTriggers) {
|
|
512
|
+
trigger.setAttribute(
|
|
513
|
+
'aria-pressed',
|
|
514
|
+
String(pinned.has(trigger.dataset.notationKey ?? '')),
|
|
515
|
+
);
|
|
516
|
+
}
|
|
505
517
|
},
|
|
506
518
|
});
|
|
507
519
|
}
|
|
508
520
|
|
|
521
|
+
function setupNotationDisclosure(details: HTMLDetailsElement) {
|
|
522
|
+
if (details.dataset.disclosureWired === 'true') return;
|
|
523
|
+
details.dataset.disclosureWired = 'true';
|
|
524
|
+
|
|
525
|
+
// Same convention as `Figure.astro`: the SSR-rendered `open` attribute
|
|
526
|
+
// already carries the author's `defaultOpen` prop verbatim.
|
|
527
|
+
const defaultOpen = details.open;
|
|
528
|
+
if (details.dataset.rememberState === 'true') {
|
|
529
|
+
wirePersistedDetails(details, `notation:${details.id}`, defaultOpen);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Deep-linking to `#page-notation` (the anchor link, or a shared URL)
|
|
533
|
+
// must reveal it even if a reader left it collapsed.
|
|
534
|
+
const openIfTargeted = () => {
|
|
535
|
+
if (location.hash === `#${details.id}`) details.open = true;
|
|
536
|
+
};
|
|
537
|
+
openIfTargeted();
|
|
538
|
+
window.addEventListener('hashchange', openIfTargeted);
|
|
539
|
+
}
|
|
540
|
+
|
|
509
541
|
for (const root of document.querySelectorAll<HTMLElement>(
|
|
510
542
|
'[data-notation-layer]',
|
|
511
543
|
)) {
|
|
512
544
|
setupNotationLayer(root);
|
|
545
|
+
const details = root.querySelector<HTMLDetailsElement>(
|
|
546
|
+
':scope > .notation-list-titlebar > .notation-list[id]',
|
|
547
|
+
);
|
|
548
|
+
if (details) setupNotationDisclosure(details);
|
|
513
549
|
}
|
|
514
550
|
</script>
|
|
515
551
|
|
|
@@ -526,9 +562,13 @@ for (const gloss of glossItems) {
|
|
|
526
562
|
margin: 0;
|
|
527
563
|
}
|
|
528
564
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
565
|
+
/* Positioning context for `.notation-list-anchor`, mirroring
|
|
566
|
+
`Figure.astro`'s `.keyed-figure__titlebar` — the anchor link is a DOM
|
|
567
|
+
sibling of `<details>`, not nested inside `<summary>` (a focusable `<a>`
|
|
568
|
+
inside `<summary>` trips axe's `nested-interactive` check the same way
|
|
569
|
+
it would there). */
|
|
570
|
+
.notation-list-titlebar {
|
|
571
|
+
position: relative;
|
|
532
572
|
}
|
|
533
573
|
|
|
534
574
|
.notation-list {
|
|
@@ -539,8 +579,52 @@ for (const gloss of glossItems) {
|
|
|
539
579
|
|
|
540
580
|
.notation-list > summary {
|
|
541
581
|
padding: 0.8rem 1rem;
|
|
582
|
+
padding-inline-end: 2.2rem;
|
|
542
583
|
cursor: pointer;
|
|
543
584
|
font-weight: 600;
|
|
585
|
+
font-size: var(--sl-text-lg);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
.notation-list-anchor {
|
|
589
|
+
position: absolute;
|
|
590
|
+
top: 0.9rem;
|
|
591
|
+
inset-inline-end: 1rem;
|
|
592
|
+
display: inline-flex;
|
|
593
|
+
align-items: center;
|
|
594
|
+
justify-content: center;
|
|
595
|
+
width: 1em;
|
|
596
|
+
height: 1em;
|
|
597
|
+
font-size: var(--sl-text-lg);
|
|
598
|
+
color: var(--sl-color-gray-3);
|
|
599
|
+
text-decoration: none;
|
|
600
|
+
-webkit-user-select: none;
|
|
601
|
+
user-select: none;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.notation-list-anchor svg {
|
|
605
|
+
width: 0.7em;
|
|
606
|
+
height: 0.7em;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.notation-list-anchor:hover,
|
|
610
|
+
.notation-list-anchor:focus-visible {
|
|
611
|
+
color: var(--sl-color-text-accent);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.notation-list-anchor:focus-visible {
|
|
615
|
+
outline: 2px solid var(--sl-color-accent);
|
|
616
|
+
outline-offset: 0.15rem;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
@media (hover: hover) {
|
|
620
|
+
.notation-list-anchor {
|
|
621
|
+
opacity: 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.notation-list-anchor:focus-visible,
|
|
625
|
+
.notation-list-titlebar:hover .notation-list-anchor {
|
|
626
|
+
opacity: 1;
|
|
627
|
+
}
|
|
544
628
|
}
|
|
545
629
|
|
|
546
630
|
.notation-count,
|
|
@@ -594,14 +678,28 @@ for (const gloss of glossItems) {
|
|
|
594
678
|
cursor: help;
|
|
595
679
|
}
|
|
596
680
|
|
|
681
|
+
/* Entries in the visible list are keyboard-operable (Enter/Space pins
|
|
682
|
+
the same card a click would) now that the list has no separate "Pin
|
|
683
|
+
explanation" button of its own — see the setup script below. */
|
|
684
|
+
.notation-entry .notation-symbol-trigger {
|
|
685
|
+
cursor: pointer;
|
|
686
|
+
}
|
|
687
|
+
|
|
597
688
|
.notation-symbol-trigger:hover,
|
|
689
|
+
.notation-symbol-trigger:focus-visible,
|
|
598
690
|
.notation-symbol-trigger.is-notation-active {
|
|
599
691
|
background: var(--sl-color-accent-low);
|
|
600
692
|
box-shadow: 0 0 0 0.12em var(--sl-color-accent-low);
|
|
601
693
|
}
|
|
602
694
|
|
|
695
|
+
.notation-symbol-trigger:focus-visible {
|
|
696
|
+
outline: 2px solid var(--sl-color-accent);
|
|
697
|
+
outline-offset: 0.1em;
|
|
698
|
+
}
|
|
699
|
+
|
|
603
700
|
@media (forced-colors: active) {
|
|
604
701
|
.notation-symbol-trigger:hover,
|
|
702
|
+
.notation-symbol-trigger:focus-visible,
|
|
605
703
|
.notation-symbol-trigger.is-notation-active {
|
|
606
704
|
outline: 2px solid Highlight;
|
|
607
705
|
color: HighlightText;
|
|
@@ -634,24 +732,6 @@ for (const gloss of glossItems) {
|
|
|
634
732
|
color: var(--sl-color-text);
|
|
635
733
|
}
|
|
636
734
|
|
|
637
|
-
.notation-pin {
|
|
638
|
-
justify-self: start;
|
|
639
|
-
padding: 0.25rem 0.55rem;
|
|
640
|
-
border: 1px solid var(--sl-color-gray-5);
|
|
641
|
-
border-radius: 0.35rem;
|
|
642
|
-
color: var(--sl-color-text-accent);
|
|
643
|
-
background: var(--sl-color-bg);
|
|
644
|
-
font: inherit;
|
|
645
|
-
font-size: var(--sl-text-sm);
|
|
646
|
-
cursor: pointer;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
.notation-pin:hover,
|
|
650
|
-
.notation-pin[aria-pressed='true'] {
|
|
651
|
-
border-color: var(--sl-color-accent);
|
|
652
|
-
background: var(--sl-color-accent-low);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
735
|
.notation-enhancement-help {
|
|
656
736
|
margin: 0.55rem 0 0;
|
|
657
737
|
font-size: var(--sl-text-xs);
|
|
@@ -40,8 +40,10 @@ const lessonId = isLessonSlug(routeSlug)
|
|
|
40
40
|
const glossaryBase = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`;
|
|
41
41
|
|
|
42
42
|
const definitions: NotationLayerDefinition[] = [];
|
|
43
|
+
let notationDefaultOpen = false;
|
|
43
44
|
if (lessonId) {
|
|
44
45
|
const manifest = await loadManifest();
|
|
46
|
+
notationDefaultOpen = manifest.disclosureDefaults.notation;
|
|
45
47
|
const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
|
|
46
48
|
const routeData = Astro.locals.starlightRoute.entry.data as {
|
|
47
49
|
notation?: { local?: readonly { key?: unknown }[] };
|
|
@@ -80,7 +82,7 @@ if (lessonId) {
|
|
|
80
82
|
}
|
|
81
83
|
---
|
|
82
84
|
|
|
83
|
-
<NotationLayer definitions={definitions} />
|
|
85
|
+
<NotationLayer definitions={definitions} defaultOpen={notationDefaultOpen} />
|
|
84
86
|
{lessonId && <CitationLayer />}
|
|
85
87
|
{lessonId && <AbbreviationLayer />}
|
|
86
88
|
{lessonId && <KeyedFloatEnhancer />}
|
package/src/content-config.ts
CHANGED
|
@@ -196,6 +196,21 @@ const assessmentItemBase = z.object({
|
|
|
196
196
|
explanation: z.string().min(1),
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* `AssessmentSet.astro` renders the numbered heading itself — `<h2>Knowledge
|
|
201
|
+
* check {number} — {title}</h2>` — so `title` must be just the topic. A title
|
|
202
|
+
* that repeats the "Knowledge check" label duplicates it on every page that
|
|
203
|
+
* uses `AssessmentSet` (the standalone `AssessmentRunner` heading fallback
|
|
204
|
+
* reads fine either way, so this only bites the common path).
|
|
205
|
+
*/
|
|
206
|
+
const assessmentTitle = z
|
|
207
|
+
.string()
|
|
208
|
+
.min(1)
|
|
209
|
+
.refine((value) => !/^knowledge check\b/i.test(value), {
|
|
210
|
+
message:
|
|
211
|
+
'Assessment title must not start with "Knowledge check" — AssessmentSet already renders "Knowledge check {number} — {title}", so a title with the same prefix duplicates it. Use just the topic, e.g. "AIC and KL risk".',
|
|
212
|
+
});
|
|
213
|
+
|
|
199
214
|
const assessments = defineCollection({
|
|
200
215
|
loader: glob({
|
|
201
216
|
pattern: '**/*.json',
|
|
@@ -203,7 +218,7 @@ const assessments = defineCollection({
|
|
|
203
218
|
}),
|
|
204
219
|
schema: z.object({
|
|
205
220
|
id,
|
|
206
|
-
title:
|
|
221
|
+
title: assessmentTitle,
|
|
207
222
|
editorialStatus,
|
|
208
223
|
items: z
|
|
209
224
|
.array(
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The heading/title-length gate (rule S1): no Markdown heading (`##`
|
|
3
|
+
* through `######`) and no `<Figure title>`, `<Diagram label>`, `<Memo
|
|
4
|
+
* title>`, or `<ShortNote title>` may run past `HEADING_LENGTH_LIMIT`
|
|
5
|
+
* characters. A long heading or float title is usually a full sentence that
|
|
6
|
+
* belongs in the body paragraph (for a heading) or the `caption` (for a
|
|
7
|
+
* float) instead — see docs/authoring/structure-rules.md#s1.
|
|
8
|
+
*
|
|
9
|
+
* `<Table>` is deliberately not covered: it has only a `caption`, no
|
|
10
|
+
* separate `title`, and that caption is the LaTeX-convention full sentence
|
|
11
|
+
* shown above the table — capping it would fight the rule that puts it
|
|
12
|
+
* there. `<Figure>`'s `caption` is the same full sentence; only its short
|
|
13
|
+
* `title` (the text on the collapsible title bar) is in scope.
|
|
14
|
+
*
|
|
15
|
+
* A lesson's own `#` (its `frontmatter.title`, rendered as the page H1 by
|
|
16
|
+
* Starlight) is Review-only — it never appears in `lesson.body`, so this
|
|
17
|
+
* build-time gate, which only scans body text, cannot see it.
|
|
18
|
+
*
|
|
19
|
+
* Ships straight as an error, same as N9/N14: no partial corpus to absorb,
|
|
20
|
+
* no `lint-ignore.yml` entry.
|
|
21
|
+
*/
|
|
22
|
+
import { stripFencedCode } from './equations.mjs';
|
|
23
|
+
import type { MathGateDiagnostic } from './gate-math';
|
|
24
|
+
import { lineOf } from './gate-prose-notation';
|
|
25
|
+
import type { NotationLessonInput } from './types';
|
|
26
|
+
|
|
27
|
+
export const HEADING_LENGTH_LIMIT = 65;
|
|
28
|
+
|
|
29
|
+
// ATX heading: 1-6 `#`, then the text, with any trailing closing `#`s
|
|
30
|
+
// trimmed off (the `# text #` form CommonMark also allows).
|
|
31
|
+
const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
|
|
32
|
+
|
|
33
|
+
// The JSX attribute that reads as a float's own short title, per component.
|
|
34
|
+
// `Table` has no entry — see the module doc comment.
|
|
35
|
+
const TITLE_ATTRIBUTE_BY_TAG: Record<string, string> = {
|
|
36
|
+
Figure: 'title',
|
|
37
|
+
Diagram: 'label',
|
|
38
|
+
Memo: 'title',
|
|
39
|
+
ShortNote: 'title',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface Finding {
|
|
43
|
+
readonly index: number;
|
|
44
|
+
readonly token: string;
|
|
45
|
+
readonly message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readAttribute(attributesSource: string, name: string): string | undefined {
|
|
49
|
+
const pattern = new RegExp(`\\b${name}\\s*=\\s*(["'])([^"']*)\\1`);
|
|
50
|
+
const match = pattern.exec(attributesSource);
|
|
51
|
+
return match ? match[2] : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function overLimitMessage(kind: string, text: string): string {
|
|
55
|
+
return (
|
|
56
|
+
`${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ` +
|
|
57
|
+
`${HEADING_LENGTH_LIMIT}-character limit (rule S1). Shorten it and move ` +
|
|
58
|
+
'the rest into the paragraph or caption that follows.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function headingFindings(text: string): Finding[] {
|
|
63
|
+
const out: Finding[] = [];
|
|
64
|
+
const re = new RegExp(HEADING_LINE.source, HEADING_LINE.flags);
|
|
65
|
+
let match: RegExpExecArray | null;
|
|
66
|
+
while ((match = re.exec(text))) {
|
|
67
|
+
const level = match[1]?.length ?? 1;
|
|
68
|
+
const title = (match[2] ?? '').trim();
|
|
69
|
+
if (title.length > HEADING_LENGTH_LIMIT) {
|
|
70
|
+
out.push({
|
|
71
|
+
index: match.index,
|
|
72
|
+
token: title,
|
|
73
|
+
message: overLimitMessage(`${'#'.repeat(level)} heading`, title),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function floatTitleFindings(text: string): Finding[] {
|
|
81
|
+
const out: Finding[] = [];
|
|
82
|
+
for (const [tagName, attribute] of Object.entries(TITLE_ATTRIBUTE_BY_TAG)) {
|
|
83
|
+
const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
|
|
84
|
+
let match: RegExpExecArray | null;
|
|
85
|
+
while ((match = opening.exec(text))) {
|
|
86
|
+
const value = readAttribute(match[1] ?? '', attribute)?.trim();
|
|
87
|
+
if (value && value.length > HEADING_LENGTH_LIMIT) {
|
|
88
|
+
out.push({
|
|
89
|
+
index: match.index,
|
|
90
|
+
token: value,
|
|
91
|
+
message: overLimitMessage(`<${tagName} ${attribute}>`, value),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every S1 finding in one lesson body, in source order. */
|
|
100
|
+
function findingsIn(text: string): Finding[] {
|
|
101
|
+
return [...headingFindings(text), ...floatTitleFindings(text)].sort(
|
|
102
|
+
(a, b) => a.index - b.index,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Scan every lesson body for a heading or a keyed float's title running past
|
|
108
|
+
* `HEADING_LENGTH_LIMIT` characters. Returns one `MathGateDiagnostic`
|
|
109
|
+
* (context `'structure'`, always an error) per finding.
|
|
110
|
+
*/
|
|
111
|
+
export function gateHeadingLength(
|
|
112
|
+
lessons: readonly NotationLessonInput[],
|
|
113
|
+
): MathGateDiagnostic[] {
|
|
114
|
+
const diagnostics: MathGateDiagnostic[] = [];
|
|
115
|
+
|
|
116
|
+
for (const lesson of lessons) {
|
|
117
|
+
const text = stripFencedCode(lesson.body);
|
|
118
|
+
for (const finding of findingsIn(text)) {
|
|
119
|
+
diagnostics.push({
|
|
120
|
+
context: 'structure',
|
|
121
|
+
severity: 'error',
|
|
122
|
+
file: lesson.source.file,
|
|
123
|
+
line: lineOf(lesson.body, finding.index),
|
|
124
|
+
token: finding.token,
|
|
125
|
+
message: finding.message,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return diagnostics;
|
|
131
|
+
}
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
} from './types';
|
|
21
21
|
|
|
22
22
|
export interface MathGateDiagnostic {
|
|
23
|
-
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation';
|
|
23
|
+
readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
|
|
24
24
|
/**
|
|
25
25
|
* Body / component-slot math blocks the build (the render path needs every
|
|
26
26
|
* glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
|
|
@@ -38,7 +38,11 @@ export interface MathGateDiagnostic {
|
|
|
38
38
|
* same change that introduced the check. `abbreviation` (N14,
|
|
39
39
|
* `gate-abbreviations.ts`) is the same "ships straight as an error" story
|
|
40
40
|
* for a different shape of plain-text token — an unexplained acronym or
|
|
41
|
-
* dotted abbreviation, rather than a mis-typed quantity/unit.
|
|
41
|
+
* dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
|
|
42
|
+
* (S1, `gate-heading-length.ts`) is unrelated to notation — it caps how
|
|
43
|
+
* long a heading or a keyed float's title may run — but reuses this same
|
|
44
|
+
* diagnostic shape and the one `manifest.diagnostics.math` array rather
|
|
45
|
+
* than adding a second array the CLI has to know about.
|
|
42
46
|
*/
|
|
43
47
|
readonly severity: 'error' | 'warning';
|
|
44
48
|
readonly file: string;
|
|
@@ -53,6 +57,7 @@ const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
|
|
|
53
57
|
assessment: 'error',
|
|
54
58
|
prose: 'error',
|
|
55
59
|
abbreviation: 'error',
|
|
60
|
+
structure: 'error',
|
|
56
61
|
};
|
|
57
62
|
|
|
58
63
|
interface AssessmentItemLike {
|