@erclx/canon 5.20.0 → 5.22.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 (98) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/auto-ship/references/draft-mark.md +8 -6
  4. package/claude/skills/auto-ship/references/verb-absent.md +1 -1
  5. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  6. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  7. package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
  8. package/claude/skills/draft-doc/SKILL.md +77 -0
  9. package/claude/skills/draft-doc/references/context.md +31 -0
  10. package/claude/skills/draft-doc/references/docs.md +33 -0
  11. package/claude/skills/draft-doc/references/readme.md +58 -0
  12. package/claude/skills/draft-doc/references/wiki.md +53 -0
  13. package/claude/skills/draft-doc/references/wireframes.md +48 -0
  14. package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
  15. package/claude/skills/draft-figure/SKILL.md +1 -1
  16. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  17. package/claude/skills/draft-ready/SKILL.md +6 -4
  18. package/claude/skills/git-pr/SKILL.md +5 -46
  19. package/claude/skills/git-pr/references/final-command.md +75 -0
  20. package/claude/skills/git-pr/references/labels.md +2 -2
  21. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  22. package/claude/skills/markdown-propose/SKILL.md +1 -1
  23. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
  26. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  27. package/claude/skills/search-craft/SKILL.md +1 -1
  28. package/docs/agents/commands.md +1 -0
  29. package/docs/agents/drift-surfaces.md +20 -9
  30. package/docs/agents/index.md +2 -1
  31. package/docs/agents/install-and-sync.md +3 -3
  32. package/docs/agents/markdown-audit.md +1 -3
  33. package/docs/agents/migrate.md +1 -1
  34. package/docs/agents/rule-citations.md +1 -1
  35. package/docs/agents/slides.md +164 -0
  36. package/docs/agents/tasks-validate.md +1 -1
  37. package/docs/agents/tasks.md +2 -2
  38. package/docs/target/migrations.md +4 -2
  39. package/docs/target/sync.md +1 -1
  40. package/docs/workflow/ai-workflow.md +1 -1
  41. package/docs/workflow/skill-map.md +25 -31
  42. package/governance/rules/canon/604-scratch.md +0 -1
  43. package/governance/rules/claude/575-hooks.md +1 -2
  44. package/governance/rules/claude/576-settings.md +1 -2
  45. package/governance/rules/lib/330-testing-py.md +1 -1
  46. package/governance/rules/lib/336-testing-php.md +1 -1
  47. package/governance/rules/standards/architecture.md +0 -1
  48. package/governance/rules/standards/context.md +0 -1
  49. package/governance/rules/standards/design.md +0 -1
  50. package/governance/rules/standards/diagrams.md +0 -3
  51. package/governance/rules/standards/requirements.md +0 -1
  52. package/governance/rules/standards/wireframes.md +0 -1
  53. package/package.json +1 -1
  54. package/scripts/manage-sandbox.sh +7 -8
  55. package/src/claude/cases/authoring.ts +5 -5
  56. package/src/claude/cases/workflow.ts +4 -20
  57. package/src/commands/slides.ts +23 -3
  58. package/src/commands/sync.ts +10 -0
  59. package/src/commands/tasks.ts +46 -2
  60. package/src/gov/citations.ts +1 -2
  61. package/src/migrate/skill-names.ts +12 -7
  62. package/src/slides/convert/chart.ts +165 -0
  63. package/src/slides/convert/deck.ts +244 -0
  64. package/src/slides/convert/export.ts +281 -23
  65. package/src/slides/convert/master.ts +195 -0
  66. package/src/slides/convert/shapes.ts +44 -4
  67. package/src/slides/convert/walk.ts +229 -2
  68. package/src/slides/package/fonts.ts +297 -0
  69. package/src/slides/package/motion.ts +316 -0
  70. package/src/slides/package/test-face.ts +113 -0
  71. package/src/sync/check.ts +9 -0
  72. package/src/sync/layout.ts +27 -1
  73. package/src/tasks/record.ts +11 -1
  74. package/src/tasks/validate.ts +14 -0
  75. package/standards/architecture.md +0 -1
  76. package/standards/context.md +0 -1
  77. package/standards/design.md +0 -1
  78. package/standards/diagrams.md +0 -3
  79. package/standards/requirements.md +0 -1
  80. package/standards/rule.md +1 -0
  81. package/standards/tasks.md +1 -1
  82. package/standards/wireframes.md +0 -1
  83. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  84. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
  85. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  86. package/claude/skills/docs-fold/SKILL.md +0 -9
  87. package/claude/skills/draft-context/REQUIREMENT.md +0 -37
  88. package/claude/skills/draft-context/SKILL.md +0 -61
  89. package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
  90. package/claude/skills/draft-docs/SKILL.md +0 -59
  91. package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
  92. package/claude/skills/draft-readme/SKILL.md +0 -73
  93. package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
  94. package/claude/skills/draft-wiki/SKILL.md +0 -83
  95. package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
  96. package/claude/skills/draft-wireframes/SKILL.md +0 -69
  97. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  98. package/claude/skills/standards-audit/SKILL.md +0 -99
@@ -65,6 +65,7 @@ const EXIT_FINDINGS = 2
65
65
 
66
66
  /** Matches `trunk.ts`'s bound on a git subprocess this verb also shells out to. */
67
67
  const GIT_TIMEOUT_MS = 10_000
68
+ const GH_TIMEOUT_MS = 10_000
68
69
 
69
70
  const TRUNK_BRANCHES: readonly string[] = ['main', 'master']
70
71
 
