explico 0.13.1 → 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.
Files changed (239) hide show
  1. package/CHANGELOG.md +879 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1045 -16
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +15 -7
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -14,15 +14,26 @@
14
14
  * part and the course as one file each); with `latex`, as PDF and LaTeX
15
15
  * source (a part and the course as a book); with `markdown`, as Markdown. A page that is not a chapter has no download of its own (D8):
16
16
  * its content is in its part's and the course's files.
17
+ *
18
+ * A lesson's deck (docs/authoring/deck.md) adds a row on its own page and on
19
+ * its chapter's: the slides as standalone HTML (Present mode offline); with
20
+ * `latex`, as PDF, as a handout (each slide with its speaker notes) and their
21
+ * LaTeX source; with `markdown`, as Marp's Markdown. A slides-first lesson
22
+ * has no chapter row: its deck is all of it. A part's deck adds the same row
23
+ * on its own page and on its part's, above "This part".
17
24
  */
18
25
  import DefaultSocialIcons from '@astrojs/starlight/components/SocialIcons.astro';
19
26
  import { loadManifest } from '../../compiler/manifest';
20
27
  import { docsSlugsForRoute } from '../../compiler/lesson-derivation';
21
28
  import {
22
29
  courseFileStem,
30
+ deckFileStem,
31
+ deckOwnerSlug,
32
+ deckSaveName,
23
33
  exportPageSlugs,
24
34
  standaloneFilename,
25
35
  } from '../../compiler/exports/export-pages';
36
+ import { chapterSlides, deckOfRoute, partDeckOf } from '../deck/deck-context';
26
37
  import AboutPanel from './AboutPanel.astro';
