@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/verb-absent.md +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -0
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/record-screencast/SKILL.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -3
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/claude/skills/task-board/REQUIREMENT.md +1 -0
- package/claude/skills/task-board/SKILL.md +6 -0
- package/claude/skills/task-board/references/decline.md +2 -0
- package/claude/skills/video-craft/REQUIREMENT.md +48 -0
- package/claude/skills/video-craft/SKILL.md +80 -0
- package/claude/skills/video-craft/references/adopted.md +66 -0
- package/docs/agents/canvas.md +4 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/demo.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +17 -3
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/rule-citations.md +4 -4
- package/docs/agents/sandbox.md +8 -7
- package/docs/agents/slides.md +120 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +1 -1
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/operating-model.md +9 -9
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +12 -13
- package/governance/renames.toml +24 -0
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
- package/governance/rules/standards/context.md +15 -0
- package/governance/rules/standards/design.md +13 -0
- package/governance/rules/standards/diagrams.md +17 -0
- package/governance/rules/standards/groundwork.md +13 -0
- package/governance/rules/standards/intake.md +12 -0
- package/governance/rules/standards/markdown.md +15 -0
- package/governance/rules/standards/memory.md +12 -0
- package/governance/rules/standards/mermaid.md +15 -0
- package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
- package/governance/rules/standards/readme.md +12 -0
- package/governance/rules/standards/ready.md +13 -0
- package/governance/rules/standards/requirements.md +13 -0
- package/governance/rules/standards/rule.md +13 -0
- package/governance/rules/standards/session.md +13 -0
- package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
- package/governance/rules/standards/standard.md +13 -0
- package/governance/rules/standards/tasks.md +15 -0
- package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
- package/governance/rules/standards/wiki.md +13 -0
- package/governance/rules/standards/wireframes.md +14 -0
- package/governance/rules/writing/800-prose.md +1 -1
- package/governance/stacks/base.toml +4 -2
- package/package.json +1 -1
- package/src/canvas/capture.ts +228 -15
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/workflow.ts +4 -20
- package/src/claude/routing.ts +1 -1
- package/src/commands/canvas.ts +76 -2
- package/src/commands/gov.ts +19 -4
- package/src/commands/sandbox.ts +7 -0
- package/src/commands/slides.ts +13 -3
- package/src/commands/tasks.ts +46 -2
- package/src/gate/stages.ts +7 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/standard-rules.ts +179 -0
- package/src/migrate/skill-names.ts +0 -7
- package/src/sandbox/equivalence.ts +43 -14
- package/src/sandbox/stub-remote.ts +140 -0
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +164 -0
- package/src/slides/convert/export.ts +217 -7
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +1 -1
- package/src/slides/convert/walk.ts +187 -2
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -0
- package/standards/architecture.md +5 -0
- package/standards/context.md +6 -0
- package/standards/design.md +3 -0
- package/standards/diagrams.md +8 -0
- package/standards/groundwork-spikes.md +66 -0
- package/standards/groundwork.md +5 -25
- package/standards/index.md +6 -2
- package/standards/intake.md +2 -0
- package/standards/markdown.md +6 -0
- package/standards/memory.md +2 -0
- package/standards/mermaid.md +6 -0
- package/standards/plan-lifecycle.md +36 -0
- package/standards/plan.md +13 -11
- package/standards/readme.md +2 -0
- package/standards/ready.md +4 -0
- package/standards/requirements.md +3 -0
- package/standards/rule.md +6 -1
- package/standards/session.md +4 -0
- package/standards/skill-paths.md +50 -0
- package/standards/skill.md +15 -23
- package/standards/standard.md +7 -1
- package/standards/tasks-decline.md +38 -0
- package/standards/tasks.md +9 -14
- package/standards/teach.md +5 -0
- package/standards/wiki.md +4 -0
- package/standards/wireframes.md +5 -0
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
- package/governance/rules/canon/610-context.md +0 -17
- package/governance/rules/canon/620-wireframes.md +0 -13
- package/governance/rules/canon/630-requirements.md +0 -12
- package/governance/rules/canon/650-design.md +0 -12
- package/governance/rules/canon/655-tasks.md +0 -13
- package/governance/rules/canon/656-groundwork.md +0 -11
- package/governance/rules/canon/657-intake.md +0 -11
- package/governance/rules/canon/659-memory.md +0 -11
- package/governance/rules/canon/660-diagrams.md +0 -19
- package/governance/rules/canon/662-session.md +0 -15
- package/governance/rules/canon/663-ready.md +0 -15
- package/governance/rules/claude/590-rule-authoring.md +0 -12
- package/governance/rules/writing/801-markdown.md +0 -14
- package/governance/rules/writing/802-mermaid.md +0 -14
- package/governance/rules/writing/880-readme.md +0 -11
- 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
|
+
}
|