uniweb 0.56.7 → 0.56.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/partials/agents.md +2 -0
- package/src/backend/site-sync.js +1 -5
- package/src/commands/snapshot.js +349 -0
- package/src/framework-index.json +8 -3
- package/src/index.js +46 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/scaffold.js +44 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.56.
|
|
3
|
+
"version": "0.56.9",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,10 +43,10 @@
|
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/core": "^0.29.4",
|
|
45
45
|
"@uniweb/runtime": "^0.26.4",
|
|
46
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
47
|
+
"@uniweb/schemas": "^0.3.2",
|
|
47
48
|
"@uniweb/content-writer": "^0.3.4",
|
|
48
|
-
"@uniweb/kit": "^0.19.3"
|
|
49
|
-
"@uniweb/semantic-parser": "^1.4.1"
|
|
49
|
+
"@uniweb/kit": "^0.19.3"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@uniweb/build": "^0.52.6",
|
package/partials/agents.md
CHANGED
|
@@ -2511,6 +2511,8 @@ uniweb validate # Check file-based data against declared schem
|
|
|
2511
2511
|
npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
|
|
2512
2512
|
# bare `uniweb update` aligns to the CLI you ALREADY have
|
|
2513
2513
|
uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
|
|
2514
|
+
uniweb snapshot # Compose site/public/preview.webp from the site; sets preview: if unset
|
|
2515
|
+
# needs `pnpm add -D -w @uniweb/snapshot` and Chrome or Edge
|
|
2514
2516
|
|
|
2515
2517
|
uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
|
|
2516
2518
|
```
|
package/src/backend/site-sync.js
CHANGED
|
@@ -17,6 +17,7 @@ import yaml from 'js-yaml'
|
|
|
17
17
|
import { hasUncommittedContent } from '../utils/git.js'
|
|
18
18
|
import { recordSiteBackend } from '../utils/site-identity.js'
|
|
19
19
|
import { humanBytes } from '../utils/bytes.js'
|
|
20
|
+
import { isAuthoredPreview } from '../utils/preview.js'
|
|
20
21
|
import {
|
|
21
22
|
backfillEntityUuids,
|
|
22
23
|
writeSiteEntityUuid,
|
|
@@ -364,11 +365,6 @@ export function dropSiteBoundValues(siteDir) {
|
|
|
364
365
|
return dropped
|
|
365
366
|
}
|
|
366
367
|
|
|
367
|
-
// An author's preview is an address — a URL, or a site-root / relative path. Anything
|
|
368
|
-
// else is the app's generated-image token.
|
|
369
|
-
const isAuthoredPreview = (v) =>
|
|
370
|
-
typeof v === 'string' && (/^https?:\/\//i.test(v) || /^\.{0,2}\//.test(v))
|
|
371
|
-
|
|
372
368
|
export function readSyncCache(siteDir) {
|
|
373
369
|
return readMap(siteDir, 'hashes')
|
|
374
370
|
}
|
|
@@ -0,0 +1,349 @@
|
|
|
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
|
+
*
|
|
16
|
+
* The layout is chosen from the page: one that scrolls gets `split` (the first
|
|
17
|
+
* view in a browser window, overlapped by a long strip of the page); one that
|
|
18
|
+
* does not — a documentation shell, an app — gets `device` (desktop and phone).
|
|
19
|
+
*
|
|
20
|
+
* `preview:` is written only when site.yml has none, or holds the app's generated
|
|
21
|
+
* token. An address the author wrote is never replaced.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process'
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
26
|
+
import { createRequire } from 'node:module'
|
|
27
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
28
|
+
import { pathToFileURL } from 'node:url'
|
|
29
|
+
import yaml from 'js-yaml'
|
|
30
|
+
import { upsertYamlScalar } from '@uniweb/build/uwx'
|
|
31
|
+
|
|
32
|
+
import { didYouMean } from '../utils/args.js'
|
|
33
|
+
import { humanBytes } from '../utils/bytes.js'
|
|
34
|
+
import { discoverSites } from '../utils/discover.js'
|
|
35
|
+
import { detectWorkspacePm } from '../utils/pm.js'
|
|
36
|
+
import { isAuthoredPreview } from '../utils/preview.js'
|
|
37
|
+
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
38
|
+
|
|
39
|
+
const RED = '\x1b[31m'
|
|
40
|
+
const GREEN = '\x1b[32m'
|
|
41
|
+
const YELLOW = '\x1b[33m'
|
|
42
|
+
const CYAN = '\x1b[36m'
|
|
43
|
+
const DIM = '\x1b[2m'
|
|
44
|
+
const RESET = '\x1b[0m'
|
|
45
|
+
|
|
46
|
+
const VALUE_FLAGS = ['--site', '--url', '--route', '--layout', '--tone', '--size', '--scale', '--quality', '--out', '--hide']
|
|
47
|
+
const BOOLEAN_FLAGS = ['--dev', '--no-build', '--no-set-preview']
|
|
48
|
+
const GLOBAL_FLAGS = ['--non-interactive', '--help', '-h']
|
|
49
|
+
const ALL_FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS, ...GLOBAL_FLAGS]
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_OUTPUT = join('public', 'preview.webp')
|
|
52
|
+
|
|
53
|
+
class UsageError extends Error {}
|
|
54
|
+
|
|
55
|
+
const camel = (flag) => flag.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse `uniweb snapshot` arguments. Throws a UsageError naming the first problem.
|
|
59
|
+
*
|
|
60
|
+
* @param {string[]} args
|
|
61
|
+
*/
|
|
62
|
+
export function parseSnapshotArgs(args = []) {
|
|
63
|
+
const options = { hide: [], positionals: [] }
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const raw = args[i]
|
|
66
|
+
if (raw === '--') {
|
|
67
|
+
options.positionals.push(...args.slice(i + 1))
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
if (!raw.startsWith('-') || raw === '-') {
|
|
71
|
+
options.positionals.push(raw)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const eq = raw.indexOf('=')
|
|
75
|
+
const name = eq === -1 ? raw : raw.slice(0, eq)
|
|
76
|
+
if (VALUE_FLAGS.includes(name)) {
|
|
77
|
+
const value = eq === -1 ? args[++i] : raw.slice(eq + 1)
|
|
78
|
+
if (value === undefined || value === '' || (eq === -1 && value.startsWith('--'))) {
|
|
79
|
+
throw new UsageError(`\`${name}\` needs a value.`)
|
|
80
|
+
}
|
|
81
|
+
if (name === '--hide') options.hide.push(value)
|
|
82
|
+
else options[camel(name)] = value
|
|
83
|
+
} else if (BOOLEAN_FLAGS.includes(name)) {
|
|
84
|
+
options[camel(name)] = true
|
|
85
|
+
} else if (!GLOBAL_FLAGS.includes(name)) {
|
|
86
|
+
const suggestion = didYouMean(name, ALL_FLAGS)
|
|
87
|
+
throw new UsageError(
|
|
88
|
+
`Unknown flag \`${name}\` for \`uniweb snapshot\`.` + (suggestion ? ` Did you mean \`${suggestion}\`?` : '')
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.dev && options.url) throw new UsageError('Pass `--dev` or `--url`, not both.')
|
|
94
|
+
if (options.layout && !['auto', 'split', 'device'].includes(options.layout)) {
|
|
95
|
+
throw new UsageError('`--layout` is auto, split or device.')
|
|
96
|
+
}
|
|
97
|
+
if (options.tone && !['auto', 'light', 'deep'].includes(options.tone)) {
|
|
98
|
+
throw new UsageError('`--tone` is auto, light or deep.')
|
|
99
|
+
}
|
|
100
|
+
if (options.size !== undefined) {
|
|
101
|
+
const match = /^(\d+)x(\d+)$/.exec(options.size)
|
|
102
|
+
const [width, height] = match ? [Number(match[1]), Number(match[2])] : []
|
|
103
|
+
if (!match || width < 320 || height < 200 || width > 4096 || height > 4096) {
|
|
104
|
+
throw new UsageError('`--size` is WIDTHxHEIGHT, e.g. 1600x1000 (320–4096 wide, 200–4096 tall).')
|
|
105
|
+
}
|
|
106
|
+
options.canvas = { width, height }
|
|
107
|
+
}
|
|
108
|
+
if (options.scale !== undefined) {
|
|
109
|
+
if (!['1', '2'].includes(options.scale)) throw new UsageError('`--scale` is 1 or 2.')
|
|
110
|
+
options.scale = Number(options.scale)
|
|
111
|
+
}
|
|
112
|
+
if (options.quality !== undefined) {
|
|
113
|
+
const quality = Number(options.quality)
|
|
114
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
|
|
115
|
+
throw new UsageError('`--quality` is a whole number from 1 to 100.')
|
|
116
|
+
}
|
|
117
|
+
options.quality = quality
|
|
118
|
+
}
|
|
119
|
+
return options
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The `preview:` value that names `output`, or null when it cannot be named — an
|
|
124
|
+
* image outside the site's `public/` folder has no site-root path.
|
|
125
|
+
*/
|
|
126
|
+
export function previewValueFor(siteDir, output) {
|
|
127
|
+
const rel = relative(join(siteDir, 'public'), output)
|
|
128
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null
|
|
129
|
+
return '/' + rel.split(sep).join('/')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What to do with site.yml's `preview:` given the value we would write.
|
|
134
|
+
* @returns {'set'|'unchanged'|'replace'|'keep'}
|
|
135
|
+
*/
|
|
136
|
+
export function previewDecision(current, next) {
|
|
137
|
+
if (current === undefined || current === null || current === '') return 'set'
|
|
138
|
+
if (current === next) return 'unchanged'
|
|
139
|
+
return isAuthoredPreview(current) ? 'keep' : 'replace'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The site the command applies to: named, else the one containing cwd, else the only one. */
|
|
143
|
+
export function pickSite(sites, rootDir, { requested, cwd }) {
|
|
144
|
+
if (requested) {
|
|
145
|
+
return { site: sites.find((s) => s.name === requested || s.path === requested) ?? null }
|
|
146
|
+
}
|
|
147
|
+
const containing = sites.find((s) => {
|
|
148
|
+
const rel = relative(join(rootDir, s.path), cwd)
|
|
149
|
+
return !rel.startsWith('..') && !isAbsolute(rel)
|
|
150
|
+
})
|
|
151
|
+
if (containing) return { site: containing }
|
|
152
|
+
return { site: sites[0] ?? null, ambiguous: sites.length > 1 }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Import `@uniweb/snapshot` from the site, the workspace, or next to this CLI. */
|
|
156
|
+
async function loadSnapshotPackage(dirs) {
|
|
157
|
+
for (const dir of dirs) {
|
|
158
|
+
if (!dir) continue
|
|
159
|
+
let entry
|
|
160
|
+
try {
|
|
161
|
+
entry = createRequire(join(dir, 'package.json')).resolve('@uniweb/snapshot')
|
|
162
|
+
} catch {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
return import(pathToFileURL(entry).href)
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return await import('@uniweb/snapshot')
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND') return null
|
|
171
|
+
throw err
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function installHint(rootDir) {
|
|
176
|
+
switch (detectWorkspacePm(rootDir)) {
|
|
177
|
+
case 'npm':
|
|
178
|
+
return 'npm install --save-dev @uniweb/snapshot'
|
|
179
|
+
case 'yarn':
|
|
180
|
+
return 'yarn add --dev -W @uniweb/snapshot'
|
|
181
|
+
default:
|
|
182
|
+
return 'pnpm add -D -w @uniweb/snapshot'
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `uniweb build` in the site, with this same CLI. Its output is held back and
|
|
188
|
+
* shown only if it fails: on success it ends in shipping advice that has nothing
|
|
189
|
+
* to do with taking a snapshot.
|
|
190
|
+
*/
|
|
191
|
+
function runBuild(siteDir) {
|
|
192
|
+
return new Promise((done, fail) => {
|
|
193
|
+
const child = spawn(process.execPath, [process.argv[1], 'build'], {
|
|
194
|
+
cwd: siteDir,
|
|
195
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
196
|
+
})
|
|
197
|
+
const output = []
|
|
198
|
+
child.stdout.on('data', (chunk) => output.push(chunk))
|
|
199
|
+
child.stderr.on('data', (chunk) => output.push(chunk))
|
|
200
|
+
child.on('error', fail)
|
|
201
|
+
child.on('close', (code) => {
|
|
202
|
+
if (code === 0) return done()
|
|
203
|
+
process.stderr.write(Buffer.concat(output))
|
|
204
|
+
fail(new Error(`The site build failed (exit ${code}).`))
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readSiteYml(siteDir) {
|
|
210
|
+
const file = join(siteDir, 'site.yml')
|
|
211
|
+
if (!existsSync(file)) return { file, data: {} }
|
|
212
|
+
try {
|
|
213
|
+
return { file, data: yaml.load(readFileSync(file, 'utf8')) || {} }
|
|
214
|
+
} catch {
|
|
215
|
+
return { file, data: {} }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function fail(message, ...details) {
|
|
220
|
+
const [first, ...rest] = String(message).split('\n')
|
|
221
|
+
console.error(`${RED}✗${RESET} ${first}`)
|
|
222
|
+
for (const line of [...rest, ...details]) console.error(` ${line.replace(/^ {2}/, '')}`)
|
|
223
|
+
process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function snapshot(args = []) {
|
|
227
|
+
let options
|
|
228
|
+
try {
|
|
229
|
+
options = parseSnapshotArgs(args)
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (!(err instanceof UsageError)) throw err
|
|
232
|
+
fail(err.message, 'Run `uniweb snapshot --help` for the accepted flags.')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const cwd = process.cwd()
|
|
236
|
+
const rootDir = findWorkspaceRoot(cwd)
|
|
237
|
+
const sites = rootDir ? await discoverSites(rootDir).catch(() => []) : []
|
|
238
|
+
const requested = options.site ?? options.positionals[0] ?? null
|
|
239
|
+
const { site, ambiguous } = pickSite(sites, rootDir ?? cwd, { requested, cwd })
|
|
240
|
+
|
|
241
|
+
if (requested && !site) {
|
|
242
|
+
fail(`Site "${requested}" not found.`, `Available: ${sites.map((s) => s.name).join(', ') || '(none)'}`)
|
|
243
|
+
}
|
|
244
|
+
if (!site && !options.url) {
|
|
245
|
+
fail('No site found here.', 'Run this inside a Uniweb workspace, or pass `--url <address> --out <file>`.')
|
|
246
|
+
}
|
|
247
|
+
if (!site && !options.out) {
|
|
248
|
+
fail('`--out <file>` is needed outside a site: there is no public/ folder to write to.')
|
|
249
|
+
}
|
|
250
|
+
if (ambiguous) {
|
|
251
|
+
console.error(`${YELLOW}⚠${RESET} Multiple sites found; using ${CYAN}${site.name}${RESET}. Pick one with \`--site <name>\`.`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const siteDir = site ? join(rootDir, site.path) : null
|
|
255
|
+
const output = options.out ? resolve(cwd, options.out) : join(siteDir, DEFAULT_OUTPUT)
|
|
256
|
+
|
|
257
|
+
const lib = await loadSnapshotPackage([siteDir, rootDir])
|
|
258
|
+
if (!lib) {
|
|
259
|
+
fail(
|
|
260
|
+
'`uniweb snapshot` needs `@uniweb/snapshot`, which is not installed here.',
|
|
261
|
+
'Add it as a dev dependency of the workspace:',
|
|
262
|
+
` ${CYAN}${installHint(rootDir)}${RESET}`
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Put the site behind a URL.
|
|
267
|
+
let source
|
|
268
|
+
try {
|
|
269
|
+
if (options.url) {
|
|
270
|
+
source = { url: options.url, label: options.url, close: async () => {} }
|
|
271
|
+
} else if (options.dev) {
|
|
272
|
+
console.error(`${DIM}→ starting the dev server for ${site.name}${RESET}`)
|
|
273
|
+
const dev = await lib.startDevServer(siteDir)
|
|
274
|
+
source = { url: dev.url, label: `dev server (${dev.url})`, close: dev.close }
|
|
275
|
+
} else {
|
|
276
|
+
if (!options.noBuild) {
|
|
277
|
+
console.error(`${DIM}→ building ${site.name}${RESET}`)
|
|
278
|
+
await runBuild(siteDir)
|
|
279
|
+
}
|
|
280
|
+
const dist = join(siteDir, 'dist')
|
|
281
|
+
if (!existsSync(join(dist, 'index.html'))) {
|
|
282
|
+
fail(`No built site at ${relative(cwd, dist) || dist}.`, 'Run without `--no-build`, or build the site first.')
|
|
283
|
+
}
|
|
284
|
+
const server = await lib.serveDirectory(dist, { base: readSiteYml(siteDir).data.base })
|
|
285
|
+
source = { url: server.url, label: 'the built site', close: server.close }
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
fail(err.message)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let result
|
|
292
|
+
try {
|
|
293
|
+
result = await lib.snapshot({
|
|
294
|
+
url: source.url,
|
|
295
|
+
route: options.route,
|
|
296
|
+
layout: options.layout,
|
|
297
|
+
tone: options.tone,
|
|
298
|
+
canvas: options.canvas,
|
|
299
|
+
scale: options.scale,
|
|
300
|
+
quality: options.quality,
|
|
301
|
+
hide: options.hide,
|
|
302
|
+
output,
|
|
303
|
+
onStep: (step) => {
|
|
304
|
+
if (step === 'capture') {
|
|
305
|
+
const page = options.route && options.route !== '/' ? ` · ${options.route}` : ''
|
|
306
|
+
console.error(`${DIM}→ capturing ${source.label}${page}${RESET}`)
|
|
307
|
+
}
|
|
308
|
+
if (step === 'compose') console.error(`${DIM}→ composing${RESET}`)
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
} catch (err) {
|
|
312
|
+
await source.close().catch(() => {})
|
|
313
|
+
fail(err.message)
|
|
314
|
+
}
|
|
315
|
+
await source.close()
|
|
316
|
+
|
|
317
|
+
const shown = relative(cwd, output) || output
|
|
318
|
+
console.log(
|
|
319
|
+
`${GREEN}✓${RESET} ${shown} ${DIM}(${result.width}×${result.height}, ${humanBytes(result.bytes)} — ${result.layout} layout, ${result.tone} background)${RESET}`
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if (!siteDir || options.noSetPreview) return
|
|
323
|
+
|
|
324
|
+
const value = previewValueFor(siteDir, output)
|
|
325
|
+
const siteYml = readSiteYml(siteDir)
|
|
326
|
+
if (!value) {
|
|
327
|
+
console.log(` ${DIM}site.yml not changed: the image is outside ${join(site.path, 'public')}/, so it has no site path.${RESET}`)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
const current = siteYml.data.preview
|
|
331
|
+
switch (previewDecision(current, value)) {
|
|
332
|
+
case 'set':
|
|
333
|
+
upsertYamlScalar(siteYml.file, 'preview', value)
|
|
334
|
+
console.log(` site.yml: ${CYAN}preview: ${value}${RESET}`)
|
|
335
|
+
break
|
|
336
|
+
case 'replace':
|
|
337
|
+
upsertYamlScalar(siteYml.file, 'preview', value)
|
|
338
|
+
console.log(` site.yml: ${CYAN}preview: ${value}${RESET} ${DIM}(replaces the app-generated preview)${RESET}`)
|
|
339
|
+
break
|
|
340
|
+
case 'unchanged':
|
|
341
|
+
console.log(` ${DIM}site.yml already has preview: ${value}${RESET}`)
|
|
342
|
+
break
|
|
343
|
+
case 'keep':
|
|
344
|
+
console.log(` ${YELLOW}site.yml keeps preview: ${current}${RESET} ${DIM}— set it to ${value} to use this image.${RESET}`)
|
|
345
|
+
break
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export default snapshot
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-17T03:24:18.741Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.3.6",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"deps": []
|
|
97
97
|
},
|
|
98
98
|
"@uniweb/schemas": {
|
|
99
|
-
"version": "0.3.
|
|
99
|
+
"version": "0.3.2",
|
|
100
100
|
"path": "framework/schemas",
|
|
101
101
|
"deps": []
|
|
102
102
|
},
|
|
@@ -110,8 +110,13 @@
|
|
|
110
110
|
"path": "framework/semantic-parser",
|
|
111
111
|
"deps": []
|
|
112
112
|
},
|
|
113
|
+
"@uniweb/snapshot": {
|
|
114
|
+
"version": "0.1.1",
|
|
115
|
+
"path": "framework/snapshot",
|
|
116
|
+
"deps": []
|
|
117
|
+
},
|
|
113
118
|
"@uniweb/templates": {
|
|
114
|
-
"version": "0.14.
|
|
119
|
+
"version": "0.14.7",
|
|
115
120
|
"path": "framework/templates",
|
|
116
121
|
"deps": []
|
|
117
122
|
},
|
package/src/index.js
CHANGED
|
@@ -731,6 +731,15 @@ async function main() {
|
|
|
731
731
|
return
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
+
// Handle snapshot command (dynamic import — depends on @uniweb/build; loads
|
|
735
|
+
// @uniweb/snapshot itself, from the workspace, and says how to add it if absent)
|
|
736
|
+
if (command === 'snapshot') {
|
|
737
|
+
const { snapshot } = await importProjectCommand('./commands/snapshot.js')
|
|
738
|
+
await snapshot(args.slice(1))
|
|
739
|
+
await showUpdateNotification()
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
|
|
734
743
|
// Handle docs command (dynamic import — depends on @uniweb/build)
|
|
735
744
|
if (command === 'docs') {
|
|
736
745
|
const { docs } = await importProjectCommand('./commands/docs.js')
|
|
@@ -1479,6 +1488,42 @@ script (\`pnpm --filter <site> dev\` or \`npm -w <site> run dev\`). Picks
|
|
|
1479
1488
|
the single site automatically; for multi-site workspaces the first
|
|
1480
1489
|
site runs by default with a notice pointing at \`--site\` for explicit
|
|
1481
1490
|
selection.
|
|
1491
|
+
`,
|
|
1492
|
+
snapshot: `
|
|
1493
|
+
${colors.cyan}${colors.bright}uniweb snapshot${colors.reset} ${colors.dim}— Compose a preview image of a site${colors.reset}
|
|
1494
|
+
|
|
1495
|
+
${colors.bright}Usage:${colors.reset}
|
|
1496
|
+
uniweb snapshot [<site>] [options]
|
|
1497
|
+
|
|
1498
|
+
Opens the site in a headless Chrome, captures it, and composes the captures into
|
|
1499
|
+
one image: by default ${colors.bright}site/public/preview.webp${colors.reset}, recorded as ${colors.cyan}preview:${colors.reset} in site.yml
|
|
1500
|
+
when site.yml has none, or only the app's generated one. A URL or image path you
|
|
1501
|
+
wrote is never replaced.
|
|
1502
|
+
|
|
1503
|
+
A page that scrolls gets the ${colors.bright}split${colors.reset} layout: the first view in a browser window,
|
|
1504
|
+
overlapped by a long strip of the page. A page that does not scroll as a page (a
|
|
1505
|
+
docs shell, an app) gets ${colors.bright}device${colors.reset}: a desktop window and a phone.
|
|
1506
|
+
|
|
1507
|
+
Needs ${colors.cyan}@uniweb/snapshot${colors.reset} in the workspace (\`pnpm add -D -w @uniweb/snapshot\`) and
|
|
1508
|
+
Google Chrome, Microsoft Edge, or a Chromium named by $UNIWEB_SNAPSHOT_BROWSER.
|
|
1509
|
+
|
|
1510
|
+
${colors.bright}Where the site comes from:${colors.reset}
|
|
1511
|
+
(default) Build the site, then capture dist/
|
|
1512
|
+
--no-build Capture the existing dist/ as is
|
|
1513
|
+
--dev Capture the site's Vite dev server (no build)
|
|
1514
|
+
--url <address> Capture a site that is already running
|
|
1515
|
+
|
|
1516
|
+
${colors.bright}Options:${colors.reset}
|
|
1517
|
+
--site <name> The site (default: the one you are in, or the only one)
|
|
1518
|
+
--route <path> The page to capture (default: the home page)
|
|
1519
|
+
--layout <name> auto (default), split, device
|
|
1520
|
+
--tone <name> Background: auto (default), light, deep
|
|
1521
|
+
--size <WxH> Image size in CSS pixels (default: 1600x1000)
|
|
1522
|
+
--scale <n> 1 (default) or 2 for a double-density image
|
|
1523
|
+
--quality <n> Encoder quality, 1–100 (default: 82)
|
|
1524
|
+
--out <file> Where to write it; .webp, .png, .jpg or .avif
|
|
1525
|
+
--hide <selector> Hide matching elements before capturing (repeatable)
|
|
1526
|
+
--no-set-preview Write the image without touching site.yml
|
|
1482
1527
|
`,
|
|
1483
1528
|
build: `
|
|
1484
1529
|
${colors.cyan}${colors.bright}uniweb build${colors.reset} ${colors.dim}— Build the current project${colors.reset}
|
|
@@ -1917,6 +1962,7 @@ ${colors.bright}Commands:${colors.reset}
|
|
|
1917
1962
|
rename <type> Rename a foundation, site, or extension across the workspace
|
|
1918
1963
|
dev Start a dev server for a site
|
|
1919
1964
|
build Build the current project
|
|
1965
|
+
snapshot Compose a preview image of a site (its site.yml preview:)
|
|
1920
1966
|
publish Publish a site to Uniweb hosting (smart: foundation + sync + go live)
|
|
1921
1967
|
deploy Ship a site to a host (asks where, if not yet configured)
|
|
1922
1968
|
export Export a self-contained site for third-party hosting
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `site.yml::preview` — the site's card image — has two writers: an author, who
|
|
3
|
+
* writes an address (a URL, or a path to an image in the project), and the app,
|
|
4
|
+
* which writes a token naming an image it generated. This is the one test that
|
|
5
|
+
* tells them apart.
|
|
6
|
+
*
|
|
7
|
+
* It recognizes the author's shapes rather than the app's, so it does not depend
|
|
8
|
+
* on the token's format: a URL, a path starting `/`, `./` or `../`, or a relative
|
|
9
|
+
* path to an image file (`images/card.png`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const IMAGE_FILE = /\.(avif|gif|jpe?g|png|svg|webp)$/i
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {unknown} value
|
|
16
|
+
* @returns {boolean} true when `value` is an author's address rather than the app's token
|
|
17
|
+
*/
|
|
18
|
+
export const isAuthoredPreview = (value) =>
|
|
19
|
+
typeof value === 'string' &&
|
|
20
|
+
(/^https?:\/\//i.test(value) || /^\.{0,2}\//.test(value) || IMAGE_FILE.test(value.trim()))
|
package/src/utils/scaffold.js
CHANGED
|
@@ -178,10 +178,22 @@ export async function applyContent(
|
|
|
178
178
|
'.gitignore'
|
|
179
179
|
])
|
|
180
180
|
|
|
181
|
-
// Config files that should be merged, not overwritten
|
|
182
|
-
//
|
|
181
|
+
// Config files that should be merged, not overwritten: for each listed
|
|
182
|
+
// key, when the scaffolded version's value is used.
|
|
183
|
+
//
|
|
184
|
+
// foundation ALWAYS — the CLI resolved it for this project, and a
|
|
185
|
+
// content template cannot know it.
|
|
186
|
+
// name ONLY WHEN THE TEMPLATE SETS NONE. A template's own name is
|
|
187
|
+
// the default name of a site made from it — the same thing
|
|
188
|
+
// cloning a template in an app gives — so a literal
|
|
189
|
+
// `name: Product Launch` survives, and the project name fills
|
|
190
|
+
// in for a template with no name, or only the
|
|
191
|
+
// `{{projectName}}` placeholder.
|
|
183
192
|
const MERGE_FILES = {
|
|
184
|
-
'site.yml': [
|
|
193
|
+
'site.yml': [
|
|
194
|
+
['name', 'when-template-has-none'],
|
|
195
|
+
['foundation', 'always']
|
|
196
|
+
]
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
await copyContentRecursive(
|
|
@@ -267,8 +279,9 @@ async function copyContentRecursive(
|
|
|
267
279
|
// educational structure of the content template survive) and
|
|
268
280
|
// override only the specific top-level keys listed in
|
|
269
281
|
// preserveKeys with the values from the already-scaffolded base
|
|
270
|
-
// file (so the
|
|
271
|
-
//
|
|
282
|
+
// file (so the resolved foundation ref is never replaced by whatever
|
|
283
|
+
// the content template hardcoded, and a project name fills in for a
|
|
284
|
+
// template that names nothing).
|
|
272
285
|
//
|
|
273
286
|
// Earlier versions of this code parsed both files through
|
|
274
287
|
// js-yaml, merged the objects, and re-emitted the result via
|
|
@@ -283,17 +296,21 @@ async function copyContentRecursive(
|
|
|
283
296
|
const existing = yaml.load(existingContent) || {}
|
|
284
297
|
let merged = newContent ?? (await fs.readFile(sourcePath, 'utf-8'))
|
|
285
298
|
|
|
286
|
-
for (const key of preserveKeys) {
|
|
299
|
+
for (const [key, when] of preserveKeys) {
|
|
287
300
|
if (existing[key] === undefined) continue
|
|
288
301
|
const baseLine = matchTopLevelLine(existingContent, key)
|
|
289
302
|
if (!baseLine) continue
|
|
290
303
|
// If the new content carries the key, replace its line with
|
|
291
|
-
// the scaffolded value
|
|
292
|
-
//
|
|
293
|
-
// (notably `docs/site/site.yml.hbs`) omit
|
|
294
|
-
// and dropping it leaves the site without a
|
|
295
|
-
// the entry's `import '#foundation/styles'` fails
|
|
304
|
+
// the scaffolded value — unless the key only fills in, and the
|
|
305
|
+
// template set a value of its own. Otherwise insert the line —
|
|
306
|
+
// older content templates (notably `docs/site/site.yml.hbs`) omit
|
|
307
|
+
// `foundation:` entirely, and dropping it leaves the site without a
|
|
308
|
+
// foundation ref so the entry's `import '#foundation/styles'` fails
|
|
309
|
+
// at build time.
|
|
296
310
|
if (matchTopLevelLine(merged, key)) {
|
|
311
|
+
if (when === 'when-template-has-none' && hasOwnValue(merged, key)) {
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
297
314
|
merged = replaceTopLevelLine(merged, key, baseLine)
|
|
298
315
|
} else {
|
|
299
316
|
merged = insertTopLevelLine(merged, baseLine)
|
|
@@ -358,6 +375,22 @@ function escapeRegex(s) {
|
|
|
358
375
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
359
376
|
}
|
|
360
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Whether a content template sets `key` to a value of its own: a non-empty
|
|
380
|
+
* string that is not an unrendered `{{placeholder}}` (a plain `site.yml`, not
|
|
381
|
+
* `.hbs`, is copied without rendering). Content that does not parse counts as
|
|
382
|
+
* setting nothing, so the scaffolded value fills in, as it always did.
|
|
383
|
+
*/
|
|
384
|
+
function hasOwnValue(content, key) {
|
|
385
|
+
let value
|
|
386
|
+
try {
|
|
387
|
+
value = (yaml.load(content) || {})[key]
|
|
388
|
+
} catch {
|
|
389
|
+
return false
|
|
390
|
+
}
|
|
391
|
+
return typeof value === 'string' && value.trim() !== '' && !value.includes('{{')
|
|
392
|
+
}
|
|
393
|
+
|
|
361
394
|
/**
|
|
362
395
|
* Find the verbatim text of a single-line top-level YAML entry like
|
|
363
396
|
* `name: foo bar` or `foundation: my-foundation`. Returns the matched
|