27
38
  import LessonExportActions, {
28
39
  type DownloadCell,
@@ -31,19 +42,63 @@ import LessonExportActions, {
31
42
  import TextSizeControl from './TextSizeControl.astro';
32
43
 
33
44
  const manifest = await loadManifest();
34
- const routeSlugs = docsSlugsForRoute(Astro.locals.starlightRoute.entry.id);
35
- const pageSlug = exportPageSlugs(manifest).find((slug) =>
36
- routeSlugs.includes(slug),
37
- );
45
+ const routeId = Astro.locals.starlightRoute.entry.id;
46
+ const routeSlugs = docsSlugsForRoute(routeId);
47
+ // A deck beside its chapter has a route of its own (`<lesson>/slides`); it
48
+ // downloads as its lesson's page does. A part's deck (`<part>/slides`)
49
+ // downloads as its part's page does.
50
+ const pageDeck = deckOfRoute(manifest, routeId);
51
+ const pageSlug =
52
+ exportPageSlugs(manifest).find((slug) => routeSlugs.includes(slug)) ??
53
+ (pageDeck?.part !== undefined
54
+ ? `${pageDeck.part}/index`
55
+ : pageDeck
56
+ ? manifest.lessons.find((entry) => entry.id === pageDeck.lessonId)?.slug
57
+ : undefined);
38
58
  const exports = manifest.lessonExports;
39
59
  const printEnabled = pageSlug !== undefined && exports.print;
40
60
  const lesson = manifest.lessons.find((entry) => entry.slug === pageSlug);
41
61
  const part = lesson
42
62
  ? manifest.parts.find((entry) => entry.ordinal === lesson.part)
43
63
  : manifest.parts.find((entry) => `${entry.dir}/index` === pageSlug);
64
+ // The slides this page lists: its own; its lesson's, or for a chapter with
65
+ // none of its own the part's deck that covers it (`chapterSlides`); or on a
66
+ // part's page the part's.
67
+ const rowDeck =
68
+ pageDeck ??
69
+ (lesson
70
+ ? ((manifest.decks ?? []).find((entry) => entry.lessonId === lesson.id) ??
71
+ chapterSlides(manifest, lesson.slug)?.deck)
72
+ : part
73
+ ? partDeckOf(manifest, part.dir)
74
+ : undefined);
44
75
 
45
76
  const downloads: DownloadRow[] = [];
46
- if (lesson) {
77
+ /** The deck's row: its slides offline, as PDF, as a handout, their source and Marp's Markdown. */
78
+ const deckRow = (): DownloadRow | undefined => {
79
+ if (!rowDeck) return undefined;
80
+ const owner = deckOwnerSlug(rowDeck, manifest);
81
+ const path = deckFileStem(owner);
82
+ const name = deckSaveName(owner);
83
+ const handout = manifest.deck.handoutPdf && manifest.deck.notes !== 'off';
84
+ const cells: DownloadCell[] = [
85
+ ...(exports.standaloneHtml ? [{ format: 'html' as const, path: `${path}.html`, filename: `${name}.html` }] : []),
86
+ ...(exports.latex
87
+ ? [
88
+ { format: 'pdf' as const, path: `${path}.pdf`, filename: `${name}.pdf` },
89
+ ...(handout ? [{ format: 'handout' as const, path: `${path}-notes.pdf`, filename: `${name}-notes.pdf` }] : []),
90
+ { format: 'latex' as const, path: `${path}-latex.zip`, filename: `${name}-latex.zip` },
91
+ ]
92
+ : []),
93
+ ...(exports.markdown ? [{ format: 'markdown' as const, path: `${path}-markdown.zip`, filename: `${name}-markdown.zip` }] : []),
94
+ ];
95
+ return cells.length === 0
96
+ ? undefined
97
+ : { scope: 'deck', label: pageDeck ? 'These slides' : 'Its slides', title: rowDeck.title, cells };
98
+ };
99
+ const slidesRow = deckRow();
100
+ if (slidesRow && pageDeck) downloads.push(slidesRow);
101
+ if (lesson && lesson.editions.includes('chapter')) {
47
102
  const name = lesson.slug.split('/').at(-1) ?? lesson.slug;
48
103
  const cells: DownloadCell[] = [];
49
104
  if (exports.standaloneHtml) {
@@ -59,9 +114,10 @@ if (lesson) {
59
114
  cells.push({ format: 'markdown', path: `${lesson.slug}-markdown.zip`, filename: `${name}-markdown.zip` });
60
115
  }
61
116
  if (cells.length > 0) {
62
- downloads.push({ scope: 'chapter', label: 'This chapter', title: lesson.title, cells });
117
+ downloads.push({ scope: 'chapter', label: pageDeck ? 'Its chapter' : 'This chapter', title: lesson.title, cells });
63
118
  }
64
119
  }
120
+ if (slidesRow && !pageDeck) downloads.push(slidesRow);
65
121
  if (pageSlug !== undefined) {
66
122
  /** A part's or the course's downloads: `stem` names the files a reader saves. */
67
123
  const wholeCells = (path: string, stem: string): DownloadCell[] => [
@@ -9,8 +9,8 @@
9
9
  * plain link to a file the build wrote. The build lists what it wrote, with
10
10
  * sizes, in `downloads/index.json`; the menu reads it the first time it
11
11
  * opens, shows each size and hides a cell whose file is not there. During
12
- * `astro dev` only a chapter's HTML builds on request; the others say they
13
- * are built when the site is deployed.
12
+ * `astro dev` only a chapter's and a deck's HTML build on request; the
13
+ * others say they are built when the site is deployed.
14
14
  *
15
15
  * **Print.** The button loads every lab that will be on paper before the
16
16
  * print dialog opens — the browser's own `beforeprint` cannot wait for that —
@@ -22,7 +22,7 @@
22
22
  * page's footer too (`PrintController.astro`), so Ctrl+P prepares a
23
23
  * downloaded page the same way.
24
24
  */
25
- export type DownloadFormat = 'html' | 'pdf' | 'latex' | 'markdown';
25
+ export type DownloadFormat = 'html' | 'pdf' | 'handout' | 'latex' | 'markdown';
26
26
 
27
27
  export interface DownloadCell {
28
28
  readonly format: DownloadFormat;
@@ -32,11 +32,11 @@ export interface DownloadCell {
32
32
  readonly filename: string;
33
33
  }
34
34
 
35
- export type DownloadScope = 'chapter' | 'part' | 'course';
35
+ export type DownloadScope = 'deck' | 'chapter' | 'part' | 'course';
36
36
 
37
37
  export interface DownloadRow {
38
38
  readonly scope: DownloadScope;
39
- /** "This chapter", "This part", "Whole course". */
39
+ /** "These slides", "This chapter", "This part", "Whole course". */
40
40
  readonly label: string;
41
41
  /** The chapter's title, the part's label or the course's name. */
42
42
  readonly title: string;
@@ -61,6 +61,7 @@ const base = siteBaseUrl.replace(/\/$/u, '');
61
61
  const development = import.meta.env.DEV;
62
62
  /** What a download's accessible name calls each scope. */
63
63
  const SCOPE_NAMES: Readonly<Record<DownloadScope, string>> = {
64
+ deck: 'the slides',
64
65
  chapter: 'this chapter',
65
66
  part: 'this part',
66
67
  course: 'the whole course',
@@ -68,9 +69,25 @@ const SCOPE_NAMES: Readonly<Record<DownloadScope, string>> = {
68
69
  const FORMATS: readonly { readonly format: DownloadFormat; readonly label: string; readonly name: string }[] = [
69
70
  { format: 'html', label: 'HTML', name: 'as standalone HTML' },
70
71
  { format: 'pdf', label: 'PDF', name: 'as PDF' },
72
+ { format: 'handout', label: 'Handout', name: 'as a handout, each slide with its notes' },
71
73
  { format: 'latex', label: 'LaTeX', name: 'as LaTeX source' },
72
74
  { format: 'markdown', label: 'Markdown', name: 'as Markdown' },
73
75
  ];
76
+ /**
77
+ * The button's tooltip names the scopes its menu offers on this page, in the
78
+ * menu's own words ("this chapter", "this part", "the whole course"): a chapter
79
+ * has all three, a part's page two, any other page only the course. Its
80
+ * accessible name stays "Download this page": courses select the button by it.
81
+ */
82
+ const scopeNames = downloads.map((row) => SCOPE_NAMES[row.scope]);
83
+ const downloadTitle =
84
+ scopeNames.length === 0
85
+ ? 'Download this page'
86
+ : `Download ${
87
+ scopeNames.length === 1
88
+ ? scopeNames[0]
89
+ : `${scopeNames.slice(0, -1).join(', ')} or ${scopeNames[scopeNames.length - 1]}`
90
+ }`;
74
91
  const columns = FORMATS.filter(({ format }) =>
75
92
  downloads.some((row) => row.cells.some((cell) => cell.format === format)),
76
93
  );
@@ -80,6 +97,7 @@ const notes = [
80
97
  (columns.some((column) => column.format === 'latex')
81
98
  ? 'PDF is typeset for print, and LaTeX is its source.'
82
99
  : 'PDF is typeset for print.'),
100
+ columns.some((column) => column.format === 'handout') && 'Handout is each slide with its speaker notes.',
83
101
  columns.some((column) => column.format === 'markdown') && 'Markdown is plain text with images.',
84
102
  ].filter(Boolean);
85
103
  ---
@@ -96,7 +114,7 @@ const notes = [
96
114
  class="lesson-export-actions__download"
97
115
  popovertarget={menuId}
98
116
  aria-label="Download this page"
99
- title="Download this page"
117
+ title={downloadTitle}
100
118
  >
101
119
  <svg viewBox="0 0 24 24" aria-hidden="true">
102
120
  <path d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14" />
@@ -148,9 +166,9 @@ const notes = [
148
166
  const cell = row.cells.find((entry) => entry.format === column.format);
149
167
  if (!cell) return <td><span class="explico-downloads__none" aria-hidden="true">—</span></td>;
150
168
  const name = `Download ${SCOPE_NAMES[row.scope]} ${column.name}`;
151
- // `astro dev` builds a chapter's HTML on request; the rest
152
- // exists only once the site is built.
153
- if (development && !(row.scope === 'chapter' && column.format === 'html')) {
169
+ // `astro dev` builds a chapter's and a deck's HTML on
170
+ // request; the rest exists only once the site is built.
171
+ if (development && !((row.scope === 'chapter' || row.scope === 'deck') && column.format === 'html')) {
154
172
  return (
155
173
  <td>
156
174
  <span
@@ -19,6 +19,7 @@ import DefaultFooter from '@astrojs/starlight/components/Footer.astro';
19
19
  import { assertLocalNotationParity } from '../../compiler/astro';
20
20
  import { loadManifest } from '../../compiler/manifest';
21
21
  import {
22
+ DECK_ROW_CLASS,
22
23
  isLessonSlug,
23
24
  lessonIdFromSlug,
24
25
  docsSlugsForRoute,
@@ -36,6 +37,8 @@ import CollapsibleSections from './CollapsibleSections.astro';
36
37
  import TableOverflowExpander from '../table/TableOverflowExpander.astro';
37
38
  import DiagramEnhancer from './DiagramEnhancer.astro';
38
39
  import PrintController from './PrintController.astro';
40
+ import DeckEnhancer from '../deck/DeckEnhancer.astro';
41
+ import { deckNeighbours, deckOfRoute } from '../deck/deck-context';
39
42
  import {
40
43
  derivedNotationForPage,
41
44
  notationDefinitionsForRenderedPage,
@@ -54,7 +57,62 @@ const routeSlugs = docsSlugsForRoute(routeSlug);
54
57
  const pageId =
55
58
  lessonId ?? manifest.pages.find((page) => routeSlugs.includes(page.slug))?.id;
56
59
 
60
+ // Next and Previous step from chapter to chapter: a chapter's slides are a
61
+ // link of their own under it in the sidebar, not a page of the reading order.
62
+ // On a deck's page they step from deck to deck instead, each named
63
+ // "Slides: *title*" (`deckNeighbours`): a reader of the slides reads on in
64
+ // the slides.
65
+ {
66
+ type SidebarEntry = {
67
+ type: string;
68
+ label?: string;
69
+ href?: string;
70
+ attrs?: Record<string, unknown>;
71
+ isCurrent?: boolean;
72
+ entries?: SidebarEntry[];
73
+ };
74
+ const route = Astro.locals.starlightRoute as unknown as {
75
+ sidebar: SidebarEntry[];
76
+ pagination: { prev?: SidebarEntry; next?: SidebarEntry };
77
+ };
78
+ const links: SidebarEntry[] = [];
79
+ const flatten = (entries: readonly SidebarEntry[]) => {
80
+ for (const entry of entries) {
81
+ if (entry.type === 'group') flatten(entry.entries ?? []);
82
+ else links.push(entry);
83
+ }
84
+ };
85
+ flatten(route.sidebar ?? []);
86
+ const isDeckRow = (entry?: SidebarEntry) =>
87
+ String(entry?.attrs?.class ?? '').split(/\s+/).includes(DECK_ROW_CLASS);
88
+ const current = links.findIndex((entry) => entry.isCurrent);
89
+ const beyond = (direction: 1 | -1) => {
90
+ for (let index = current + direction; index >= 0 && index < links.length; index += direction) {
91
+ if (!isDeckRow(links[index])) return links[index];
92
+ }
93
+ return undefined;
94
+ };
95
+ if (current >= 0 && route.pagination) {
96
+ if (links[current]?.attrs?.['data-edition'] === 'slides') {
97
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
98
+ const titleByHref = new Map(
99
+ (manifest.decks ?? []).map((deck) => [`${base}/${deck.slug}`, deck.title]),
100
+ );
101
+ const { prev, next } = deckNeighbours(links, current, (link) =>
102
+ titleByHref.get(String(link.href ?? '').replace(/\/$/, '')),
103
+ );
104
+ route.pagination.prev = prev;
105
+ route.pagination.next = next;
106
+ } else {
107
+ if (isDeckRow(route.pagination.next)) route.pagination.next = beyond(1);
108
+ if (isDeckRow(route.pagination.prev)) route.pagination.prev = beyond(-1);
109
+ }
110
+ }
111
+ }
112
+
57
113
  const glossaryBase = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`;
114
+ // A lesson's deck: its frames, Present mode and the presenter window.
115
+ const deck = deckOfRoute(manifest, routeSlug);
58
116
 
59
117
  const definitions: NotationLayerDefinition[] = [];
60
118
  let notationDefaultOpen = false;
@@ -164,6 +222,7 @@ if (pageId) {
164
222
  {lessonId && <CollapsibleSections />}
165
223
  {lessonId && <TableOverflowExpander />}
166
224
  <DiagramEnhancer />
225
+ {deck && <DeckEnhancer />}
167
226
  {lessonId && <HeadingAnchorEnhancer />}
168
227
  {
169
228
  pageId && manifest.lessonExports.print && (
@@ -0,0 +1,114 @@
1
+ ---
2
+ /**
3
+ * The one question explico asks a reader (`session/reading-memory.ts`): may
4
+ * this course remember their reading in this browser — which chapters they
5
+ * opened, and the explanation cards they keep or carry, across visits?
6
+ *
7
+ * It sits at the bottom of the window, over nothing the reader needs, until
8
+ * they answer, on every page; the two answers are the same size and weight.
9
+ * Before an answer nothing of their reading is kept beyond the tab, so
10
+ * ignoring the question is the same as "Don't remember" for now. The answer
11
+ * stands for 13 months; the `[?]` panel changes it or forgets everything.
12
+ *
13
+ * Rendered once per page by the header, with the course's `readingMemory`
14
+ * setting and base path on `[data-explico-reading-memory]`, which every
15
+ * reading-memory reader on the page looks at. A course with the setting
16
+ * `off` gets the element but never the question. Without JavaScript nothing
17
+ * is stored at all, so the question stays hidden.
18
+ */
19
+ import { loadManifest } from '../../compiler/manifest';
20
+
21
+ const manifest = await loadManifest();
22
+ const mode = manifest.readingMemory ?? 'ask';
23
+ const base = import.meta.env.BASE_URL;
24
+ ---
25
+
26
+ <div
27
+ data-explico-reading-memory={mode}
28
+ data-explico-base={base}
29
+ hidden
30
+ >
31
+ </div>
32
+ {
33
+ mode === 'ask' && (
34
+ <section
35
+ class="explico-reading-notice"
36
+ aria-label="Remember your reading?"
37
+ data-explico-reading-notice
38
+ hidden
39
+ >
40
+ <p class="explico-reading-notice__text">
41
+ <strong>Remember your reading?</strong> This course can keep, in this
42
+ browser only, which chapters you open, which slides you present and
43
+ the explanation cards you keep open, so it can mark new and changed
44
+ chapters and slides and bring your cards back next time. No cookies;
45
+ nothing is sent anywhere.
46
+ </p>
47
+ <div class="explico-reading-notice__actions">
48
+ <button type="button" data-reading-answer="yes">
49
+ Remember
50
+ </button>
51
+ <button type="button" data-reading-answer="no">
52
+ Don't remember
53
+ </button>
54
+ </div>
55
+ <p class="explico-reading-notice__note">
56
+ Your settings, such as the theme and the text size, are kept either
57
+ way. Change your answer any time with the{' '}
58
+ <span aria-hidden="true">?</span><span class="sr-only">About</span>{' '}
59
+ button at the top of the page.
60
+ </p>
61
+ </section>
62
+ )
63
+ }
64
+
65
+ <script>
66
+ import {
67
+ answerReadingMemory,
68
+ readingAnswer,
69
+ readingMemoryMode,
70
+ READING_MEMORY_EVENT,
71
+ } from '../../session/reading-memory';
72
+
73
+ (() => {
74
+ const config = document.querySelector<HTMLElement>(
75
+ '[data-explico-reading-memory]',
76
+ );
77
+ const notice = document.querySelector<HTMLElement>(
78
+ '[data-explico-reading-notice]',
79
+ );
80
+ if (!config || !notice) return;
81
+ // A standalone export is one page with no other pages to remember.
82
+ if (document.documentElement.hasAttribute('data-explico-standalone')) return;
83
+ const base = config.dataset.explicoBase ?? '/';
84
+ // Out of the header, whose own positioning would otherwise hold it: a
85
+ // fixed box at the window's bottom, last in the reading order.
86
+ document.body.append(notice);
87
+
88
+ const sync = () => {
89
+ notice.hidden =
90
+ readingMemoryMode() !== 'ask' || readingAnswer(base) !== 'unasked';
91
+ };
92
+
93
+ notice.addEventListener('click', (event) => {
94
+ const button =
95
+ event.target instanceof Element
96
+ ? event.target.closest<HTMLButtonElement>('button[data-reading-answer]')
97
+ : null;
98
+ if (!button) return;
99
+ const hadFocus = notice.contains(document.activeElement);
100
+ answerReadingMemory(base, button.dataset.readingAnswer === 'yes');
101
+ // The question goes; focus goes back to the page, not to nowhere.
102
+ if (hadFocus) {
103
+ const title = document.querySelector<HTMLElement>('main h1');
104
+ if (title) {
105
+ title.tabIndex = -1;
106
+ title.focus({ preventScroll: true });
107
+ }
108
+ }
109
+ });
110
+
111
+ window.addEventListener(READING_MEMORY_EVENT, sync);
112
+ sync();
113
+ })();
114
+ </script>
@@ -1,24 +1,32 @@
1
1
  ---
2
2
  /**
3
- * The reader's text size on a touch screen: 100%, 90%, 80% or 70% of the
4
- * site's own. A PC has the browser's zoom for this; a phone's text-size
5
- * settings are buried in its browser's menus, so the page offers its own.
6
- * It sits beside the theme switch (in the phone menu, and in the header
7
- * of a tablet held wide) and shows only where there is no hover.
3
+ * The reader's text size on a touch screen: 50% to 100% of the site's own, in
4
+ * five steps of a geometric series (each 2^(1/4), about 1.19, times the one
5
+ * below: 50, 59, 71, 84, 100). A PC has the browser's zoom for this; a
6
+ * phone's text-size settings are buried in its browser's menus, so the page
7
+ * offers its own. It sits beside the theme switch (in the phone menu, and in
8
+ * the header of a tablet held wide) and shows only where there is no hover.
8
9
  *
9
10
  * The choice scales the root font size (`data-text-size` on `<html>`,
10
11
  * `global.css`), so the text, the math, the spacing and the cards all
11
- * follow. It is remembered per reader and copied onto the root before the
12
- * page first paints by the inline script below, so the page never jumps
13
- * from one size to another. `LayoutSocialIcons.astro` renders this in the
12
+ * follow, and sets `--explico-text-scale` for what is drawn in px, a
13
+ * diagram, which `Diagram.astro` scales as a whole. `global.css` has one rule per step: the end-to-end
14
+ * test holds it to this list. It is remembered per reader and copied onto
15
+ * the root before the page first paints by the inline script below, so the
16
+ * page never jumps from one size to another. A size an earlier release
17
+ * offered and this one does not (90, 80, 70) is ignored: the page shows 100%. `LayoutSocialIcons.astro` renders this in the
14
18
  * header and in the phone menu; both copies stay in step.
15
19
  */
16
20
  import { Icon } from '@astrojs/starlight/components';
17
21
  import { inlineRootAttributeScript } from '../../session/PreferenceStore';
18
22
 
19
23
  const TEXT_SIZE_KEY = 'explico:display:text-size';
20
- const SIZES = ['100', '90', '80', '70'] as const;
21
- const boot = inlineRootAttributeScript(TEXT_SIZE_KEY, 'data-text-size', SIZES.slice(1));
24
+ const STEPS = 4;
25
+ /** 50, 59, 71, 84, 100: the series' ratio is 2^(1/STEPS), and its end is 100. */
26
+ const SIZES = Array.from({ length: STEPS + 1 }, (_, step) =>
27
+ String(Math.round(100 * 2 ** ((step - STEPS) / STEPS))),
28
+ );
29
+ const boot = inlineRootAttributeScript(TEXT_SIZE_KEY, 'data-text-size', SIZES.slice(0, -1));
22
30
  ---
23
31
 
24
32
  <script is:inline set:html={boot} />
@@ -35,15 +43,18 @@ const boot = inlineRootAttributeScript(TEXT_SIZE_KEY, 'data-text-size', SIZES.sl
35
43
  import { createBrowserPreferenceStore } from '../../session/PreferenceStore';
36
44
 
37
45
  const KEY = 'explico:display:text-size';
38
- const SIZES = new Set(['100', '90', '80', '70']);
39
46
  const preferences = createBrowserPreferenceStore();
40
47
  const root = document.documentElement;
41
48
  const selects = () =>
42
49
  Array.from(document.querySelectorAll<HTMLSelectElement>('[data-text-size-select]'));
43
50
 
51
+ // The steps are the options the page rendered, so the list lives in one place.
52
+ const sizes = (): Set<string> =>
53
+ new Set(selects().flatMap((select) => Array.from(select.options, (option) => option.value)));
54
+
44
55
  const current = (): string => {
45
56
  const value = root.getAttribute('data-text-size') ?? '100';
46
- return SIZES.has(value) ? value : '100';
57
+ return sizes().has(value) ? value : '100';
47
58
  };
48
59
 
49
60
  const show = (value: string) => {
@@ -51,7 +62,7 @@ const boot = inlineRootAttributeScript(TEXT_SIZE_KEY, 'data-text-size', SIZES.sl
51
62
  };
52
63
 
53
64
  const choose = (value: string) => {
54
- if (!SIZES.has(value)) return;
65
+ if (!sizes().has(value)) return;
55
66
  if (value === '100') {
56
67
  root.removeAttribute('data-text-size');
57
68
  preferences.remove?.(KEY);
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The sidebar's marks for one reader (`sidebarMarks` in the course settings):
3
+ * a quiet ring beside every chapter and part page they have not opened yet,
4
+ * and an accent dot beside every one whose content changed since they last
5
+ * opened it.
6
+ *
7
+ * A page is named by its slug, and its content by the hash rule C5 keeps for
8
+ * it in `content/versions.json` (the current one, `pending ?? source`), cut to
9
+ * 12 hex digits. That hash moves with every change to what the page shows —
10
+ * its text, its checks, the shared notation and sources it uses, its numbers
11
+ * — and not when a release only stamps a version on it. So "changed" means
12
+ * "not what this reader saw", which an unreleased change on the live site
13
+ * already is, and a release of something they had read is not.
14
+ *
15
+ * What a reader has seen is view state, the same kind as an unread mark, not
16
+ * learning progress: it claims nothing about what they learned. It is one
17
+ * small JSON map per course, `{ "v": 1, "seen": { slug: id } }`, kept through
18
+ * the `PreferenceStore` under a key scoped by the site's base path, since
19
+ * several courses can share one origin. It is reading memory
20
+ * (`../../session/reading-memory.ts`): recorded only after the reader said
21
+ * yes to the course keeping it, and deleted with a no. Pure: `LayoutSidebar.astro` does the
22
+ * DOM and the storage, and `tests/unit/page-marks.test.ts` checks this in Node.
23
+ */
24
+
25
+ /** One chapter or part page the sidebar can mark. */
26
+ export interface TrackedPage {
27
+ /** The sidebar link's path, base included: `/course/motion/constant-acceleration/`. */
28
+ readonly href: string;
29
+ /** The docs slug: `motion/constant-acceleration`, `motion/index`. */
30
+ readonly slug: string;
31
+ /** The content's identity: the first 12 hex digits of its rule C5 hash. */
32
+ readonly id: string;
33
+ }
34
+
35
+ export type PageMark = 'unopened' | 'changed';
36
+
37
+ /** What `LayoutSidebar.astro` hands its script, as JSON. */
38
+ export interface PageMarksPayload {
39
+ /** The storage key, scoped by the site's base path. */
40
+ readonly key: string;
41
+ /** The site's base path, which names the reader's reading-memory answer. */
42
+ readonly base: string;
43
+ /** The course's `sidebarMarks` setting. */
44
+ readonly unopened: boolean;
45
+ readonly changed: boolean;
46
+ readonly pages: readonly TrackedPage[];
47
+ /**
48
+ * Every deck and its frames' ids now, which the legend's two actions cover
49
+ * too (D9, `../../session/deck-memory.ts`): `frames[i]` is frame `i + 1`'s id.
50
+ */
51
+ readonly decks?: readonly { readonly slug: string; readonly frames: readonly string[] }[];
52
+ }
53
+
54
+ /** Bumped whenever the stored shape changes; a record of another version is dropped. */
55
+ const VERSION = 1;
56
+ const ID = /^[0-9a-f]{12}$/u;
57
+
58
+ /** The storage key for a site whose base path is `base` (`/`, `/course/`). */
59
+ export function pageMarksKey(base: string): string {
60
+ return `explico-page-marks:${base}`;
61
+ }
62
+
63
+ /** The identity of a page's content: 12 hex digits of its `sha256:…` hash. */
64
+ export function pageMarkId(hash: string): string {
65
+ return hash.replace(/^sha256:/u, '').slice(0, 12);
66
+ }
67
+
68
+ /**
69
+ * The pages the reader has seen, by slug, from the stored text. Anything
70
+ * that is not a record of this version, and any entry that is not a slug
71
+ * and an id, is dropped, never trusted.
72
+ */
73
+ export function decodeSeen(raw: string | null): Map<string, string> {
74
+ const seen = new Map<string, string>();
75
+ if (!raw) return seen;
76
+ let parsed: unknown;
77
+ try {
78
+ parsed = JSON.parse(raw);
79
+ } catch {
80
+ return seen;
81
+ }
82
+ if (!parsed || typeof parsed !== 'object') return seen;
83
+ const { v, seen: entries } = parsed as { v?: unknown; seen?: unknown };
84
+ if (v !== VERSION || !entries || typeof entries !== 'object' || Array.isArray(entries)) return seen;
85
+ for (const [slug, id] of Object.entries(entries as Record<string, unknown>)) {
86
+ if (typeof id === 'string' && ID.test(id) && slug.length > 0) seen.set(slug, id);
87
+ }
88
+ return seen;
89
+ }
90
+
91
+ /** The stored text, slugs sorted so the same state is always the same string. */
92
+ export function encodeSeen(seen: ReadonlyMap<string, string>): string {
93
+ const entries = [...seen].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
94
+ return JSON.stringify({ v: VERSION, seen: Object.fromEntries(entries) });
95
+ }
96
+
97
+ /**
98
+ * The record after the reader opened `current` (none for a page the sidebar
99
+ * does not mark), keeping only the pages the course still has.
100
+ */
101
+ export function withVisit(
102
+ seen: ReadonlyMap<string, string>,
103
+ current: TrackedPage | undefined,
104
+ pages: readonly TrackedPage[],
105
+ ): Map<string, string> {
106
+ const known = new Set(pages.map((page) => page.slug));
107
+ const next = new Map([...seen].filter(([slug]) => known.has(slug)));
108
+ if (current) next.set(current.slug, current.id);
109
+ return next;
110
+ }
111
+
112
+ /** The record with every page seen as it is now: "Mark all as seen". */
113
+ export function withAllSeen(pages: readonly TrackedPage[]): Map<string, string> {
114
+ return new Map(pages.map((page) => [page.slug, page.id]));
115
+ }
116
+
117
+ /**
118
+ * The record with no page seen: "Mark all as unseen". The page open when the
119
+ * reader asks is recorded again by {@link withVisit}, since it is open.
120
+ */
121
+ export function withNoneSeen(): Map<string, string> {
122
+ return new Map();
123
+ }
124
+
125
+ /** A page's mark for a reader who has seen `seen`, under the course's setting. */
126
+ export function markOf(
127
+ page: TrackedPage,
128
+ seen: ReadonlyMap<string, string>,
129
+ options: { readonly unopened: boolean; readonly changed: boolean },
130
+ ): PageMark | undefined {
131
+ const id = seen.get(page.slug);
132
+ if (id === undefined) return options.unopened ? 'unopened' : undefined;
133
+ return id !== page.id && options.changed ? 'changed' : undefined;
134
+ }
135
+
136
+ /** A path as the sidebar compares it: no trailing slash, `/` for the root. */
137
+ export function samePath(href: string): string {
138
+ return href.replace(/\/+$/u, '') || '/';
139
+ }
@@ -13,7 +13,7 @@
13
13
  * `hiddenContentParts` / `describeHiddenContent` turn what it found into the
14
14
  * reader-facing line.
15
15
  */
16
- import { FLOAT_KIND_WORDS, floatOf, type NamedFloat } from '../float-name';
16
+ import { FLOAT_KIND_WORDS, floatOf, type NamedFloat } from '../../reference/float-name';
17
17
 
18
18
  export interface HiddenContentCounts {
19
19
  readonly subsections: number;