uniweb 1.0.0 → 1.0.2

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 (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Snapshot Command
3
+ *
4
+ * Captures a site in a real browser and composes a preview image from the
5
+ * captures — by default the site's card image, recorded as `preview:` in
6
+ * site.yml.
7
+ *
8
+ * The capturing and composing is `@uniweb/snapshot`, a separate package so that
9
+ * nobody who never takes a snapshot installs a browser driver. This command finds
10
+ * the site, puts it behind a URL, and records the result:
11
+ *
12
+ * uniweb snapshot build the site, serve dist/, capture it
13
+ * uniweb snapshot --dev capture the site's Vite dev server (no build)
14
+ * uniweb snapshot --url <url> capture a site that is already running
15
+ * uniweb snapshot --compare one capture, several looks, on one sheet
16
+ *
17
+ * The layout is chosen from the page: one that scrolls gets `split` (the first
18
+ * view in a browser window, beside a long strip of the page); one that does not
19
+ * — a documentation shell, an app — gets `device` (desktop and phone). How the
20
+ * two frames sit together is the look: `--gap`/`--overlap`, `--strip`, `--side`,
21
+ * `--frame`, `--tone`.
22
+ *
23
+ * A site keeps its chosen look in `site/snapshot.yml` (utils/snapshot-settings.js):
24
+ * the command's defaults for that site, which flags override and `--save` writes.
25
+ *
26
+ * `preview:` is written only when site.yml has none, or holds the app's generated
27
+ * token. An address the author wrote is never replaced.
28
+ */
29
+
30
+ import { spawn } from 'node:child_process'
31
+ import { existsSync, readFileSync } from 'node:fs'
32
+ import { createRequire } from 'node:module'
33
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
34
+ import { pathToFileURL } from 'node:url'
35
+ import yaml from 'js-yaml'
36
+ import { writeSiteConfig } from '@uniweb/build/uwx'
37
+
38
+ import { didYouMean } from '../utils/args.js'
39
+ import { humanBytes } from '../utils/bytes.js'
40
+ import { discoverSites } from '../utils/discover.js'
41
+ import { detectWorkspacePm } from '../utils/pm.js'
42
+ import { isAuthoredPreview } from '../utils/preview.js'
43
+ import {
44
+ SETTINGS_FILE,
45
+ SettingsError,
46
+ mergeSettings,
47
+ readSnapshotSettings,
48
+ saveSnapshotSettings
49
+ } from '../utils/snapshot-settings.js'
50
+ import { findWorkspaceRoot } from '../utils/workspace.js'
51
+
52
+ const RED = '\x1b[31m'
53
+ const GREEN = '\x1b[32m'
54
+ const YELLOW = '\x1b[33m'
55
+ const CYAN = '\x1b[36m'
56
+ const DIM = '\x1b[2m'
57
+ const RESET = '\x1b[0m'
58
+
59
+ /** Flags that choose the image, by the `snapshot.yml` setting each one sets. */
60
+ const SETTING_FLAGS = {
61
+ '--route': 'route',
62
+ '--layout': 'layout',
63
+ '--tone': 'tone',
64
+ '--gap': 'gap',
65
+ '--overlap': 'overlap',
66
+ '--strip': 'strip',
67
+ '--side': 'side',
68
+ '--frame': 'frame',
69
+ '--size': 'size',
70
+ '--scale': 'scale',
71
+ '--quality': 'quality',
72
+ '--hide': 'hide',
73
+ '--out': 'out'
74
+ }
75
+ const NUMBER_SETTINGS = ['gap', 'overlap', 'scale', 'quality']
76
+ /** Flags that choose where the site comes from and what the run does. */
77
+ const CONTROL_VALUE_FLAGS = ['--site', '--url']
78
+ const CONTROL_BOOLEAN_FLAGS = ['--dev', '--no-build', '--no-set-preview', '--compare', '--save']
79
+ const GLOBAL_FLAGS = ['--non-interactive', '--help', '-h']
80
+ const ALL_FLAGS = [...Object.keys(SETTING_FLAGS), ...CONTROL_VALUE_FLAGS, ...CONTROL_BOOLEAN_FLAGS, ...GLOBAL_FLAGS]
81
+ const LOOK_SETTINGS = ['gap', 'overlap', 'strip', 'side', 'frame']
82
+
83
+ export const DEFAULT_OUTPUT = join('public', 'preview.webp')
84
+ export const COMPARE_OUTPUT = join('.uniweb', 'snapshot', 'compare.webp')
85
+
86
+ class UsageError extends Error {}
87
+
88
+ const camel = (flag) => flag.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
89
+
90
+ /**
91
+ * Parse `uniweb snapshot` arguments into the image settings they choose and the
92
+ * run controls. Checks the shape of each value; what a value may be (a layout
93
+ * name, a range) is checked by the package, before anything is built.
94
+ *
95
+ * @param {string[]} args
96
+ * @param {string} [cwd] - `--out` is relative to it
97
+ */
98
+ export function parseSnapshotArgs(args = [], cwd = process.cwd()) {
99
+ const settings = {}
100
+ const control = {}
101
+ const positionals = []
102
+ for (let i = 0; i < args.length; i++) {
103
+ const raw = args[i]
104
+ if (raw === '--') {
105
+ positionals.push(...args.slice(i + 1))
106
+ break
107
+ }
108
+ if (!raw.startsWith('-') || raw === '-') {
109
+ positionals.push(raw)
110
+ continue
111
+ }
112
+ const eq = raw.indexOf('=')
113
+ const name = eq === -1 ? raw : raw.slice(0, eq)
114
+ const takesValue = name in SETTING_FLAGS || CONTROL_VALUE_FLAGS.includes(name)
115
+ if (takesValue) {
116
+ const value = eq === -1 ? args[++i] : raw.slice(eq + 1)
117
+ if (value === undefined || value === '' || (eq === -1 && value.startsWith('--'))) {
118
+ throw new UsageError(`\`${name}\` needs a value.`)
119
+ }
120
+ const key = SETTING_FLAGS[name]
121
+ if (!key) control[camel(name)] = value
122
+ else if (key === 'hide') settings.hide = [...(settings.hide ?? []), value]
123
+ else if (key === 'out') settings.out = resolve(cwd, value)
124
+ else if (NUMBER_SETTINGS.includes(key)) {
125
+ const number = Number(value)
126
+ if (!Number.isFinite(number)) throw new UsageError(`\`${name}\` needs a number.`)
127
+ settings[key] = number
128
+ } else settings[key] = value
129
+ } else if (CONTROL_BOOLEAN_FLAGS.includes(name)) {
130
+ control[camel(name)] = true
131
+ } else if (!GLOBAL_FLAGS.includes(name)) {
132
+ const suggestion = didYouMean(name, ALL_FLAGS)
133
+ throw new UsageError(
134
+ `Unknown flag \`${name}\` for \`uniweb snapshot\`.` + (suggestion ? ` Did you mean \`${suggestion}\`?` : '')
135
+ )
136
+ }
137
+ }
138
+
139
+ if (control.dev && control.url) throw new UsageError('Pass `--dev` or `--url`, not both.')
140
+ if (settings.gap !== undefined && settings.overlap !== undefined) {
141
+ throw new UsageError('Pass `--gap` or `--overlap`, not both.')
142
+ }
143
+ if (control.compare && control.save) {
144
+ throw new UsageError('`--save` keeps the look of a snapshot; choose a look from the sheet, then save that.')
145
+ }
146
+ return { settings, control, positionals }
147
+ }
148
+
149
+ /**
150
+ * The package's options for a set of settings. `size` is the one setting whose
151
+ * shape the package does not take as written.
152
+ */
153
+ export function libraryOptions({ size, out, scale, quality, ...rest }) {
154
+ const options = { ...rest }
155
+ if (size !== undefined) {
156
+ const match = /^(\d+)x(\d+)$/.exec(String(size))
157
+ if (!match) throw new UsageError('`size` is WIDTHxHEIGHT, e.g. 1600x1000.')
158
+ options.canvas = { width: Number(match[1]), height: Number(match[2]) }
159
+ }
160
+ if (scale !== undefined) options.scale = Number(scale)
161
+ if (quality !== undefined) options.quality = Number(quality)
162
+ if (out !== undefined) options.output = out
163
+ return options
164
+ }
165
+
166
+ /** A variant's changes as the flags that apply them; `current` for none. */
167
+ export function flagsFor(changes) {
168
+ const parts = Object.entries(changes)
169
+ .filter(([, value]) => value !== undefined)
170
+ .map(([key, value]) => `--${key} ${value}`)
171
+ return parts.length ? parts.join(' ') : 'current'
172
+ }
173
+
174
+ /**
175
+ * The `preview:` value that names `output`, or null when it cannot be named — an
176
+ * image outside the site's `public/` folder has no site-root path.
177
+ */
178
+ export function previewValueFor(siteDir, output) {
179
+ const rel = relative(join(siteDir, 'public'), output)
180
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null
181
+ return '/' + rel.split(sep).join('/')
182
+ }
183
+
184
+ /**
185
+ * What to do with site.yml's `preview:` given the value we would write.
186
+ * @returns {'set'|'unchanged'|'replace'|'keep'}
187
+ */
188
+ export function previewDecision(current, next) {
189
+ if (current === undefined || current === null || current === '') return 'set'
190
+ if (current === next) return 'unchanged'
191
+ return isAuthoredPreview(current) ? 'keep' : 'replace'
192
+ }
193
+
194
+ /** The site the command applies to: named, else the one containing cwd, else the only one. */
195
+ export function pickSite(sites, rootDir, { requested, cwd }) {
196
+ if (requested) {
197
+ return { site: sites.find((s) => s.name === requested || s.path === requested) ?? null }
198
+ }
199
+ const containing = sites.find((s) => {
200
+ const rel = relative(join(rootDir, s.path), cwd)
201
+ return !rel.startsWith('..') && !isAbsolute(rel)
202
+ })
203
+ if (containing) return { site: containing }
204
+ return { site: sites[0] ?? null, ambiguous: sites.length > 1 }
205
+ }
206
+
207
+ /** Import `@uniweb/snapshot` from the site, the workspace, or next to this CLI. */
208
+ async function loadSnapshotPackage(dirs) {
209
+ for (const dir of dirs) {
210
+ if (!dir) continue
211
+ let entry
212
+ try {
213
+ entry = createRequire(join(dir, 'package.json')).resolve('@uniweb/snapshot')
214
+ } catch {
215
+ continue
216
+ }
217
+ return import(pathToFileURL(entry).href)
218
+ }
219
+ try {
220
+ return await import('@uniweb/snapshot')
221
+ } catch (err) {
222
+ if (err.code === 'ERR_MODULE_NOT_FOUND') return null
223
+ throw err
224
+ }
225
+ }
226
+
227
+ function installHint(rootDir, command = 'add') {
228
+ switch (detectWorkspacePm(rootDir)) {
229
+ case 'npm':
230
+ return 'npm install --save-dev @uniweb/snapshot@latest'
231
+ case 'yarn':
232
+ return `yarn ${command} --dev -W @uniweb/snapshot@latest`
233
+ default:
234
+ return `pnpm ${command} -D -w @uniweb/snapshot@latest`
235
+ }
236
+ }
237
+
238
+ /**
239
+ * `uniweb build` in the site, with this same CLI. Its output is held back and
240
+ * shown only if it fails: on success it ends in shipping advice that has nothing
241
+ * to do with taking a snapshot.
242
+ */
243
+ function runBuild(siteDir) {
244
+ return new Promise((done, fail) => {
245
+ const child = spawn(process.execPath, [process.argv[1], 'build'], {
246
+ cwd: siteDir,
247
+ stdio: ['ignore', 'pipe', 'pipe']
248
+ })
249
+ const output = []
250
+ child.stdout.on('data', (chunk) => output.push(chunk))
251
+ child.stderr.on('data', (chunk) => output.push(chunk))
252
+ child.on('error', fail)
253
+ child.on('close', (code) => {
254
+ if (code === 0) return done()
255
+ process.stderr.write(Buffer.concat(output))
256
+ fail(new Error(`The site build failed (exit ${code}).`))
257
+ })
258
+ })
259
+ }
260
+
261
+ function readSiteYml(siteDir) {
262
+ const file = join(siteDir, 'site.yml')
263
+ if (!existsSync(file)) return { file, data: {} }
264
+ try {
265
+ return { file, data: yaml.load(readFileSync(file, 'utf8')) || {} }
266
+ } catch {
267
+ return { file, data: {} }
268
+ }
269
+ }
270
+
271
+ function fail(message, ...details) {
272
+ const [first, ...rest] = String(message).split('\n')
273
+ console.error(`${RED}✗${RESET} ${first}`)
274
+ for (const line of [...rest, ...details]) console.error(` ${line.replace(/^ {2}/, '')}`)
275
+ process.exit(1)
276
+ }
277
+
278
+ export async function snapshot(args = []) {
279
+ const cwd = process.cwd()
280
+ let parsed
281
+ try {
282
+ parsed = parseSnapshotArgs(args, cwd)
283
+ } catch (err) {
284
+ if (!(err instanceof UsageError)) throw err
285
+ fail(err.message, 'Run `uniweb snapshot --help` for the accepted flags.')
286
+ }
287
+ const { settings: flagSettings, control, positionals } = parsed
288
+
289
+ const rootDir = findWorkspaceRoot(cwd)
290
+ const sites = rootDir ? await discoverSites(rootDir).catch(() => []) : []
291
+ const requested = control.site ?? positionals[0] ?? null
292
+ const { site, ambiguous } = pickSite(sites, rootDir ?? cwd, { requested, cwd })
293
+
294
+ if (requested && !site) {
295
+ fail(`Site "${requested}" not found.`, `Available: ${sites.map((s) => s.name).join(', ') || '(none)'}`)
296
+ }
297
+ if (!site && !control.url) {
298
+ fail('No site found here.', 'Run this inside a Uniweb workspace, or pass `--url <address> --out <file>`.')
299
+ }
300
+ if (!site && !flagSettings.out) {
301
+ fail('`--out <file>` is needed outside a site: there is no site folder to write to.')
302
+ }
303
+ if (!site && control.save) fail(`\`--save\` writes ${SETTINGS_FILE} into a site, and there is none here.`)
304
+ if (ambiguous) {
305
+ console.error(`${YELLOW}⚠${RESET} Multiple sites found; using ${CYAN}${site.name}${RESET}. Pick one with \`--site <name>\`.`)
306
+ }
307
+
308
+ const siteDir = site ? join(rootDir, site.path) : null
309
+
310
+ // The site's saved look, under this run's flags. For a comparison, the file's
311
+ // `out` names the preview image, not the sheet.
312
+ let fileSettings = {}
313
+ if (siteDir) {
314
+ try {
315
+ fileSettings = readSnapshotSettings(siteDir).settings
316
+ } catch (err) {
317
+ if (!(err instanceof SettingsError)) throw err
318
+ fail(err.message)
319
+ }
320
+ }
321
+ const base = { ...fileSettings }
322
+ if (control.compare) delete base.out
323
+ const merged = mergeSettings(base, flagSettings)
324
+ let options
325
+ try {
326
+ options = libraryOptions(merged)
327
+ } catch (err) {
328
+ if (!(err instanceof UsageError)) throw err
329
+ fail(err.message)
330
+ }
331
+ options.output ??= join(siteDir, control.compare ? COMPARE_OUTPUT : DEFAULT_OUTPUT)
332
+
333
+ const lib = await loadSnapshotPackage([siteDir, rootDir])
334
+ if (!lib) {
335
+ fail(
336
+ '`uniweb snapshot` needs `@uniweb/snapshot`, which is not installed here.',
337
+ 'Add it as a dev dependency of the workspace:',
338
+ ` ${CYAN}${installHint(rootDir)}${RESET}`
339
+ )
340
+ }
341
+ const chosesLook = LOOK_SETTINGS.some((key) => options[key] !== undefined)
342
+ if ((control.compare || chosesLook) && typeof lib.compare !== 'function') {
343
+ fail(
344
+ 'The installed `@uniweb/snapshot` predates looks and comparisons.',
345
+ 'Update it:',
346
+ ` ${CYAN}${installHint(rootDir)}${RESET}`
347
+ )
348
+ }
349
+ if (typeof lib.normalizeOptions === 'function') {
350
+ try {
351
+ lib.normalizeOptions(options)
352
+ } catch (err) {
353
+ fail(err.message, `Check the flags${Object.keys(fileSettings).length ? ` and ${SETTINGS_FILE}` : ''}.`)
354
+ }
355
+ }
356
+
357
+ // Put the site behind a URL.
358
+ let source
359
+ try {
360
+ if (control.url) {
361
+ source = { url: control.url, label: control.url, close: async () => {} }
362
+ } else if (control.dev) {
363
+ console.error(`${DIM}→ starting the dev server for ${site.name}${RESET}`)
364
+ const dev = await lib.startDevServer(siteDir)
365
+ source = { url: dev.url, label: `dev server (${dev.url})`, close: dev.close }
366
+ } else {
367
+ if (!control.noBuild) {
368
+ console.error(`${DIM}→ building ${site.name}${RESET}`)
369
+ await runBuild(siteDir)
370
+ }
371
+ const dist = join(siteDir, 'dist')
372
+ if (!existsSync(join(dist, 'index.html'))) {
373
+ fail(`No built site at ${relative(cwd, dist) || dist}.`, 'Run without `--no-build`, or build the site first.')
374
+ }
375
+ const server = await lib.serveDirectory(dist, { base: readSiteYml(siteDir).data.base })
376
+ source = { url: server.url, label: 'the built site', close: server.close }
377
+ }
378
+ } catch (err) {
379
+ fail(err.message)
380
+ }
381
+
382
+ const onStep = (step) => {
383
+ if (step === 'capture') {
384
+ const page = options.route && options.route !== '/' ? ` · ${options.route}` : ''
385
+ console.error(`${DIM}→ capturing ${source.label}${page}${RESET}`)
386
+ }
387
+ if (step === 'compose') console.error(`${DIM}→ composing${RESET}`)
388
+ }
389
+
390
+ let result
391
+ try {
392
+ result = control.compare
393
+ ? await lib.compare({ ...options, url: source.url, label: flagsFor, onStep })
394
+ : await lib.snapshot({ ...options, url: source.url, onStep })
395
+ } catch (err) {
396
+ await source.close().catch(() => {})
397
+ fail(err.message)
398
+ }
399
+ await source.close()
400
+
401
+ const shown = relative(cwd, options.output) || options.output
402
+
403
+ if (control.compare) {
404
+ console.log(
405
+ `${GREEN}✓${RESET} ${shown} ${DIM}(${result.width}×${result.height}, ${result.variants.length} looks from one capture)${RESET}`
406
+ )
407
+ result.variants.forEach((variant, i) => {
408
+ console.log(` ${String(i + 1).padStart(2)} ${i === 0 ? `${DIM}current${RESET}` : `${CYAN}${variant.label}${RESET}`}`)
409
+ })
410
+ console.log(` ${DIM}Take one with its flags, and add --save to keep it: uniweb snapshot ${result.variants[1]?.label ?? ''} --save${RESET}`)
411
+ return
412
+ }
413
+
414
+ console.log(
415
+ `${GREEN}✓${RESET} ${shown} ${DIM}(${result.width}×${result.height}, ${humanBytes(result.bytes)} — ${result.layout} layout, ${result.tone} background)${RESET}`
416
+ )
417
+
418
+ if (control.save) {
419
+ const { saved } = saveSnapshotSettings(siteDir, flagSettings, fileSettings)
420
+ const where = relative(cwd, join(siteDir, SETTINGS_FILE))
421
+ if (saved.length) console.log(` ${where}: ${CYAN}saved ${saved.join(', ')}${RESET}`)
422
+ else console.log(` ${DIM}${where}: nothing to save — pass the flags you want to keep.${RESET}`)
423
+ }
424
+
425
+ if (!siteDir || control.noSetPreview) return
426
+
427
+ const value = previewValueFor(siteDir, options.output)
428
+ const siteYml = readSiteYml(siteDir)
429
+ if (!value) {
430
+ console.log(` ${DIM}site.yml not changed: the image is outside ${join(site.path, 'public')}/, so it has no site path.${RESET}`)
431
+ return
432
+ }
433
+ const current = siteYml.data.preview
434
+ // Edited in place, its comments kept (`writeSiteConfig`) — and not at all when site.yml
435
+ // does not parse: that is said, and the file left as the author has it.
436
+ const setPreview = () => {
437
+ try {
438
+ writeSiteConfig(siteDir, { preview: value })
439
+ return true
440
+ } catch (err) {
441
+ console.log(` ${YELLOW}site.yml not changed:${RESET} ${DIM}${err.message}${RESET}`)
442
+ return false
443
+ }
444
+ }
445
+ switch (previewDecision(current, value)) {
446
+ case 'set':
447
+ if (setPreview()) console.log(` site.yml: ${CYAN}preview: ${value}${RESET}`)
448
+ break
449
+ case 'replace':
450
+ if (setPreview()) console.log(` site.yml: ${CYAN}preview: ${value}${RESET} ${DIM}(replaces the app-generated preview)${RESET}`)
451
+ break
452
+ case 'unchanged':
453
+ console.log(` ${DIM}site.yml already has preview: ${value}${RESET}`)
454
+ break
455
+ case 'keep':
456
+ console.log(` ${YELLOW}site.yml keeps preview: ${current}${RESET} ${DIM}— set it to ${value} to use this image.${RESET}`)
457
+ break
458
+ }
459
+ }
460
+
461
+ export default snapshot
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Starter content for a section type — `uniweb add section <Name> --starter`.
3
+ *
4
+ * ⭐ IT IS DERIVED FROM `content:`, NOT AUTHORED. A developer declares what
5
+ * their component expects; this turns that declaration into something an author
6
+ * can edit instead of an empty box. There is no `starter:` key in `meta.js` and
7
+ * there should not be [Diego, 2026-09-16] — authored sample copy drifts against
8
+ * the declaration it is meant to match, and it can never be localized, because a
9
+ * developer's string is the foundation's own words and is shown verbatim in
10
+ * every UI language.
11
+ *
12
+ * ⚖️ THE GENERATOR IS NOT HERE. It is `@uniweb/schemas/starter`, because the
13
+ * visual editor is its other caller and must reach it in a browser — this file
14
+ * is the CLI's rendering of the same answer. Anything that decides WHAT the
15
+ * content is belongs there; what is left here is reading a `meta.js` off disk
16
+ * and serializing.
17
+ */
18
+
19
+ import { existsSync } from 'node:fs'
20
+ import { readFile, writeFile } from 'node:fs/promises'
21
+ import { join, dirname } from 'node:path'
22
+ import { pathToFileURL } from 'node:url'
23
+ import { createHash } from 'node:crypto'
24
+
25
+ const colors = {
26
+ reset: '\x1b[0m',
27
+ bright: '\x1b[1m',
28
+ dim: '\x1b[2m',
29
+ cyan: '\x1b[36m',
30
+ yellow: '\x1b[33m',
31
+ }
32
+
33
+ /**
34
+ * Load a section's `meta.js` as it is on disk NOW.
35
+ *
36
+ * ⛔ The URL carries the file's content hash. Node caches an ES module by URL
37
+ * for the life of the process, and `@uniweb/build`'s own loader hit exactly this
38
+ * — a dev server regenerating after a `meta.js` edit re-imported the file it had
39
+ * started with. Same rule here, same reason.
40
+ */
41
+ async function loadMeta(metaPath) {
42
+ const version = createHash('sha1').update(await readFile(metaPath)).digest('hex').slice(0, 16)
43
+ const mod = await import(`${pathToFileURL(metaPath).href}?content=${version}`)
44
+ return mod.default
45
+ }
46
+
47
+ /**
48
+ * The three framework functions this composes, imported at call time.
49
+ *
50
+ * ⚖️ Dynamic, matching `inspect.js`: these resolve through the project's own
51
+ * workspace, and a clear message beats a stack trace when one is missing.
52
+ */
53
+ async function loadPipeline() {
54
+ try {
55
+ const [schemas, content, parser, writer] = await Promise.all([
56
+ import('@uniweb/schemas/starter'),
57
+ import('@uniweb/schemas/content'),
58
+ import('@uniweb/semantic-parser'),
59
+ import('@uniweb/content-writer'),
60
+ ])
61
+ return {
62
+ starterContent: schemas.starterContent,
63
+ describeContent: content.describeContent,
64
+ buildDoc: parser.buildDoc,
65
+ serializeSection: writer.serializeSection,
66
+ }
67
+ } catch (err) {
68
+ throw new Error(
69
+ `Starter content needs @uniweb/schemas, @uniweb/semantic-parser and @uniweb/content-writer.\n` +
70
+ ` ${err.message}\n` +
71
+ ` Run your package manager's install in this workspace and try again.`,
72
+ )
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Generate starter content for one section type and render it.
78
+ *
79
+ * @param {object} args
80
+ * @param {string} args.name - the section type (PascalCase).
81
+ * @param {string} args.sectionDir - the section's directory in the foundation.
82
+ * @param {string} [args.preset] - a declared preset whose params become the frontmatter.
83
+ * @param {boolean} [args.json] - emit the structure and the ProseMirror doc instead of markdown.
84
+ * @param {string} [args.write] - write the markdown to this path instead of printing it.
85
+ * @returns {Promise<{markdown: string, result: object}>}
86
+ */
87
+ export async function generateStarter({ name, sectionDir, preset, json, write }) {
88
+ const { starterContent, describeContent, buildDoc, serializeSection } = await loadPipeline()
89
+
90
+ const metaPath = join(sectionDir, 'meta.js')
91
+ const meta = existsSync(metaPath) ? await loadMeta(metaPath) : {}
92
+ const result = starterContent({ name, ...meta }, { preset })
93
+ // What the declaration says that could not be read — the build warns the same way.
94
+ result.problems = describeContent({ name, ...meta }).problems
95
+
96
+ const doc = buildDoc(result.content)
97
+ const markdown = doc ? serializeSection(result.params, doc) : ''
98
+
99
+ if (json) {
100
+ process.stdout.write(
101
+ JSON.stringify(
102
+ {
103
+ section: name,
104
+ family: result.family,
105
+ elementsInferred: result.elementsInferred,
106
+ unfilled: result.unfilled,
107
+ // What the component SAYS it expects, parsed — the same structure an
108
+ // editor renders a "what does this section want?" panel from. Carried
109
+ // here so the declaration and what was generated from it can be read
110
+ // side by side, without the app.
111
+ expects: describeContent({ name, ...meta }),
112
+ params: result.params,
113
+ content: result.content,
114
+ doc,
115
+ },
116
+ null,
117
+ 2,
118
+ ) + '\n',
119
+ )
120
+ return { markdown, result }
121
+ }
122
+
123
+ if (write) {
124
+ await writeFile(write, markdown, 'utf-8')
125
+ }
126
+
127
+ return { markdown, result }
128
+ }
129
+
130
+ /**
131
+ * The human-facing report that follows generation.
132
+ *
133
+ * ⭐ It SAYS when the element list was ours. A component declaring no `content:`
134
+ * gets its family's canonical set, and a developer reading generated content
135
+ * they never specified should be told why — otherwise the natural conclusion is
136
+ * that the generator invented a declaration on their behalf.
137
+ */
138
+ export function reportStarter(result, { write } = {}) {
139
+ const c = colors
140
+ const fam = result.family.id
141
+ ? `${c.cyan}${result.family.id}${c.reset} ${c.dim}(${result.family.source})${c.reset}`
142
+ : `${c.dim}no family — generic copy${c.reset}`
143
+
144
+ console.log('')
145
+ console.log(` ${c.dim}family:${c.reset} ${fam}`)
146
+ console.log(` ${c.dim}slots:${c.reset} ${Object.keys(result.content).join(', ') || '—'}`)
147
+
148
+ if (result.elementsInferred) {
149
+ console.log('')
150
+ console.log(
151
+ ` ${c.yellow}!${c.reset} This section declares no ${c.bright}content:${c.reset} — the elements above came from its family.`,
152
+ )
153
+ console.log(
154
+ ` ${c.dim}Declare what the component expects and the starter content follows it instead.${c.reset}`,
155
+ )
156
+ }
157
+
158
+ if (result.unfilled.length) {
159
+ console.log('')
160
+ console.log(` ${c.yellow}!${c.reset} Not filled: ${result.unfilled.join(', ')}`)
161
+ console.log(
162
+ ` ${c.dim}A starter makes up no file, video or embedded component, and writes no table, equation,${c.reset}`,
163
+ )
164
+ console.log(` ${c.dim}quote or concept block (md:<tag>) — add those in the section.${c.reset}`)
165
+ }
166
+
167
+ if (result.problems?.length) {
168
+ console.log('')
169
+ for (const problem of result.problems) console.log(` ${c.yellow}!${c.reset} ${problem}`)
170
+ }
171
+
172
+ if (write) {
173
+ console.log('')
174
+ console.log(` ${c.cyan}→${c.reset} written to ${write}`)
175
+ }
176
+ console.log('')
177
+ }
178
+
179
+ /**
180
+ * A `content:` declaration for a section being scaffolded, derived from the
181
+ * family the name resolves to. Written into the new `meta.js` so the scaffold is
182
+ * coherent: a declaration, a component, and content that fills it.
183
+ */
184
+ export async function declarationFor(result) {
185
+ const LABELS = {
186
+ title: 'Headline',
187
+ pretitle: 'Small label above the headline',
188
+ subtitle: 'Secondary headline',
189
+ paragraphs: 'Body copy [1-2]',
190
+ links: 'Calls to action [0-2]',
191
+ lists: 'Bullet points [0-1]',
192
+ items: 'One per entry [3-6]',
193
+ images: 'Image [1]',
194
+ icons: 'Icon [1]',
195
+ videos: 'Video [1]',
196
+ snippets: 'Code sample [1]',
197
+ }
198
+ // ⛔ The slot → declaration spelling comes from @uniweb/schemas, not from a
199
+ // copy here. `image` declares and `images` delivers; that mapping is the
200
+ // generator's, and a second copy rots the moment a row is added there.
201
+ const { declarationKey } = await import('@uniweb/schemas/starter')
202
+ const lines = Object.keys(result.content).map(
203
+ (slot) => ` ${declarationKey(slot)}: '${LABELS[slot] || slot}',`,
204
+ )
205
+ return lines.length ? `\n content: {\n${lines.join('\n')}\n },\n` : ''
206
+ }