@erclx/canon 5.51.0 → 5.53.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 (142) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -4
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +0 -3
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-taste/references/kinds.md +0 -1
  7. package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
  8. package/claude/skills/draw-figure/SKILL.md +4 -5
  9. package/claude/skills/draw-figure/references/figures.md +0 -1
  10. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  11. package/claude/skills/git-pr/references/pr.md +1 -1
  12. package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
  13. package/claude/skills/pick-by-eye/SKILL.md +1 -1
  14. package/claude/skills/review-address/SKILL.md +1 -1
  15. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  16. package/claude/skills/review-pr/references/post.md +2 -2
  17. package/claude/skills/target-check/SKILL.md +2 -0
  18. package/claude/skills/target-setup/SKILL.md +1 -1
  19. package/claude/skills/target-update/REQUIREMENT.md +33 -0
  20. package/claude/skills/target-update/SKILL.md +136 -0
  21. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  22. package/claude/skills/teach-craft/SKILL.md +39 -0
  23. package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
  24. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
  25. package/docs/agents/audits.md +0 -1
  26. package/docs/agents/migrate.md +2 -1
  27. package/docs/target/migrations.md +12 -1
  28. package/docs/target/projects.md +1 -1
  29. package/docs/target/sync.md +1 -1
  30. package/docs/workflow/parallel-features.md +1 -1
  31. package/docs/workflow/skill-map-on-request.md +10 -22
  32. package/docs/workflow/skill-map.md +13 -14
  33. package/package.json +1 -4
  34. package/scripts/core/regen-web-previews.ts +2 -67
  35. package/src/audits/catalog.ts +0 -1
  36. package/src/browser/engine.ts +3 -6
  37. package/src/capture/render.ts +1 -1
  38. package/src/claude/cases/authoring.ts +0 -12
  39. package/src/claude/cases/misc.ts +0 -17
  40. package/src/claude/cases/setup.ts +7 -0
  41. package/src/claude/cases/workflow.ts +2 -38
  42. package/src/claude/skills-audit.ts +0 -1
  43. package/src/cli/help.ts +0 -7
  44. package/src/cli.ts +0 -6
  45. package/src/commands/labels.ts +9 -7
  46. package/src/commands/records.ts +0 -1
  47. package/src/commands/serve.ts +2 -2
  48. package/src/design/components.ts +8 -1514
  49. package/src/design/css.ts +5 -6
  50. package/src/design/fonts.ts +3 -5
  51. package/src/driver/drive.ts +3 -4
  52. package/src/driver/steps.ts +2 -5
  53. package/src/inventory/walk.ts +1 -1
  54. package/src/markdown/scope.ts +4 -31
  55. package/src/migrate/status.ts +5 -1
  56. package/src/records/validate.ts +4 -176
  57. package/src/sandbox/equivalence.ts +0 -5
  58. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
  59. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  60. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  61. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  62. package/claude/skills/draft-doc/SKILL.md +0 -11
  63. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  64. package/claude/skills/draft-figure/SKILL.md +0 -11
  65. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  66. package/claude/skills/draft-identity/SKILL.md +0 -11
  67. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  68. package/claude/skills/draft-ready/SKILL.md +0 -11
  69. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  70. package/claude/skills/draft-screencast/SKILL.md +0 -11
  71. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  72. package/claude/skills/draft-slides/SKILL.md +0 -11
  73. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  74. package/claude/skills/make-deck/SKILL.md +0 -80
  75. package/claude/skills/make-deck/references/deck.md +0 -183
  76. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  77. package/claude/skills/read-frames/SKILL.md +0 -55
  78. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  79. package/claude/skills/record-screencast/SKILL.md +0 -99
  80. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  81. package/claude/skills/script-screencast/SKILL.md +0 -70
  82. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  83. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  84. package/claude/skills/teach-workspace/SKILL.md +0 -196
  85. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  86. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  87. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  88. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  89. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  90. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  91. package/claude/skills/teach-workspace/references/teach.md +0 -211
  92. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  93. package/claude/skills/video-craft/SKILL.md +0 -96
  94. package/claude/skills/video-craft/references/adopted.md +0 -110
  95. package/src/commands/demo.ts +0 -542
  96. package/src/commands/slides.ts +0 -184
  97. package/src/commands/teach.ts +0 -1401
  98. package/src/demo/beats.ts +0 -135
  99. package/src/demo/compile.ts +0 -350
  100. package/src/demo/container.ts +0 -219
  101. package/src/demo/cursors.ts +0 -55
  102. package/src/demo/drive.ts +0 -442
  103. package/src/demo/pointer.ts +0 -178
  104. package/src/demo/theme.ts +0 -112
  105. package/src/demo/timeline.ts +0 -64
  106. package/src/slides/convert/chart.ts +0 -165
  107. package/src/slides/convert/deck.ts +0 -244
  108. package/src/slides/convert/export.ts +0 -567
  109. package/src/slides/convert/master.ts +0 -198
  110. package/src/slides/convert/shapes.ts +0 -804
  111. package/src/slides/convert/svg.ts +0 -38
  112. package/src/slides/convert/walk.ts +0 -671
  113. package/src/slides/decks.ts +0 -110
  114. package/src/slides/open.ts +0 -18
  115. package/src/slides/package/fonts.ts +0 -301
  116. package/src/slides/package/motion.ts +0 -316
  117. package/src/slides/package/test-face.ts +0 -113
  118. package/src/teach/browser/compile.ts +0 -44
  119. package/src/teach/browser/dismiss.ts +0 -76
  120. package/src/teach/browser/focus-line.ts +0 -19
  121. package/src/teach/browser/glossary-filter.ts +0 -44
  122. package/src/teach/browser/head.ts +0 -30
  123. package/src/teach/browser/quiz.ts +0 -19
  124. package/src/teach/browser/reload.ts +0 -9
  125. package/src/teach/browser/sidebar.ts +0 -233
  126. package/src/teach/browser/theme.ts +0 -18
  127. package/src/teach/components/code.tsx +0 -24
  128. package/src/teach/components/heading.tsx +0 -11
  129. package/src/teach/components/list.tsx +0 -17
  130. package/src/teach/components/paragraph.tsx +0 -29
  131. package/src/teach/components/refs.tsx +0 -28
  132. package/src/teach/cross-references.ts +0 -78
  133. package/src/teach/fonts.ts +0 -16
  134. package/src/teach/highlight.ts +0 -71
  135. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  136. package/src/teach/html/jsx-runtime.ts +0 -113
  137. package/src/teach/html/raw.ts +0 -28
  138. package/src/teach/lesson.ts +0 -178
  139. package/src/teach/nav.ts +0 -1337
  140. package/src/teach/render.ts +0 -240
  141. package/src/teach/workspace.ts +0 -1164
  142. package/src/teach/write-if-changed.ts +0 -17
