@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.
Files changed (88) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/auto-ship/references/draft-mark.md +8 -6
  4. package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
  5. package/claude/skills/draft-doc/SKILL.md +77 -0
  6. package/claude/skills/draft-doc/references/context.md +31 -0
  7. package/claude/skills/draft-doc/references/docs.md +33 -0
  8. package/claude/skills/draft-doc/references/readme.md +58 -0
  9. package/claude/skills/draft-doc/references/wiki.md +53 -0
  10. package/claude/skills/draft-doc/references/wireframes.md +48 -0
  11. package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
  12. package/claude/skills/draft-figure/SKILL.md +1 -1
  13. package/claude/skills/git-pr/SKILL.md +5 -46
  14. package/claude/skills/git-pr/references/final-command.md +75 -0
  15. package/claude/skills/git-pr/references/labels.md +2 -2
  16. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
  17. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
  19. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  20. package/claude/skills/search-craft/SKILL.md +1 -1
  21. package/claude/skills/target-check/SKILL.md +9 -9
  22. package/claude/skills/target-check/references/domains.md +11 -5
  23. package/docs/agents/architecture-record.md +2 -2
  24. package/docs/agents/commands.md +1 -1
  25. package/docs/agents/drift-surfaces.md +20 -9
  26. package/docs/agents/index.md +3 -3
  27. package/docs/agents/install-and-sync.md +3 -3
  28. package/docs/agents/migrate.md +1 -1
  29. package/docs/agents/rule-citations.md +1 -1
  30. package/docs/agents/slides.md +47 -3
  31. package/docs/agents/tasks-validate.md +4 -2
  32. package/docs/agents/tasks.md +2 -2
  33. package/docs/target/migrations.md +3 -1
  34. package/docs/target/projects.md +1 -1
  35. package/docs/target/sync.md +1 -1
  36. package/docs/workflow/skill-map.md +14 -18
  37. package/governance/rules/canon/604-scratch.md +0 -1
  38. package/governance/rules/claude/575-hooks.md +1 -2
  39. package/governance/rules/claude/576-settings.md +1 -2
  40. package/governance/rules/standards/architecture.md +0 -1
  41. package/governance/rules/standards/context.md +0 -1
  42. package/governance/rules/standards/design.md +0 -1
  43. package/governance/rules/standards/diagrams.md +0 -3
  44. package/governance/rules/standards/requirements.md +0 -1
  45. package/governance/rules/standards/wireframes.md +0 -1
  46. package/package.json +1 -1
  47. package/scripts/manage-sandbox.sh +7 -8
  48. package/src/claude/cases/authoring.ts +5 -5
  49. package/src/commands/slides.ts +14 -4
  50. package/src/commands/sync.ts +10 -0
  51. package/src/commands/tasks.ts +16 -0
  52. package/src/gov/citations.ts +1 -2
  53. package/src/migrate/skill-names.ts +12 -0
  54. package/src/slides/convert/deck.ts +83 -3
  55. package/src/slides/convert/export.ts +67 -19
  56. package/src/slides/convert/shapes.ts +43 -3
  57. package/src/slides/convert/walk.ts +43 -1
  58. package/src/slides/package/fonts.ts +297 -0
  59. package/src/slides/package/motion.ts +316 -0
  60. package/src/slides/package/test-face.ts +113 -0
  61. package/src/sync/check.ts +9 -0
  62. package/src/sync/layout.ts +27 -1
  63. package/src/tasks/validate.ts +65 -0
  64. package/standards/architecture.md +2 -3
  65. package/standards/board.md +2 -0
  66. package/standards/context.md +0 -1
  67. package/standards/design.md +0 -1
  68. package/standards/diagrams.md +0 -3
  69. package/standards/requirements.md +3 -2
  70. package/standards/rule.md +1 -0
  71. package/standards/wireframes.md +0 -1
  72. package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -2
  73. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +10 -5
  74. package/tooling/claude/seeds/.claude/hooks/{tasks-index.sh → records-index.sh} +42 -35
  75. package/tooling/claude/seeds/.claude/settings.json +1 -5
  76. package/tooling/claude/seeds/canon/ARCHITECTURE.md +1 -1
  77. package/tooling/claude/seeds/canon/REQUIREMENTS.md +2 -0
  78. package/claude/skills/draft-context/REQUIREMENT.md +0 -37
  79. package/claude/skills/draft-context/SKILL.md +0 -61
  80. package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
  81. package/claude/skills/draft-docs/SKILL.md +0 -59
  82. package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
  83. package/claude/skills/draft-readme/SKILL.md +0 -73
  84. package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
  85. package/claude/skills/draft-wiki/SKILL.md +0 -83
  86. package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
  87. package/claude/skills/draft-wireframes/SKILL.md +0 -69
  88. 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-docs` | For a brand-new `docs/*.md` page, drafted against `standards/docs.md` |
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-context` | For a brand-new `canon/context/<domain>.md` entry, drafted against `standards/context.md` |
129
- | `canon:draft-wireframes` | For a brand-new `canon/wireframes/<surface>.md` file, drafted against `standards/wireframes.md` |
130
- | `canon:draft-wiki` | For a brand-new wiki reference page on a subject owned outside the project, drafted against `standards/wiki.md` |
131
- | `canon:draft-figure` | For a hand-drawn figure inside an existing doc, drafted against `standards/figures.md` in Mermaid or freehand SVG |
132
- | `canon:draft-readme` | For a project's `README.md`, drafted against `standards/readme.md` |
133
- | `canon:bash-cli-script` | For a small non-interactive shell wrapper, stopping at 100 lines in favor of TypeScript on Bun |
134
- | `canon:ci-workflow` | For a GitHub Actions workflow file |
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 and their seeded copies
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 and its seeded copy
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/ARCHITECTURE.md
4
4
  paths:
5
5
  - 'canon/ARCHITECTURE.md'
6
- - '.claude/ARCHITECTURE.md'
7
6
  ---
8
7
 
9
8
  # Architecture standards
@@ -3,7 +3,6 @@
3
3
  description: Shape and content rules for canon/context/<domain>.md entries
4
4
  paths:
5
5
  - 'canon/context/**'
6
- - '.claude/context/**'
7
6
  ---
8
7
 
9
8
  # Context entry standards
@@ -3,7 +3,6 @@
3
3
  description: Shape and content rules for canon/DESIGN.md
4
4
  paths:
5
5
  - 'canon/DESIGN.md'
6
- - '.claude/DESIGN.md'
7
6
  ---
8
7
 
9
8
  # Design 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
@@ -3,7 +3,6 @@
3
3
  description: Shape and content rules for canon/REQUIREMENTS.md
4
4
  paths:
5
5
  - 'canon/REQUIREMENTS.md'
6
- - '.claude/REQUIREMENTS.md'
7
6
  ---
8
7
 
9
8
  # Requirements standards
@@ -3,7 +3,6 @@
3
3
  description: Shape and content rules for canon/wireframes/<surface>.md files
4
4
  paths:
5
5
  - 'canon/wireframes/**'
6
- - '.claude/wireframes/**'
7
6
  ---
8
7
 
9
8
  # Wireframe standards
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.21.0",
4
+ "version": "5.23.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -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 two copies
194
- # these replaced each reimplemented an installer's selection rules, so a change
195
- # to what install produces left the sandbox provisioning the old shape and no
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
- local seeds_source="$PROJECT_ROOT/tooling/claude/seeds"
227
- if [ -d "$seeds_source" ]; then
228
- cp -r "$seeds_source/." "$SANDBOX/"
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-docs',
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-readme',
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-context',
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-wireframes',
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-wiki',
58
+ expect: 'draft-doc',
59
59
  },
60
60
  {
61
61
  prompt: 'Say what that dense answer actually means in plain terms.',
@@ -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 or chart,
123
- * or a token the master fell back on reports one line each and keeps the exit
124
- * at 0, as an unrecognized layout does on the markdown path, since the deck is
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
  }
@@ -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) {
@@ -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
  }
@@ -266,8 +266,7 @@ export function collectCitations(text: string): RawCitation[] {
266
266
  *
267
267
  * A rule spelling a whole path there is declaring that exact artifact, and a
268
268
  * body line naming it again is naming what the rule governs rather than
269
- * pointing a reader somewhere. `governance/rules/standards/diagrams.md` tells
270
- * its reader to convert a `.claude/DIAGRAMS.md` left by an older install, which
269
+ * pointing a reader somewhere. Such a path names a file a target holds, so it
271
270
  * is correctly absent from this tree and correctly named in the rule.
272
271
  */
