@erclx/canon 5.19.0 → 5.21.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 (135) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/references/verb-absent.md +1 -1
  3. package/claude/skills/create-skill/SKILL.md +1 -0
  4. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  6. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  7. package/claude/skills/draft-ready/SKILL.md +6 -4
  8. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  9. package/claude/skills/markdown-propose/SKILL.md +1 -1
  10. package/claude/skills/plan-feature/SKILL.md +1 -1
  11. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  12. package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
  13. package/claude/skills/record-screencast/SKILL.md +2 -2
  14. package/claude/skills/role-worker/REQUIREMENT.md +3 -1
  15. package/claude/skills/role-worker/SKILL.md +5 -3
  16. package/claude/skills/session-resume/SKILL.md +1 -1
  17. package/claude/skills/task-board/REQUIREMENT.md +1 -0
  18. package/claude/skills/task-board/SKILL.md +6 -0
  19. package/claude/skills/task-board/references/decline.md +2 -0
  20. package/claude/skills/video-craft/REQUIREMENT.md +48 -0
  21. package/claude/skills/video-craft/SKILL.md +80 -0
  22. package/claude/skills/video-craft/references/adopted.md +66 -0
  23. package/docs/agents/canvas.md +4 -0
  24. package/docs/agents/commands.md +1 -0
  25. package/docs/agents/demo.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/install-and-sync.md +17 -3
  28. package/docs/agents/markdown-audit.md +1 -3
  29. package/docs/agents/rule-citations.md +4 -4
  30. package/docs/agents/sandbox.md +8 -7
  31. package/docs/agents/slides.md +120 -0
  32. package/docs/agents/tasks-validate.md +1 -1
  33. package/docs/agents/tasks.md +1 -1
  34. package/docs/target/migrations.md +1 -1
  35. package/docs/workflow/ai-workflow.md +1 -1
  36. package/docs/workflow/operating-model.md +9 -9
  37. package/docs/workflow/parallel-features.md +1 -1
  38. package/docs/workflow/skill-map.md +12 -13
  39. package/governance/renames.toml +24 -0
  40. package/governance/rules/lib/330-testing-py.md +1 -1
  41. package/governance/rules/lib/336-testing-php.md +1 -1
  42. package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
  43. package/governance/rules/standards/context.md +15 -0
  44. package/governance/rules/standards/design.md +13 -0
  45. package/governance/rules/standards/diagrams.md +17 -0
  46. package/governance/rules/standards/groundwork.md +13 -0
  47. package/governance/rules/standards/intake.md +12 -0
  48. package/governance/rules/standards/markdown.md +15 -0
  49. package/governance/rules/standards/memory.md +12 -0
  50. package/governance/rules/standards/mermaid.md +15 -0
  51. package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
  52. package/governance/rules/standards/readme.md +12 -0
  53. package/governance/rules/standards/ready.md +13 -0
  54. package/governance/rules/standards/requirements.md +13 -0
  55. package/governance/rules/standards/rule.md +13 -0
  56. package/governance/rules/standards/session.md +13 -0
  57. package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
  58. package/governance/rules/standards/standard.md +13 -0
  59. package/governance/rules/standards/tasks.md +15 -0
  60. package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
  61. package/governance/rules/standards/wiki.md +13 -0
  62. package/governance/rules/standards/wireframes.md +14 -0
  63. package/governance/rules/writing/800-prose.md +1 -1
  64. package/governance/stacks/base.toml +4 -2
  65. package/package.json +1 -1
  66. package/src/canvas/capture.ts +228 -15
  67. package/src/claude/cases/misc.ts +5 -0
  68. package/src/claude/cases/workflow.ts +4 -20
  69. package/src/claude/routing.ts +1 -1
  70. package/src/commands/canvas.ts +76 -2
  71. package/src/commands/gov.ts +19 -4
  72. package/src/commands/sandbox.ts +7 -0
  73. package/src/commands/slides.ts +13 -3
  74. package/src/commands/tasks.ts +46 -2
  75. package/src/gate/stages.ts +7 -1
  76. package/src/gov/citations.ts +1 -1
  77. package/src/gov/standard-rules.ts +179 -0
  78. package/src/migrate/skill-names.ts +0 -7
  79. package/src/sandbox/equivalence.ts +43 -14
  80. package/src/sandbox/stub-remote.ts +140 -0
  81. package/src/slides/convert/chart.ts +165 -0
  82. package/src/slides/convert/deck.ts +164 -0
  83. package/src/slides/convert/export.ts +217 -7
  84. package/src/slides/convert/master.ts +195 -0
  85. package/src/slides/convert/shapes.ts +1 -1
  86. package/src/slides/convert/walk.ts +187 -2
  87. package/src/tasks/record.ts +11 -1
  88. package/src/tasks/validate.ts +14 -0
  89. package/standards/architecture.md +5 -0
  90. package/standards/context.md +6 -0
  91. package/standards/design.md +3 -0
  92. package/standards/diagrams.md +8 -0
  93. package/standards/groundwork-spikes.md +66 -0
  94. package/standards/groundwork.md +5 -25
  95. package/standards/index.md +6 -2
  96. package/standards/intake.md +2 -0
  97. package/standards/markdown.md +6 -0
  98. package/standards/memory.md +2 -0
  99. package/standards/mermaid.md +6 -0
  100. package/standards/plan-lifecycle.md +36 -0
  101. package/standards/plan.md +13 -11
  102. package/standards/readme.md +2 -0
  103. package/standards/ready.md +4 -0
  104. package/standards/requirements.md +3 -0
  105. package/standards/rule.md +6 -1
  106. package/standards/session.md +4 -0
  107. package/standards/skill-paths.md +50 -0
  108. package/standards/skill.md +15 -23
  109. package/standards/standard.md +7 -1
  110. package/standards/tasks-decline.md +38 -0
  111. package/standards/tasks.md +9 -14
  112. package/standards/teach.md +5 -0
  113. package/standards/wiki.md +4 -0
  114. package/standards/wireframes.md +5 -0
  115. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  116. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  117. package/claude/skills/docs-fold/SKILL.md +0 -9
  118. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  119. package/claude/skills/standards-audit/SKILL.md +0 -99
  120. package/governance/rules/canon/610-context.md +0 -17
  121. package/governance/rules/canon/620-wireframes.md +0 -13
  122. package/governance/rules/canon/630-requirements.md +0 -12
  123. package/governance/rules/canon/650-design.md +0 -12
  124. package/governance/rules/canon/655-tasks.md +0 -13
  125. package/governance/rules/canon/656-groundwork.md +0 -11
  126. package/governance/rules/canon/657-intake.md +0 -11
  127. package/governance/rules/canon/659-memory.md +0 -11
  128. package/governance/rules/canon/660-diagrams.md +0 -19
  129. package/governance/rules/canon/662-session.md +0 -15
  130. package/governance/rules/canon/663-ready.md +0 -15
  131. package/governance/rules/claude/590-rule-authoring.md +0 -12
  132. package/governance/rules/writing/801-markdown.md +0 -14
  133. package/governance/rules/writing/802-mermaid.md +0 -14
  134. package/governance/rules/writing/880-readme.md +0 -11
  135. package/governance/rules/writing/891-standard-authoring.md +0 -12
