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