273
272
  function governedPaths(text: string): string[] {
@@ -33,6 +33,13 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
33
33
  * its name collided with `docs-sync`, which owns `docs/`. `claude-docs` is
34
34
  * retargeted onto `context-fold` for the same reason `claude-ui-test` was.
35
35
  *
36
+ * Five rows merge skills rather than rename one. `draft-docs`,
37
+ * `draft-context`, `draft-wireframes`, `draft-readme`, and `draft-wiki` ran
38
+ * one procedure five times over, differing only in the standard each read and
39
+ * the catalog each checked, so they became `draft-doc`, which picks the kind
40
+ * from where the document lands. Every retired name lands on the one skill, so
41
+ * a target citing any of them resolves after a single sweep.
42
+ *
36
43
  * Every name takes two words. Ten of these would have landed as a bare single
37
44
  * word under a plain strip, and a bare word such as `review` or `docs` is a
38
45
  * substring of ordinary prose with no token left for a later sweep to find.
@@ -79,6 +86,11 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
79
86
  'canon-slides-draft': 'draft-slides',
80
87
  'canon-record': 'record-screencast',
81
88
  'canon-frames-read': 'read-frames',
89
+ 'draft-docs': 'draft-doc',
90
+ 'draft-context': 'draft-doc',
91
+ 'draft-wireframes': 'draft-doc',
92
+ 'draft-readme': 'draft-doc',
93
+ 'draft-wiki': 'draft-doc',
82
94
  }
83
95
 
84
96
  /**
@@ -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
- return { title, header, footer, slideNumbers, mark }
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 [opIndex, op] of plan.ops.entries()) {
231
- const name = `canon-${slideNumber}-${opIndex + 1}`
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
- const patched = await applyPatches(written, patches)
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, patched)
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
- target: { readonly slide: number; readonly name: string },
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 `draw` gave
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
- deck: Uint8Array,
529
+ zip: JSZip,
480
530
  patches: readonly Patch[],
481
- ): Promise<Buffer> {
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 {