@erclx/canon 5.79.0 → 5.80.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 (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +13 -10
  4. package/claude/skills/design-pick/SKILL.md +8 -0
  5. package/claude/skills/design-pick/references/design-config.md +9 -6
  6. package/claude/skills/design-references/REQUIREMENT.md +32 -0
  7. package/claude/skills/design-references/SKILL.md +120 -0
  8. package/claude/skills/design-references/references/films.md +90 -0
  9. package/claude/skills/design-references/references/sites.md +36 -0
  10. package/claude/skills/design-taste/SKILL.md +16 -4
  11. package/claude/skills/design-taste/references/adopted.md +5 -1
  12. package/claude/skills/design-taste/references/kinds.md +1 -0
  13. package/claude/skills/design-taste/references/preflight.md +27 -1
  14. package/claude/skills/design-taste/references/tells.md +2 -0
  15. package/claude/skills/review-ui/references/driving.md +33 -1
  16. package/claude/skills/search-craft/REQUIREMENT.md +3 -3
  17. package/claude/skills/search-craft/SKILL.md +12 -1
  18. package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
  19. package/claude/skills/target-scaffold/SKILL.md +44 -0
  20. package/claude/skills/target-setup/SKILL.md +5 -1
  21. package/claude/skills/ui-checklist/SKILL.md +5 -1
  22. package/claude/skills/ui-craft/SKILL.md +9 -9
  23. package/claude/skills/ui-craft/references/adopted.md +4 -2
  24. package/claude/skills/ui-craft/references/capture.md +8 -0
  25. package/claude/skills/ui-craft/references/controls.md +6 -0
  26. package/claude/skills/ui-craft/references/motion.md +6 -0
  27. package/claude/skills/ui-film/references/take-craft.md +2 -0
  28. package/docs/agents/output-shape.md +2 -1
  29. package/docs/target/projects.md +2 -2
  30. package/docs/workflow/skill-map.md +7 -5
  31. package/docs/workflow/visual-design-workflow.md +4 -0
  32. package/governance/ledger/skills/design-references/SKILL.md +38 -0
  33. package/governance/ledger/skills/design-taste/SKILL.md +36 -30
  34. package/governance/ledger/skills/design-taste/kinds.md +1 -0
  35. package/governance/ledger/skills/design-taste/tells.md +22 -20
  36. package/governance/ledger/skills/search-craft/SKILL.md +6 -0
  37. package/governance/ledger/skills/ui-craft/capture.md +10 -0
  38. package/governance/ledger/skills/ui-craft/controls.md +8 -0
  39. package/governance/ledger/skills/ui-craft/motion.md +9 -1
  40. package/package.json +3 -3
  41. package/src/capture/render.ts +154 -2
  42. package/src/capture/sections.ts +257 -0
  43. package/src/claude/cases/misc.ts +5 -0
  44. package/src/claude/cases/setup.ts +7 -0
  45. package/src/commands/capture.ts +149 -4
  46. package/src/commands/tooling.ts +72 -0
  47. package/src/take/perform.ts +29 -4
  48. package/src/tooling/list.ts +3 -0
  49. package/src/tooling/scaffold.ts +193 -0
  50. package/tooling/cloudflare/manifest.toml +1 -1
  51. package/tooling/cloudflare/reference.md +8 -0
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import {
@@ -32,6 +32,7 @@ const DEFAULT_SOURCE = 'assets'
32
32
  type Renderer = typeof import('@/capture/render')
33
33
  type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
34
34
  type ColorScheme = import('@/capture/render').ColorScheme
35
+ type SectionsResult = import('@/capture/render').SectionsResult
35
36
 
36
37
  const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark']
37
38
 
@@ -56,7 +57,15 @@ export function register(program: Command): void {
56
57
  '-o, --out <path>',
57
58
  'Output directory for a file source, or the destination PNG for a URL source; defaults beside the source',
58
59
  )
59
- .option('-s, --selector <selector>', 'Element to capture')
60
+ .option(
61
+ '-s, --selector <selector>',
62
+ 'Element to capture, or with --sections the root to split, defaulting to main and then body',
63
+ )
64
+ .option(
65
+ '--sections',
66
+ 'Write one PNG per section of one page into the --out folder, in page order',
67
+ )
68
+ .option('--json', 'With --sections, print the result as one JSON record')
60
69
  .option(
61
70
  '--width <px>',
62
71
  'Viewport width in pixels, so a media query resolves at that width',
@@ -69,8 +78,8 @@ export function register(program: Command): void {
69
78
  'after',
70
79
  [
71
80
  '',
72
- 'The selector has no default, since the element to crop to belongs to the',
73
- 'page. Each element holding text inside it must resolve its first font',
81
+ 'Outside --sections the selector has no default, since the element to',
82
+ 'crop to belongs to the page. Each element holding text inside it must resolve its first font',
74
83
  'family, and a generic keyword such as monospace or system-ui fails,',
75
84
  'since it resolves differently on each machine. The user agent sets',
76
85
  'monospace on pre, code, kbd, and samp, so each needs its own',
@@ -81,6 +90,21 @@ export function register(program: Command): void {
81
90
  'a light or dark class on the root, so a stylesheet keyed on any of the',
82
91
  'three renders that theme. Run it once per scheme with its own --out.',
83
92
  '',
93
+ '--sections takes one HTML file or URL and needs --out naming a folder.',
94
+ 'It wheels down the page so lazy content loads, hides fixed and sticky',
95
+ 'layers, splits any block taller than 1.6 viewports into its children,',
96
+ 'and joins a block under 400px to the one above. Each shot is cut at',
97
+ '2400px and writes section-NN.png with its stamp. A generic or missing',
98
+ 'font family is recorded rather than refused. --json prints',
99
+ '{ source, sections: [{ index, path, top, height }], failed: [{ index,',
100
+ 'reason }], fonts: { generic, missing } }, or { source, reason } when',
101
+ 'the page fails whole.',
102
+ '',
103
+ 'Examples:',
104
+ ' canon capture assets --selector .window',
105
+ ' canon capture https://example.com --sections --out refs/example --width 1440',
106
+ ' canon capture https://example.com --sections --out refs/example --json',
107
+ '',
84
108
  ].join('\n'),
85
109
  )
86
110
  .action(
@@ -91,8 +115,20 @@ export function register(program: Command): void {
91
115
  selector?: string
92
116
  width?: string
93
117
  colorScheme?: string
118
+ sections?: boolean
119
+ json?: boolean
94
120
  },
95
121
  ): Promise<void> => {
122
+ if (opts.json && !opts.sections) {
123
+ frameError('--json applies to --sections. See canon capture --help.')
124
+ process.exitCode = 1
125
+ return
126
+ }
127
+ if (opts.sections) {
128
+ await captureSectionsAction(source, opts)
129
+ return
130
+ }
131
+
96
132
  /**
97
133
  * Refused rather than defaulted, and refused ahead of every other
98
134
  * check, so the message names the invocation rather than whatever the
@@ -176,6 +212,115 @@ export function register(program: Command): void {
176
212
  )
177
213
  }
178
214
 
215
+ interface SectionFlags {
216
+ out?: string
217
+ selector?: string
218
+ width?: string
219
+ colorScheme?: string
220
+ json?: boolean
221
+ }
222
+
223
+ /**
224
+ * Section mode reads `--out` as a folder for every source kind, so it is
225
+ * required here even for a file source, and a directory source is refused,
226
+ * since a section run describes one page.
227
+ */
228
+ async function captureSectionsAction(
229
+ source: string,
230
+ opts: SectionFlags,
231
+ ): Promise<void> {
232
+ const width = parseWidth(opts.width)
233
+ if (Number.isNaN(width)) {
234
+ frameError(
235
+ `--width takes a whole number of pixels of 1 or more, got ${opts.width}.`,
236
+ )
237
+ process.exitCode = 1
238
+ return
239
+ }
240
+ const colorScheme = parseColorScheme(opts.colorScheme)
241
+ if (colorScheme === null) {
242
+ frameError(
243
+ `--color-scheme takes ${COLOR_SCHEMES.join(' or ')}, got ${JSON.stringify(opts.colorScheme)}.`,
244
+ )
245
+ process.exitCode = 1
246
+ return
247
+ }
248
+ if (!opts.out) {
249
+ frameError('--sections needs --out naming the folder the shots go in.')
250
+ process.exitCode = 1
251
+ return
252
+ }
253
+
254
+ const isUrl = isUrlSource(source)
255
+ const sourcePath = isUrl ? source : resolve(process.cwd(), source)
256
+ if (!isUrl && !existsSync(sourcePath)) {
257
+ frameError(`${source} not found`)
258
+ process.exitCode = 1
259
+ return
260
+ }
261
+ if (!isUrl && statSync(sourcePath).isDirectory()) {
262
+ frameError(
263
+ `--sections takes one HTML file or URL, and ${source} is a directory.`,
264
+ )
265
+ process.exitCode = 1
266
+ return
267
+ }
268
+
269
+ const json = opts.json ?? false
270
+ if (!json) intro('Capture sections')
271
+ let result: SectionsResult
272
+ try {
273
+ const renderer = await import('@/capture/render')
274
+ result = await renderer.captureSections(sourcePath, {
275
+ outDir: resolve(process.cwd(), opts.out),
276
+ root: opts.selector,
277
+ width,
278
+ colorScheme,
279
+ })
280
+ } catch (error) {
281
+ if (json) {
282
+ logError(error instanceof Error ? error.message : String(error))
283
+ } else {
284
+ reportInFrame(error)
285
+ }
286
+ process.exitCode = 1
287
+ return
288
+ }
289
+
290
+ if (result.status === 'failed') {
291
+ if (json) {
292
+ process.stdout.write(
293
+ `${JSON.stringify({ source: result.source, reason: result.reason })}\n`,
294
+ )
295
+ }
296
+ logError(`${displayPath(result.source)}: ${result.reason}`)
297
+ if (!json) outro()
298
+ process.exitCode = 1
299
+ return
300
+ }
301
+
302
+ const { source: page, sections, failed, fonts } = result
303
+ if (json) {
304
+ process.stdout.write(
305
+ `${JSON.stringify({ source: page, sections, failed, fonts })}\n`,
306
+ )
307
+ }
308
+ for (const shot of json ? [] : sections) {
309
+ logInfo(`${displayPath(shot.path)} top ${shot.top} height ${shot.height}`)
310
+ }
311
+ for (const fault of failed) {
312
+ logError(`section ${fault.index}: ${fault.reason}`)
313
+ }
314
+ if (!json && fonts.generic.length) {
315
+ logWarn(`generic families drawn: ${fonts.generic.join(', ')}`)
316
+ }
317
+ if (!json && fonts.missing.length) {
318
+ logWarn(`families not installed: ${fonts.missing.join(', ')}`)
319
+ }
320
+ if (!json) outro()
321
+ if (failed.length || !sections.length) process.exitCode = 1
322
+ }
323
+
179
324
  /**
180
325
  * Undefined for an absent flag and `NaN` for a value that is not a positive
181
326
  * integer, so the caller can tell no width from a bad one.
@@ -37,6 +37,7 @@ import {
37
37
  import { scan, type ScanResult } from '@/tooling/scan'
38
38
  import { recordToolingChain } from '@/tooling/stamp'
39
39
  import { subfolderPath } from '@/tooling/subfolder'
40
+ import { scaffoldStack } from '@/tooling/scaffold'
40
41
  import { verifyStack } from '@/tooling/verify-stack'
41
42
  import {
42
43
  intro,
@@ -377,6 +378,77 @@ export function register(program: Command): void {
377
378
  })
378
379
  process.exitCode = outcome.exitCode
379
380
  })
381
+
382
+ tooling
383
+ .command('scaffold')
384
+ .description("Run a stack's starter command into an empty folder")
385
+ .argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
386
+ .argument('[target]', 'Target directory, created when missing', '.')
387
+ .helpOption('-h, --help', 'Show this help message')
388
+ .option('--json', 'Add a machine-readable record on stdout')
389
+ .addHelpText(
390
+ 'after',
391
+ [
392
+ '',
393
+ "Runs the stack's manifest scaffold in a scratch folder, with the",
394
+ "target's folder name as the project name, then moves the result into",
395
+ 'the target. A target holding anything but .git is refused, and a',
396
+ 'failed command moves nothing and leaves the scratch folder named.',
397
+ 'Run `canon init` or the target-setup skill afterward to sync tooling.',
398
+ '',
399
+ 'Exit codes:',
400
+ ' 0 scaffolded',
401
+ ' 1 refused: excluded, no-manifest, no-scaffold, not-empty, bad-name,',
402
+ ' scaffold-failed, or no-output',
403
+ '',
404
+ 'Examples:',
405
+ ' canon tooling scaffold vite-react',
406
+ ' canon tooling scaffold astro my-site --json',
407
+ '',
408
+ ].join('\n'),
409
+ )
410
+ .action(async (stack: string, target: string, opts: ListOptions) => {
411
+ process.exitCode = await runScaffold(stack, target, opts)
412
+ })
413
+ }
414
+
415
+ /**
416
+ * A `--json` run writes no frame, so a skill reads the record alone, and a
417
+ * refusal carries its reason on stdout beside the exit 1.
418
+ */
419
+ async function runScaffold(
420
+ stack: string,
421
+ target: string,
422
+ opts: ListOptions,
423
+ ): Promise<number> {
424
+ const json = opts.json === true
425
+ if (!json) intro(`canon tooling scaffold ${stack}`)
426
+
427
+ const result = await scaffoldStack({ root: PROJECT_ROOT, stack, target })
428
+
429
+ if (!result.ok) {
430
+ logError(result.message)
431
+ if (result.scratch !== undefined) {
432
+ if (result.output !== undefined && result.output !== '') {
433
+ process.stderr.write(
434
+ `${result.output.split('\n').slice(-20).join('\n')}\n`,
435
+ )
436
+ }
437
+ logInfo(`Kept ${result.scratch}`)
438
+ }
439
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`)
440
+ else outro()
441
+ return 1
442
+ }
443
+
444
+ if (json) {
445
+ process.stdout.write(`${JSON.stringify(result)}\n`)
446
+ return 0
447
+ }
448
+ logStep(`Scaffolded ${result.target}`)
449
+ for (const file of result.files) logAdd(file)
450
+ outroSuccess('Scaffolded', `Next: canon init ${target}`)
451
+ return 0
380
452
  }
381
453
 
382
454
  /**
@@ -472,6 +472,12 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
472
472
  * innermost element with a size whose whole text is those words, so a hidden
473
473
  * copy earlier in the page cannot shadow a visible one, and anything else as a
474
474
  * CSS selector.
475
+ * The document is searched first and every open shadow root after it in tree
476
+ * order, so a document-tree match wins, except that innermost reads containment
477
+ * through a shadow root to its host and a `text=` match holding a sized match
478
+ * inside its subtree's roots gives way to it. A selector matches within one
479
+ * root and never across a shadow boundary. A closed root and a frame stay
480
+ * unreachable.
475
481
  * It runs inside the page so both routes resolve a target the same way, and a
476
482
  * match with no size counts as missing, since a click on it would land on
477
483
  * whatever sits behind.
@@ -479,19 +485,38 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
479
485
  export function boxExpression(target: string): string {
480
486
  return `(() => {
481
487
  const target = ${JSON.stringify(target)}
488
+ const roots = []
489
+ const collect = (root) => {
490
+ roots.push(root)
491
+ for (const node of root.querySelectorAll('*')) {
492
+ if (node.shadowRoot) collect(node.shadowRoot)
493
+ }
494
+ }
495
+ collect(document)
496
+ const within = (outer, inner) => {
497
+ for (let node = inner; node; node = node.parentNode ?? node.host ?? null) {
498
+ if (node === outer) return true
499
+ }
500
+ return false
501
+ }
482
502
  let element = null
483
503
  if (target.startsWith('text=')) {
484
504
  const want = target.slice(5).trim()
485
- const matches = [...document.querySelectorAll('body *')].filter(
486
- (node) => node.textContent.trim() === want,
505
+ const matches = roots.flatMap((root) =>
506
+ [...root.querySelectorAll(root === document ? 'body *' : '*')].filter(
507
+ (node) => node.textContent.trim() === want,
508
+ ),
487
509
  )
488
- const innermost = matches.filter((node) => !matches.some((other) => other !== node && node.contains(other)))
510
+ const innermost = matches.filter((node) => !matches.some((other) => other !== node && within(node, other)))
489
511
  element = innermost.find((node) => {
490
512
  const rect = node.getBoundingClientRect()
491
513
  return rect.width > 0 && rect.height > 0
492
514
  }) ?? null
493
515
  } else {
494
- element = document.querySelector(target)
516
+ for (const root of roots) {
517
+ element = root.querySelector(target)
518
+ if (element) break
519
+ }
495
520
  }
496
521
  if (!element) return null
497
522
  const rect = element.getBoundingClientRect()
@@ -6,6 +6,8 @@ export interface StackSummary {
6
6
  readonly devDeps: number
7
7
  readonly scripts: number
8
8
  readonly gitignoreGroups: number
9
+ /** Whether `canon tooling scaffold` can start a project on this stack. */
10
+ readonly scaffold: boolean
9
11
  }
10
12
 
11
13
  /**
@@ -26,6 +28,7 @@ export function buildStackSummaries(root: string): StackSummary[] {
26
28
  devDeps: manifest.devPackages.length,
27
29
  scripts: Object.keys(manifest.scripts).length,
28
30
  gitignoreGroups: manifest.gitignore.length,
31
+ scaffold: manifest.scaffold !== undefined,
29
32
  })
30
33
  }
31
34
 
@@ -0,0 +1,193 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ renameSync,
8
+ rmSync,
9
+ } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { basename, join, resolve } from 'node:path'
12
+ import { execa } from 'execa'
13
+ import { isStackExcluded, loadManifest } from '@/tooling/manifest'
14
+
15
+ export interface ScaffoldOptions {
16
+ /** Root holding `tooling/<stack>/manifest.toml`. */
17
+ readonly root: string
18
+ readonly stack: string
19
+ readonly target: string
20
+ /** Parent of the scratch folder the command runs in. Defaults to the OS temp dir. */
21
+ readonly scratchBase?: string
22
+ }
23
+
24
+ export type ScaffoldRefusal =
25
+ | 'excluded'
26
+ | 'no-manifest'
27
+ | 'no-scaffold'
28
+ | 'not-empty'
29
+ | 'bad-name'
30
+ | 'scaffold-failed'
31
+ | 'no-output'
32
+
33
+ export type ScaffoldResult =
34
+ | {
35
+ readonly ok: true
36
+ readonly stack: string
37
+ readonly target: string
38
+ readonly name: string
39
+ readonly files: readonly string[]
40
+ }
41
+ | {
42
+ readonly ok: false
43
+ readonly reason: ScaffoldRefusal
44
+ readonly message: string
45
+ readonly suggested?: string
46
+ readonly scratch?: string
47
+ readonly output?: string
48
+ }
49
+
50
+ /**
51
+ * npm, create-next-app, and composer all refuse a package name with capitals,
52
+ * spaces, or a leading dot, and the target's basename becomes that name.
53
+ */
54
+ const PACKAGE_NAME = /^[a-z0-9][a-z0-9._-]*$/
55
+ const MAX_NAME = 214
56
+
57
+ /** A target holding only `.git` counts as empty, since `git init` first is common. */
58
+ const IGNORED_ENTRIES = new Set(['.git'])
59
+
60
+ /**
61
+ * Runs the stack's `manifest.scaffold` in a scratch folder with `{{name}}` set
62
+ * to the target's basename, then moves what it produced into the target. The
63
+ * scratch step is what lets a command that opens on `mkdir {{name}} && cd`
64
+ * land in a folder that already exists.
65
+ *
66
+ * Nothing reaches the target unless the command exits 0 and produced its
67
+ * folder. A failed run leaves the scratch folder in place and names it.
68
+ */
69
+ export async function scaffoldStack(
70
+ opts: ScaffoldOptions,
71
+ ): Promise<ScaffoldResult> {
72
+ const { root, stack } = opts
73
+ const target = resolve(opts.target)
74
+
75
+ if (isStackExcluded(stack)) {
76
+ return refuse('excluded', `Stack '${stack}' is excluded from tooling.`)
77
+ }
78
+ const manifest = loadManifest(root, stack)
79
+ if (manifest === undefined) {
80
+ return refuse(
81
+ 'no-manifest',
82
+ `No manifest at tooling/${stack}/manifest.toml`,
83
+ )
84
+ }
85
+ if (manifest.scaffold === undefined) {
86
+ return refuse(
87
+ 'no-scaffold',
88
+ `Stack '${stack}' has no scaffold command in its manifest.`,
89
+ )
90
+ }
91
+
92
+ const held = existsSync(target) ? occupiedEntries(target) : []
93
+ if (held.length > 0) {
94
+ return refuse(
95
+ 'not-empty',
96
+ `${target} is not empty (${held.slice(0, 3).join(', ')}${held.length > 3 ? ', ...' : ''}). Scaffold into an empty folder.`,
97
+ )
98
+ }
99
+
100
+ const name = basename(target)
101
+ if (!PACKAGE_NAME.test(name) || name.length > MAX_NAME) {
102
+ const suggested = suggestName(name)
103
+ return {
104
+ ok: false,
105
+ reason: 'bad-name',
106
+ message: `Folder name '${name}' is not a valid package name. Use lowercase letters, digits, '.', '_', and '-', such as '${suggested}'.`,
107
+ suggested,
108
+ }
109
+ }
110
+
111
+ const scratch = mkdtempSync(
112
+ join(opts.scratchBase ?? tmpdir(), 'canon-scaffold-'),
113
+ )
114
+ // Manifests carry shell syntax (`&&`, `cd`, quoted globs), so the command
115
+ // runs under a shell rather than as a split argv, as `tooling verify` runs it.
116
+ const command = manifest.scaffold.replaceAll('{{name}}', name)
117
+ const run = await execa(command, {
118
+ cwd: scratch,
119
+ shell: 'bash',
120
+ all: true,
121
+ reject: false,
122
+ stdin: 'ignore',
123
+ env: { CI: '1' },
124
+ })
125
+ const output = typeof run.all === 'string' ? run.all : ''
126
+
127
+ if (run.failed) {
128
+ return {
129
+ ok: false,
130
+ reason: 'scaffold-failed',
131
+ message: `Scaffold command failed: ${command}`,
132
+ scratch,
133
+ output,
134
+ }
135
+ }
136
+ const produced = join(scratch, name)
137
+ if (!existsSync(produced)) {
138
+ return {
139
+ ok: false,
140
+ reason: 'no-output',
141
+ message: `Scaffold command exited 0 but produced no ${name}/ folder.`,
142
+ scratch,
143
+ output,
144
+ }
145
+ }
146
+
147
+ mkdirSync(target, { recursive: true })
148
+ const keepTargetGit = existsSync(join(target, '.git'))
149
+ const files: string[] = []
150
+ for (const entry of readdirSync(produced).sort()) {
151
+ if (entry === '.git' && keepTargetGit) continue
152
+ move(join(produced, entry), join(target, entry))
153
+ files.push(entry)
154
+ }
155
+ rmSync(scratch, { recursive: true, force: true })
156
+
157
+ return { ok: true, stack, target, name, files }
158
+ }
159
+
160
+ function refuse(reason: ScaffoldRefusal, message: string): ScaffoldResult {
161
+ return { ok: false, reason, message }
162
+ }
163
+
164
+ function occupiedEntries(dir: string): string[] {
165
+ return readdirSync(dir)
166
+ .filter((entry) => !IGNORED_ENTRIES.has(entry))
167
+ .sort()
168
+ }
169
+
170
+ /**
171
+ * Lowercases the name and turns every run of other characters into one dash,
172
+ * falling back to `app` when nothing usable is left.
173
+ */
174
+ export function suggestName(name: string): string {
175
+ const cleaned = name
176
+ .toLowerCase()
177
+ .replace(/[^a-z0-9._-]+/g, '-')
178
+ .replace(/^[^a-z0-9]+/, '')
179
+ .replace(/-+$/, '')
180
+ .slice(0, MAX_NAME)
181
+ return cleaned === '' ? 'app' : cleaned
182
+ }
183
+
184
+ /** The OS temp dir often sits on another filesystem, where a rename fails. */
185
+ function move(from: string, to: string): void {
186
+ try {
187
+ renameSync(from, to)
188
+ } catch (error) {
189
+ if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error
190
+ cpSync(from, to, { recursive: true, preserveTimestamps: true })
191
+ rmSync(from, { recursive: true, force: true })
192
+ }
193
+ }
@@ -2,4 +2,4 @@
2
2
  name = "cloudflare"
3
3
  extends = ""
4
4
  runtime = "bun"
5
- scaffold = ""
5
+ scaffold = "npm create cloudflare@latest {{name}} -- --type hello-world --lang ts --no-deploy --no-git"
@@ -6,6 +6,14 @@ The cloudflare stack ships one parameterized GitHub Actions workflow that deploy
6
6
 
7
7
  It ships one script, `scripts/delete-previews.sh`, which the cleanup job calls and which overwrites a project's own file of that name on every sync.
8
8
 
9
+ ## Scaffold
10
+
11
+ The manifest's scaffold starts a new project as a TypeScript Worker from the `hello-world` template, through `npm create cloudflare@latest` with deploy and git both off. `canon tooling scaffold cloudflare` runs it in an empty folder.
12
+
13
+ - The starter installs its own dependencies, since `create-cloudflare` runs the install itself.
14
+ - A project already on `astro` or `vite-react` never takes this scaffold. It syncs the stack onto the framework it has.
15
+ - The stack extends nothing and ships no scripts, so `canon tooling verify cloudflare` passes Scaffold and Sync and stops at `lint:fix`. Run `canon init` on the starter to bring in the base scripts.
16
+
9
17
  ## Deploy workflow
10
18
 
11
19
  ### Job shape