explico 0.12.0 → 0.13.1
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 +1137 -1
- package/README.md +91 -13
- package/dist/compiler/astro.d.ts +4 -3
- package/dist/compiler/astro.js +45 -15
- package/dist/compiler/cli.d.ts +26 -12
- package/dist/compiler/cli.js +96 -25
- package/dist/compiler/collections.d.ts +9 -1
- package/dist/compiler/collections.js +55 -16
- package/dist/compiler/content-schema.d.ts +31 -0
- package/dist/compiler/content-schema.js +82 -0
- package/dist/compiler/content-schemas.d.ts +422 -0
- package/dist/compiler/content-schemas.js +229 -0
- package/dist/compiler/course-config.d.ts +1 -1
- package/dist/compiler/course-config.js +30 -2
- package/dist/compiler/exports/combined-html.d.ts +97 -0
- package/dist/compiler/exports/combined-html.js +492 -0
- package/dist/compiler/exports/download-index.d.ts +9 -0
- package/dist/compiler/exports/download-index.js +33 -0
- package/dist/compiler/exports/export-chapters.d.ts +98 -0
- package/dist/compiler/exports/export-chapters.js +164 -0
- package/dist/compiler/exports/export-pages.d.ts +48 -0
- package/dist/compiler/exports/export-pages.js +22 -0
- package/dist/compiler/exports/html-tree.d.ts +22 -0
- package/dist/compiler/exports/html-tree.js +47 -0
- package/dist/compiler/exports/latex/capture.d.ts +106 -0
- package/dist/compiler/exports/latex/capture.js +435 -0
- package/dist/compiler/exports/latex/compile.d.ts +73 -0
- package/dist/compiler/exports/latex/compile.js +115 -0
- package/dist/compiler/exports/latex/document.d.ts +102 -0
- package/dist/compiler/exports/latex/document.js +349 -0
- package/dist/compiler/exports/latex/explico.cls +415 -0
- package/dist/compiler/exports/latex/numbering.d.ts +18 -0
- package/dist/compiler/exports/latex/numbering.js +57 -0
- package/dist/compiler/exports/latex/serialize.d.ts +112 -0
- package/dist/compiler/exports/latex/serialize.js +606 -0
- package/dist/compiler/exports/latex-export.d.ts +44 -0
- package/dist/compiler/exports/latex-export.js +169 -0
- package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
- package/dist/compiler/exports/markdown/serialize.js +527 -0
- package/dist/compiler/exports/markdown-export.d.ts +39 -0
- package/dist/compiler/exports/markdown-export.js +240 -0
- package/dist/compiler/exports/standalone-html.d.ts +14 -5
- package/dist/compiler/exports/standalone-html.js +23 -51
- package/dist/compiler/exports/static-tree.d.ts +253 -0
- package/dist/compiler/exports/static-tree.js +625 -0
- package/dist/compiler/exports/zip.d.ts +16 -0
- package/dist/compiler/exports/zip.js +72 -0
- package/dist/compiler/lesson-derivation.d.ts +8 -2
- package/dist/compiler/lesson-derivation.js +6 -7
- package/dist/compiler/manifest.d.ts +87 -6
- package/dist/compiler/manifest.js +174 -31
- package/dist/compiler/render-diagnostics.d.ts +65 -0
- package/dist/compiler/render-diagnostics.js +56 -0
- package/dist/compiler/resolution-report.d.ts +2 -1
- package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
- package/dist/curriculum/validation.d.ts +9 -3
- package/dist/curriculum/validation.js +35 -17
- package/dist/reference/base-abbreviations.d.mts +2 -2
- package/dist/reference/base-abbreviations.mjs +2 -2
- package/dist/reference/base-operators.d.mts +2 -2
- package/dist/reference/base-operators.mjs +2 -2
- package/dist/reference/base-roman-labels.d.mts +4 -3
- package/dist/reference/base-roman-labels.mjs +4 -3
- package/dist/reference/citation-format.d.mts +35 -0
- package/dist/reference/citation-format.mjs +74 -0
- package/dist/reference/consistency.d.ts +5 -9
- package/dist/reference/consistency.js +34 -38
- package/dist/reference/curriculum-alignment.js +3 -24
- package/dist/reference/derived-entries.d.ts +45 -0
- package/dist/reference/derived-entries.js +50 -0
- package/dist/reference/derived-notation.d.mts +125 -0
- package/dist/reference/derived-notation.mjs +121 -0
- package/dist/reference/diagram-fit.d.ts +45 -0
- package/dist/reference/diagram-fit.js +54 -0
- package/dist/reference/diagram-layout.d.ts +59 -10
- package/dist/reference/diagram-layout.js +85 -24
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +1 -1
- package/dist/reference/equations-validate.js +26 -1
- package/dist/reference/equations.d.mts +27 -0
- package/dist/reference/equations.mjs +120 -22
- package/dist/reference/examples-validate.d.ts +1 -1
- package/dist/reference/figure-fit.d.ts +70 -0
- package/dist/reference/figure-fit.js +60 -0
- package/dist/reference/figure-pictures.d.ts +27 -0
- package/dist/reference/figure-pictures.js +49 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.d.ts +1 -1
- package/dist/reference/gate-abbreviations.js +1 -1
- package/dist/reference/gate-course-rules.d.ts +4 -3
- package/dist/reference/gate-course-rules.js +9 -4
- package/dist/reference/gate-heading-length.d.ts +2 -0
- package/dist/reference/gate-heading-length.js +7 -5
- package/dist/reference/gate-heading-structure.js +26 -0
- package/dist/reference/gate-indices.d.ts +35 -0
- package/dist/reference/gate-indices.js +299 -0
- package/dist/reference/gate-math.d.ts +32 -2
- package/dist/reference/gate-math.js +68 -12
- package/dist/reference/gate-mdx-expression.d.ts +17 -0
- package/dist/reference/gate-mdx-expression.js +29 -0
- package/dist/reference/gate-part-pages.d.ts +1 -1
- package/dist/reference/gate-prop-text.d.ts +28 -0
- package/dist/reference/gate-prop-text.js +106 -0
- package/dist/reference/gate-prose-notation.d.ts +3 -1
- package/dist/reference/gate-prose-notation.js +4 -2
- package/dist/reference/gate-retired-keys.d.ts +35 -0
- package/dist/reference/gate-retired-keys.js +58 -0
- package/dist/reference/gloss.d.ts +2 -0
- package/dist/reference/katex-measure.d.ts +17 -0
- package/dist/reference/katex-measure.js +223 -0
- package/dist/reference/keyed-float-validate.js +12 -0
- package/dist/reference/keyed-jsx-floats.d.mts +2 -2
- package/dist/reference/keyed-jsx-floats.mjs +11 -14
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-scope.js +13 -2
- package/dist/reference/math-glyphs.d.mts +94 -2
- package/dist/reference/math-glyphs.mjs +851 -46
- package/dist/reference/mixed-content.d.ts +8 -3
- package/dist/reference/mixed-content.js +4 -2
- package/dist/reference/page-notation.d.ts +34 -4
- package/dist/reference/page-notation.js +54 -2
- package/dist/reference/references.d.ts +11 -1
- package/dist/reference/references.js +6 -0
- package/dist/reference/registry.js +104 -10
- package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
- package/dist/reference/rehype-heading-anchors.mjs +11 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +84 -20
- package/dist/reference/render-lab-math.d.ts +2 -0
- package/dist/reference/render-lab-math.js +17 -5
- package/dist/reference/sections-validate.d.ts +28 -3
- package/dist/reference/sections-validate.js +31 -4
- package/dist/reference/sections.d.mts +24 -6
- package/dist/reference/sections.mjs +92 -17
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/tables-validate.js +24 -1
- package/dist/reference/text-measure.d.ts +10 -0
- package/dist/reference/text-measure.js +41 -0
- package/dist/reference/text-widths.d.ts +10 -0
- package/dist/reference/text-widths.js +1238 -0
- package/dist/reference/types.d.ts +64 -8
- package/dist/rules/catalog.js +191 -30
- package/dist/rules/in-effect.d.ts +1 -1
- package/dist/rules/rules.schema.json +79 -24
- package/dist/rules/settings.d.ts +1 -1
- package/dist/rules/settings.js +7 -0
- package/dist/session/PreferenceStore.d.ts +18 -1
- package/dist/session/PreferenceStore.js +29 -1
- package/docs/authoring/README.md +32 -21
- package/docs/authoring/agents.md +38 -7
- package/docs/authoring/components.md +111 -6
- package/docs/authoring/migrating.md +707 -0
- package/docs/authoring/notation.md +281 -29
- package/docs/authoring/retired.md +1 -0
- package/docs/authoring/structure-rules.md +10 -41
- package/docs/authoring/structure.md +53 -2
- package/docs/authoring/visualizations.md +144 -18
- package/package.json +12 -7
- package/src/compiler/astro.ts +57 -17
- package/src/compiler/cli.ts +143 -35
- package/src/compiler/collections.ts +92 -19
- package/src/compiler/content-schema.ts +108 -0
- package/src/compiler/content-schemas.ts +391 -0
- package/src/compiler/course-config.ts +86 -15
- package/src/compiler/exports/combined-html.ts +669 -0
- package/src/compiler/exports/download-index.ts +36 -0
- package/src/compiler/exports/export-chapters.ts +233 -0
- package/src/compiler/exports/export-pages.ts +44 -0
- package/src/compiler/exports/html-tree.ts +62 -0
- package/src/compiler/exports/latex/capture.ts +651 -0
- package/src/compiler/exports/latex/compile.ts +168 -0
- package/src/compiler/exports/latex/document.ts +479 -0
- package/src/compiler/exports/latex/explico.cls +415 -0
- package/src/compiler/exports/latex/numbering.ts +74 -0
- package/src/compiler/exports/latex/serialize.ts +787 -0
- package/src/compiler/exports/latex/texlive-packages.txt +76 -0
- package/src/compiler/exports/latex-export.ts +255 -0
- package/src/compiler/exports/markdown/serialize.ts +632 -0
- package/src/compiler/exports/markdown-export.ts +329 -0
- package/src/compiler/exports/standalone-html.ts +39 -70
- package/src/compiler/exports/static-tree.ts +944 -0
- package/src/compiler/exports/zip.ts +88 -0
- package/src/compiler/lesson-derivation.ts +13 -8
- package/src/compiler/manifest.ts +299 -28
- package/src/compiler/render-diagnostics.ts +111 -0
- package/src/components/HoverPanel.astro +204 -6
- package/src/components/PartOverview.astro +12 -9
- package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
- package/src/components/assessments/AssessmentRunner.tsx +7 -1
- package/src/components/assessments/AssessmentSet.astro +14 -1
- package/src/components/block-heading.ts +38 -0
- package/src/components/card-arrow.ts +18 -4
- package/src/components/card-carry.ts +406 -0
- package/src/components/card-drag.ts +1 -30
- package/src/components/card-fetch.ts +87 -0
- package/src/components/card-manager.ts +1222 -38
- package/src/components/card-memory.ts +5 -1
- package/src/components/card-placement.ts +211 -13
- package/src/components/card-side.ts +281 -31
- package/src/components/citation/CitationLayer.astro +45 -40
- package/src/components/diagram/Diagram.astro +123 -44
- package/src/components/figure/Figure.astro +11 -0
- package/src/components/figure/FloatCaption.astro +4 -17
- package/src/components/float-toolbar.ts +274 -0
- package/src/components/memo/Memo.astro +21 -10
- package/src/components/memo/ShortNote.astro +4 -0
- package/src/components/notation/NotationGlossary.astro +22 -17
- package/src/components/notation/NotationLayer.astro +321 -79
- package/src/components/notation/NotationMath.astro +8 -1
- package/src/components/notation/render-notation-math.ts +51 -5
- package/src/components/readable-text.ts +25 -0
- package/src/components/reference/FloatRefLayer.astro +162 -89
- package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
- package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
- package/src/components/starlight/LayoutPageSidebar.astro +17 -3
- package/src/components/starlight/LayoutSocialIcons.astro +79 -29
- package/src/components/starlight/LessonExportActions.astro +384 -310
- package/src/components/starlight/LessonFooter.astro +47 -9
- package/src/components/starlight/PrintController.astro +28 -0
- package/src/components/starlight/TextSizeControl.astro +137 -0
- package/src/components/starlight/print-controller.ts +380 -0
- package/src/components/starlight/section-summary.ts +13 -5
- package/src/components/table/Table.astro +13 -21
- package/src/components/table/table-truncation.ts +15 -0
- package/src/content-config.ts +20 -312
- package/src/curriculum/validation.ts +72 -24
- package/src/reference/base-abbreviations.mjs +2 -2
- package/src/reference/base-operators.mjs +2 -2
- package/src/reference/base-roman-labels.mjs +4 -3
- package/src/reference/citation-format.mjs +74 -0
- package/src/reference/consistency.ts +47 -61
- package/src/reference/curriculum-alignment.ts +4 -41
- package/src/reference/derived-entries.ts +68 -0
- package/src/reference/derived-notation.mjs +121 -0
- package/src/reference/diagram-fit.ts +104 -0
- package/src/reference/diagram-layout.ts +182 -51
- package/src/reference/diagrams-validate.ts +2 -1
- package/src/reference/equations-validate.ts +41 -2
- package/src/reference/equations.mjs +120 -22
- package/src/reference/examples-validate.ts +1 -0
- package/src/reference/figure-fit.ts +139 -0
- package/src/reference/figure-pictures.ts +96 -0
- package/src/reference/figures-validate.ts +2 -1
- package/src/reference/gate-abbreviations.ts +1 -1
- package/src/reference/gate-course-rules.ts +22 -7
- package/src/reference/gate-heading-length.ts +25 -7
- package/src/reference/gate-heading-structure.ts +40 -1
- package/src/reference/gate-indices.ts +470 -0
- package/src/reference/gate-math.ts +102 -13
- package/src/reference/gate-mdx-expression.ts +45 -0
- package/src/reference/gate-prop-text.ts +145 -0
- package/src/reference/gate-prose-notation.ts +19 -5
- package/src/reference/gate-retired-keys.ts +101 -0
- package/src/reference/gloss.ts +2 -0
- package/src/reference/katex-measure.ts +384 -0
- package/src/reference/keyed-float-validate.ts +22 -0
- package/src/reference/keyed-jsx-floats.mjs +11 -14
- package/src/reference/lab-math-scope.ts +15 -1
- package/src/reference/math-glyphs.mjs +851 -46
- package/src/reference/mixed-content.ts +11 -2
- package/src/reference/page-notation.ts +100 -3
- package/src/reference/references.ts +14 -0
- package/src/reference/registry.ts +154 -8
- package/src/reference/rehype-fail-katex-errors.mjs +19 -0
- package/src/reference/rehype-heading-anchors.mjs +11 -1
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +84 -20
- package/src/reference/render-lab-math.ts +29 -5
- package/src/reference/sections-validate.ts +67 -3
- package/src/reference/sections.mjs +92 -17
- package/src/reference/tables-validate.ts +40 -2
- package/src/reference/text-measure.ts +63 -0
- package/src/reference/text-widths.ts +133 -0
- package/src/reference/types.ts +75 -8
- package/src/rules/catalog.ts +205 -29
- package/src/rules/settings.ts +10 -0
- package/src/session/PreferenceStore.ts +53 -0
- package/src/styles/global.css +291 -75
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import type { SourceFile } from './document';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compile a LaTeX folder to PDF with `latexmk`. LuaLaTeX is the engine: it
|
|
10
|
+
* is the one the LaTeX Project recommends for new documents and the only one
|
|
11
|
+
* that writes MathML into a tagged PDF. `EXPLICO_LATEX_ENGINE=pdflatex`
|
|
12
|
+
* switches to pdfLaTeX, for a machine whose TeX Live lacks LuaLaTeX's font
|
|
13
|
+
* loader.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface LatexEngine {
|
|
17
|
+
readonly name: 'lualatex' | 'pdflatex';
|
|
18
|
+
readonly latexmkOption: '-lualatex' | '-pdf';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CompiledLatex {
|
|
22
|
+
readonly pdf: Buffer;
|
|
23
|
+
readonly aux: string;
|
|
24
|
+
readonly log: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RunResult {
|
|
28
|
+
readonly code: number | null;
|
|
29
|
+
readonly output: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(command: string, args: readonly string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<RunResult> {
|
|
33
|
+
return new Promise((resolveRun) => {
|
|
34
|
+
let output = '';
|
|
35
|
+
let child;
|
|
36
|
+
try {
|
|
37
|
+
child = spawn(command, args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
38
|
+
} catch {
|
|
39
|
+
resolveRun({ code: null, output: '' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const capture = (chunk: Buffer) => {
|
|
43
|
+
if (output.length < 200_000) output += chunk.toString('utf8');
|
|
44
|
+
};
|
|
45
|
+
child.stdout.on('data', capture);
|
|
46
|
+
child.stderr.on('data', capture);
|
|
47
|
+
child.once('error', () => resolveRun({ code: null, output }));
|
|
48
|
+
child.once('close', (code) => resolveRun({ code, output }));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The engine to compile with, or the reason there is none. LuaLaTeX also
|
|
54
|
+
* needs `luaotfload` (Debian ships it separately, in `texlive-luatex`).
|
|
55
|
+
*/
|
|
56
|
+
export async function findLatexEngine(): Promise<LatexEngine | { readonly missing: string }> {
|
|
57
|
+
const requested = process.env.EXPLICO_LATEX_ENGINE ?? 'lualatex';
|
|
58
|
+
if (requested !== 'lualatex' && requested !== 'pdflatex') {
|
|
59
|
+
return { missing: `EXPLICO_LATEX_ENGINE must be "lualatex" or "pdflatex", not ${JSON.stringify(requested)}.` };
|
|
60
|
+
}
|
|
61
|
+
if ((await run('latexmk', ['-v'])).code !== 0) {
|
|
62
|
+
return { missing: 'latexmk is not installed (TeX Live 2025 or later provides it).' };
|
|
63
|
+
}
|
|
64
|
+
if ((await run(requested, ['--version'])).code !== 0) {
|
|
65
|
+
return { missing: `${requested} is not installed.` };
|
|
66
|
+
}
|
|
67
|
+
if (requested === 'lualatex') {
|
|
68
|
+
const loader = await run('kpsewhich', ['luaotfload.sty']);
|
|
69
|
+
if (loader.code !== 0 || !loader.output.trim()) {
|
|
70
|
+
return {
|
|
71
|
+
missing:
|
|
72
|
+
'LuaLaTeX cannot load fonts: luaotfload is missing (on Debian or Ubuntu, install texlive-luatex). ' +
|
|
73
|
+
'Set EXPLICO_LATEX_ENGINE=pdflatex to compile with pdfLaTeX instead.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return requested === 'lualatex'
|
|
78
|
+
? { name: 'lualatex', latexmkOption: '-lualatex' }
|
|
79
|
+
: { name: 'pdflatex', latexmkOption: '-pdf' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The last LaTeX errors of a log, with their file and line. A Lua error
|
|
84
|
+
* (`[\directlua]:1: …`, `….lua:12: …`) carries no TeX file and line.
|
|
85
|
+
*/
|
|
86
|
+
function latexErrors(log: string): string {
|
|
87
|
+
const lines = log.split('\n');
|
|
88
|
+
const found: string[] = [];
|
|
89
|
+
lines.forEach((line, index) => {
|
|
90
|
+
if (/^(?:\.\/[^:]+:\d+: |! |\[\\directlua\]:\d+: |\S+\.lua:\d+: )/u.test(line)) {
|
|
91
|
+
found.push([line, lines[index + 1] ?? ''].join(' ').trim());
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return found.slice(0, 6).join('\n ');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The characters a compile could not draw, in first-seen order: LuaLaTeX
|
|
99
|
+
* leaves a blank where the font has no glyph and only logs `Missing
|
|
100
|
+
* character: There is no 中 (U+4E2D) in font …` — Greek, CJK or emoji in
|
|
101
|
+
* prose against Latin Modern. Each comes back once, as `中 (U+4E2D)`.
|
|
102
|
+
*/
|
|
103
|
+
export function missingCharacters(log: string): string[] {
|
|
104
|
+
const found = new Set<string>();
|
|
105
|
+
for (const match of log.matchAll(/Missing character: There is no (\S+) \((U\+[0-9A-F]+)\)/gu)) {
|
|
106
|
+
found.add(`${match[1]} (${match[2]})`);
|
|
107
|
+
}
|
|
108
|
+
return [...found];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compile `files` (a folder with `main.tex`) and return the PDF with the
|
|
113
|
+
* `.aux` LaTeX wrote, for the numbering check. `epoch` fixes the PDF's dates
|
|
114
|
+
* so the same source gives the same file.
|
|
115
|
+
*/
|
|
116
|
+
export async function compileLatex(
|
|
117
|
+
files: readonly SourceFile[],
|
|
118
|
+
engine: LatexEngine,
|
|
119
|
+
options: { readonly epoch?: number; readonly name?: string } = {},
|
|
120
|
+
): Promise<CompiledLatex> {
|
|
121
|
+
const root = await mkdtemp(join(tmpdir(), 'explico-latex-'));
|
|
122
|
+
try {
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
const path = join(root, file.path);
|
|
125
|
+
await mkdir(dirname(path), { recursive: true });
|
|
126
|
+
await writeFile(path, file.content);
|
|
127
|
+
}
|
|
128
|
+
const result = await run(
|
|
129
|
+
'latexmk',
|
|
130
|
+
[
|
|
131
|
+
engine.latexmkOption,
|
|
132
|
+
'-interaction=nonstopmode',
|
|
133
|
+
'-halt-on-error',
|
|
134
|
+
'-file-line-error',
|
|
135
|
+
'-norc',
|
|
136
|
+
// luaotfload's caches are inputs latexmk tracks; the build's other
|
|
137
|
+
// compiles rewrite them meanwhile, and a compile that sees its inputs
|
|
138
|
+
// change on every run never settles ("needed too many passes").
|
|
139
|
+
'-e',
|
|
140
|
+
"$hash_calc_ignore_pattern{'luc'} = '^';",
|
|
141
|
+
'main.tex',
|
|
142
|
+
],
|
|
143
|
+
{
|
|
144
|
+
cwd: root,
|
|
145
|
+
env: {
|
|
146
|
+
...process.env,
|
|
147
|
+
SOURCE_DATE_EPOCH: String(options.epoch ?? 0),
|
|
148
|
+
FORCE_SOURCE_DATE: '1',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const log = await readFile(join(root, 'main.log'), 'utf8').catch(() => result.output);
|
|
153
|
+
if (result.code !== 0) {
|
|
154
|
+
// A failure latexmk reports itself (runs that never settle, a missing
|
|
155
|
+
// tool) leaves no error in the LaTeX log: say what latexmk said.
|
|
156
|
+
const errors = latexErrors(log);
|
|
157
|
+
const said = errors === '' ? `\n latexmk: ${result.output.trim().split('\n').slice(-12).join('\n ')}` : '';
|
|
158
|
+
throw new Error(`LaTeX could not compile ${options.name ?? 'the document'}:\n ${errors || log.slice(-1500)}${said}`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
pdf: await readFile(join(root, 'main.pdf')),
|
|
162
|
+
aux: await readFile(join(root, 'main.aux'), 'utf8'),
|
|
163
|
+
log,
|
|
164
|
+
};
|
|
165
|
+
} finally {
|
|
166
|
+
await rm(root, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
import type { Manifest, ManifestLesson, ManifestPart } from '../../manifest';
|
|
5
|
+
import type { Block, StaticPage } from '../static-tree';
|
|
6
|
+
import {
|
|
7
|
+
escapeText,
|
|
8
|
+
serializeAbbreviationAppendix,
|
|
9
|
+
serializeChapter,
|
|
10
|
+
serializeNotationAppendix,
|
|
11
|
+
serializePartOpener,
|
|
12
|
+
type AppendixNotation,
|
|
13
|
+
type CheckItem,
|
|
14
|
+
} from './serialize';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The LaTeX source of one chapter as a folder: what a reader unzips and what
|
|
18
|
+
* explico compiles to make the PDF it offers. Files are returned in memory,
|
|
19
|
+
* keyed by their path inside the folder, so the same tree feeds the zip and
|
|
20
|
+
* the compile.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface SourceFile {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly content: string | Uint8Array;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** explico's class, shipped next to this module (copied into `dist/` by `copy-assets`). */
|
|
29
|
+
export function explicoClass(): string {
|
|
30
|
+
return readFileSync(new URL('./explico.cls', import.meta.url), 'utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function twoDigits(value: number): string {
|
|
34
|
+
return String(value).padStart(2, '0');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `parts/02-projectiles/02-optimal-launch-angle.tex` for chapter 2.2. */
|
|
38
|
+
export function chapterPath(manifest: Pick<Manifest, 'parts'>, lesson: ManifestLesson): string {
|
|
39
|
+
const part = manifest.parts.find((entry) => entry.ordinal === lesson.part);
|
|
40
|
+
const partDir = part?.dir ?? lesson.slug.split('/')[0] ?? 'part';
|
|
41
|
+
const name = lesson.slug.split('/').at(-1) ?? lesson.slug;
|
|
42
|
+
return `parts/${twoDigits(lesson.part)}-${partDir}/${twoDigits(lesson.chapter)}-${name}.tex`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The folder name a chapter's download unzips to. */
|
|
46
|
+
export function chapterFolderName(lesson: ManifestLesson): string {
|
|
47
|
+
return `${lesson.slug.split('/').at(-1) ?? lesson.slug}-latex`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A chapter's check answers, by assessment id, in authored order. */
|
|
51
|
+
export function checkItems(manifest: Pick<Manifest, 'assessmentContent'>, lesson: Pick<ManifestLesson, 'id'>): Map<string, CheckItem[]> {
|
|
52
|
+
const items = new Map<string, CheckItem[]>();
|
|
53
|
+
for (const item of manifest.assessmentContent) {
|
|
54
|
+
if (item.lessonId !== lesson.id) continue;
|
|
55
|
+
const list = items.get(item.assessmentId) ?? [];
|
|
56
|
+
list.push(item);
|
|
57
|
+
items.set(item.assessmentId, list);
|
|
58
|
+
}
|
|
59
|
+
return items;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The seed comes first: tagpdf names its "user" structure namespace with
|
|
64
|
+
* `\int_rand:n` digits as it loads, and TeX seeds its random numbers from
|
|
65
|
+
* the clock, so without it no two compiles of a source agree.
|
|
66
|
+
*
|
|
67
|
+
* LuaLaTeX writes a tagged PDF/UA-2 with MathML for every formula. pdfLaTeX
|
|
68
|
+
* tags less: no MathML, booktabs rules left untagged, and a box that breaks
|
|
69
|
+
* across pages opens its marked content on one page and closes it on the
|
|
70
|
+
* next (poppler: "Mismatched EMC operator"). So its PDF claims no standard.
|
|
71
|
+
*/
|
|
72
|
+
const DOCUMENT_METADATA = [
|
|
73
|
+
'% A fixed seed for TeX\'s random numbers, before tagging loads: tagpdf names',
|
|
74
|
+
'% one of its structure namespaces with random digits, and the same source',
|
|
75
|
+
'% should give the same PDF.',
|
|
76
|
+
'\\ExplSyntaxOn \\sys_gset_rand_seed:n { 1 } \\ExplSyntaxOff',
|
|
77
|
+
'% LuaLaTeX: a tagged, accessible PDF (PDF/UA-2) with MathML for every formula.',
|
|
78
|
+
'% pdfLaTeX tags it less completely, so it claims no standard.',
|
|
79
|
+
'\\ifdefined\\directlua',
|
|
80
|
+
' \\DocumentMetadata{lang=en,pdfversion=2.0,pdfstandard=ua-2,tagging=on}',
|
|
81
|
+
'\\else',
|
|
82
|
+
' \\DocumentMetadata{lang=en,pdfversion=2.0,tagging=on}',
|
|
83
|
+
'\\fi',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The PDF's file identifier (its trailer `/ID`), which the engines derive
|
|
88
|
+
* from the output file's path unless told: fixed from the course, its
|
|
89
|
+
* content version and the document, so every compile of a source agrees.
|
|
90
|
+
*/
|
|
91
|
+
export function trailerId(manifest: Pick<Manifest, 'about'>, document: string): string {
|
|
92
|
+
return createHash('md5')
|
|
93
|
+
.update(`${manifest.about.material.name}\n${manifest.about.knowledge.version}\n${document}`)
|
|
94
|
+
.digest('hex')
|
|
95
|
+
.toUpperCase();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function trailerIdLine(id: string): string {
|
|
99
|
+
return `\\ifdefined\\directlua\\pdfvariable trailerid {[<${id}><${id}>]}\\else\\pdftrailerid{${id}}\\fi`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mainTex(
|
|
103
|
+
manifest: Manifest,
|
|
104
|
+
lesson: ManifestLesson,
|
|
105
|
+
chapterFile: string,
|
|
106
|
+
): string {
|
|
107
|
+
const paper = manifest.lessonExports.latexPaper === 'a4' ? 'a4paper' : 'letterpaper';
|
|
108
|
+
const { numbering } = manifest;
|
|
109
|
+
const offsets = lesson.numbering;
|
|
110
|
+
const course = manifest.about.material.name;
|
|
111
|
+
const authors = (manifest.about as { authors?: readonly { name?: string }[] }).authors
|
|
112
|
+
?.map((author) => author.name)
|
|
113
|
+
.filter((name): name is string => !!name);
|
|
114
|
+
return [
|
|
115
|
+
'% Compile with `latexmk` (LuaLaTeX, see latexmkrc) or `latexmk -pdf` (pdfLaTeX).',
|
|
116
|
+
...DOCUMENT_METADATA,
|
|
117
|
+
`\\documentclass[${paper}]{explico}`,
|
|
118
|
+
'% The same identifier for every compile of this source.',
|
|
119
|
+
trailerIdLine(trailerId(manifest, lesson.slug)),
|
|
120
|
+
...(numbering.floats === 'part' ? ['\\ExplicoFloatsByPart'] : []),
|
|
121
|
+
`\\ExplicoSectionDepth{${numbering.sections.show ? numbering.sections.depth : 1}}`,
|
|
122
|
+
`\\hypersetup{pdftitle={${escapeText(lesson.title)}},pdfauthor={${escapeText(
|
|
123
|
+
authors && authors.length > 0 ? authors.join(', ') : course,
|
|
124
|
+
)}},pdfsubject={${escapeText(course)}}}`,
|
|
125
|
+
'\\begin{document}',
|
|
126
|
+
`\\ExplicoStartChapterAt{${lesson.part}}{${lesson.chapter}}{${offsets.equations}}{${offsets.tables}}{${offsets.figures}}{${offsets.diagrams}}{${offsets.checks}}{${offsets.examples}}`,
|
|
127
|
+
`\\input{${chapterFile.replace(/\.tex$/u, '')}}`,
|
|
128
|
+
'\\end{document}',
|
|
129
|
+
'',
|
|
130
|
+
].join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const LATEXMKRC = [
|
|
134
|
+
'# LuaLaTeX by default; `latexmk -pdf` uses pdfLaTeX instead.',
|
|
135
|
+
'$pdf_mode = 4;',
|
|
136
|
+
'$postscript_mode = $dvi_mode = 0;',
|
|
137
|
+
"# luaotfload rewrites its font caches as fonts are looked up; they are not",
|
|
138
|
+
"# this document's sources, so a rewrite is no reason to run again.",
|
|
139
|
+
"$hash_calc_ignore_pattern{'luc'} = '^';",
|
|
140
|
+
'',
|
|
141
|
+
].join('\n');
|
|
142
|
+
|
|
143
|
+
function readme(manifest: Manifest, lesson: ManifestLesson, url: string | undefined): string {
|
|
144
|
+
const course = manifest.about.material.name;
|
|
145
|
+
return [
|
|
146
|
+
`# ${lesson.title}`,
|
|
147
|
+
'',
|
|
148
|
+
`The LaTeX source of a chapter of *${course}*` +
|
|
149
|
+
` (content version ${manifest.about.knowledge.version}).` +
|
|
150
|
+
(url ? ` Interactive version: ${url}` : ''),
|
|
151
|
+
'',
|
|
152
|
+
'## Build',
|
|
153
|
+
'',
|
|
154
|
+
'Needs TeX Live 2025 or later.',
|
|
155
|
+
'',
|
|
156
|
+
'```sh',
|
|
157
|
+
'latexmk # LuaLaTeX (preferred)',
|
|
158
|
+
'latexmk -pdf # pdfLaTeX',
|
|
159
|
+
'```',
|
|
160
|
+
'',
|
|
161
|
+
'LuaLaTeX writes a tagged, accessible PDF (PDF/UA-2) with MathML for every',
|
|
162
|
+
'formula. pdfLaTeX tags it less completely and claims no standard.',
|
|
163
|
+
'',
|
|
164
|
+
'## Files',
|
|
165
|
+
'',
|
|
166
|
+
'- `main.tex` — the document: class options, then the chapter.',
|
|
167
|
+
'- `explico.cls` — explico\'s book class: layout, numbering, and the',
|
|
168
|
+
' environments the chapter uses (memos, notes, worked examples, checks).',
|
|
169
|
+
`- \`${chapterPath(manifest, lesson)}\` — the chapter.`,
|
|
170
|
+
'- `figures/` — the chapter\'s figures, as vector PDF where the website draws them.',
|
|
171
|
+
'',
|
|
172
|
+
'Numbers are LaTeX\'s own and match the website when the source is built',
|
|
173
|
+
'unchanged; edit freely and they follow.',
|
|
174
|
+
'',
|
|
175
|
+
].join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ChapterSourceInput {
|
|
179
|
+
readonly manifest: Manifest;
|
|
180
|
+
readonly lesson: ManifestLesson;
|
|
181
|
+
readonly page: StaticPage;
|
|
182
|
+
/** Routes of every chapter in the document (just this one for a chapter download). */
|
|
183
|
+
readonly documentRoutes: ReadonlySet<string>;
|
|
184
|
+
/** Figure files already produced for this chapter, by float anchor, with their bytes. */
|
|
185
|
+
readonly pictures: ReadonlyMap<string, { readonly path: string; readonly content: Uint8Array }>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every file of one chapter's LaTeX folder. */
|
|
189
|
+
export function chapterSource(input: ChapterSourceInput): SourceFile[] {
|
|
190
|
+
const { manifest, lesson, page } = input;
|
|
191
|
+
const file = chapterPath(manifest, lesson);
|
|
192
|
+
const base = manifest.lessonExports.canonicalBaseUrl;
|
|
193
|
+
const url = base ? `${base}${lesson.slug}/` : undefined;
|
|
194
|
+
const chapter = serializeChapter({
|
|
195
|
+
page,
|
|
196
|
+
documentRoutes: input.documentRoutes,
|
|
197
|
+
canonicalBaseUrl: base,
|
|
198
|
+
pictures: new Map([...input.pictures].map(([anchor, picture]) => [anchor, picture.path])),
|
|
199
|
+
checkItems: checkItems(manifest, lesson),
|
|
200
|
+
source: {
|
|
201
|
+
course: manifest.about.material.name,
|
|
202
|
+
version: manifest.about.knowledge.version,
|
|
203
|
+
...(url ? { url } : {}),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
return withScripts([
|
|
207
|
+
{ path: 'README.md', content: readme(manifest, lesson, url) },
|
|
208
|
+
{ path: 'latexmkrc', content: LATEXMKRC },
|
|
209
|
+
{ path: 'main.tex', content: mainTex(manifest, lesson, file) },
|
|
210
|
+
{ path: 'explico.cls', content: explicoClass() },
|
|
211
|
+
{ path: file, content: chapter },
|
|
212
|
+
...[...input.pictures.values()].map((picture) => ({ path: picture.path, content: picture.content })),
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Greek, Cyrillic, Chinese, Japanese and Korean letters, and emoji: the
|
|
218
|
+
* characters Latin Modern lacks and the class's fallback fonts set.
|
|
219
|
+
*/
|
|
220
|
+
const OTHER_SCRIPTS =
|
|
221
|
+
/[\u0370-\u03FF\u0400-\u04FF\u1F00-\u1FFF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF\u2600-\u27BF\u{1F000}-\u{1FAFF}]/u;
|
|
222
|
+
|
|
223
|
+
/** The Greek letters explico.cls sets as math symbols when they stand alone. */
|
|
224
|
+
const MATH_GREEK = /[αβγδεθλμπρστφωΓΔΘΛΠΣΦΩ]/gu;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The document's files, with the class option `scripts` on `main.tex` when a
|
|
228
|
+
* `.tex` file holds a character of another script: the class then loads its
|
|
229
|
+
* fallback fonts under LuaLaTeX, which costs every compile that does not
|
|
230
|
+
* need them about two seconds.
|
|
231
|
+
*/
|
|
232
|
+
function withScripts(files: SourceFile[]): SourceFile[] {
|
|
233
|
+
// A Greek letter on its own is a symbol the class sets as math; a Greek
|
|
234
|
+
// word (`\ExplicoGreekWord`), an accented letter or another script needs a
|
|
235
|
+
// text font.
|
|
236
|
+
const needed = files.some(
|
|
237
|
+
(file) =>
|
|
238
|
+
file.path.endsWith('.tex') &&
|
|
239
|
+
typeof file.content === 'string' &&
|
|
240
|
+
(file.content.includes('\\ExplicoGreekWord{') || OTHER_SCRIPTS.test(file.content.replace(MATH_GREEK, ''))),
|
|
241
|
+
);
|
|
242
|
+
if (!needed) return files;
|
|
243
|
+
return files.map((file) =>
|
|
244
|
+
file.path === 'main.tex' && typeof file.content === 'string'
|
|
245
|
+
? { ...file, content: file.content.replace(/\\documentclass\[([^\]]*)\]\{explico\}/u, '\\documentclass[$1,scripts]{explico}') }
|
|
246
|
+
: file,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A chapter of a book, with its figure files. */
|
|
251
|
+
export interface BookChapter {
|
|
252
|
+
readonly lesson: ManifestLesson;
|
|
253
|
+
readonly page: StaticPage;
|
|
254
|
+
readonly pictures: ReadonlyMap<string, { readonly path: string; readonly content: Uint8Array }>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A part of a book: its own page, when it has one, and its chapters in order. */
|
|
258
|
+
export interface BookPart {
|
|
259
|
+
readonly part: ManifestPart;
|
|
260
|
+
readonly overview?: StaticPage;
|
|
261
|
+
readonly chapters: readonly BookChapter[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface BookSourceInput {
|
|
265
|
+
readonly manifest: Manifest;
|
|
266
|
+
readonly parts: readonly BookPart[];
|
|
267
|
+
/** What the book is (`part/projectiles`, `course`): it names the PDF's identifier. */
|
|
268
|
+
readonly document: string;
|
|
269
|
+
readonly title: string;
|
|
270
|
+
/** What the book belongs to, on its title page: `Part 2 of …` for a part. */
|
|
271
|
+
readonly subtitle?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Every numbered block of a kind in a book, at any depth. */
|
|
275
|
+
function countNumbered(parts: readonly BookPart[], kind: 'figure' | 'table' | 'diagram'): number {
|
|
276
|
+
let count = 0;
|
|
277
|
+
const visit = (blocks: readonly Block[]) => {
|
|
278
|
+
for (const block of blocks) {
|
|
279
|
+
if (block.kind === kind && 'number' in block && block.number) count += 1;
|
|
280
|
+
if (block.kind === 'list') block.items.forEach(visit);
|
|
281
|
+
if (block.kind === 'examples') block.items.forEach((item) => visit(item.children));
|
|
282
|
+
if ('children' in block && Array.isArray(block.children) && block.kind !== 'paragraph' && block.kind !== 'heading') {
|
|
283
|
+
visit(block.children as readonly Block[]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
for (const { chapters } of parts) for (const { page } of chapters) visit(page.blocks);
|
|
288
|
+
return count;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The book's symbols in the order they first appear, each once. A symbol
|
|
293
|
+
* only one chapter defines names that chapter.
|
|
294
|
+
*/
|
|
295
|
+
function appendixNotation(manifest: Manifest, parts: readonly BookPart[]): AppendixNotation[] {
|
|
296
|
+
const local = new Map<string, string>();
|
|
297
|
+
for (const lesson of manifest.lessons) {
|
|
298
|
+
for (const definition of lesson.notation.definitions) {
|
|
299
|
+
if (definition.scope === 'local') local.set(definition.key, `${lesson.part}.${lesson.chapter}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const seen = new Set<string>();
|
|
303
|
+
const entries: AppendixNotation[] = [];
|
|
304
|
+
const add = (page: StaticPage) => {
|
|
305
|
+
for (const entry of page.notation) {
|
|
306
|
+
if (seen.has(entry.key)) continue;
|
|
307
|
+
seen.add(entry.key);
|
|
308
|
+
const chapter = local.get(entry.key);
|
|
309
|
+
entries.push({ entry, page, ...(chapter ? { chapter } : {}) });
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
for (const { overview, chapters } of parts) {
|
|
313
|
+
if (overview) add(overview);
|
|
314
|
+
for (const { page } of chapters) add(page);
|
|
315
|
+
}
|
|
316
|
+
return entries;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function bookMainTex(input: BookSourceInput, inputs: readonly string[], appendices: readonly string[]): string {
|
|
320
|
+
const { manifest, parts } = input;
|
|
321
|
+
const paper = manifest.lessonExports.latexPaper === 'a4' ? 'a4paper' : 'letterpaper';
|
|
322
|
+
const { numbering } = manifest;
|
|
323
|
+
const course = manifest.about.material.name;
|
|
324
|
+
const authors = (manifest.about as { authors?: readonly { name?: string }[] }).authors
|
|
325
|
+
?.map((author) => author.name)
|
|
326
|
+
.filter((name): name is string => !!name) ?? [];
|
|
327
|
+
const url = manifest.lessonExports.canonicalBaseUrl;
|
|
328
|
+
const lists = [
|
|
329
|
+
...(countNumbered(parts, 'figure') > 0 ? ['\\listoffigures'] : []),
|
|
330
|
+
...(countNumbered(parts, 'table') > 0 ? ['\\listoftables'] : []),
|
|
331
|
+
...(countNumbered(parts, 'diagram') > 0 ? ['\\listofdiagrams'] : []),
|
|
332
|
+
];
|
|
333
|
+
return [
|
|
334
|
+
'% Compile with `latexmk` (LuaLaTeX, see latexmkrc) or `latexmk -pdf` (pdfLaTeX).',
|
|
335
|
+
...DOCUMENT_METADATA,
|
|
336
|
+
`\\documentclass[${paper}]{explico}`,
|
|
337
|
+
'% The same identifier for every compile of this source.',
|
|
338
|
+
trailerIdLine(trailerId(manifest, input.document)),
|
|
339
|
+
...(numbering.floats === 'part' ? ['\\ExplicoFloatsByPart'] : []),
|
|
340
|
+
`\\ExplicoSectionDepth{${numbering.sections.show ? numbering.sections.depth : 1}}`,
|
|
341
|
+
'% The contents list parts, chapters and sections.',
|
|
342
|
+
'\\setcounter{tocdepth}{1}',
|
|
343
|
+
`\\hypersetup{pdftitle={${escapeText(input.title)}},pdfauthor={${escapeText(
|
|
344
|
+
authors.length > 0 ? authors.join(', ') : course,
|
|
345
|
+
)}},pdfsubject={${escapeText(course)}}}`,
|
|
346
|
+
'\\begin{document}',
|
|
347
|
+
'\\frontmatter',
|
|
348
|
+
`\\ExplicoTitlePage{${escapeText(input.title)}}{${escapeText(input.subtitle ?? '')}}{${escapeText(
|
|
349
|
+
manifest.about.knowledge.version,
|
|
350
|
+
)}}{${escapeText(authors.join(', '))}}{${url ? url.replace(/[\\{}]/gu, '').replace(/[%#]/gu, (char) => `\\${char}`) : ''}}`,
|
|
351
|
+
'% The contents and lists in black, as a book sets them; links in the text keep their colour.',
|
|
352
|
+
'\\begingroup\\hypersetup{linkcolor=black}',
|
|
353
|
+
'\\tableofcontents',
|
|
354
|
+
...lists,
|
|
355
|
+
'\\endgroup',
|
|
356
|
+
'\\mainmatter',
|
|
357
|
+
...inputs.map((file) => `\\input{${file.replace(/\.tex$/u, '')}}`),
|
|
358
|
+
...(appendices.length > 0 ? ['\\appendix', ...appendices.map((file) => `\\input{${file.replace(/\.tex$/u, '')}}`)] : []),
|
|
359
|
+
'\\end{document}',
|
|
360
|
+
'',
|
|
361
|
+
].join('\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function bookReadme(input: BookSourceInput): string {
|
|
365
|
+
const { manifest } = input;
|
|
366
|
+
const url = manifest.lessonExports.canonicalBaseUrl;
|
|
367
|
+
return [
|
|
368
|
+
`# ${input.title}`,
|
|
369
|
+
'',
|
|
370
|
+
`The LaTeX source of ${input.subtitle ? `${input.subtitle.replace(/^Part/u, 'part')}` : `*${manifest.about.material.name}*`}` +
|
|
371
|
+
` (content version ${manifest.about.knowledge.version}).` +
|
|
372
|
+
(url ? ` Interactive version: ${url}` : ''),
|
|
373
|
+
'',
|
|
374
|
+
'## Build',
|
|
375
|
+
'',
|
|
376
|
+
'Needs TeX Live 2025 or later.',
|
|
377
|
+
'',
|
|
378
|
+
'```sh',
|
|
379
|
+
'latexmk # LuaLaTeX (preferred)',
|
|
380
|
+
'latexmk -pdf # pdfLaTeX',
|
|
381
|
+
'```',
|
|
382
|
+
'',
|
|
383
|
+
'LuaLaTeX writes a tagged, accessible PDF (PDF/UA-2) with MathML for every',
|
|
384
|
+
'formula. pdfLaTeX tags it less completely and claims no standard.',
|
|
385
|
+
'',
|
|
386
|
+
'## Files',
|
|
387
|
+
'',
|
|
388
|
+
'- `main.tex` — the book: title page, contents, lists of figures, tables and',
|
|
389
|
+
' diagrams, then each part and its chapters, then the appendices.',
|
|
390
|
+
"- `explico.cls` — explico's book class: layout, numbering, and the",
|
|
391
|
+
' environments the chapters use (memos, notes, worked examples, checks).',
|
|
392
|
+
"- `parts/NN-<part>.tex` — a part's opening page and text;",
|
|
393
|
+
' `parts/NN-<part>/MM-<chapter>.tex` — its chapters.',
|
|
394
|
+
'- `backmatter/` — the notation and abbreviation appendices.',
|
|
395
|
+
"- `figures/` — the chapters' figures, as vector PDF where the website draws them.",
|
|
396
|
+
'',
|
|
397
|
+
"Numbers are LaTeX's own and match the website when the source is built",
|
|
398
|
+
'unchanged; edit freely and they follow.',
|
|
399
|
+
'',
|
|
400
|
+
].join('\n');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Every file of a part's or the course's LaTeX folder: a book with a title
|
|
405
|
+
* page, contents and lists, each part opening with its own page's text,
|
|
406
|
+
* every chapter in order with references between them as `\ref`s, and the
|
|
407
|
+
* notation and abbreviation appendices.
|
|
408
|
+
*/
|
|
409
|
+
export function bookSource(input: BookSourceInput): SourceFile[] {
|
|
410
|
+
const { manifest, parts } = input;
|
|
411
|
+
const canonicalBaseUrl = manifest.lessonExports.canonicalBaseUrl;
|
|
412
|
+
const course = manifest.about.material.name;
|
|
413
|
+
const version = manifest.about.knowledge.version;
|
|
414
|
+
const documentRoutes = new Set(
|
|
415
|
+
parts.flatMap(({ overview, chapters }) => [
|
|
416
|
+
...(overview ? [overview.route] : []),
|
|
417
|
+
...chapters.map(({ page }) => page.route),
|
|
418
|
+
]),
|
|
419
|
+
);
|
|
420
|
+
// The keys the book's notation appendix defines: a formula links to these.
|
|
421
|
+
const notationTargets = new Set(appendixNotation(manifest, parts).map(({ entry }) => entry.key));
|
|
422
|
+
const common = { documentRoutes, canonicalBaseUrl, inBook: true, notationTargets } as const;
|
|
423
|
+
const files: SourceFile[] = [];
|
|
424
|
+
const inputs: string[] = [];
|
|
425
|
+
for (const { part, overview, chapters } of parts) {
|
|
426
|
+
const partFile = `parts/${twoDigits(part.ordinal)}-${part.dir}.tex`;
|
|
427
|
+
files.push({
|
|
428
|
+
path: partFile,
|
|
429
|
+
content: overview
|
|
430
|
+
? serializePartOpener({ ...common, page: overview, pictures: new Map(), checkItems: new Map() }, part)
|
|
431
|
+
: `\\ExplicoPart{${part.ordinal}}{${escapeText(part.label)}}\n`,
|
|
432
|
+
});
|
|
433
|
+
inputs.push(partFile);
|
|
434
|
+
for (const { lesson, page, pictures } of chapters) {
|
|
435
|
+
const file = chapterPath(manifest, lesson);
|
|
436
|
+
const url = canonicalBaseUrl ? `${canonicalBaseUrl}${lesson.slug}/` : undefined;
|
|
437
|
+
files.push({
|
|
438
|
+
path: file,
|
|
439
|
+
content: serializeChapter({
|
|
440
|
+
...common,
|
|
441
|
+
page,
|
|
442
|
+
pictures: new Map([...pictures].map(([anchor, picture]) => [anchor, picture.path])),
|
|
443
|
+
checkItems: checkItems(manifest, lesson),
|
|
444
|
+
source: { course, version, ...(url ? { url } : {}) },
|
|
445
|
+
}),
|
|
446
|
+
});
|
|
447
|
+
inputs.push(file);
|
|
448
|
+
files.push(...[...pictures.values()].map((picture) => ({ path: picture.path, content: picture.content })));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const appendices: string[] = [];
|
|
452
|
+
const notation = serializeNotationAppendix(appendixNotation(manifest, parts), {
|
|
453
|
+
...common,
|
|
454
|
+
pictures: new Map(),
|
|
455
|
+
checkItems: new Map(),
|
|
456
|
+
});
|
|
457
|
+
if (notation) {
|
|
458
|
+
files.push({ path: 'backmatter/notation.tex', content: notation });
|
|
459
|
+
appendices.push('backmatter/notation.tex');
|
|
460
|
+
}
|
|
461
|
+
const abbreviations = serializeAbbreviationAppendix(
|
|
462
|
+
manifest.abbreviations.flatMap((entry) =>
|
|
463
|
+
typeof entry.abbr === 'string' && typeof entry.expansion === 'string'
|
|
464
|
+
? [{ abbr: entry.abbr, expansion: entry.expansion }]
|
|
465
|
+
: [],
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
if (abbreviations) {
|
|
469
|
+
files.push({ path: 'backmatter/abbreviations.tex', content: abbreviations });
|
|
470
|
+
appendices.push('backmatter/abbreviations.tex');
|
|
471
|
+
}
|
|
472
|
+
return withScripts([
|
|
473
|
+
{ path: 'README.md', content: bookReadme(input) },
|
|
474
|
+
{ path: 'latexmkrc', content: LATEXMKRC },
|
|
475
|
+
{ path: 'main.tex', content: bookMainTex(input, inputs, appendices) },
|
|
476
|
+
{ path: 'explico.cls', content: explicoClass() },
|
|
477
|
+
...files,
|
|
478
|
+
]);
|
|
479
|
+
}
|