@@ -522,7 +523,9 @@ export function register(program: Command): void {
522
523
  'Run from a branch other than main or master, a close that ticks a new',
523
524
  'box names that branch on a Pending branch: line, which holds the task',
524
525
  'against a merge-time archive until canon tasks pull-request records',
525
- "the branch's own number.",
526
+ "the branch's own number. The marker is skipped once gh lists a pull",
527
+ "request for the branch whose number is already on the task's Pull",
528
+ 'request: line, and a failed gh lookup writes it.',
526
529
  '',
527
530
  'Examples:',
528
531
  ' canon tasks outcome v28.1-trigger-escalation --close 1 --close 3',
@@ -728,6 +731,45 @@ async function workingBranch(): Promise<string | undefined> {
728
731
  return branch
729
732
  }
730
733
 
734
+ /**
735
+ * The pull request numbers whose head is `branch`, open or not. A failed run,
736
+ * an unreadable answer, or no `gh` at all returns an empty list, which writes
737
+ * the marker, the direction that holds a task open rather than archiving it.
738
+ */
739
+ async function branchPullNumbers(
740
+ branch: string | undefined,
741
+ ): Promise<readonly number[]> {
742
+ if (branch === undefined) return []
743
+
744
+ const result = await execa(
745
+ 'gh',
746
+ ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'number'],
747
+ {
748
+ cwd: process.cwd(),
749
+ reject: false,
750
+ timeout: GH_TIMEOUT_MS,
751
+ env: gitEnv(),
752
+ extendEnv: false,
753
+ },
754
+ )
755
+ if (result.exitCode !== 0) return []
756
+
757
+ try {
758
+ const rows: unknown = JSON.parse(result.stdout)
759
+ if (!Array.isArray(rows)) return []
760
+
761
+ return rows
762
+ .map((row: unknown) =>
763
+ typeof row === 'object' && row !== null && 'number' in row
764
+ ? row.number
765
+ : undefined,
766
+ )
767
+ .filter((number): number is number => Number.isInteger(number))
768
+ } catch {
769
+ return []
770
+ }
771
+ }
772
+
731
773
  async function runPlanLink(
732
774
  task: string,
733
775
  plan: string,
@@ -789,11 +831,13 @@ async function runOutcome(
789
831
  }
790
832
 
791
833
  const root = opts.root ?? (await mainWorktreeRoot())
834
+ const branch = await workingBranch()
792
835
  const outcome = await closeOutcomes(
793
836
  root,
794
837
  selector,
795
838
  raw.map(Number),
796
- await workingBranch(),
839
+ branch,
840
+ await branchPullNumbers(branch),
797
841
  )
798
842
 
799
843
  return reportOutcome(outcome, emitJson, root)
@@ -266,8 +266,7 @@ export function collectCitations(text: string): RawCitation[] {
266
266
  *
267
267
  * A rule spelling a whole path there is declaring that exact artifact, and a
268
268
  * body line naming it again is naming what the rule governs rather than
269
- * pointing a reader somewhere. `governance/rules/standards/diagrams.md` tells
270
- * its reader to convert a `.claude/DIAGRAMS.md` left by an older install, which
269
+ * pointing a reader somewhere. Such a path names a file a target holds, so it
271
270
  * is correctly absent from this tree and correctly named in the rule.
272
271
  */
273
272
  function governedPaths(text: string): string[] {
@@ -33,6 +33,13 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
33
33
  * its name collided with `docs-sync`, which owns `docs/`. `claude-docs` is
34
34
  * retargeted onto `context-fold` for the same reason `claude-ui-test` was.
35
35
  *
36
+ * Five rows merge skills rather than rename one. `draft-docs`,
37
+ * `draft-context`, `draft-wireframes`, `draft-readme`, and `draft-wiki` ran
38
+ * one procedure five times over, differing only in the standard each read and
39
+ * the catalog each checked, so they became `draft-doc`, which picks the kind
40
+ * from where the document lands. Every retired name lands on the one skill, so
41
+ * a target citing any of them resolves after a single sweep.
42
+ *
36
43
  * Every name takes two words. Ten of these would have landed as a bare single
37
44
  * word under a plain strip, and a bare word such as `review` or `docs` is a
38
45
  * substring of ordinary prose with no token left for a later sweep to find.
@@ -79,6 +86,11 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
79
86
  'canon-slides-draft': 'draft-slides',
80
87
  'canon-record': 'record-screencast',
81
88
  'canon-frames-read': 'read-frames',
89
+ 'draft-docs': 'draft-doc',
90
+ 'draft-context': 'draft-doc',
91
+ 'draft-wireframes': 'draft-doc',
92
+ 'draft-readme': 'draft-doc',
93
+ 'draft-wiki': 'draft-doc',
82
94
  }
83
95
 
84
96
  /**
@@ -110,11 +122,6 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
110
122
  * what shipped or what a session ran under whatever name was current then, so
111
123
  * rewriting one makes it testify to a release or a run that never happened.
112
124
  *
113
- * The pointer left at `docs-fold` is excluded because it exists to answer under
114
- * the old name, so a later run would move it onto the skill it points at. The
115
- * exclusion is only safe once the real body has moved, since excluding the path
116
- * ahead of the first sweep leaves that body behind.
117
- *
118
125
  * The record archives need no entry here. They are gitignored, so the tracked
119
126
  * listing every sweep reads never reaches them.
120
127
  */
@@ -127,7 +134,5 @@ export const SKILL_NAME_RULES: RenameRules = defineRenameRules({
127
134
  'CHANGELOG.md',
128
135
  'src/migrate/skill-names.ts',
129
136
  'src/migrate/skill-names.test.ts',
130
- 'claude/skills/docs-fold/SKILL.md',
131
- 'claude/skills/docs-fold/REQUIREMENT.md',
132
137
  ],
133
138
  })
@@ -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,244 @@
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
+ }