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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|
+
}
|