@erclx/canon 5.52.0 → 5.54.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 (201) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +11 -13
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +5 -8
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-identity/REQUIREMENT.md +2 -2
  7. package/claude/skills/design-identity/SKILL.md +6 -6
  8. package/claude/skills/design-record/REQUIREMENT.md +2 -2
  9. package/claude/skills/design-record/SKILL.md +4 -4
  10. package/claude/skills/design-record/references/folder.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/references/kinds.md +0 -1
  13. package/claude/skills/draw-figure/REQUIREMENT.md +3 -4
  14. package/claude/skills/draw-figure/SKILL.md +5 -6
  15. package/claude/skills/draw-figure/references/figures.md +0 -1
  16. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  17. package/claude/skills/pick-by-eye/REQUIREMENT.md +4 -5
  18. package/claude/skills/pick-by-eye/SKILL.md +34 -41
  19. package/claude/skills/pick-by-eye/references/board.md +42 -0
  20. package/claude/skills/pick-by-eye/references/live-arms.md +3 -3
  21. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  22. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  23. package/claude/skills/teach-craft/SKILL.md +70 -0
  24. package/claude/skills/teach-craft/references/adopted.md +113 -0
  25. package/claude/skills/teach-craft/references/lesson-craft.md +60 -0
  26. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +41 -6
  27. package/docs/agents/audits.md +0 -1
  28. package/docs/agents/install-and-sync.md +0 -8
  29. package/docs/target/migrations.md +16 -2
  30. package/docs/target/projects.md +1 -1
  31. package/docs/workflow/parallel-features.md +1 -1
  32. package/docs/workflow/skill-map-on-request.md +10 -22
  33. package/docs/workflow/skill-map.md +11 -14
  34. package/docs/workflow/visual-design-workflow.md +1 -4
  35. package/governance/detect.toml +1 -6
  36. package/governance/renames.toml +0 -1
  37. package/package.json +2 -8
  38. package/scripts/core/regen-web-previews.ts +2 -67
  39. package/src/audits/catalog.ts +0 -1
  40. package/src/browser/engine.ts +3 -6
  41. package/src/capture/render.ts +1 -1
  42. package/src/claude/cases/authoring.ts +0 -12
  43. package/src/claude/cases/misc.ts +0 -22
  44. package/src/claude/cases/workflow.ts +2 -38
  45. package/src/claude/skills-audit.ts +1 -1
  46. package/src/cli/help.ts +0 -10
  47. package/src/cli.ts +0 -8
  48. package/src/commands/design.ts +16 -315
  49. package/src/commands/records.ts +0 -1
  50. package/src/commands/serve.ts +2 -2
  51. package/src/commands/sync.ts +4 -5
  52. package/src/design/components.ts +8 -1514
  53. package/src/design/css.ts +5 -6
  54. package/src/design/fonts.ts +3 -5
  55. package/src/design/regen.ts +5 -32
  56. package/src/design/render.ts +1 -1
  57. package/src/design/tokens.ts +3 -2
  58. package/src/driver/drive.ts +3 -4
  59. package/src/driver/steps.ts +2 -5
  60. package/src/gate/stages.ts +4 -10
  61. package/src/gov/audit.ts +1 -6
  62. package/src/inventory/walk.ts +1 -1
  63. package/src/markdown/scope.ts +6 -35
  64. package/src/records/validate.ts +4 -176
  65. package/src/sandbox/equivalence.ts +0 -5
  66. package/src/shipped/references.ts +1 -2
  67. package/src/sync/check.ts +4 -29
  68. package/src/sync/stamp.ts +6 -6
  69. package/src/sync/target.ts +1 -4
  70. package/tooling/claude/reference.md +1 -1
  71. package/tooling/claude/seeds/canon/DESIGN.md +1 -1
  72. package/claude/skills/canvas-board/REQUIREMENT.md +0 -35
  73. package/claude/skills/canvas-board/SKILL.md +0 -143
  74. package/claude/skills/canvas-board/references/canvas.md +0 -170
  75. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  76. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  77. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  78. package/claude/skills/draft-doc/SKILL.md +0 -11
  79. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  80. package/claude/skills/draft-figure/SKILL.md +0 -11
  81. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  82. package/claude/skills/draft-identity/SKILL.md +0 -11
  83. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  84. package/claude/skills/draft-ready/SKILL.md +0 -11
  85. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  86. package/claude/skills/draft-screencast/SKILL.md +0 -11
  87. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  88. package/claude/skills/draft-slides/SKILL.md +0 -11
  89. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  90. package/claude/skills/make-deck/SKILL.md +0 -80
  91. package/claude/skills/make-deck/references/deck.md +0 -183
  92. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  93. package/claude/skills/read-frames/SKILL.md +0 -55
  94. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  95. package/claude/skills/record-screencast/SKILL.md +0 -99
  96. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  97. package/claude/skills/script-screencast/SKILL.md +0 -70
  98. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  99. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  100. package/claude/skills/teach-workspace/SKILL.md +0 -196
  101. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  102. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  104. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  105. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  106. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  107. package/claude/skills/teach-workspace/references/teach.md +0 -211
  108. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  109. package/claude/skills/video-craft/SKILL.md +0 -96
  110. package/claude/skills/video-craft/references/adopted.md +0 -110
  111. package/governance/rules/ui/canvas.md +0 -13
  112. package/src/canvas/address.ts +0 -227
  113. package/src/canvas/arrange.ts +0 -66
  114. package/src/canvas/capture.ts +0 -391
  115. package/src/canvas/client/app.tsx +0 -188
  116. package/src/canvas/client/inspector/align-grid.tsx +0 -162
  117. package/src/canvas/client/inspector/color-field.tsx +0 -398
  118. package/src/canvas/client/inspector/color-inputs.tsx +0 -139
  119. package/src/canvas/client/inspector/color-picker.tsx +0 -668
  120. package/src/canvas/client/inspector/color-space.ts +0 -240
  121. package/src/canvas/client/inspector/field.tsx +0 -263
  122. package/src/canvas/client/inspector/fields.css +0 -763
  123. package/src/canvas/client/inspector/section.tsx +0 -27
  124. package/src/canvas/client/inspector/size-field.tsx +0 -69
  125. package/src/canvas/client/inspector/token-select.tsx +0 -109
  126. package/src/canvas/client/inspector/values.ts +0 -297
  127. package/src/canvas/client/inspector.tsx +0 -692
  128. package/src/canvas/client/keys.ts +0 -71
  129. package/src/canvas/client/layers.tsx +0 -118
  130. package/src/canvas/client/main.tsx +0 -71
  131. package/src/canvas/client/pages-panel.tsx +0 -212
  132. package/src/canvas/client/panel-handle.tsx +0 -90
  133. package/src/canvas/client/selection.tsx +0 -199
  134. package/src/canvas/client/shell.css +0 -762
  135. package/src/canvas/client/state.ts +0 -792
  136. package/src/canvas/client/surface.tsx +0 -803
  137. package/src/canvas/client/theme-panel.tsx +0 -55
  138. package/src/canvas/client/tools.tsx +0 -153
  139. package/src/canvas/content.ts +0 -781
  140. package/src/canvas/edit.ts +0 -575
  141. package/src/canvas/editing.ts +0 -203
  142. package/src/canvas/fit.ts +0 -238
  143. package/src/canvas/global.d.ts +0 -15
  144. package/src/canvas/history.ts +0 -116
  145. package/src/canvas/index.html +0 -20
  146. package/src/canvas/server.ts +0 -766
  147. package/src/canvas/tokens.ts +0 -221
  148. package/src/commands/canvas.ts +0 -1097
  149. package/src/commands/demo.ts +0 -542
  150. package/src/commands/slides.ts +0 -184
  151. package/src/commands/teach.ts +0 -1401
  152. package/src/demo/beats.ts +0 -135
  153. package/src/demo/compile.ts +0 -350
  154. package/src/demo/container.ts +0 -219
  155. package/src/demo/cursors.ts +0 -55
  156. package/src/demo/drive.ts +0 -442
  157. package/src/demo/pointer.ts +0 -178
  158. package/src/demo/theme.ts +0 -112
  159. package/src/demo/timeline.ts +0 -64
  160. package/src/design/adapter.ts +0 -59
  161. package/src/design/base.css +0 -203
  162. package/src/design/brand-favicon.ts +0 -18
  163. package/src/design/neutral.ts +0 -127
  164. package/src/design/project-fonts.ts +0 -392
  165. package/src/slides/convert/chart.ts +0 -165
  166. package/src/slides/convert/deck.ts +0 -244
  167. package/src/slides/convert/export.ts +0 -567
  168. package/src/slides/convert/master.ts +0 -198
  169. package/src/slides/convert/shapes.ts +0 -804
  170. package/src/slides/convert/svg.ts +0 -38
  171. package/src/slides/convert/walk.ts +0 -671
  172. package/src/slides/decks.ts +0 -110
  173. package/src/slides/open.ts +0 -18
  174. package/src/slides/package/fonts.ts +0 -301
  175. package/src/slides/package/motion.ts +0 -316
  176. package/src/slides/package/test-face.ts +0 -113
  177. package/src/teach/browser/compile.ts +0 -44
  178. package/src/teach/browser/dismiss.ts +0 -76
  179. package/src/teach/browser/focus-line.ts +0 -19
  180. package/src/teach/browser/glossary-filter.ts +0 -44
  181. package/src/teach/browser/head.ts +0 -30
  182. package/src/teach/browser/quiz.ts +0 -19
  183. package/src/teach/browser/reload.ts +0 -9
  184. package/src/teach/browser/sidebar.ts +0 -233
  185. package/src/teach/browser/theme.ts +0 -18
  186. package/src/teach/components/code.tsx +0 -24
  187. package/src/teach/components/heading.tsx +0 -11
  188. package/src/teach/components/list.tsx +0 -17
  189. package/src/teach/components/paragraph.tsx +0 -29
  190. package/src/teach/components/refs.tsx +0 -28
  191. package/src/teach/cross-references.ts +0 -78
  192. package/src/teach/fonts.ts +0 -16
  193. package/src/teach/highlight.ts +0 -71
  194. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  195. package/src/teach/html/jsx-runtime.ts +0 -113
  196. package/src/teach/html/raw.ts +0 -28
  197. package/src/teach/lesson.ts +0 -178
  198. package/src/teach/nav.ts +0 -1337
  199. package/src/teach/render.ts +0 -240
  200. package/src/teach/workspace.ts +0 -1164
  201. package/src/teach/write-if-changed.ts +0 -17