package/src/demo/theme.ts DELETED
@@ -1,112 +0,0 @@
1
- import { readdirSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { cursorHotspot, type Cursor, type CursorSet } from '@/demo/pointer'
4
-
5
- /**
6
- * Reads a cursor theme folder so `--cursor` can point at one. Spike 3 measured
7
- * the browser decoding a Windows cursor resource directly, so nothing here
8
- * converts anything: the bytes go into a data URI and the header supplies the
9
- * hotspot.
10
- *
11
- * Three of the nineteen states a theme carries are read. A drag, a resize, or a
12
- * wait shows the default arrow where a real session would show something else,
13
- * and the two animated states have no still frame to draw at all.
14
- */
15
-
16
- const CURSOR_MIME = 'image/x-icon'
17
- const EXTENSION = /\.cur$/i
18
-
19
- /** Ordered per state, most specific first, so an exact name beats a longer one. */
20
- const NAMES: Record<string, readonly string[]> = {
21
- default: ['arrow', 'normal', 'default'],
22
- pointer: ['link', 'hand', 'pointer'],
23
- text: ['ibeam', 'beam', 'text'],
24
- }
25
-
26
- export type CursorFiles = Partial<Record<string, string>>
27
-
28
- /**
29
- * Picks one file per state out of a directory listing. Matching runs on the
30
- * name rather than on the contents because a theme states its intent there, and
31
- * a shorter match wins so `Arrow.cur` beats `Arrow Alternate.cur`.
32
- */
33
- export function matchCursorFiles(files: readonly string[]): CursorFiles {
34
- const cursors = files.filter((file) => EXTENSION.test(file))
35
- const matched: CursorFiles = {}
36
-
37
- for (const [state, names] of Object.entries(NAMES)) {
38
- const found = names
39
- .flatMap((name) =>
40
- cursors.filter((file) => file.toLowerCase().includes(name)),
41
- )
42
- .sort((left, right) => left.length - right.length)[0]
43
- if (found) matched[state] = found
44
- }
45
-
46
- return matched
47
- }
48
-
49
- export type ThemeLoad =
50
- | { status: 'loaded'; cursors: CursorSet; states: string[] }
51
- | { status: 'failed'; reason: string }
52
-
53
- /**
54
- * Falls back to the bundled artwork per state rather than per theme, so a
55
- * folder carrying an arrow and no hand still contributes its arrow.
56
- */
57
- export function loadCursorTheme(dir: string, fallback: CursorSet): ThemeLoad {
58
- let listing: string[]
59
- try {
60
- listing = readdirNames(dir)
61
- } catch (error) {
62
- return {
63
- status: 'failed',
64
- reason: `${dir} could not be read: ${message(error)}`,
65
- }
66
- }
67
-
68
- const matched = matchCursorFiles(listing)
69
- const states = Object.keys(matched)
70
- if (!states.length) {
71
- return {
72
- status: 'failed',
73
- reason: `${dir} holds no .cur file named after a pointer, link, or text state`,
74
- }
75
- }
76
-
77
- const cursors: Record<string, Cursor> = { ...fallback }
78
- for (const [state, file] of Object.entries(matched)) {
79
- if (!file) continue
80
- const loaded = readCursor(join(dir, file))
81
- if (loaded) cursors[state] = loaded
82
- }
83
-
84
- return { status: 'loaded', cursors, states }
85
- }
86
-
87
- function readCursor(path: string): Cursor | undefined {
88
- let bytes: Uint8Array
89
- try {
90
- bytes = readFileSync(path)
91
- } catch {
92
- return undefined
93
- }
94
-
95
- const hotspot = cursorHotspot(bytes)
96
- if (!hotspot) return undefined
97
-
98
- return {
99
- image: `data:${CURSOR_MIME};base64,${Buffer.from(bytes).toString('base64')}`,
100
- hotspot,
101
- }
102
- }
103
-
104
- function readdirNames(dir: string): string[] {
105
- return readdirSync(dir, { withFileTypes: true })
106
- .filter((entry) => entry.isFile())
107
- .map((entry) => entry.name)
108
- }
109
-
110
- function message(error: unknown): string {
111
- return error instanceof Error ? error.message : String(error)
112
- }
@@ -1,64 +0,0 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
2
- import { basename, dirname, extname, join } from 'node:path'
3
- import type { DemoStep } from '@/demo/compile'
4
-
5
- /**
6
- * A step's target in viewport pixels, which are the video frame's own
7
- * coordinates because the recording is sized to the viewport.
8
- */
9
- export interface TargetBox {
10
- readonly x: number
11
- readonly y: number
12
- readonly width: number
13
- readonly height: number
14
- }
15
-
16
- /** One step as the run clocked it, in wall-clock milliseconds. */
17
- export interface StepTiming {
18
- readonly index: number
19
- readonly kind: DemoStep['kind']
20
- readonly startedAt: number
21
- readonly endedAt: number
22
- readonly box?: TargetBox
23
- }
24
-
25
- /**
26
- * Times count from the recording's start rather than the first step, since
27
- * that is the clock a composition places the take on. A step ends once its
28
- * hold has played, so the span covers what the viewer sees of it.
29
- */
30
- export interface TimelineEntry {
31
- readonly index: number
32
- readonly kind: DemoStep['kind']
33
- readonly startMs: number
34
- readonly endMs: number
35
- readonly box?: TargetBox
36
- }
37
-
38
- export function timelineEntry(
39
- timing: StepTiming,
40
- recordingStart: number,
41
- ): TimelineEntry {
42
- return {
43
- index: timing.index,
44
- kind: timing.kind,
45
- startMs: Math.round(timing.startedAt - recordingStart),
46
- endMs: Math.round(timing.endedAt - recordingStart),
47
- ...(timing.box ? { box: timing.box } : {}),
48
- }
49
- }
50
-
51
- export function timelinePath(videoPath: string): string {
52
- return join(
53
- dirname(videoPath),
54
- `${basename(videoPath, extname(videoPath))}.timeline.json`,
55
- )
56
- }
57
-
58
- export function writeTimeline(
59
- path: string,
60
- entries: readonly TimelineEntry[],
61
- ): void {
62
- mkdirSync(dirname(path), { recursive: true })
63
- writeFileSync(path, `${JSON.stringify({ entries }, null, 2)}\n`)
64
- }
@@ -1,165 +0,0 @@
1
- import type PptxGenJS from 'pptxgenjs'
2
- import { position, type Rect } from '@/slides/convert/shapes'
3
-
4
- /**
5
- * A `<figure data-chart="...">` holding a `<table>` lands as a native chart.
6
- * The table stays the page's accessible source and is not also drawn. Its
7
- * header row names the series and its first column names the categories.
8
- */
9
-
10
- export const CHART_TYPES = ['bar', 'line', 'area', 'pie', 'doughnut'] as const
11
- export type ChartType = (typeof CHART_TYPES)[number]
12
-
13
- export interface ChartRecord {
14
- readonly selector: string
15
- readonly box: Rect
16
- /** The raw `data-chart` value, checked against `CHART_TYPES` here. */
17
- readonly type: string
18
- readonly hasLabels: boolean
19
- /** The figure's `<figcaption>`, when it has one. */
20
- readonly title?: string
21
- readonly rows: readonly (readonly string[])[]
22
- }
23
-
24
- export interface ChartTheme {
25
- /** Series colors in order, repeated past the last. */
26
- readonly colors: readonly string[]
27
- readonly ink: string
28
- readonly muted: string
29
- readonly face: string
30
- }
31
-
32
- export interface ChartSeries {
33
- readonly name: string
34
- readonly labels: readonly string[]
35
- /** `null` is an empty cell, which the chart draws as a gap. */
36
- readonly values: readonly (number | null)[]
37
- }
38
-
39
- export type ChartPlan =
40
- | {
41
- readonly status: 'chart'
42
- readonly type: ChartType
43
- readonly data: readonly ChartSeries[]
44
- readonly options: PptxGenJS.IChartOpts
45
- }
46
- | {
47
- readonly status: 'refused'
48
- readonly selector: string
49
- readonly message: string
50
- }
51
-
52
- export interface ColorRole {
53
- readonly name: string
54
- readonly hex: string
55
- }
56
-
57
- /** The grounds a series would vanish into. */
58
- const GROUND_ROLES = new Set(['--color-background', '--color-surface'])
59
-
60
- const NUMBER = /^-?(\d+\.?\d*|\.\d+)$/
61
-
62
- /**
63
- * A comma counts as a thousands separator only where it groups in threes, so a
64
- * comma-decimal `3,5` refuses rather than reading as 35.
65
- */
66
- const GROUPED = /^-?\d{1,3}(,\d{3})+(\.\d+)?$/
67
-
68
- const isChartType = (value: string): value is ChartType =>
69
- (CHART_TYPES as readonly string[]).includes(value)
70
-
71
- /**
72
- * The accent, then every other declared color role in token order except the
73
- * grounds, each color once. No project declares a chart palette, so the
74
- * project's own roles stand in for one.
75
- */
76
- export function seriesColors(
77
- accent: string,
78
- roles: readonly ColorRole[],
79
- ): string[] {
80
- const colors = [accent]
81
- for (const role of roles) {
82
- if (GROUND_ROLES.has(role.name) || colors.includes(role.hex)) continue
83
- colors.push(role.hex)
84
- }
85
- return colors
86
- }
87
-
88
- function cycle(colors: readonly string[], count: number): string[] {
89
- return Array.from(
90
- { length: count },
91
- (_, index) => colors[index % colors.length] ?? '',
92
- )
93
- }
94
-
95
- export function planChart(record: ChartRecord, theme: ChartTheme): ChartPlan {
96
- const refuse = (message: string): ChartPlan => ({
97
- status: 'refused',
98
- selector: record.selector,
99
- message,
100
- })
101
- if (!isChartType(record.type)) {
102
- return refuse(
103
- `data-chart "${record.type}" is not one of ${CHART_TYPES.join(', ')}`,
104
- )
105
- }
106
- const [header = [], ...body] = record.rows
107
- if (header.length < 2 || body.length === 0) {
108
- return refuse(
109
- 'the table needs a header row and at least one data row of two cells',
110
- )
111
- }
112
-
113
- const labels = body.map((row) => row[0] ?? '')
114
- const data: ChartSeries[] = []
115
- for (let column = 1; column < header.length; column += 1) {
116
- const values: (number | null)[] = []
117
- for (const [index, row] of body.entries()) {
118
- const text = (row[column] ?? '').trim()
119
- if (text === '') {
120
- values.push(null)
121
- continue
122
- }
123
- const bare = GROUPED.test(text) ? text.replace(/,/g, '') : text
124
- if (!NUMBER.test(bare)) {
125
- return refuse(
126
- `cell "${text}" at row ${index + 2}, column ${column + 1} is not a number`,
127
- )
128
- }
129
- values.push(Number(bare))
130
- }
131
- data.push({ name: header[column] ?? '', labels, values })
132
- }
133
-
134
- const isRound = record.type === 'pie' || record.type === 'doughnut'
135
- return {
136
- status: 'chart',
137
- type: record.type,
138
- data,
139
- options: {
140
- ...position(record.box),
141
- chartColors: cycle(theme.colors, isRound ? labels.length : data.length),
142
- displayBlanksAs: 'gap',
143
- showValue: record.hasLabels,
144
- dataLabelColor: theme.ink,
145
- dataLabelFontFace: theme.face,
146
- showLegend: isRound || data.length > 1,
147
- legendPos: 'b',
148
- legendColor: theme.ink,
149
- legendFontFace: theme.face,
150
- catAxisLabelColor: theme.muted,
151
- catAxisLabelFontFace: theme.face,
152
- valAxisLabelColor: theme.muted,
153
- valAxisLabelFontFace: theme.face,
154
- ...(record.title
155
- ? {
156
- showTitle: true,
157
- title: record.title,
158
- titleColor: theme.ink,
159
- titleFontFace: theme.face,
160
- altText: record.title,
161
- }
162
- : {}),
163
- },
164
- }
165
- }
@@ -1,244 +0,0 @@
1
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
- import { basename, isAbsolute, join, relative, resolve } from 'node:path'
3
-
4
- /**
5
- * What belongs to the deck rather than to one slide, read from `deck.json`
6
- * beside the slides. A slide overrides a band through attributes on its own
7
- * `<body>`, which `walk.ts` reads.
8
- */
9
-
10
- export const DECK_FILE = 'deck.json'
11
-
12
- export const BAND_SLOTS = ['left', 'center', 'right'] as const
13
- export type BandSlot = (typeof BAND_SLOTS)[number]
14
-
15
- export interface Band {
16
- readonly show: boolean
17
- readonly left?: string
18
- readonly center?: string
19
- readonly right?: string
20
- }
21
-
22
- export interface DeckConfig {
23
- readonly title: string
24
- readonly header: Band
25
- readonly footer: Band
26
- /** Drawn at the right of the footer, so they show only where it does. */
27
- readonly slideNumbers: boolean
28
- /** Absolute, resolved against the deck folder. */
29
- readonly mark?: string
30
- /** The faces the package step embeds, in the order the deck lists them. */
31
- readonly fonts: readonly DeckFont[]
32
- }
33
-
34
- export interface DeckFont {
35
- readonly family: string
36
- readonly weight: number
37
- readonly style: 'normal' | 'italic'
38
- /** Absolute, resolved against the deck folder and kept inside it. */
39
- readonly path: string
40
- /** The path as `deck.json` wrote it, which a refusal names. */
41
- readonly source: string
42
- }
43
-
44
- export type DeckRead =
45
- | { readonly status: 'read'; readonly deck: DeckConfig }
46
- | {
47
- readonly status: 'refused'
48
- readonly field: string
49
- readonly message: string
50
- }
51
-
52
- const DECK_FIELDS = new Set([
53
- 'title',
54
- 'header',
55
- 'footer',
56
- 'slideNumbers',
57
- 'mark',
58
- 'fonts',
59
- ])
60
- const BAND_FIELDS = new Set<string>(['show', ...BAND_SLOTS])
61
- const FONT_FIELDS = new Set(['family', 'weight', 'style', 'path'])
62
-
63
- class FieldError extends Error {
64
- constructor(
65
- readonly field: string,
66
- message: string,
67
- ) {
68
- super(message)
69
- }
70
- }
71
-
72
- const isRecord = (value: unknown): value is Record<string, unknown> =>
73
- typeof value === 'object' && value !== null && !Array.isArray(value)
74
-
75
- function rejectUnknown(
76
- record: Record<string, unknown>,
77
- known: ReadonlySet<string>,
78
- prefix: string,
79
- ): void {
80
- const unknown = Object.keys(record).find((key) => !known.has(key))
81
- if (unknown !== undefined) {
82
- throw new FieldError(
83
- `${prefix}${unknown}`,
84
- `unknown field ${prefix}${unknown}`,
85
- )
86
- }
87
- }
88
-
89
- function stringField(value: unknown, field: string): string | undefined {
90
- if (value === undefined) return undefined
91
- if (typeof value !== 'string') {
92
- throw new FieldError(field, `${field} must be a string`)
93
- }
94
- return value
95
- }
96
-
97
- function booleanField(value: unknown, field: string): boolean | undefined {
98
- if (value === undefined) return undefined
99
- if (typeof value !== 'boolean') {
100
- throw new FieldError(field, `${field} must be true or false`)
101
- }
102
- return value
103
- }
104
-
105
- /**
106
- * A declared band merges over its default, so `{ "center": "x" }` keeps the
107
- * footer's title on the left. Declaring any text turns a band on unless it
108
- * also says `show: false`.
109
- */
110
- function band(value: unknown, field: string, fallback: Band): Band {
111
- if (value === undefined) return fallback
112
- if (!isRecord(value)) {
113
- throw new FieldError(field, `${field} must be an object`)
114
- }
115
- rejectUnknown(value, BAND_FIELDS, `${field}.`)
116
- const slots: { -readonly [slot in BandSlot]?: string } = {}
117
- for (const slot of BAND_SLOTS) {
118
- const text = stringField(value[slot], `${field}.${slot}`)
119
- if (text !== undefined) slots[slot] = text
120
- }
121
- const show =
122
- booleanField(value.show, `${field}.show`) ??
123
- (Object.keys(slots).length > 0 || fallback.show)
124
- return { ...fallback, ...slots, show }
125
- }
126
-
127
- function parse(raw: unknown, folder: string): DeckConfig {
128
- if (!isRecord(raw)) {
129
- throw new FieldError(DECK_FILE, `${DECK_FILE} must hold an object`)
130
- }
131
- rejectUnknown(raw, DECK_FIELDS, '')
132
- const title = stringField(raw.title, 'title') ?? basename(folder)
133
- const header = band(raw.header, 'header', { show: false })
134
- const footer = band(raw.footer, 'footer', { show: true, left: title })
135
- const slideNumbers = booleanField(raw.slideNumbers, 'slideNumbers') ?? true
136
- if (slideNumbers && footer.right !== undefined) {
137
- throw new FieldError(
138
- 'footer.right',
139
- 'footer.right is where slide numbers sit. Set slideNumbers to false to use it',
140
- )
141
- }
142
- const markPath = stringField(raw.mark, 'mark')
143
- let mark: string | undefined
144
- if (markPath !== undefined) {
145
- mark = join(folder, markPath)
146
- if (!existsSync(mark) || !statSync(mark).isFile()) {
147
- throw new FieldError('mark', `mark ${mark} does not exist`)
148
- }
149
- }
150
- const fonts = fontList(raw.fonts, folder)
151
- return { title, header, footer, slideNumbers, mark, fonts }
152
- }
153
-
154
- function isWithin(folder: string, path: string): boolean {
155
- const inside = relative(folder, path)
156
- return inside !== '' && !inside.startsWith('..') && !isAbsolute(inside)
157
- }
158
-
159
- /**
160
- * A face is read from the deck folder only, so a `deck.json` cannot point the
161
- * export at a file elsewhere on the machine and carry it out inside the deck.
162
- */
163
- function fontList(value: unknown, folder: string): DeckFont[] {
164
- if (value === undefined) return []
165
- if (!Array.isArray(value)) {
166
- throw new FieldError('fonts', 'fonts must be a list')
167
- }
168
- return value.map((entry: unknown, index): DeckFont => {
169
- const field = `fonts[${index}]`
170
- if (!isRecord(entry)) {
171
- throw new FieldError(field, `${field} must be an object`)
172
- }
173
- rejectUnknown(entry, FONT_FIELDS, `${field}.`)
174
- const family = stringField(entry.family, `${field}.family`)
175
- if (!family) {
176
- throw new FieldError(`${field}.family`, `${field}.family is required`)
177
- }
178
- const weight = entry.weight ?? 400
179
- if (
180
- typeof weight !== 'number' ||
181
- !Number.isInteger(weight) ||
182
- weight < 1 ||
183
- weight > 1000
184
- ) {
185
- throw new FieldError(
186
- `${field}.weight`,
187
- `${field}.weight must be a whole number from 1 to 1000`,
188
- )
189
- }
190
- const style = entry.style ?? 'normal'
191
- if (style !== 'normal' && style !== 'italic') {
192
- throw new FieldError(
193
- `${field}.style`,
194
- `${field}.style must be normal or italic`,
195
- )
196
- }
197
- const source = stringField(entry.path, `${field}.path`)
198
- if (!source) {
199
- throw new FieldError(`${field}.path`, `${field}.path is required`)
200
- }
201
- const path = resolve(folder, source)
202
- const outside = new FieldError(
203
- `${field}.path`,
204
- `${field}.path ${source} lies outside the deck folder`,
205
- )
206
- if (!isWithin(folder, path)) throw outside
207
- if (!existsSync(path) || !statSync(path).isFile()) {
208
- throw new FieldError(
209
- `${field}.path`,
210
- `${field}.path ${path} does not exist`,
211
- )
212
- }
213
- // A link inside the folder can still lead out of it.
214
- if (!isWithin(realpathSync(folder), realpathSync(path))) throw outside
215
- return { family, weight, style, path, source }
216
- })
217
- }
218
-
219
- export function readDeck(folder: string): DeckRead {
220
- const path = join(folder, DECK_FILE)
221
- let raw: unknown = {}
222
- if (existsSync(path)) {
223
- try {
224
- raw = JSON.parse(readFileSync(path, 'utf8'))
225
- } catch (error) {
226
- const reason = error instanceof Error ? error.message : String(error)
227
- return {
228
- status: 'refused',
229
- field: DECK_FILE,
230
- message: `${path}: not valid JSON (${reason})`,
231
- }
232
- }
233
- }
234
- try {
235
- return { status: 'read', deck: parse(raw, folder) }
236
- } catch (error) {
237
- if (!(error instanceof FieldError)) throw error
238
- return {
239
- status: 'refused',
240
- field: error.field,
241
- message: `${path}: ${error.message}`,
242
- }
243
- }
244
- }