@erclx/canon 5.21.0 → 5.23.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/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/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/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/claude/skills/target-check/SKILL.md +9 -9
- package/claude/skills/target-check/references/domains.md +11 -5
- package/docs/agents/architecture-record.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/drift-surfaces.md +20 -9
- package/docs/agents/index.md +3 -3
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/slides.md +47 -3
- package/docs/agents/tasks-validate.md +4 -2
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +3 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/skill-map.md +14 -18
- 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/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/commands/slides.ts +14 -4
- package/src/commands/sync.ts +10 -0
- package/src/commands/tasks.ts +16 -0
- package/src/gov/citations.ts +1 -2
- package/src/migrate/skill-names.ts +12 -0
- package/src/slides/convert/deck.ts +83 -3
- package/src/slides/convert/export.ts +67 -19
- package/src/slides/convert/shapes.ts +43 -3
- package/src/slides/convert/walk.ts +43 -1
- 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/validate.ts +65 -0
- package/standards/architecture.md +2 -3
- package/standards/board.md +2 -0
- package/standards/context.md +0 -1
- package/standards/design.md +0 -1
- package/standards/diagrams.md +0 -3
- package/standards/requirements.md +3 -2
- package/standards/rule.md +1 -0
- package/standards/wireframes.md +0 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +10 -5
- package/tooling/claude/seeds/.claude/hooks/{tasks-index.sh → records-index.sh} +42 -35
- package/tooling/claude/seeds/.claude/settings.json +1 -5
- package/tooling/claude/seeds/canon/ARCHITECTURE.md +1 -1
- package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -0
- 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/tooling/claude/seeds/.claude/hooks/memory-index.sh +0 -95
|
@@ -118,24 +118,20 @@ This page is the corpus the coverage claim is measured against: every name `cano
|
|
|
118
118
|
|
|
119
119
|
## Generate an artifact on demand
|
|
120
120
|
|
|
121
|
-
| Skill | When to use
|
|
122
|
-
| ------------------------- |
|
|
123
|
-
| `canon:create-rule` | For a project-specific governance rule the toolkit does not ship
|
|
124
|
-
| `canon:create-skill` | For a new `SKILL.md`
|
|
125
|
-
| `canon:create-standard` | For a new authoring convention
|
|
126
|
-
| `canon:draft-
|
|
127
|
-
| `canon:draft-ready` | For finished files a worker should copy, written as a ready folder with its overview, thin plan, and task
|
|
128
|
-
| `canon:draft-
|
|
129
|
-
| `canon:
|
|
130
|
-
| `canon:
|
|
131
|
-
| `canon:draft-
|
|
132
|
-
| `canon:draft-
|
|
133
|
-
| `canon:
|
|
134
|
-
| `canon:
|
|
135
|
-
| `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
|
|
136
|
-
| `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
|
|
137
|
-
| `canon:record-screencast` | For recording a screencast draft, then composing the take into a finished mp4 when the draft asks for a wrap |
|
|
138
|
-
| `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
|
|
121
|
+
| Skill | When to use |
|
|
122
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| `canon:create-rule` | For a project-specific governance rule the toolkit does not ship |
|
|
124
|
+
| `canon:create-skill` | For a new `SKILL.md` |
|
|
125
|
+
| `canon:create-standard` | For a new authoring convention |
|
|
126
|
+
| `canon:draft-doc` | For a brand-new `docs/` page, context entry, wireframe surface, wiki reference page, or `README.md`, drafted against the standard for its kind |
|
|
127
|
+
| `canon:draft-ready` | For finished files a worker should copy, written as a ready folder with its overview, thin plan, and task |
|
|
128
|
+
| `canon:draft-figure` | For a hand-drawn figure inside an existing doc, drafted against `standards/figures.md` in Mermaid or freehand SVG |
|
|
129
|
+
| `canon:bash-cli-script` | For a small non-interactive shell wrapper, stopping at 100 lines in favor of TypeScript on Bun |
|
|
130
|
+
| `canon:ci-workflow` | For a GitHub Actions workflow file |
|
|
131
|
+
| `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
|
|
132
|
+
| `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
|
|
133
|
+
| `canon:record-screencast` | For recording a screencast draft, then composing the take into a finished mp4 when the draft asks for a wrap |
|
|
134
|
+
| `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
|
|
139
135
|
|
|
140
136
|
## Answer a question at any point
|
|
141
137
|
|
|
@@ -7,4 +7,3 @@ description: Write temporary files to the scratch folder structure
|
|
|
7
7
|
## Temporary files
|
|
8
8
|
|
|
9
9
|
- Write temporary files to `.canon/tmp/<slug>/<file>.md` at the current worktree's root, a nested folder with a kebab-slug tied to the topic rather than a flat `<slug>-<file>.md`. The slug must not take one of the five reserved names `runs`, `hooks`, `handoff`, `pr`, and `render`. A skill that resolves a write at the main root instead states so in its own body.
|
|
10
|
-
- Write to `.claude/.tmp/<slug>/` instead in a project that carries no `.canon/` root, and `canon migrate records` moves it to the first spelling. <!-- canon-keep-record-root -->
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: State the hook stdin guard and the silencing rule for .claude/hooks scripts
|
|
2
|
+
description: State the hook stdin guard and the silencing rule for .claude/hooks scripts
|
|
3
3
|
paths:
|
|
4
4
|
- '.claude/hooks/**/*.sh'
|
|
5
|
-
- 'tooling/claude/seeds/.claude/hooks/**/*.sh'
|
|
6
5
|
---
|
|
7
6
|
|
|
8
7
|
# Hook standards
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: State the autoContinueAtUsageLimit inversion and the autoCompactWindow cap for .claude/settings.json
|
|
2
|
+
description: State the autoContinueAtUsageLimit inversion and the autoCompactWindow cap for .claude/settings.json
|
|
3
3
|
paths:
|
|
4
4
|
- '.claude/settings.json'
|
|
5
|
-
- 'tooling/claude/seeds/.claude/settings.json'
|
|
6
5
|
---
|
|
7
6
|
|
|
8
7
|
# Settings standards
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
description: Shape and content rules for .canon/diagrams/<kind>.md files
|
|
4
4
|
paths:
|
|
5
5
|
- '.canon/diagrams/**'
|
|
6
|
-
- '.claude/DIAGRAMS.md'
|
|
7
6
|
---
|
|
8
7
|
|
|
9
8
|
# Diagram standards
|
|
@@ -13,5 +12,3 @@ paths:
|
|
|
13
12
|
- Follow the diagrams standard for shape and content rules for .canon/diagrams/<kind>.md files. It is the single source. Read it with `canon standards diagrams`.
|
|
14
13
|
- Layout, budgets, accessibility, labels, and render verification inside the fence are a separate topic. The mermaid standard routes them.
|
|
15
14
|
- A diagram entry carries structure and flow, not implementation. Read the standard before adding or revising a kind.
|
|
16
|
-
- Write a new diagram to `.canon/diagrams/<kind>.md`, never to `.claude/DIAGRAMS.md`
|
|
17
|
-
- Convert a `.claude/DIAGRAMS.md` left by an older install into per-kind entries before editing it
|
package/package.json
CHANGED
|
@@ -190,10 +190,10 @@ provision_sandbox() {
|
|
|
190
190
|
(cd "$SANDBOX" && configure_sandbox_git_credentials)
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
# Runs the real installer rather than copying the source tree. The
|
|
194
|
-
#
|
|
195
|
-
# to what install produces left the sandbox
|
|
196
|
-
# scenario could observe the difference.
|
|
193
|
+
# Runs the real installer rather than copying the source tree. The copies these
|
|
194
|
+
# replaced, for gov rules and for seeds, each reimplemented an installer's
|
|
195
|
+
# selection rules, so a change to what install produces left the sandbox
|
|
196
|
+
# provisioning the old shape and no scenario could observe the difference.
|
|
197
197
|
run_sandbox_install() {
|
|
198
198
|
local label="$1"
|
|
199
199
|
shift
|
|
@@ -223,10 +223,9 @@ inject_gov_rules() {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
inject_seeds() {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
fi
|
|
226
|
+
[ ! -d "$PROJECT_ROOT/tooling/claude/seeds" ] && return
|
|
227
|
+
|
|
228
|
+
run_sandbox_install "seeds" claude init "$SANDBOX"
|
|
230
229
|
}
|
|
231
230
|
|
|
232
231
|
commit_environment_setup() {
|
|
@@ -31,7 +31,7 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
|
|
|
31
31
|
{
|
|
32
32
|
prompt:
|
|
33
33
|
'Write a brand-new docs page for the capture command, nothing under docs/ covers it yet.',
|
|
34
|
-
expect: 'draft-
|
|
34
|
+
expect: 'draft-doc',
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
prompt:
|
|
@@ -40,22 +40,22 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
|
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
prompt: 'This project has no README.md at all, write one from scratch.',
|
|
43
|
-
expect: 'draft-
|
|
43
|
+
expect: 'draft-doc',
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
prompt:
|
|
47
47
|
'Write a context entry for the payments domain, there is no canon/context page for it yet.',
|
|
48
|
-
expect: 'draft-
|
|
48
|
+
expect: 'draft-doc',
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
prompt:
|
|
52
52
|
'Draft a wireframe for the settings panel, nothing under canon/wireframes covers that surface yet.',
|
|
53
|
-
expect: 'draft-
|
|
53
|
+
expect: 'draft-doc',
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
prompt:
|
|
57
57
|
'Write a wiki reference page for Claude Code output styles, no page covers that subject yet.',
|
|
58
|
-
expect: 'draft-
|
|
58
|
+
expect: 'draft-doc',
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
prompt: 'Say what that dense answer actually means in plain terms.',
|
package/src/commands/slides.ts
CHANGED
|
@@ -119,10 +119,10 @@ export function register(program: Command): void {
|
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* The converter loads on demand, so no other command resolves pptxgenjs,
|
|
122
|
-
* jszip, or the token module at startup. A fallback, a refused link
|
|
123
|
-
* or a token the master fell back on reports one line each and keeps
|
|
124
|
-
* at 0, as an unrecognized layout does on the markdown path, since the
|
|
125
|
-
* written either way. A malformed `deck.json` refuses before any slide is laid
|
|
122
|
+
* jszip, or the token module at startup. A fallback, a refused link, chart, or
|
|
123
|
+
* effect, or a token the master fell back on reports one line each and keeps
|
|
124
|
+
* the exit at 0, as an unrecognized layout does on the markdown path, since the
|
|
125
|
+
* deck is written either way. A malformed `deck.json` refuses before any slide is laid
|
|
126
126
|
* out and exits 1.
|
|
127
127
|
*/
|
|
128
128
|
async function renderHtmlFolder(
|
|
@@ -163,6 +163,16 @@ async function renderHtmlFolder(
|
|
|
163
163
|
`${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} chart left out: ${message}\n`,
|
|
164
164
|
)
|
|
165
165
|
}
|
|
166
|
+
for (const { slide, message } of result.refusedMotion) {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
`${GREY}│${NC} ${RED}✗${NC} slide ${slide} motion left out: ${message}\n`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
for (const { path, message } of result.refusedFonts) {
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
`${GREY}│${NC} ${RED}✗${NC} font ${path} not embedded: ${message}\n`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
166
176
|
for (const notice of result.notices) {
|
|
167
177
|
process.stderr.write(`${GREY}│${NC} ${RED}✗${NC} ${notice}\n`)
|
|
168
178
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -168,6 +168,16 @@ function renderCheck(report: CheckReport): void {
|
|
|
168
168
|
logInfo('Move the content yourself. No sync command touches these.')
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
if (report.unmovedSurfaces.length > 0) {
|
|
172
|
+
logStep('Surfaces at the old .claude/ root')
|
|
173
|
+
for (const entry of report.unmovedSurfaces) {
|
|
174
|
+
logWarn(`${entry.rel} (moves to ${entry.movesTo})`)
|
|
175
|
+
}
|
|
176
|
+
logInfo(
|
|
177
|
+
'Rules glob the canon/ spelling only. Run `canon migrate surface-roots` to move these.',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
171
181
|
renderSeeds(report)
|
|
172
182
|
|
|
173
183
|
if (report.superseded.length > 0) {
|
package/src/commands/tasks.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import {
|
|
43
43
|
type Finding,
|
|
44
44
|
type FolderClaim,
|
|
45
|
+
type WideToken,
|
|
45
46
|
type Unplaced,
|
|
46
47
|
type Untested,
|
|
47
48
|
type ValidateOutcome,
|
|
@@ -1411,6 +1412,16 @@ function reportValidation(
|
|
|
1411
1412
|
for (const claim of outcome.claims) logWarn(describeClaim(claim))
|
|
1412
1413
|
}
|
|
1413
1414
|
|
|
1415
|
+
// The width limit is a heuristic over one preview pane, so a wide token
|
|
1416
|
+
// reports and moves no exit code, and a measure failing on a cell that
|
|
1417
|
+
// fits would teach a reader to skip it.
|
|
1418
|
+
logStep('Wide tokens')
|
|
1419
|
+
if (outcome.wide.length === 0) {
|
|
1420
|
+
logInfo('every code span fits the preview pane')
|
|
1421
|
+
} else {
|
|
1422
|
+
for (const token of outcome.wide) logWarn(describeWide(token))
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1414
1425
|
// Unplaced is the normal state between a session filing a task and an
|
|
1415
1426
|
// orchestrator placing its row, so it moves no exit code. It still reports,
|
|
1416
1427
|
// since it is the only local detector for a row a hand-edit dropped or a
|
|
@@ -1436,6 +1447,7 @@ function reportValidation(
|
|
|
1436
1447
|
findings: outcome.findings,
|
|
1437
1448
|
untested: outcome.untested,
|
|
1438
1449
|
claims: outcome.claims,
|
|
1450
|
+
wide: outcome.wide,
|
|
1439
1451
|
unplaced: outcome.unplaced,
|
|
1440
1452
|
})}\n`,
|
|
1441
1453
|
)
|
|
@@ -1457,6 +1469,10 @@ function describeClaim(claim: FolderClaim): string {
|
|
|
1457
1469
|
return `${claim.group}: ${claim.subject} ${claim.message}`
|
|
1458
1470
|
}
|
|
1459
1471
|
|
|
1472
|
+
function describeWide(token: WideToken): string {
|
|
1473
|
+
return `${token.group}: ${token.subject} ${token.message}`
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1460
1476
|
function describeUnplaced(task: Unplaced): string {
|
|
1461
1477
|
return `${task.subject} ${task.message}`
|
|
1462
1478
|
}
|
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
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
-
import { basename, join } from 'node:path'
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What belongs to the deck rather than to one slide, read from `deck.json`
|
|
@@ -27,6 +27,18 @@ export interface DeckConfig {
|
|
|
27
27
|
readonly slideNumbers: boolean
|
|
28
28
|
/** Absolute, resolved against the deck folder. */
|
|
29
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
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
export type DeckRead =
|
|
@@ -43,8 +55,10 @@ const DECK_FIELDS = new Set([
|
|
|
43
55
|
'footer',
|
|
44
56
|
'slideNumbers',
|
|
45
57
|
'mark',
|
|
58
|
+
'fonts',
|
|
46
59
|
])
|
|
47
60
|
const BAND_FIELDS = new Set<string>(['show', ...BAND_SLOTS])
|
|
61
|
+
const FONT_FIELDS = new Set(['family', 'weight', 'style', 'path'])
|
|
48
62
|
|
|
49
63
|
class FieldError extends Error {
|
|
50
64
|
constructor(
|
|
@@ -133,7 +147,73 @@ function parse(raw: unknown, folder: string): DeckConfig {
|
|
|
133
147
|
throw new FieldError('mark', `mark ${mark} does not exist`)
|
|
134
148
|
}
|
|
135
149
|
}
|
|
136
|
-
|
|
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
|
+
})
|
|
137
217
|
}
|
|
138
218
|
|
|
139
219
|
export function readDeck(folder: string): DeckRead {
|
|
@@ -42,13 +42,23 @@ import {
|
|
|
42
42
|
type WalkedSlide,
|
|
43
43
|
walkSlide,
|
|
44
44
|
} from '@/slides/convert/walk'
|
|
45
|
+
import { embedFonts, type FontNotice } from '@/slides/package/fonts'
|
|
46
|
+
import {
|
|
47
|
+
type EntranceSpec,
|
|
48
|
+
entranceShapes,
|
|
49
|
+
type MotionNotice,
|
|
50
|
+
type SlideMotionEdit,
|
|
51
|
+
writeMotion,
|
|
52
|
+
} from '@/slides/package/motion'
|
|
45
53
|
|
|
46
54
|
/**
|
|
47
55
|
* Turns a folder of HTML slides into one editable deck. Chromium lays each
|
|
48
56
|
* slide out at 1280 by 720 in the project's tokens, `walkSlide` reads what it
|
|
49
57
|
* placed, `planSlide` maps the records onto shapes, and an element whose CSS has
|
|
50
58
|
* no mapping lands as a screenshot of itself. The first slide's computed tokens
|
|
51
|
-
* build the masters, and `deck.json` sets the bands they carry.
|
|
59
|
+
* build the masters, and `deck.json` sets the bands they carry. Once pptxgenjs
|
|
60
|
+
* has written the package, `src/slides/package/` adds the transitions,
|
|
61
|
+
* entrances, and embedded faces it declares nothing for.
|
|
52
62
|
*
|
|
53
63
|
* Every browser reference this feature adds lives here, and the command reaches
|
|
54
64
|
* it through a dynamic import so no other command resolves the engine.
|
|
@@ -94,6 +104,10 @@ export type ExportResult =
|
|
|
94
104
|
readonly fallbacks: readonly SlideFallback[]
|
|
95
105
|
readonly refusedLinks: readonly SlideRefusedLink[]
|
|
96
106
|
readonly refusedCharts: readonly RefusedChart[]
|
|
107
|
+
/** Transitions and entrances left out, each with the reason. */
|
|
108
|
+
readonly refusedMotion: readonly MotionNotice[]
|
|
109
|
+
/** Faces `deck.json` lists that the deck does not carry, with the reason. */
|
|
110
|
+
readonly refusedFonts: readonly FontNotice[]
|
|
97
111
|
/** What the deck master could not take from the project's tokens. */
|
|
98
112
|
readonly notices: readonly string[]
|
|
99
113
|
}
|
|
@@ -172,6 +186,7 @@ export async function exportHtmlDeck(
|
|
|
172
186
|
const refusedLinks: SlideRefusedLink[] = []
|
|
173
187
|
const refusedCharts: RefusedChart[] = []
|
|
174
188
|
const patches: Patch[] = []
|
|
189
|
+
const motions: SlideMotionEdit[] = []
|
|
175
190
|
const notices: string[] = []
|
|
176
191
|
|
|
177
192
|
try {
|
|
@@ -227,9 +242,8 @@ export async function exportHtmlDeck(
|
|
|
227
242
|
slide.background = { color: walked.background.hex }
|
|
228
243
|
}
|
|
229
244
|
if (walked.meta.isHidden) slide.hidden = true
|
|
230
|
-
for (const
|
|
231
|
-
|
|
232
|
-
await draw(page, slide, { slide: slideNumber, name }, op, patches)
|
|
245
|
+
for (const op of plan.ops) {
|
|
246
|
+
await draw(page, slide, slideNumber, op, patches)
|
|
233
247
|
}
|
|
234
248
|
for (const record of walked.charts) {
|
|
235
249
|
const chart = planChart(record, chartTheme)
|
|
@@ -241,6 +255,24 @@ export async function exportHtmlDeck(
|
|
|
241
255
|
}
|
|
242
256
|
drawOverrides(slide, deck, theme, walked.meta)
|
|
243
257
|
if (walked.meta.notes) slide.addNotes(walked.meta.notes)
|
|
258
|
+
const { transition } = walked.meta
|
|
259
|
+
const roots = new Set(
|
|
260
|
+
walked.entrances.flatMap(({ record }) =>
|
|
261
|
+
record === null ? [] : [record],
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
const entrances = walked.entrances.map(
|
|
265
|
+
({ record, ...spec }): EntranceSpec => ({
|
|
266
|
+
...spec,
|
|
267
|
+
shapes: entranceShapes(record, walked.records, plan.names, roots),
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
if (transition || entrances.length > 0) {
|
|
271
|
+
motions.push({
|
|
272
|
+
slide: slideNumber,
|
|
273
|
+
motion: { ...(transition ? { transition } : {}), entrances },
|
|
274
|
+
})
|
|
275
|
+
}
|
|
244
276
|
}
|
|
245
277
|
if (drifted.length > 0) {
|
|
246
278
|
const noun = drifted.length === 1 ? 'slide' : 'slides'
|
|
@@ -262,12 +294,32 @@ export async function exportHtmlDeck(
|
|
|
262
294
|
if (!(written instanceof Uint8Array)) {
|
|
263
295
|
return refused('export-failed', 'pptxgenjs returned no buffer')
|
|
264
296
|
}
|
|
265
|
-
|
|
297
|
+
// A deck needing no edit keeps the bytes pptxgenjs wrote.
|
|
298
|
+
let packaged: Uint8Array = written
|
|
299
|
+
const refusedMotion: MotionNotice[] = []
|
|
300
|
+
const refusedFonts: FontNotice[] = []
|
|
301
|
+
if (patches.length > 0 || motions.length > 0 || deck.fonts.length > 0) {
|
|
302
|
+
const zip = await JSZip.loadAsync(written)
|
|
303
|
+
await applyPatches(zip, patches)
|
|
304
|
+
refusedMotion.push(...(await writeMotion(zip, motions)))
|
|
305
|
+
const faces = deck.fonts.map((font) => ({
|
|
306
|
+
family: font.family,
|
|
307
|
+
weight: font.weight,
|
|
308
|
+
style: font.style,
|
|
309
|
+
path: font.source,
|
|
310
|
+
bytes: readFileSync(font.path),
|
|
311
|
+
}))
|
|
312
|
+
refusedFonts.push(...(await embedFonts(zip, faces)))
|
|
313
|
+
packaged = await zip.generateAsync({
|
|
314
|
+
type: 'nodebuffer',
|
|
315
|
+
compression: 'DEFLATE',
|
|
316
|
+
})
|
|
317
|
+
}
|
|
266
318
|
|
|
267
319
|
mkdirSync(outDir, { recursive: true })
|
|
268
320
|
const fileName = `${basename(sourceDir)}.pptx`
|
|
269
321
|
const pptxPath = join(outDir, fileName)
|
|
270
|
-
writeFileSync(pptxPath,
|
|
322
|
+
writeFileSync(pptxPath, packaged)
|
|
271
323
|
|
|
272
324
|
let mirrorPath: string | undefined
|
|
273
325
|
if (options.mirror) {
|
|
@@ -284,6 +336,8 @@ export async function exportHtmlDeck(
|
|
|
284
336
|
fallbacks,
|
|
285
337
|
refusedLinks,
|
|
286
338
|
refusedCharts,
|
|
339
|
+
refusedMotion,
|
|
340
|
+
refusedFonts,
|
|
287
341
|
notices,
|
|
288
342
|
}
|
|
289
343
|
}
|
|
@@ -437,10 +491,11 @@ const base64 = (bytes: Buffer | string): string =>
|
|
|
437
491
|
async function draw(
|
|
438
492
|
page: Page,
|
|
439
493
|
slide: PptxGenJS.Slide,
|
|
440
|
-
|
|
494
|
+
slideNumber: number,
|
|
441
495
|
op: DrawOp,
|
|
442
496
|
patches: Patch[],
|
|
443
497
|
): Promise<void> {
|
|
498
|
+
const target = { slide: slideNumber, name: op.options.objectName ?? '' }
|
|
444
499
|
if (op.kind === 'shape') {
|
|
445
500
|
slide.addShape(op.shape, op.options)
|
|
446
501
|
} else if (op.kind === 'text') {
|
|
@@ -448,18 +503,13 @@ async function draw(
|
|
|
448
503
|
} else if (op.kind === 'table') {
|
|
449
504
|
slide.addTable(op.rows, op.options)
|
|
450
505
|
} else if (op.kind === 'image') {
|
|
451
|
-
slide.addImage({
|
|
452
|
-
...op.options,
|
|
453
|
-
...imageSource(op.src),
|
|
454
|
-
objectName: target.name,
|
|
455
|
-
})
|
|
506
|
+
slide.addImage({ ...op.options, ...imageSource(op.src) })
|
|
456
507
|
if (op.radius > 0) patches.push({ ...target, radius: op.radius })
|
|
457
508
|
if (op.frame) slide.addShape(op.radius > 0 ? 'roundRect' : 'rect', op.frame)
|
|
458
509
|
} else if (op.kind === 'svg') {
|
|
459
510
|
slide.addImage({
|
|
460
511
|
...op.options,
|
|
461
512
|
data: `image/svg+xml;base64,${base64(op.markup)}`,
|
|
462
|
-
objectName: target.name,
|
|
463
513
|
})
|
|
464
514
|
patches.push({ ...target, png: await screenshot(page, op.id) })
|
|
465
515
|
} else {
|
|
@@ -472,14 +522,13 @@ async function draw(
|
|
|
472
522
|
* Two things pptxgenjs cannot write, fixed in the package it wrote. An SVG's
|
|
473
523
|
* PNG fallback comes out as the SVG's own bytes under a `.png` name in Node, so
|
|
474
524
|
* a viewer that cannot draw SVG gets a broken image, and a picture takes no
|
|
475
|
-
* corner radius. Each patched picture is found by the object name `
|
|
476
|
-
* it, which is the only handle the written XML keeps.
|
|
525
|
+
* corner radius. Each patched picture is found by the object name `planSlide`
|
|
526
|
+
* gave it, which is the only handle the written XML keeps.
|
|
477
527
|
*/
|
|
478
528
|
async function applyPatches(
|
|
479
|
-
|
|
529
|
+
zip: JSZip,
|
|
480
530
|
patches: readonly Patch[],
|
|
481
|
-
): Promise<
|
|
482
|
-
const zip = await JSZip.loadAsync(deck)
|
|
531
|
+
): Promise<void> {
|
|
483
532
|
const bySlide = Map.groupBy(patches, (patch) => patch.slide)
|
|
484
533
|
for (const [slideNumber, slidePatches] of bySlide) {
|
|
485
534
|
const slidePath = `ppt/slides/slide${slideNumber}.xml`
|
|
@@ -504,7 +553,6 @@ async function applyPatches(
|
|
|
504
553
|
}
|
|
505
554
|
zip.file(slidePath, xml)
|
|
506
555
|
}
|
|
507
|
-
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
|
|
508
556
|
}
|
|
509
557
|
|
|
510
558
|
function pictureXml(xml: string, name: string): string | undefined {
|