@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +8 -6
- package/claude/skills/auto-ship/references/verb-absent.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
- package/claude/skills/draft-doc/SKILL.md +77 -0
- package/claude/skills/draft-doc/references/context.md +31 -0
- package/claude/skills/draft-doc/references/docs.md +33 -0
- package/claude/skills/draft-doc/references/readme.md +58 -0
- package/claude/skills/draft-doc/references/wiki.md +53 -0
- package/claude/skills/draft-doc/references/wireframes.md +48 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/git-pr/SKILL.md +5 -46
- package/claude/skills/git-pr/references/final-command.md +75 -0
- package/claude/skills/git-pr/references/labels.md +2 -2
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
- package/claude/skills/search-craft/REQUIREMENT.md +4 -4
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/drift-surfaces.md +20 -9
- package/docs/agents/index.md +2 -1
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/slides.md +164 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/target/sync.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +25 -31
- package/governance/rules/canon/604-scratch.md +0 -1
- package/governance/rules/claude/575-hooks.md +1 -2
- package/governance/rules/claude/576-settings.md +1 -2
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- package/governance/rules/standards/architecture.md +0 -1
- package/governance/rules/standards/context.md +0 -1
- package/governance/rules/standards/design.md +0 -1
- package/governance/rules/standards/diagrams.md +0 -3
- package/governance/rules/standards/requirements.md +0 -1
- package/governance/rules/standards/wireframes.md +0 -1
- package/package.json +1 -1
- package/scripts/manage-sandbox.sh +7 -8
- package/src/claude/cases/authoring.ts +5 -5
- package/src/claude/cases/workflow.ts +4 -20
- package/src/commands/slides.ts +23 -3
- package/src/commands/sync.ts +10 -0
- package/src/commands/tasks.ts +46 -2
- package/src/gov/citations.ts +1 -2
- package/src/migrate/skill-names.ts +12 -7
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +244 -0
- package/src/slides/convert/export.ts +281 -23
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +44 -4
- package/src/slides/convert/walk.ts +229 -2
- package/src/slides/package/fonts.ts +297 -0
- package/src/slides/package/motion.ts +316 -0
- package/src/slides/package/test-face.ts +113 -0
- package/src/sync/check.ts +9 -0
- package/src/sync/layout.ts +27 -1
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -0
- package/standards/architecture.md +0 -1
- package/standards/context.md +0 -1
- package/standards/design.md +0 -1
- package/standards/diagrams.md +0 -3
- package/standards/requirements.md +0 -1
- package/standards/rule.md +1 -0
- package/standards/tasks.md +1 -1
- package/standards/wireframes.md +0 -1
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/draft-context/REQUIREMENT.md +0 -37
- package/claude/skills/draft-context/SKILL.md +0 -61
- package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
- package/claude/skills/draft-docs/SKILL.md +0 -59
- package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
- package/claude/skills/draft-readme/SKILL.md +0 -73
- package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
- package/claude/skills/draft-wiki/SKILL.md +0 -83
- package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
- package/claude/skills/draft-wireframes/SKILL.md +0 -69
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
-
|
|
839
|
+
branch,
|
|
840
|
+
await branchPullNumbers(branch),
|
|
797
841
|
)
|
|
798
842
|
|
|
799
843
|
return reportOutcome(outcome, emitJson, root)
|
package/src/gov/citations.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|