explico 0.13.0 → 0.14.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 +894 -1
- package/README.md +200 -30
- package/dist/compiler/astro.d.ts +15 -2
- package/dist/compiler/astro.js +40 -11
- package/dist/compiler/cli-main.d.ts +43 -0
- package/dist/compiler/cli-main.js +392 -0
- package/dist/compiler/cli.d.ts +424 -9
- package/dist/compiler/cli.js +1289 -8
- package/dist/compiler/collections.d.ts +88 -1
- package/dist/compiler/collections.js +170 -25
- package/dist/compiler/course-config.d.ts +1 -1
- package/dist/compiler/course-config.js +105 -1
- package/dist/compiler/decks.d.ts +161 -0
- package/dist/compiler/decks.js +235 -0
- package/dist/compiler/exports/combined-html.d.ts +5 -1
- package/dist/compiler/exports/deck-fit.d.ts +55 -0
- package/dist/compiler/exports/deck-fit.js +90 -0
- package/dist/compiler/exports/export-chapters.d.ts +37 -6
- package/dist/compiler/exports/export-chapters.js +64 -5
- package/dist/compiler/exports/export-pages.d.ts +30 -3
- package/dist/compiler/exports/export-pages.js +24 -1
- package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
- package/dist/compiler/exports/latex/cache.d.ts +78 -0
- package/dist/compiler/exports/latex/cache.js +129 -0
- package/dist/compiler/exports/latex/capture.d.ts +10 -1
- package/dist/compiler/exports/latex/capture.js +3 -1
- package/dist/compiler/exports/latex/compile.d.ts +13 -2
- package/dist/compiler/exports/latex/compile.js +12 -1
- package/dist/compiler/exports/latex/deck.d.ts +92 -0
- package/dist/compiler/exports/latex/deck.js +349 -0
- package/dist/compiler/exports/latex/document.d.ts +51 -6
- package/dist/compiler/exports/latex/document.js +71 -12
- package/dist/compiler/exports/latex/explico-common.sty +198 -0
- package/dist/compiler/exports/latex/explico.cls +13 -183
- package/dist/compiler/exports/latex/serialize.d.ts +60 -4
- package/dist/compiler/exports/latex/serialize.js +4 -2
- package/dist/compiler/exports/latex-export.d.ts +24 -2
- package/dist/compiler/exports/latex-export.js +107 -26
- package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
- package/dist/compiler/exports/markdown/serialize.js +85 -8
- package/dist/compiler/exports/markdown-export.d.ts +6 -1
- package/dist/compiler/exports/markdown-export.js +50 -11
- package/dist/compiler/exports/standalone-html.d.ts +10 -5
- package/dist/compiler/exports/standalone-html.js +3 -3
- package/dist/compiler/exports/static-tree.d.ts +38 -1
- package/dist/compiler/exports/static-tree.js +120 -4
- package/dist/compiler/knowledge-version.d.ts +26 -2
- package/dist/compiler/knowledge-version.js +27 -17
- package/dist/compiler/lesson-derivation.d.ts +143 -6
- package/dist/compiler/lesson-derivation.js +145 -13
- package/dist/compiler/manifest.d.ts +133 -4
- package/dist/compiler/manifest.js +359 -58
- package/dist/compiler/page-versions.d.ts +160 -0
- package/dist/compiler/page-versions.js +198 -0
- package/dist/compiler/render-diagnostics.d.ts +5 -1
- package/dist/compiler/resolution-report.d.ts +24 -6
- package/dist/compiler/resolution-report.js +22 -4
- package/dist/compiler/validate-content.d.ts +7 -0
- package/dist/compiler/validate-content.js +160 -0
- package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
- package/dist/curriculum/competency-graph.d.ts +54 -0
- package/dist/curriculum/competency-graph.js +41 -0
- package/dist/reference/base-library.d.mts +8 -0
- package/dist/reference/base-library.mjs +19 -0
- package/dist/reference/consistency.d.ts +8 -4
- package/dist/reference/consistency.js +76 -26
- package/dist/reference/deck-remark.d.mts +123 -0
- package/dist/reference/deck-remark.mjs +600 -0
- package/dist/reference/deck-source.d.ts +107 -0
- package/dist/reference/deck-source.js +140 -0
- package/dist/reference/decks-validate.d.ts +133 -0
- package/dist/reference/decks-validate.js +359 -0
- package/dist/reference/derived-entries.d.ts +5 -1
- package/dist/reference/diagram-fit.d.ts +1 -1
- package/dist/reference/figure-symbols.d.ts +23 -6
- package/dist/reference/figure-symbols.js +51 -15
- package/dist/reference/float-name.d.ts +43 -0
- package/dist/reference/float-name.js +58 -0
- package/dist/reference/folds-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.d.ts +1 -1
- package/dist/reference/gate-indices.js +5 -4
- package/dist/reference/gate-math.d.ts +29 -2
- package/dist/reference/gate-math.js +99 -18
- package/dist/reference/gate-part-pages.d.ts +1 -1
- package/dist/reference/lab-katex-validate.d.ts +10 -6
- package/dist/reference/lab-katex-validate.js +17 -9
- package/dist/reference/lab-math-scope.js +29 -4
- package/dist/reference/math-glyphs.d.mts +25 -0
- package/dist/reference/math-glyphs.mjs +189 -25
- package/dist/reference/page-notation.d.ts +22 -3
- package/dist/reference/page-notation.js +32 -10
- package/dist/reference/references.d.ts +14 -1
- package/dist/reference/references.js +13 -0
- package/dist/reference/registry-scope.d.mts +33 -0
- package/dist/reference/registry-scope.mjs +34 -0
- package/dist/reference/registry.js +29 -10
- package/dist/reference/rehype-deck.d.mts +6 -0
- package/dist/reference/rehype-deck.mjs +286 -0
- package/dist/reference/rehype-heading-anchors.d.mts +20 -0
- package/dist/reference/rehype-heading-anchors.mjs +48 -6
- package/dist/reference/remark-notation.mjs +249 -40
- package/dist/reference/source-lines.d.mts +12 -0
- package/dist/reference/source-lines.mjs +65 -0
- package/dist/reference/types.d.ts +28 -0
- package/dist/rules/catalog.d.ts +21 -4
- package/dist/rules/catalog.js +221 -2
- package/dist/rules/course-rules.d.ts +5 -1
- package/dist/rules/course-rules.js +17 -3
- package/dist/rules/course-rules.schema.json +12 -0
- package/dist/rules/in-effect.d.ts +13 -3
- package/dist/rules/in-effect.js +30 -7
- package/dist/rules/rules.schema.json +259 -1
- package/dist/rules/settings.d.ts +1 -1
- package/dist/session/PreferenceStore.d.ts +6 -0
- package/dist/session/PreferenceStore.js +18 -1
- package/dist/session/deck-memory.d.ts +116 -0
- package/dist/session/deck-memory.js +143 -0
- package/dist/session/reading-memory.d.ts +96 -0
- package/dist/session/reading-memory.js +133 -0
- package/docs/VERSIONING.md +21 -18
- package/docs/authoring/README.md +29 -9
- package/docs/authoring/agents.md +63 -16
- package/docs/authoring/components.md +15 -0
- package/docs/authoring/deck.md +588 -0
- package/docs/authoring/migrating.md +1087 -14
- package/docs/authoring/notation.md +49 -6
- package/docs/authoring/structure.md +80 -1
- package/docs/authoring/versioning.md +54 -2
- package/docs/authoring/visualizations.md +7 -2
- package/docs/course-agent/AGENTS.md +37 -0
- package/docs/course-agent/agents/rules-reviewer.md +33 -0
- package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
- package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
- package/docs/course-agent/skills/release-content/SKILL.md +24 -0
- package/docs/diagram-scaling.md +148 -0
- package/docs/privacy.md +59 -0
- package/package.json +8 -2
- package/src/compiler/astro.ts +60 -21
- package/src/compiler/cli-main.ts +469 -0
- package/src/compiler/cli.ts +1905 -16
- package/src/compiler/collections.ts +284 -25
- package/src/compiler/course-config.ts +267 -0
- package/src/compiler/decks.ts +455 -0
- package/src/compiler/exports/deck-fit.ts +127 -0
- package/src/compiler/exports/export-chapters.ts +105 -6
- package/src/compiler/exports/export-pages.ts +46 -2
- package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
- package/src/compiler/exports/latex/cache.ts +183 -0
- package/src/compiler/exports/latex/capture.ts +5 -2
- package/src/compiler/exports/latex/compile.ts +18 -0
- package/src/compiler/exports/latex/deck.ts +419 -0
- package/src/compiler/exports/latex/document.ts +117 -21
- package/src/compiler/exports/latex/explico-common.sty +198 -0
- package/src/compiler/exports/latex/explico.cls +13 -183
- package/src/compiler/exports/latex/serialize.ts +25 -21
- package/src/compiler/exports/latex/texlive-packages.txt +6 -2
- package/src/compiler/exports/latex-export.ts +187 -45
- package/src/compiler/exports/markdown/serialize.ts +118 -9
- package/src/compiler/exports/markdown-export.ts +56 -11
- package/src/compiler/exports/standalone-html.ts +8 -7
- package/src/compiler/exports/static-tree.ts +175 -3
- package/src/compiler/knowledge-version.ts +56 -21
- package/src/compiler/lesson-derivation.ts +282 -16
- package/src/compiler/manifest.ts +558 -63
- package/src/compiler/page-versions.ts +359 -0
- package/src/compiler/resolution-report.ts +38 -7
- package/src/compiler/validate-content.ts +212 -0
- package/src/components/CourseContents.astro +124 -6
- package/src/components/CurriculumMap.astro +49 -1
- package/src/components/FloatIndex.astro +1 -1
- package/src/components/PartOverview.astro +51 -5
- package/src/components/assessments/AssessmentSet.astro +41 -15
- package/src/components/block-heading.ts +15 -6
- package/src/components/card-carry.ts +7 -4
- package/src/components/card-manager.ts +14 -2
- package/src/components/card-memory.ts +13 -9
- package/src/components/deck/DeckEnhancer.astro +13 -0
- package/src/components/deck/deck-context.ts +104 -0
- package/src/components/deck/deck-controller.ts +1287 -0
- package/src/components/deck/deck-map.ts +91 -0
- package/src/components/deck-lane.ts +163 -0
- package/src/components/diagram/Diagram.astro +131 -86
- package/src/components/disclosure/persisted-disclosure.ts +9 -1
- package/src/components/figure/Figure.astro +7 -2
- package/src/components/figure/FloatCaption.astro +5 -3
- package/src/components/fold/Fold.astro +2 -2
- package/src/components/notation/NotationFormula.astro +81 -11
- package/src/components/notation/NotationGlossary.astro +132 -1
- package/src/components/notation/NotationLayer.astro +29 -0
- package/src/components/starlight/AboutPanel.astro +186 -3
- package/src/components/starlight/CollapsibleSections.astro +24 -3
- package/src/components/starlight/DiagramEnhancer.astro +5 -1
- package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
- package/src/components/starlight/LayoutHeader.astro +17 -0
- package/src/components/starlight/LayoutPageTitle.astro +110 -0
- package/src/components/starlight/LayoutSidebar.astro +807 -21
- package/src/components/starlight/LayoutSocialIcons.astro +62 -6
- package/src/components/starlight/LessonExportActions.astro +27 -9
- package/src/components/starlight/LessonFooter.astro +59 -0
- package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
- package/src/components/starlight/TextSizeControl.astro +24 -13
- package/src/components/starlight/page-marks.ts +139 -0
- package/src/components/starlight/section-summary.ts +1 -1
- package/src/components/starlight/sidebar-editions.ts +245 -0
- package/src/components/table/Table.astro +15 -7
- package/src/content-config.ts +87 -1
- package/src/curriculum/competency-graph.ts +95 -0
- package/src/reference/base-library.mjs +19 -0
- package/src/reference/consistency.ts +126 -39
- package/src/reference/deck-remark.mjs +600 -0
- package/src/reference/deck-source.ts +273 -0
- package/src/reference/decks-validate.ts +604 -0
- package/src/reference/diagram-layout.ts +3 -1
- package/src/reference/figure-symbols.ts +108 -31
- package/src/reference/folds-validate.ts +1 -1
- package/src/reference/gate-indices.ts +5 -3
- package/src/reference/gate-math.ts +169 -25
- package/src/reference/lab-katex-validate.ts +30 -15
- package/src/reference/lab-math-scope.ts +34 -3
- package/src/reference/math-glyphs.mjs +189 -25
- package/src/reference/page-notation.ts +69 -15
- package/src/reference/references.ts +28 -0
- package/src/reference/registry-scope.mjs +34 -0
- package/src/reference/registry.ts +41 -11
- package/src/reference/rehype-deck.mjs +286 -0
- package/src/reference/rehype-heading-anchors.mjs +48 -6
- package/src/reference/remark-notation.mjs +249 -40
- package/src/reference/source-lines.mjs +65 -0
- package/src/reference/types.ts +28 -0
- package/src/rules/catalog.ts +257 -4
- package/src/rules/course-rules.ts +21 -1
- package/src/rules/in-effect.ts +54 -8
- package/src/session/PreferenceStore.ts +23 -0
- package/src/session/deck-memory.ts +263 -0
- package/src/session/reading-memory.ts +243 -0
- package/src/styles/deck.css +1150 -0
- package/src/styles/global.css +958 -27
- package/src/styles/zones.css +277 -0
- /package/src/{components → reference}/float-name.ts +0 -0
|
@@ -51,11 +51,19 @@ import { indexEntryFormula } from '../reference/derived-notation.mjs';
|
|
|
51
51
|
import { isIndexEntryLatex } from './content-schemas';
|
|
52
52
|
import type { ContentRecord } from '../reference/gate-retired-keys';
|
|
53
53
|
import {
|
|
54
|
+
deckLessonSlug,
|
|
55
|
+
deckPageId,
|
|
56
|
+
deckRouteSlug,
|
|
54
57
|
deriveRequires,
|
|
55
58
|
deriveSources,
|
|
59
|
+
isDeckSlug,
|
|
56
60
|
isLessonSlug,
|
|
61
|
+
isPartDeckSlug,
|
|
57
62
|
lessonIdFromSlug,
|
|
58
63
|
parseCheckList,
|
|
64
|
+
partDeckDir,
|
|
65
|
+
partDeckPageId,
|
|
66
|
+
partDeckRouteSlug,
|
|
59
67
|
} from './lesson-derivation';
|
|
60
68
|
|
|
61
69
|
// Every invocation — the CLI, `astro build`/`check`, and vitest — runs with the
|
|
@@ -325,14 +333,166 @@ function docSlug(path: string): string {
|
|
|
325
333
|
return relative(join(CONTENT_ROOT, 'docs'), path).replace(/\\/g, '/');
|
|
326
334
|
}
|
|
327
335
|
|
|
328
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* Assessment ids from a lesson's colocated `<name>.checks.yml`, or `[]`. A
|
|
338
|
+
* deck file (`<name>.deck.mdx`) reads the same file as its chapter: the checks
|
|
339
|
+
* belong to the lesson, whichever edition shows them.
|
|
340
|
+
*/
|
|
329
341
|
function lessonCheckIds(lessonPath: string): string[] {
|
|
330
|
-
const checksPath = lessonPath.replace(
|
|
342
|
+
const checksPath = lessonPath.replace(/(?:\.deck)?\.mdx?$/, '.checks.yml');
|
|
331
343
|
return existsSync(checksPath)
|
|
332
344
|
? parseCheckList(readFileSync(checksPath, 'utf8'))
|
|
333
345
|
: [];
|
|
334
346
|
}
|
|
335
347
|
|
|
348
|
+
/** Every `.md` / `.mdx` file under `content/docs/`, sorted. */
|
|
349
|
+
function docsFiles(): string[] {
|
|
350
|
+
const root = join(CONTENT_ROOT, 'docs');
|
|
351
|
+
return existsSync(root) ? filesBelow(root, ['.md', '.mdx']) : [];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** What every deck file is, whichever it belongs to ({@link DeckFile}). */
|
|
355
|
+
interface DeckFileBase {
|
|
356
|
+
/** Absolute path of the deck file. */
|
|
357
|
+
readonly path: string;
|
|
358
|
+
/** Repository-relative path, the `file` of its diagnostics. */
|
|
359
|
+
readonly file: string;
|
|
360
|
+
/** Docs slug of the deck file, extension included: `motion/range.deck.mdx`. */
|
|
361
|
+
readonly slug: string;
|
|
362
|
+
/** Where the deck is served: `motion/range/slides`, `motion/range` alone, or a part's `motion/slides`. */
|
|
363
|
+
readonly route: string;
|
|
364
|
+
/** The id of the deck's page: `motion.range.slides`, the lesson's id alone, or a part's `motion.slides`. */
|
|
365
|
+
readonly pageId: string;
|
|
366
|
+
/** The part directory the deck sits in: `motion`; empty for a deck outside one. */
|
|
367
|
+
readonly partDir: string;
|
|
368
|
+
/** True when the deck sits in a part's directory, as a lesson does. */
|
|
369
|
+
readonly inPart: boolean;
|
|
370
|
+
/** The deck file's frontmatter. */
|
|
371
|
+
readonly data: Record<string, unknown>;
|
|
372
|
+
readonly body: string;
|
|
373
|
+
/** 1-based file line {@link body} starts on. */
|
|
374
|
+
readonly bodyLine: number;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* A lesson's deck (`<lesson>.deck.mdx`): its slide edition, beside the
|
|
379
|
+
* lesson's chapter or, for a lesson that is only slides, alone. See
|
|
380
|
+
* `docs/authoring/deck.md`.
|
|
381
|
+
*/
|
|
382
|
+
export interface LessonDeckFile extends DeckFileBase {
|
|
383
|
+
readonly scope: 'lesson';
|
|
384
|
+
/** The lesson's slug: `motion/range`. */
|
|
385
|
+
readonly lessonSlug: string;
|
|
386
|
+
readonly lessonId: string;
|
|
387
|
+
/** The lesson's chapter file, when it has one. */
|
|
388
|
+
readonly chapterPath?: string;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* A part's deck (`<part>/index.deck.mdx`, beside the part's page): the
|
|
393
|
+
* slides of the part, whose sections may name the chapters they cover. It
|
|
394
|
+
* belongs to no lesson.
|
|
395
|
+
*/
|
|
396
|
+
export interface PartDeckFile extends DeckFileBase {
|
|
397
|
+
readonly scope: 'part';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A deck file: a lesson's slide edition, or a part's deck. */
|
|
401
|
+
export type DeckFile = LessonDeckFile | PartDeckFile;
|
|
402
|
+
|
|
403
|
+
/** A lesson's chapter file, `.mdx` before `.md`, if it has one. */
|
|
404
|
+
function chapterPathOf(lessonSlug: string): string | undefined {
|
|
405
|
+
for (const extension of ['.mdx', '.md']) {
|
|
406
|
+
const path = join(CONTENT_ROOT, 'docs', `${lessonSlug}${extension}`);
|
|
407
|
+
if (existsSync(path)) return path;
|
|
408
|
+
}
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Every deck file, by slug: each lesson's, and each part's. A part's deck
|
|
414
|
+
* (`<part>/index.deck.mdx`) is never read as the deck of a lesson
|
|
415
|
+
* `<part>/index` whose chapter is the part's page.
|
|
416
|
+
*/
|
|
417
|
+
export function loadDeckFiles(): DeckFile[] {
|
|
418
|
+
const decks: DeckFile[] = [];
|
|
419
|
+
for (const path of docsFiles()) {
|
|
420
|
+
const slug = docSlug(path);
|
|
421
|
+
if (!isDeckSlug(slug)) continue;
|
|
422
|
+
const { file, data, body, bodyLine } = parseDocument(path);
|
|
423
|
+
if (isPartDeckSlug(slug)) {
|
|
424
|
+
const partDir = partDeckDir(slug);
|
|
425
|
+
decks.push({
|
|
426
|
+
scope: 'part',
|
|
427
|
+
path,
|
|
428
|
+
file,
|
|
429
|
+
slug,
|
|
430
|
+
route: partDeckRouteSlug(partDir),
|
|
431
|
+
pageId: partDeckPageId(partDir),
|
|
432
|
+
partDir,
|
|
433
|
+
inPart: true,
|
|
434
|
+
data,
|
|
435
|
+
body,
|
|
436
|
+
bodyLine,
|
|
437
|
+
});
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const lessonSlug = deckLessonSlug(slug);
|
|
441
|
+
const lessonId = lessonIdFromSlug(lessonSlug);
|
|
442
|
+
const chapterPath = chapterPathOf(lessonSlug);
|
|
443
|
+
const hasChapter = chapterPath !== undefined;
|
|
444
|
+
decks.push({
|
|
445
|
+
scope: 'lesson',
|
|
446
|
+
path,
|
|
447
|
+
file,
|
|
448
|
+
slug,
|
|
449
|
+
lessonSlug,
|
|
450
|
+
lessonId,
|
|
451
|
+
...(chapterPath === undefined ? {} : { chapterPath }),
|
|
452
|
+
route: deckRouteSlug(lessonSlug, hasChapter),
|
|
453
|
+
pageId: deckPageId(lessonId, hasChapter),
|
|
454
|
+
partDir: lessonSlug.includes('/') ? lessonSlug.slice(0, lessonSlug.indexOf('/')) : '',
|
|
455
|
+
inPart: lessonSlug.includes('/') && !lessonSlug.endsWith('/index'),
|
|
456
|
+
data,
|
|
457
|
+
body,
|
|
458
|
+
bodyLine,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return decks;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* A file that defines a lesson: every chapter, and every deck whose lesson has
|
|
466
|
+
* no chapter (a slides-first lesson). Its frontmatter is the lesson's —
|
|
467
|
+
* `teaches`, `assumptions`, `studyMinutes`, `notation` — and its body is what
|
|
468
|
+
* the lesson's numbering and curriculum passes read.
|
|
469
|
+
*/
|
|
470
|
+
export interface LessonFile {
|
|
471
|
+
readonly path: string;
|
|
472
|
+
/** Docs slug of the file, extension included. */
|
|
473
|
+
readonly slug: string;
|
|
474
|
+
readonly lessonSlug: string;
|
|
475
|
+
readonly lessonId: string;
|
|
476
|
+
readonly edition: 'chapter' | 'deck';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Every lesson's defining file, sorted by lesson slug. */
|
|
480
|
+
export function loadLessonFiles(): LessonFile[] {
|
|
481
|
+
const lessons: LessonFile[] = [];
|
|
482
|
+
for (const path of docsFiles()) {
|
|
483
|
+
const slug = docSlug(path);
|
|
484
|
+
if (isLessonSlug(slug)) {
|
|
485
|
+
const lessonSlug = slug.replace(/\.(md|mdx)$/, '');
|
|
486
|
+
lessons.push({ path, slug, lessonSlug, lessonId: lessonIdFromSlug(slug), edition: 'chapter' });
|
|
487
|
+
} else if (isDeckSlug(slug) && slug.includes('/') && !isPartDeckSlug(slug)) {
|
|
488
|
+
const lessonSlug = deckLessonSlug(slug);
|
|
489
|
+
if (chapterPathOf(lessonSlug) !== undefined) continue;
|
|
490
|
+
lessons.push({ path, slug, lessonSlug, lessonId: lessonIdFromSlug(lessonSlug), edition: 'deck' });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return lessons.sort((left, right) => left.lessonSlug.localeCompare(right.lessonSlug));
|
|
494
|
+
}
|
|
495
|
+
|
|
336
496
|
/**
|
|
337
497
|
* One part's **overview page** — `content/docs/<dir>/index.mdx`, the page the
|
|
338
498
|
* part's own directory URL resolves to. `isLessonSlug` already excludes any
|
|
@@ -388,17 +548,26 @@ function lessonEntries(
|
|
|
388
548
|
prerequisitesById: ReadonlyMap<string, readonly string[]>,
|
|
389
549
|
): LessonDefinition[] {
|
|
390
550
|
const lessons: LessonDefinition[] = [];
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
551
|
+
// A deck beside its chapter cites what its chapter does not need to: its
|
|
552
|
+
// sources count for the lesson too.
|
|
553
|
+
const deckBodies = new Map(
|
|
554
|
+
loadDeckFiles()
|
|
555
|
+
.filter((deck): deck is LessonDeckFile => deck.scope === 'lesson' && deck.chapterPath !== undefined)
|
|
556
|
+
.map((deck) => [deck.lessonId, parseDocument(deck.path).body]),
|
|
557
|
+
);
|
|
558
|
+
for (const { path, lessonId } of loadLessonFiles()) {
|
|
394
559
|
const { data, body } = parseDocument(path);
|
|
395
560
|
const teaches = (data.teaches as string[] | undefined) ?? [];
|
|
561
|
+
const deckBody = deckBodies.get(lessonId);
|
|
396
562
|
lessons.push({
|
|
397
|
-
id:
|
|
563
|
+
id: lessonId,
|
|
398
564
|
requires: deriveRequires(teaches, prerequisitesById),
|
|
399
565
|
teaches,
|
|
400
566
|
assessments: lessonCheckIds(path),
|
|
401
|
-
sources:
|
|
567
|
+
sources:
|
|
568
|
+
deckBody === undefined
|
|
569
|
+
? deriveSources(body)
|
|
570
|
+
: [...new Set([...deriveSources(body), ...deriveSources(deckBody)])].sort(),
|
|
402
571
|
assumptions: (data.assumptions as string[] | undefined) ?? [],
|
|
403
572
|
body,
|
|
404
573
|
});
|
|
@@ -538,7 +707,7 @@ function localDefinition(
|
|
|
538
707
|
function sharedNotationEntries(): SharedNotationDefinitionInput[] {
|
|
539
708
|
const directory = join(CONTENT_ROOT, 'notation');
|
|
540
709
|
return filesBelow(directory, ['.md']).map((path) => {
|
|
541
|
-
const { file, data, body } = parseDocument(path);
|
|
710
|
+
const { file, data, body, bodyLine } = parseDocument(path);
|
|
542
711
|
const key = string(data.key, `${file} key`);
|
|
543
712
|
const filenameKey = basename(path, '.md');
|
|
544
713
|
if (key !== filenameKey) {
|
|
@@ -566,6 +735,7 @@ function sharedNotationEntries(): SharedNotationDefinitionInput[] {
|
|
|
566
735
|
? false
|
|
567
736
|
: boolean(data.aiAssisted, `${file} aiAssisted`),
|
|
568
737
|
body,
|
|
738
|
+
bodyLine,
|
|
569
739
|
references: extractNotationReferences(body, file, undefined),
|
|
570
740
|
glosses: glossEntries(data.glosses, key, `${file} glosses`),
|
|
571
741
|
source: { file },
|
|
@@ -652,21 +822,31 @@ export function notationSuspensions(
|
|
|
652
822
|
});
|
|
653
823
|
}
|
|
654
824
|
|
|
825
|
+
/** A lesson file's `notation.local` entries and `notation.suspend` list, parsed. */
|
|
826
|
+
function lessonNotationOf(
|
|
827
|
+
file: string,
|
|
828
|
+
data: Record<string, unknown>,
|
|
829
|
+
): { localDefinitions: LocalNotationDefinitionInput[]; suspended: NotationSuspension[] } {
|
|
830
|
+
const notation = record(data.notation ?? {}, `${file} notation`);
|
|
831
|
+
const local = Array.isArray(notation.local) ? notation.local : [];
|
|
832
|
+
const localDefinitions = local.map((entry, index) =>
|
|
833
|
+
localDefinition(entry, file, index),
|
|
834
|
+
);
|
|
835
|
+
return {
|
|
836
|
+
localDefinitions,
|
|
837
|
+
suspended: notationSuspensions(notation.suspend, localDefinitions, file),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
655
841
|
function notationLessonEntries(): NotationLessonInput[] {
|
|
656
842
|
const lessons: NotationLessonInput[] = [];
|
|
657
|
-
for (const path of
|
|
658
|
-
const slug = docSlug(path);
|
|
659
|
-
if (!isLessonSlug(slug)) continue;
|
|
843
|
+
for (const { path, lessonId } of loadLessonFiles()) {
|
|
660
844
|
const { file, data, body, bodyLine } = parseDocument(path);
|
|
661
|
-
const
|
|
662
|
-
const local = Array.isArray(notation.local) ? notation.local : [];
|
|
663
|
-
const localDefinitions = local.map((entry, index) =>
|
|
664
|
-
localDefinition(entry, file, index),
|
|
665
|
-
);
|
|
845
|
+
const { localDefinitions, suspended } = lessonNotationOf(file, data);
|
|
666
846
|
lessons.push({
|
|
667
|
-
lessonId
|
|
847
|
+
lessonId,
|
|
668
848
|
localDefinitions,
|
|
669
|
-
suspended
|
|
849
|
+
suspended,
|
|
670
850
|
references: extractNotationReferences(body, file, undefined),
|
|
671
851
|
body,
|
|
672
852
|
bodyLine,
|
|
@@ -676,6 +856,87 @@ function notationLessonEntries(): NotationLessonInput[] {
|
|
|
676
856
|
return lessons;
|
|
677
857
|
}
|
|
678
858
|
|
|
859
|
+
/**
|
|
860
|
+
* A deck beside its chapter, as a page of the notation registry: its own id
|
|
861
|
+
* (`<lessonId>.slides`), no entries of its own, and its chapter's scope — the
|
|
862
|
+
* chapter's `notation.local` entries are the lesson's, so a deck names them
|
|
863
|
+
* as the chapter does, and it suspends what the chapter suspends. A part's
|
|
864
|
+
* deck (`<part>.slides`) has its part's scope: every chapter of the part, in
|
|
865
|
+
* the order the tracks read them, a key one chapter defines locally binding
|
|
866
|
+
* to the first that does; it suspends what any of them suspends. A deck that
|
|
867
|
+
* is a lesson's only edition is not here: it is that lesson's page
|
|
868
|
+
* (`loadNotationRegistryInput`).
|
|
869
|
+
*/
|
|
870
|
+
export interface DeckNotationPage {
|
|
871
|
+
readonly deck: DeckFile;
|
|
872
|
+
readonly data: Record<string, unknown>;
|
|
873
|
+
readonly notation: NotationLessonInput;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
export function loadDeckNotationPages(
|
|
877
|
+
decks: readonly DeckFile[] = loadDeckFiles(),
|
|
878
|
+
{
|
|
879
|
+
order = (lessonIds) => lessonIds,
|
|
880
|
+
}: {
|
|
881
|
+
/** The lessons of a part in reading order (the tracks'); by slug without it. */
|
|
882
|
+
readonly order?: (lessonIds: readonly string[]) => readonly string[];
|
|
883
|
+
} = {},
|
|
884
|
+
): DeckNotationPage[] {
|
|
885
|
+
const pages: DeckNotationPage[] = [];
|
|
886
|
+
const chapterFiles = loadLessonFiles().filter((lesson) => lesson.edition === 'chapter');
|
|
887
|
+
for (const deck of decks) {
|
|
888
|
+
let chapters: { readonly lessonId: string; readonly path: string }[];
|
|
889
|
+
if (deck.scope === 'part') {
|
|
890
|
+
const inPart = chapterFiles.filter((lesson) => lesson.lessonSlug.startsWith(`${deck.partDir}/`));
|
|
891
|
+
const pathById = new Map(inPart.map((lesson) => [lesson.lessonId, lesson.path]));
|
|
892
|
+
chapters = order(inPart.map((lesson) => lesson.lessonId)).flatMap((lessonId) => {
|
|
893
|
+
const path = pathById.get(lessonId);
|
|
894
|
+
return path === undefined ? [] : [{ lessonId, path }];
|
|
895
|
+
});
|
|
896
|
+
} else if (deck.chapterPath !== undefined) {
|
|
897
|
+
chapters = [{ lessonId: deck.lessonId, path: deck.chapterPath }];
|
|
898
|
+
} else {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const inheritedLocalDefinitions: LocalNotationDefinitionInput[] = [];
|
|
902
|
+
const suspended: NotationSuspension[] = [];
|
|
903
|
+
const inheritedKeys = new Set<string>();
|
|
904
|
+
for (const { path } of chapters) {
|
|
905
|
+
const chapter = parseDocument(path);
|
|
906
|
+
const notation = lessonNotationOf(chapter.file, chapter.data);
|
|
907
|
+
for (const definition of notation.localDefinitions) {
|
|
908
|
+
if (!inheritedLocalDefinitions.some((known) => known.key === definition.key)) {
|
|
909
|
+
inheritedLocalDefinitions.push(definition);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
for (const suspension of notation.suspended) {
|
|
913
|
+
if (!suspended.some((known) => known.operator === suspension.operator)) suspended.push(suspension);
|
|
914
|
+
}
|
|
915
|
+
for (const reference of extractNotationReferences(chapter.body, chapter.file, undefined)) {
|
|
916
|
+
inheritedKeys.add(reference.key);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const { file, data, body, bodyLine } = deck;
|
|
920
|
+
pages.push({
|
|
921
|
+
deck,
|
|
922
|
+
data,
|
|
923
|
+
notation: {
|
|
924
|
+
lessonId: deck.pageId,
|
|
925
|
+
localDefinitions: [],
|
|
926
|
+
suspended,
|
|
927
|
+
scopeLessonIds: chapters.map((chapter) => chapter.lessonId),
|
|
928
|
+
inheritedLocalDefinitions,
|
|
929
|
+
inheritedKeys: [...inheritedKeys].sort(),
|
|
930
|
+
references: extractNotationReferences(body, file, undefined),
|
|
931
|
+
body,
|
|
932
|
+
bodyLine,
|
|
933
|
+
source: { file },
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
return pages;
|
|
938
|
+
}
|
|
939
|
+
|
|
679
940
|
/**
|
|
680
941
|
* A docs page that is **not a chapter** (rule S10): the landing page, a part's
|
|
681
942
|
* overview page, and any other page outside a part directory — every slug
|
|
@@ -701,9 +962,10 @@ export interface NonChapterPage {
|
|
|
701
962
|
|
|
702
963
|
export function loadNonChapterPages(): NonChapterPage[] {
|
|
703
964
|
const pages: NonChapterPage[] = [];
|
|
704
|
-
for (const path of
|
|
965
|
+
for (const path of docsFiles()) {
|
|
705
966
|
const slug = docSlug(path);
|
|
706
|
-
|
|
967
|
+
// A deck is an edition of a lesson (`loadDeckFiles`), not a page of its own kind.
|
|
968
|
+
if (isLessonSlug(slug) || isDeckSlug(slug)) continue;
|
|
707
969
|
const { file, data, body, bodyLine } = parseDocument(path);
|
|
708
970
|
const id = lessonIdFromSlug(slug);
|
|
709
971
|
pages.push({
|
|
@@ -799,7 +1061,7 @@ export function loadSourceRecords(): Record<string, unknown>[] {
|
|
|
799
1061
|
*/
|
|
800
1062
|
export function loadLessonChecks(lessonId: string): string[] {
|
|
801
1063
|
const slug = lessonId.replace(/\./g, '/');
|
|
802
|
-
for (const extension of ['.mdx', '.md']) {
|
|
1064
|
+
for (const extension of ['.mdx', '.md', '.deck.mdx', '.deck.md']) {
|
|
803
1065
|
const lessonPath = join(CONTENT_ROOT, 'docs', `${slug}${extension}`);
|
|
804
1066
|
if (existsSync(lessonPath)) return lessonCheckIds(lessonPath);
|
|
805
1067
|
}
|
|
@@ -861,11 +1123,8 @@ export function loadNotationSpecs(): NotationSpec[] {
|
|
|
861
1123
|
});
|
|
862
1124
|
}
|
|
863
1125
|
|
|
864
|
-
for (const path of
|
|
865
|
-
const slug = docSlug(path);
|
|
866
|
-
if (!isLessonSlug(slug)) continue;
|
|
1126
|
+
for (const { path, lessonId } of loadLessonFiles()) {
|
|
867
1127
|
const { file, data } = parseDocument(path);
|
|
868
|
-
const lessonId = lessonIdFromSlug(slug);
|
|
869
1128
|
const local = (data as { notation?: { local?: unknown } }).notation?.local;
|
|
870
1129
|
if (!Array.isArray(local)) continue;
|
|
871
1130
|
for (const raw of local) {
|
|
@@ -386,6 +386,258 @@ export function normalizeCards(config: CourseConfig): NormalizedCards {
|
|
|
386
386
|
return { carry };
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
+
/**
|
|
390
|
+
* The marks the sidebar puts beside a chapter or part page for one reader
|
|
391
|
+
* (`sidebarMarks` in the course settings, `page-marks.ts`): a quiet ring
|
|
392
|
+
* while they have not opened it, an accent dot when its content changed
|
|
393
|
+
* since they last did. Every field is optional; {@link normalizeSidebarMarks}
|
|
394
|
+
* fills in the defaults.
|
|
395
|
+
*/
|
|
396
|
+
export interface CourseSidebarMarks {
|
|
397
|
+
/** Mark a page the reader has not opened yet. Default `true`. */
|
|
398
|
+
readonly unopened?: boolean;
|
|
399
|
+
/** Mark a page whose content changed since the reader last opened it. Default `true`. */
|
|
400
|
+
readonly changed?: boolean;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** {@link CourseSidebarMarks} with every default applied. */
|
|
404
|
+
export interface NormalizedSidebarMarks {
|
|
405
|
+
readonly unopened: boolean;
|
|
406
|
+
readonly changed: boolean;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export const SIDEBAR_MARKS_DEFAULTS: NormalizedSidebarMarks = { unopened: true, changed: true };
|
|
410
|
+
|
|
411
|
+
/** Validate `CourseConfig.sidebarMarks` and apply its defaults. Throws on a bad value. */
|
|
412
|
+
export function normalizeSidebarMarks(config: CourseConfig): NormalizedSidebarMarks {
|
|
413
|
+
const marks = config.sidebarMarks ?? {};
|
|
414
|
+
const read = (field: keyof NormalizedSidebarMarks): boolean => {
|
|
415
|
+
const value: unknown = marks[field] ?? SIDEBAR_MARKS_DEFAULTS[field];
|
|
416
|
+
if (typeof value !== 'boolean') {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`CourseConfig sidebarMarks.${field} must be true or false; received ${JSON.stringify(value)}.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
return value;
|
|
422
|
+
};
|
|
423
|
+
return { unopened: read('unopened'), changed: read('changed') };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Which edition of a lesson its sidebar row opens, for a reader who has not
|
|
428
|
+
* chosen (`sidebarEditions` in the course settings, `sidebar-editions.ts`). A
|
|
429
|
+
* lesson with a chapter and a deck is one row: the row opens the **lead**
|
|
430
|
+
* edition, and the **other** is an icon at the row's end, or not listed. A
|
|
431
|
+
* reader switches both in the sidebar's foot. The plain `string` fields
|
|
432
|
+
* arrive as JSON; {@link normalizeSidebarEditions} rejects any value it does
|
|
433
|
+
* not list.
|
|
434
|
+
*/
|
|
435
|
+
export interface CourseSidebarEditions {
|
|
436
|
+
/** The edition a lesson's row opens: `chapter` (default) or `slides`. */
|
|
437
|
+
readonly lead?: string;
|
|
438
|
+
/** Whether the row also lists the other edition: `listed` (default) or `hidden`. */
|
|
439
|
+
readonly other?: string;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export type SidebarEditionLead = 'chapter' | 'slides';
|
|
443
|
+
export type SidebarEditionOther = 'listed' | 'hidden';
|
|
444
|
+
|
|
445
|
+
/** {@link CourseSidebarEditions} with every default applied. */
|
|
446
|
+
export interface NormalizedSidebarEditions {
|
|
447
|
+
readonly lead: SidebarEditionLead;
|
|
448
|
+
readonly other: SidebarEditionOther;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The engine's defaults (decision D10): the chapter leads, the slides are listed. */
|
|
452
|
+
export const SIDEBAR_EDITIONS_DEFAULTS: NormalizedSidebarEditions = { lead: 'chapter', other: 'listed' };
|
|
453
|
+
|
|
454
|
+
/** Validate `CourseConfig.sidebarEditions` and apply its defaults. Throws on a bad value. */
|
|
455
|
+
export function normalizeSidebarEditions(config: CourseConfig): NormalizedSidebarEditions {
|
|
456
|
+
const editions = config.sidebarEditions ?? {};
|
|
457
|
+
const choice = <T extends string>(field: keyof NormalizedSidebarEditions, allowed: readonly T[]): T => {
|
|
458
|
+
const value: unknown = editions[field] ?? SIDEBAR_EDITIONS_DEFAULTS[field];
|
|
459
|
+
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`CourseConfig sidebarEditions.${field} must be one of ${allowed.map((item) => JSON.stringify(item)).join(', ')}; ` +
|
|
462
|
+
`received ${JSON.stringify(value)}.`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return value as T;
|
|
466
|
+
};
|
|
467
|
+
return {
|
|
468
|
+
lead: choice<SidebarEditionLead>('lead', ['chapter', 'slides']),
|
|
469
|
+
other: choice<SidebarEditionOther>('other', ['listed', 'hidden']),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* A course's decks: the slide edition of a lesson (`<lesson>.deck.mdx`,
|
|
475
|
+
* `docs/authoring/deck.md`). Every field is optional; {@link normalizeDeck}
|
|
476
|
+
* fills in the defaults. The plain `string` fields arrive as JSON, and
|
|
477
|
+
* {@link normalizeDeck} rejects any value it does not list.
|
|
478
|
+
*/
|
|
479
|
+
export interface CourseDeck {
|
|
480
|
+
/**
|
|
481
|
+
* The canvas every deck of the course is laid out on: `16:9` (default),
|
|
482
|
+
* `16:10` or `4:3`. One setting for the course, because the frame-fit check,
|
|
483
|
+
* the captured figures and the PDF's page all read it.
|
|
484
|
+
*/
|
|
485
|
+
readonly aspectRatio?: string;
|
|
486
|
+
/**
|
|
487
|
+
* The line on a frame that names the symbols it shows: `first-use`
|
|
488
|
+
* (default, the symbols the deck has not shown before), `every-frame`
|
|
489
|
+
* (every symbol on the frame), or `off`.
|
|
490
|
+
*/
|
|
491
|
+
readonly legend?: string;
|
|
492
|
+
/**
|
|
493
|
+
* Who sees a frame's speaker notes: `reader` (default — under each frame in
|
|
494
|
+
* the scroll view, in the presenter window and in the handout), `presenter`
|
|
495
|
+
* (the presenter window and the handout only), or `off` (nowhere; the
|
|
496
|
+
* handout PDF is not built).
|
|
497
|
+
*/
|
|
498
|
+
readonly notes?: string;
|
|
499
|
+
/** End each deck on a frame of what its lesson's competencies say a student can now do. Default `true`. */
|
|
500
|
+
readonly outcomesFrame?: boolean;
|
|
501
|
+
/** Offer the slides-with-notes PDF beside the slides PDF. Default `true`. */
|
|
502
|
+
readonly handoutPdf?: boolean;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export type DeckAspectRatio = '16:9' | '16:10' | '4:3';
|
|
506
|
+
export type DeckLegend = 'first-use' | 'every-frame' | 'off';
|
|
507
|
+
export type DeckNotes = 'reader' | 'presenter' | 'off';
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The logical canvas a frame is laid out on, in CSS pixels: the width of the
|
|
511
|
+
* page's reading column, so a figure, a table or a lab lays out on a frame at
|
|
512
|
+
* the size it was made for, and the frame is scaled to the screen. Every
|
|
513
|
+
* ratio is 450 px tall, so the type scale and a frame's capacity in lines are
|
|
514
|
+
* the same in all three. Printed, the canvas is scaled by 1.6 onto a page
|
|
515
|
+
* 7.5 in tall (13.333 × 7.5 in for 16:9).
|
|
516
|
+
*/
|
|
517
|
+
export const DECK_CANVAS: Readonly<Record<DeckAspectRatio, { readonly width: number; readonly height: number }>> = {
|
|
518
|
+
'16:9': { width: 800, height: 450 },
|
|
519
|
+
'16:10': { width: 720, height: 450 },
|
|
520
|
+
'4:3': { width: 600, height: 450 },
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** {@link CourseDeck} with every default applied, as the manifest publishes it. */
|
|
524
|
+
export interface NormalizedDeck {
|
|
525
|
+
readonly aspectRatio: DeckAspectRatio;
|
|
526
|
+
readonly legend: DeckLegend;
|
|
527
|
+
readonly notes: DeckNotes;
|
|
528
|
+
readonly outcomesFrame: boolean;
|
|
529
|
+
readonly handoutPdf: boolean;
|
|
530
|
+
readonly canvas: { readonly width: number; readonly height: number };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export const DECK_DEFAULTS: Omit<NormalizedDeck, 'canvas'> = {
|
|
534
|
+
aspectRatio: '16:9',
|
|
535
|
+
legend: 'first-use',
|
|
536
|
+
notes: 'reader',
|
|
537
|
+
outcomesFrame: true,
|
|
538
|
+
handoutPdf: true,
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
/** Validate `CourseConfig.deck` and apply its defaults. Throws on a bad value. */
|
|
542
|
+
export function normalizeDeck(config: CourseConfig): NormalizedDeck {
|
|
543
|
+
const deck = config.deck ?? {};
|
|
544
|
+
const choice = <T extends string>(field: keyof CourseDeck, allowed: readonly T[]): T => {
|
|
545
|
+
const value: unknown = deck[field] ?? DECK_DEFAULTS[field as keyof typeof DECK_DEFAULTS];
|
|
546
|
+
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`CourseConfig deck.${field} must be one of ${allowed.map((item) => JSON.stringify(item)).join(', ')}; ` +
|
|
549
|
+
`received ${JSON.stringify(value)}.`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return value as T;
|
|
553
|
+
};
|
|
554
|
+
const flag = (field: 'outcomesFrame' | 'handoutPdf'): boolean => {
|
|
555
|
+
const value: unknown = deck[field] ?? DECK_DEFAULTS[field];
|
|
556
|
+
if (typeof value !== 'boolean') {
|
|
557
|
+
throw new Error(`CourseConfig deck.${field} must be true or false; received ${JSON.stringify(value)}.`);
|
|
558
|
+
}
|
|
559
|
+
return value;
|
|
560
|
+
};
|
|
561
|
+
const aspectRatio = choice<DeckAspectRatio>('aspectRatio', ['16:9', '16:10', '4:3']);
|
|
562
|
+
return {
|
|
563
|
+
aspectRatio,
|
|
564
|
+
legend: choice<DeckLegend>('legend', ['first-use', 'every-frame', 'off']),
|
|
565
|
+
notes: choice<DeckNotes>('notes', ['reader', 'presenter', 'off']),
|
|
566
|
+
outcomesFrame: flag('outcomesFrame'),
|
|
567
|
+
handoutPdf: flag('handoutPdf'),
|
|
568
|
+
canvas: DECK_CANVAS[aspectRatio],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Whether the course asks each reader if it may remember their reading in
|
|
574
|
+
* their browser (`readingMemory` in the course settings,
|
|
575
|
+
* `session/reading-memory.ts`): which chapters they opened (the sidebar's
|
|
576
|
+
* marks) and the cards they keep or carry, across visits. `ask` (default)
|
|
577
|
+
* shows the question until the reader answers; `off` never asks and never
|
|
578
|
+
* keeps reading memory, so the marks are off and cards last as long as the tab.
|
|
579
|
+
*/
|
|
580
|
+
export type ReadingMemorySetting = 'ask' | 'off';
|
|
581
|
+
|
|
582
|
+
/** Validate `CourseConfig.readingMemory`. Throws on a bad value. */
|
|
583
|
+
export function normalizeReadingMemory(config: CourseConfig): ReadingMemorySetting {
|
|
584
|
+
const value: unknown = config.readingMemory ?? 'ask';
|
|
585
|
+
if (value !== 'ask' && value !== 'off') {
|
|
586
|
+
throw new Error(`CourseConfig readingMemory must be "ask" or "off"; received ${JSON.stringify(value)}.`);
|
|
587
|
+
}
|
|
588
|
+
return value;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* How the page tells its zones apart, and how a deck's slides sit on it
|
|
593
|
+
* (`appearance` in the course settings; `zones.css` and `deck.css` hold the
|
|
594
|
+
* colours). `zones`: `tonal` (the default) gives both sidebars one step of the
|
|
595
|
+
* neutral grey and the header a step further; `accent-header` makes the
|
|
596
|
+
* header a deep band of the course accent; `paper` lays the reading column
|
|
597
|
+
* on the sidebars' colour as a sheet; `none` keeps Starlight's colours.
|
|
598
|
+
* `slides`: `lifted` (the default) gives each slide an edge and a drop
|
|
599
|
+
* shadow, and in the dark theme a sheet a step lighter than the page; `edge`
|
|
600
|
+
* the edge and shadow on the page's own colour; `sorter` lays the deck on a
|
|
601
|
+
* tinted well. Every field is optional; {@link normalizeAppearance} fills in
|
|
602
|
+
* the defaults.
|
|
603
|
+
*/
|
|
604
|
+
export interface CourseAppearance {
|
|
605
|
+
/** `tonal` (default), `accent-header`, `paper` or `none`. */
|
|
606
|
+
readonly zones?: string;
|
|
607
|
+
/** `lifted` (default), `edge` or `sorter`. */
|
|
608
|
+
readonly slides?: string;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export type AppearanceZones = 'tonal' | 'accent-header' | 'paper' | 'none';
|
|
612
|
+
export type AppearanceSlides = 'lifted' | 'edge' | 'sorter';
|
|
613
|
+
|
|
614
|
+
/** {@link CourseAppearance} with every default applied. */
|
|
615
|
+
export interface NormalizedAppearance {
|
|
616
|
+
readonly zones: AppearanceZones;
|
|
617
|
+
readonly slides: AppearanceSlides;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export const APPEARANCE_DEFAULTS: NormalizedAppearance = { zones: 'tonal', slides: 'lifted' };
|
|
621
|
+
|
|
622
|
+
const APPEARANCE_ZONES: readonly AppearanceZones[] = ['tonal', 'accent-header', 'paper', 'none'];
|
|
623
|
+
const APPEARANCE_SLIDES: readonly AppearanceSlides[] = ['lifted', 'edge', 'sorter'];
|
|
624
|
+
|
|
625
|
+
/** Validate `CourseConfig.appearance` and apply its defaults. Throws on a bad value. */
|
|
626
|
+
export function normalizeAppearance(config: CourseConfig): NormalizedAppearance {
|
|
627
|
+
const appearance = config.appearance ?? {};
|
|
628
|
+
const choice = <T extends string>(field: keyof NormalizedAppearance, allowed: readonly T[]): T => {
|
|
629
|
+
const value: unknown = appearance[field] ?? APPEARANCE_DEFAULTS[field];
|
|
630
|
+
if (!allowed.includes(value as T)) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`CourseConfig appearance.${field} must be one of ${allowed.map((v) => `"${v}"`).join(', ')}; ` +
|
|
633
|
+
`received ${JSON.stringify(value)}.`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return value as T;
|
|
637
|
+
};
|
|
638
|
+
return { zones: choice('zones', APPEARANCE_ZONES), slides: choice('slides', APPEARANCE_SLIDES) };
|
|
639
|
+
}
|
|
640
|
+
|
|
389
641
|
/**
|
|
390
642
|
* Rule S2's word ranges (`rules.S2.bridgeMinWords` … in the course settings),
|
|
391
643
|
* in the shape `gate-part-pages.ts` takes. Built by `partPageLimits` in
|
|
@@ -488,6 +740,21 @@ export interface CourseConfig {
|
|
|
488
740
|
readonly numbering?: CourseNumbering;
|
|
489
741
|
/** How explanation cards start for a reader: "Carry cards" on or off. */
|
|
490
742
|
readonly cards?: CourseCards;
|
|
743
|
+
/** The sidebar's marks for a page the reader has not opened, or that changed since. */
|
|
744
|
+
readonly sidebarMarks?: CourseSidebarMarks;
|
|
745
|
+
/** Which edition a lesson's sidebar row opens, and whether it lists the other. */
|
|
746
|
+
readonly sidebarEditions?: CourseSidebarEditions;
|
|
747
|
+
/** How the course's decks look and what they show (`docs/authoring/deck.md`). */
|
|
748
|
+
readonly deck?: CourseDeck;
|
|
749
|
+
/**
|
|
750
|
+
* Ask each reader whether the course may remember their reading in their
|
|
751
|
+
* browser (`ask`, the default), or never keep it (`off`). A plain `string`
|
|
752
|
+
* because the settings arrive as JSON; {@link normalizeReadingMemory}
|
|
753
|
+
* rejects any other value.
|
|
754
|
+
*/
|
|
755
|
+
readonly readingMemory?: string;
|
|
756
|
+
/** How the page tells header, sidebars and article apart, and how slides sit on it. */
|
|
757
|
+
readonly appearance?: CourseAppearance;
|
|
491
758
|
/**
|
|
492
759
|
* How a check's and an example set's heading names its kind: `icon-word`
|
|
493
760
|
* (default) or `icon` ({@link BlockLabels}). A plain `string` because the
|