@@ -0,0 +1,140 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ const STUB_ORG = 'stub-org'
14
+ const ANCHOR_REPO = 'canon-sandbox'
15
+
16
+ /** The URL `sandbox_anchor_url` builds once `GITHUB_ORG` names the stub organization. */
17
+ export const STUB_ANCHOR_URL = `https://github.com/${STUB_ORG}/${ANCHOR_REPO}.git`
18
+
19
+ /** Port 9 is the discard service, which nothing listens on, so a real HTTPS attempt fails. */
20
+ const DEAD_PROXY = 'http://127.0.0.1:9'
21
+
22
+ /** Pinned so a commit id depends on its tree alone and two provisions of one tree agree. */
23
+ const PINNED_DATE = '2026-01-01T00:00:00Z'
24
+
25
+ /**
26
+ * Answers the `gh` calls the git scenarios make and fails on any other, so a new
27
+ * call reaches a reader as a red arm rather than a silent pass. Every call is
28
+ * logged as one line, quoted so an argument holding a space stays one argument.
29
+ */
30
+ const STUB_GH = `#!/usr/bin/env bash
31
+ # stub gh
32
+ dir="$STUB_GH_DIR"
33
+ line="$(printf '%q ' "$@")"
34
+ line="\${line% }"
35
+ printf '%s\\n' "$line" >>"$dir/gh.log"
36
+
37
+ case "$1" in
38
+ api) exit 0 ;;
39
+ pr)
40
+ case "$2" in
41
+ create)
42
+ count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
43
+ count=$((count + 1))
44
+ echo "$count" >"$dir/pr-count"
45
+ echo "https://github.com/${STUB_ORG}/${ANCHOR_REPO}/pull/$count"
46
+ exit 0
47
+ ;;
48
+ close | list) exit 0 ;;
49
+ esac
50
+ ;;
51
+ esac
52
+
53
+ printf '%s\\n' "$line" >>"$dir/gh-unknown.log"
54
+ echo "stub gh: unknown call: $line" >&2
55
+ exit 1
56
+ `
57
+
58
+ /**
59
+ * Builds a local bare repository and a stub `gh` under `stubDir` and returns the
60
+ * environment that sends a provision's pushes and `gh` calls there. Nothing in
61
+ * it reaches the shared anchor: the URL rewrite turns every push into a file
62
+ * path, and the stub heads `PATH` ahead of any real `gh`.
63
+ */
64
+ export function createStubRemote(stubDir: string): Record<string, string> {
65
+ rmSync(stubDir, { recursive: true, force: true })
66
+ const bin = join(stubDir, 'bin')
67
+ const remotes = join(stubDir, 'remotes')
68
+ mkdirSync(bin, { recursive: true })
69
+ mkdirSync(remotes, { recursive: true })
70
+
71
+ execFileSync(
72
+ 'git',
73
+ ['init', '-q', '--bare', '-b', 'main', join(remotes, `${ANCHOR_REPO}.git`)],
74
+ { stdio: 'ignore' },
75
+ )
76
+
77
+ const gh = join(bin, 'gh')
78
+ writeFileSync(gh, STUB_GH)
79
+ chmodSync(gh, 0o755)
80
+
81
+ return {
82
+ PATH: `${bin}:${process.env.PATH ?? ''}`,
83
+ STUB_GH_DIR: stubDir,
84
+ GITHUB_ORG: STUB_ORG,
85
+ GIT_CONFIG_COUNT: '1',
86
+ GIT_CONFIG_KEY_0: `url.${remotes}/.insteadOf`,
87
+ GIT_CONFIG_VALUE_0: `https://github.com/${STUB_ORG}/`,
88
+ GIT_AUTHOR_DATE: PINNED_DATE,
89
+ GIT_COMMITTER_DATE: PINNED_DATE,
90
+ HTTPS_PROXY: DEAD_PROXY,
91
+ https_proxy: DEAD_PROXY,
92
+ ALL_PROXY: DEAD_PROXY,
93
+ }
94
+ }
95
+
96
+ function git(cwd: string, args: string[]): string {
97
+ return execFileSync('git', args, {
98
+ cwd,
99
+ encoding: 'utf8',
100
+ stdio: ['ignore', 'pipe', 'pipe'],
101
+ }).trimEnd()
102
+ }
103
+
104
+ function readLog(path: string): string {
105
+ return existsSync(path) ? readFileSync(path, 'utf8').trimEnd() : ''
106
+ }
107
+
108
+ /**
109
+ * Reads what a provision left in the stub: the `gh` calls, the calls it did not
110
+ * recognize, and for each bare repository every ref with its tip tree and the
111
+ * commit subjects beneath it. A tree id carries no timestamp, so it compares
112
+ * across two runs where a commit id would not.
113
+ */
114
+ export function readStubManifest(stubDir: string): Map<string, string> {
115
+ const manifest = new Map<string, string>()
116
+ manifest.set('stub:gh', readLog(join(stubDir, 'gh.log')))
117
+ manifest.set('stub:gh-unknown', readLog(join(stubDir, 'gh-unknown.log')))
118
+
119
+ const remotes = join(stubDir, 'remotes')
120
+ if (!existsSync(remotes)) return manifest
121
+
122
+ for (const entry of readdirSync(remotes).sort()) {
123
+ if (!entry.endsWith('.git')) continue
124
+ const repo = join(remotes, entry)
125
+ const name = entry.slice(0, -'.git'.length)
126
+
127
+ const refs = git(repo, ['for-each-ref', '--format=%(refname) %(tree)'])
128
+ .split('\n')
129
+ .filter((line) => line !== '')
130
+ .map((line) => {
131
+ const ref = line.split(' ')[0] ?? ''
132
+ const subjects = git(repo, ['log', '--format= %s', '--reverse', ref])
133
+
134
+ return `${line}\n${subjects}`
135
+ })
136
+ manifest.set(`stub:${name}:refs`, refs.join('\n'))
137
+ }
138
+
139
+ return manifest
140
+ }
@@ -0,0 +1,165 @@
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
+ }
@@ -0,0 +1,164 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { basename, join } 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
+ }
31
+
32
+ export type DeckRead =
33
+ | { readonly status: 'read'; readonly deck: DeckConfig }
34
+ | {
35
+ readonly status: 'refused'
36
+ readonly field: string
37
+ readonly message: string
38
+ }
39
+
40
+ const DECK_FIELDS = new Set([
41
+ 'title',
42
+ 'header',
43
+ 'footer',
44
+ 'slideNumbers',
45
+ 'mark',
46
+ ])
47
+ const BAND_FIELDS = new Set<string>(['show', ...BAND_SLOTS])
48
+
49
+ class FieldError extends Error {
50
+ constructor(
51
+ readonly field: string,
52
+ message: string,
53
+ ) {
54
+ super(message)
55
+ }
56
+ }
57
+
58
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
59
+ typeof value === 'object' && value !== null && !Array.isArray(value)
60
+
61
+ function rejectUnknown(
62
+ record: Record<string, unknown>,
63
+ known: ReadonlySet<string>,
64
+ prefix: string,
65
+ ): void {
66
+ const unknown = Object.keys(record).find((key) => !known.has(key))
67
+ if (unknown !== undefined) {
68
+ throw new FieldError(
69
+ `${prefix}${unknown}`,
70
+ `unknown field ${prefix}${unknown}`,
71
+ )
72
+ }
73
+ }
74
+
75
+ function stringField(value: unknown, field: string): string | undefined {
76
+ if (value === undefined) return undefined
77
+ if (typeof value !== 'string') {
78
+ throw new FieldError(field, `${field} must be a string`)
79
+ }
80
+ return value
81
+ }
82
+
83
+ function booleanField(value: unknown, field: string): boolean | undefined {
84
+ if (value === undefined) return undefined
85
+ if (typeof value !== 'boolean') {
86
+ throw new FieldError(field, `${field} must be true or false`)
87
+ }
88
+ return value
89
+ }
90
+
91
+ /**
92
+ * A declared band merges over its default, so `{ "center": "x" }` keeps the
93
+ * footer's title on the left. Declaring any text turns a band on unless it
94
+ * also says `show: false`.
95
+ */
96
+ function band(value: unknown, field: string, fallback: Band): Band {
97
+ if (value === undefined) return fallback
98
+ if (!isRecord(value)) {
99
+ throw new FieldError(field, `${field} must be an object`)
100
+ }
101
+ rejectUnknown(value, BAND_FIELDS, `${field}.`)
102
+ const slots: { -readonly [slot in BandSlot]?: string } = {}
103
+ for (const slot of BAND_SLOTS) {
104
+ const text = stringField(value[slot], `${field}.${slot}`)
105
+ if (text !== undefined) slots[slot] = text
106
+ }
107
+ const show =
108
+ booleanField(value.show, `${field}.show`) ??
109
+ (Object.keys(slots).length > 0 || fallback.show)
110
+ return { ...fallback, ...slots, show }
111
+ }
112
+
113
+ function parse(raw: unknown, folder: string): DeckConfig {
114
+ if (!isRecord(raw)) {
115
+ throw new FieldError(DECK_FILE, `${DECK_FILE} must hold an object`)
116
+ }
117
+ rejectUnknown(raw, DECK_FIELDS, '')
118
+ const title = stringField(raw.title, 'title') ?? basename(folder)
119
+ const header = band(raw.header, 'header', { show: false })
120
+ const footer = band(raw.footer, 'footer', { show: true, left: title })
121
+ const slideNumbers = booleanField(raw.slideNumbers, 'slideNumbers') ?? true
122
+ if (slideNumbers && footer.right !== undefined) {
123
+ throw new FieldError(
124
+ 'footer.right',
125
+ 'footer.right is where slide numbers sit. Set slideNumbers to false to use it',
126
+ )
127
+ }
128
+ const markPath = stringField(raw.mark, 'mark')
129
+ let mark: string | undefined
130
+ if (markPath !== undefined) {
131
+ mark = join(folder, markPath)
132
+ if (!existsSync(mark) || !statSync(mark).isFile()) {
133
+ throw new FieldError('mark', `mark ${mark} does not exist`)
134
+ }
135
+ }
136
+ return { title, header, footer, slideNumbers, mark }
137
+ }
138
+
139
+ export function readDeck(folder: string): DeckRead {
140
+ const path = join(folder, DECK_FILE)
141
+ let raw: unknown = {}
142
+ if (existsSync(path)) {
143
+ try {
144
+ raw = JSON.parse(readFileSync(path, 'utf8'))
145
+ } catch (error) {
146
+ const reason = error instanceof Error ? error.message : String(error)
147
+ return {
148
+ status: 'refused',
149
+ field: DECK_FILE,
150
+ message: `${path}: not valid JSON (${reason})`,
151
+ }
152
+ }
153
+ }
154
+ try {
155
+ return { status: 'read', deck: parse(raw, folder) }
156
+ } catch (error) {
157
+ if (!(error instanceof FieldError)) throw error
158
+ return {
159
+ status: 'refused',
160
+ field: error.field,
161
+ message: `${path}: ${error.message}`,
162
+ }
163
+ }
164
+ }