@@ -1,392 +0,0 @@
1
- import {
2
- appendFileSync,
3
- copyFileSync,
4
- existsSync,
5
- mkdirSync,
6
- readdirSync,
7
- readFileSync,
8
- realpathSync,
9
- statSync,
10
- } from 'node:fs'
11
- import {
12
- basename,
13
- dirname,
14
- extname,
15
- isAbsolute,
16
- join,
17
- relative,
18
- resolve,
19
- } from 'node:path'
20
- import { primaryFontFamily } from '@/capture/sources'
21
- import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
22
-
23
- /**
24
- * A project's own faces, declared as plain `@font-face` rules in its override
25
- * sheets and stored beside them under `project/`, which sync treats as
26
- * project-authored by location and never writes. CSS is the declaration
27
- * format because every consumer already reads it, and reading descriptors
28
- * out of the font's own tables cannot open WOFF2 without a Brotli parser.
29
- *
30
- * A rule's `url()` is read from inside `project/` only, after `realpath`, so
31
- * an override cannot carry a file from elsewhere on the machine into a frame.
32
- * A hand-edited sheet reaches this module as often as `addProjectFace` does,
33
- * so the check sits on the read rather than on the write.
34
- */
35
-
36
- export const PROJECT_FONTS_DIR = 'fonts'
37
- export const PROJECT_FONTS_SHEET = 'fonts.css'
38
-
39
- export type FaceProblem = 'missing' | 'outside-folder'
40
-
41
- export interface ProjectFace {
42
- readonly family: string
43
- /** The `font-weight` descriptor verbatim, `400` where the rule omits it. */
44
- readonly weight: string
45
- readonly style: string
46
- /** Relative to the root. */
47
- readonly sheet: string
48
- /** The first local `url()` as the rule writes it. */
49
- readonly file: string
50
- readonly present: boolean
51
- readonly problem?: FaceProblem
52
- }
53
-
54
- export interface DroppedFace {
55
- readonly sheet: string
56
- readonly file: string
57
- readonly reason: FaceProblem
58
- }
59
-
60
- export interface InlinedSheet {
61
- readonly css: string
62
- readonly dropped: readonly DroppedFace[]
63
- /** The families of the rules that survived, in sheet order. */
64
- readonly declared: readonly string[]
65
- }
66
-
67
- const FORMATS = {
68
- '.woff2': { mime: 'font/woff2', format: 'woff2', magic: ['wOF2'] },
69
- '.woff': { mime: 'font/woff', format: 'woff', magic: ['wOFF'] },
70
- '.ttf': {
71
- mime: 'font/ttf',
72
- format: 'truetype',
73
- magic: ['\u0000\u0001\u0000\u0000', 'true'],
74
- },
75
- '.otf': { mime: 'font/otf', format: 'opentype', magic: ['OTTO'] },
76
- } as const
77
-
78
- type FontExtension = keyof typeof FORMATS
79
-
80
- function isFontExtension(ext: string): ext is FontExtension {
81
- return Object.hasOwn(FORMATS, ext)
82
- }
83
-
84
- const FACE_RULE = /@font-face\s*\{[^}]*\}/g
85
- const URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g
86
- const SCHEME = /^[a-z][a-z0-9+.-]*:/i
87
- const DECLARATION = /([A-Za-z-]+)\s*:\s*([^;}]*)/g
88
-
89
- /** Comments blanked to equal length, so an index into the result fits the source. */
90
- function masked(css: string): string {
91
- return css.replace(/\/\*[\s\S]*?\*\//g, (comment) =>
92
- ' '.repeat(comment.length),
93
- )
94
- }
95
-
96
- function descriptor(rule: string, name: string): string | undefined {
97
- const match = rule.match(new RegExp(`(?:^|[;{\\s])${name}\\s*:\\s*([^;}]*)`))
98
- return match?.[1]?.trim()
99
- }
100
-
101
- function familyOf(rule: string): string {
102
- return primaryFontFamily(descriptor(rule, 'font-family') ?? '')
103
- }
104
-
105
- /**
106
- * Every family a declaration value lists, quoted or not, across the sheet
107
- * outside its `@font-face` rules, which declare a face rather than use one.
108
- * Reads every property rather than only `font-family`, since a token such as
109
- * `--figure-hand` names its stack under a name that ends in neither.
110
- */
111
- export function namedFamilies(css: string): Set<string> {
112
- const uses = masked(css).replace(FACE_RULE, '')
113
- const named = new Set<string>()
114
- for (const [, , value] of uses.matchAll(DECLARATION)) {
115
- for (const item of (value ?? '').split(',')) {
116
- const family = primaryFontFamily(item)
117
- if (family) named.add(family)
118
- }
119
- }
120
- return named
121
- }
122
-
123
- function projectDir(root: string): string {
124
- return join(root, DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
125
- }
126
-
127
- function isWithin(folder: string, path: string): boolean {
128
- const inside = relative(folder, path)
129
- return inside !== '' && !inside.startsWith('..') && !isAbsolute(inside)
130
- }
131
-
132
- type Located =
133
- | { readonly ok: true; readonly path: string }
134
- | { readonly ok: false; readonly reason: FaceProblem }
135
-
136
- /** A URL path as the name on disk, or undefined for a malformed escape. */
137
- function decoded(url: string): string | undefined {
138
- try {
139
- return decodeURIComponent(url.replace(/[?#].*$/, ''))
140
- } catch {
141
- return undefined
142
- }
143
- }
144
-
145
- /**
146
- * Confined lexically before any read, then again after resolving links. The
147
- * check runs on the decoded path, so an escaped `..` cannot pass it.
148
- */
149
- function locate(root: string, sheet: string, url: string): Located {
150
- const folder = projectDir(root)
151
- const clean = decoded(url)
152
- if (clean === undefined) return { ok: false, reason: 'missing' }
153
- if (isAbsolute(clean)) return { ok: false, reason: 'outside-folder' }
154
- const path = resolve(join(root, dirname(sheet)), clean)
155
- if (!isWithin(folder, path)) return { ok: false, reason: 'outside-folder' }
156
- if (!existsSync(path) || !statSync(path).isFile()) {
157
- return { ok: false, reason: 'missing' }
158
- }
159
- if (!isWithin(realpathSync(folder), realpathSync(path))) {
160
- return { ok: false, reason: 'outside-folder' }
161
- }
162
- return { ok: true, path }
163
- }
164
-
165
- function localUrls(rule: string): string[] {
166
- return [...rule.matchAll(URL)]
167
- .map((match) => match[2]?.trim() ?? '')
168
- .filter((url) => url !== '' && !SCHEME.test(url))
169
- }
170
-
171
- function mimeOf(path: string): string {
172
- const ext = extname(path).toLowerCase()
173
- return isFontExtension(ext) ? FORMATS[ext].mime : 'application/octet-stream'
174
- }
175
-
176
- /**
177
- * Rewrites each local `url()` in a sheet's `@font-face` rules to a `data:`
178
- * URI, so a frame carries the face rather than a path the frame's origin
179
- * cannot serve. A rule naming a file that is missing or leads out of
180
- * `project/` is dropped whole and reported, and the sheet otherwise renders.
181
- * A `data:` or remote URL is left as written.
182
- */
183
- export function inlineProjectFaces(
184
- root: string,
185
- sheet: string,
186
- css: string,
187
- ): InlinedSheet {
188
- const dropped: DroppedFace[] = []
189
- const declared: string[] = []
190
- let out = ''
191
- let cursor = 0
192
- for (const match of masked(css).matchAll(FACE_RULE)) {
193
- const start = match.index
194
- const end = start + match[0].length
195
- const rule = css.slice(start, end)
196
- out += css.slice(cursor, start)
197
- cursor = end
198
-
199
- const urls = localUrls(match[0])
200
- const located = urls.map((url) => ({ url, at: locate(root, sheet, url) }))
201
- const failed = located.find(({ at }) => !at.ok)
202
- if (failed && !failed.at.ok) {
203
- dropped.push({ sheet, file: failed.url, reason: failed.at.reason })
204
- continue
205
- }
206
- let inlined = rule
207
- for (const { url, at } of located) {
208
- if (!at.ok) continue
209
- const data = `data:${mimeOf(at.path)};base64,${readFileSync(at.path).toString('base64')}`
210
- inlined = inlined.replace(url, data)
211
- }
212
- out += inlined
213
- declared.push(familyOf(match[0]))
214
- }
215
- return { css: out + css.slice(cursor), dropped, declared }
216
- }
217
-
218
- /** The project's override sheets, root-relative, in the order a frame cascades them. */
219
- export function projectSheets(root: string): string[] {
220
- const folder = projectDir(root)
221
- if (!existsSync(folder)) return []
222
- return readdirSync(folder)
223
- .filter((name) => name.endsWith('.css'))
224
- .sort((a, b) => a.localeCompare(b))
225
- .map((name) => join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR, name))
226
- }
227
-
228
- /** Every face the override sheets declare, without reading a font file. */
229
- export function listProjectFaces(root: string): ProjectFace[] {
230
- return projectSheets(root).flatMap((sheet) => {
231
- const css = masked(readFileSync(join(root, sheet), 'utf8'))
232
- return [...css.matchAll(FACE_RULE)].map((match): ProjectFace => {
233
- const rule = match[0]
234
- const local = localUrls(rule)[0]
235
- const external = [...rule.matchAll(URL)][0]?.[2]?.trim() ?? ''
236
- /*
237
- * A rule carrying its face inline or remotely has no file to read, and
238
- * the frame keeps it as written, so it lists as present. A `data:` URI
239
- * is shortened to its scheme rather than echoing the whole face.
240
- */
241
- const file = local ?? (external.startsWith('data:') ? 'data:' : external)
242
- const at: Located = local
243
- ? locate(root, sheet, local)
244
- : external
245
- ? { ok: true, path: external }
246
- : { ok: false, reason: 'missing' }
247
- return {
248
- family: familyOf(rule),
249
- weight: descriptor(rule, 'font-weight') ?? '400',
250
- style: descriptor(rule, 'font-style') ?? 'normal',
251
- sheet,
252
- file,
253
- present: at.ok,
254
- ...(!at.ok && { problem: at.reason }),
255
- }
256
- })
257
- })
258
- }
259
-
260
- export interface FaceSpec {
261
- readonly family: string
262
- readonly weight?: string
263
- readonly style?: string
264
- }
265
-
266
- export type AddOutcome =
267
- | {
268
- readonly ok: true
269
- readonly family: string
270
- readonly weight: string
271
- readonly style: string
272
- /** Relative to the sheet's folder, as the rule writes it. */
273
- readonly file: string
274
- readonly sheet: string
275
- }
276
- | {
277
- readonly ok: false
278
- readonly reason: 'invalid-font' | 'invalid-face' | 'exists'
279
- readonly detail: string
280
- }
281
-
282
- /** A name CSS takes inside single quotes with no escaping. */
283
- const SAFE_FAMILY = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/
284
- /** A file name a single-quoted `url()` takes with no escaping. */
285
- const SAFE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
286
- const WEIGHT = /^\d{1,4}( \d{1,4})?$/
287
-
288
- function weightInRange(weight: string): boolean {
289
- return weight.split(' ').every((n) => Number(n) >= 1 && Number(n) <= 1000)
290
- }
291
-
292
- /**
293
- * Copies a face into `project/fonts/` and appends one rule for it to
294
- * `project/fonts.css`. Refuses before writing anything when the extension is
295
- * not a font, the bytes disagree with it, a descriptor would need escaping,
296
- * or the folder already holds a file of that name.
297
- */
298
- export function addProjectFace(
299
- root: string,
300
- source: string,
301
- spec: FaceSpec,
302
- ): AddOutcome {
303
- const name = basename(source)
304
- const ext = extname(name).toLowerCase()
305
- if (!isFontExtension(ext)) {
306
- return {
307
- ok: false,
308
- reason: 'invalid-font',
309
- detail: `${name} is not a font. Use woff2, woff, ttf, or otf`,
310
- }
311
- }
312
- if (!SAFE_FILE.test(name)) {
313
- return {
314
- ok: false,
315
- reason: 'invalid-font',
316
- detail: `${name} must be letters, digits, dots, dashes, or underscores to sit in a url(). Rename it`,
317
- }
318
- }
319
- if (!existsSync(source) || !statSync(source).isFile()) {
320
- return { ok: false, reason: 'invalid-font', detail: `${source} not found` }
321
- }
322
- const head = readFileSync(source).subarray(0, 4).toString('binary')
323
- if (!FORMATS[ext].magic.some((magic) => head === magic)) {
324
- return {
325
- ok: false,
326
- reason: 'invalid-font',
327
- detail: `${name} does not read as ${ext.slice(1)} from its first bytes`,
328
- }
329
- }
330
-
331
- const weight = spec.weight ?? '400'
332
- const style = spec.style ?? 'normal'
333
- if (!SAFE_FAMILY.test(spec.family)) {
334
- return {
335
- ok: false,
336
- reason: 'invalid-face',
337
- detail: `family ${spec.family} must be letters, digits, spaces, dashes, or underscores`,
338
- }
339
- }
340
- if (!WEIGHT.test(weight) || !weightInRange(weight)) {
341
- return {
342
- ok: false,
343
- reason: 'invalid-face',
344
- detail: `weight ${weight} must be one number or a range of two, from 1 to 1000`,
345
- }
346
- }
347
- if (style !== 'normal' && style !== 'italic') {
348
- return {
349
- ok: false,
350
- reason: 'invalid-face',
351
- detail: `style ${style} must be normal or italic`,
352
- }
353
- }
354
-
355
- const folder = join(projectDir(root), PROJECT_FONTS_DIR)
356
- const dest = join(folder, name)
357
- const file = `${PROJECT_FONTS_DIR}/${name}`
358
- if (existsSync(dest)) {
359
- return {
360
- ok: false,
361
- reason: 'exists',
362
- detail: `${relative(root, dest)} already exists. Remove it or rename the source`,
363
- }
364
- }
365
-
366
- mkdirSync(folder, { recursive: true })
367
- copyFileSync(source, dest)
368
- const sheetPath = join(projectDir(root), PROJECT_FONTS_SHEET)
369
- const lead =
370
- existsSync(sheetPath) && readFileSync(sheetPath, 'utf8').trim() !== ''
371
- ? '\n'
372
- : ''
373
- appendFileSync(
374
- sheetPath,
375
- `${lead}@font-face {
376
- font-family: '${spec.family}';
377
- font-weight: ${weight};
378
- font-style: ${style};
379
- font-display: swap;
380
- src: url('${file}') format('${FORMATS[ext].format}');
381
- }
382
- `,
383
- )
384
- return {
385
- ok: true,
386
- family: spec.family,
387
- weight,
388
- style,
389
- file,
390
- sheet: relative(root, sheetPath),
391
- }
392
- }
@@ -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
- }