uniweb 0.12.20 → 0.12.22
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/README.md +30 -1
- package/package.json +7 -7
- package/partials/agents.md +41 -11
- package/partials/config-reference.hbs +1 -2
- package/src/commands/add.js +1 -87
- package/src/commands/build.js +2 -2
- package/src/commands/clone.js +337 -0
- package/src/commands/content.js +199 -0
- package/src/commands/deploy.js +27 -6
- package/src/commands/docs.js +2 -3
- package/src/commands/doctor.js +24 -5
- package/src/commands/org.js +66 -0
- package/src/commands/publish.js +4 -3
- package/src/commands/pull.js +238 -0
- package/src/commands/push.js +400 -0
- package/src/commands/register.js +274 -0
- package/src/commands/rename.js +10 -5
- package/src/commands/update.js +211 -245
- package/src/commands/validate.js +288 -0
- package/src/framework-index.json +11 -10
- package/src/index.js +155 -26
- package/src/templates/processor.js +41 -19
- package/src/utils/config.js +30 -2
- package/src/utils/dep-survey.js +99 -0
- package/src/utils/json-file.js +68 -0
- package/src/utils/placement.js +100 -0
- package/src/utils/pm.js +29 -0
- package/src/utils/registry-auth.js +380 -0
- package/src/utils/registry-orgs.js +179 -0
- package/src/utils/scaffold.js +18 -5
- package/src/utils/site-content-refs.js +21 -0
- package/src/utils/update-check.js +4 -2
- package/src/versions.js +11 -4
- package/templates/foundation/_gitignore +5 -0
- package/templates/site/_gitignore +5 -0
- package/templates/site/package.json.hbs +2 -2
- package/templates/workspace/_gitignore +33 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb clone <site-uuid> — materialize a backend site as a local file project.
|
|
3
|
+
*
|
|
4
|
+
* The "git clone" of the site-content remote model (see
|
|
5
|
+
* kb/framework/plans/site-content-remote-model.md): the backend is the remote, a
|
|
6
|
+
* file project is a working clone. `clone` is the create-side sibling of
|
|
7
|
+
* `uniweb pull`/`uniweb push` — it bootstraps a brand-new project from a site that
|
|
8
|
+
* already lives in the backend (typically authored in the visual app).
|
|
9
|
+
*
|
|
10
|
+
* What it does, and a key constraint: clone runs from a GLOBAL install before any
|
|
11
|
+
* project exists, so it must NOT statically import `@uniweb/build` (that would crash
|
|
12
|
+
* `npx uniweb clone`, same reason utils/workspace.js loads the classifier lazily).
|
|
13
|
+
* So clone does the minimum itself and delegates the heavy lifting:
|
|
14
|
+
*
|
|
15
|
+
* 1. plain `fetch` GET <origin>/dev/site/content/pull/<uuid> — read the `foundation`
|
|
16
|
+
* ref out of that one document (no `@uniweb/build` needed for a GET);
|
|
17
|
+
* 2. scaffold the HARNESS — a full Vite site package whose foundation is
|
|
18
|
+
* REFERENCED (runtime-loaded), no local foundation sibling (scaffoldSite with
|
|
19
|
+
* foundationRef and no foundationPath) + AGENTS.md + deps pinned to this CLI's
|
|
20
|
+
* version matrix; placement reuses create (new workspace / in-place) and add's
|
|
21
|
+
* resolver (into an existing workspace, any shape);
|
|
22
|
+
* 3. seed the site's one identity — site.yml::$uuid (a plain YAML scalar write).
|
|
23
|
+
* The folder is pulled by this same uuid, so there is no separate folder uuid to
|
|
24
|
+
* seed;
|
|
25
|
+
* 4. install, then delegate the projection to the project-local `uniweb pull` (which
|
|
26
|
+
* resolves the now-installed project-local `@uniweb/build`; clone forwards
|
|
27
|
+
* `--no-collections` to it when set).
|
|
28
|
+
*
|
|
29
|
+
* Sites are private — authenticate with `uniweb login` first; the session carries
|
|
30
|
+
* identity + the backend origin. There is no `--foundation` flag: the site carries
|
|
31
|
+
* its foundation ref and clone honors it verbatim (switching a site's foundation is a
|
|
32
|
+
* deliberate, high-risk operation, never a clone convenience).
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* uniweb login
|
|
36
|
+
* uniweb clone <site-uuid> [name|.] New workspace (or `.` in-place / a site in
|
|
37
|
+
* the current workspace when run inside one)
|
|
38
|
+
* uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
|
|
39
|
+
* uniweb clone <uuid> --project docs Co-located docs/site
|
|
40
|
+
* uniweb clone <uuid> --no-collections Pull pages only; skip collection records
|
|
41
|
+
*
|
|
42
|
+
* Endpoints: <origin>/dev/site/content/pull/<uuid>. Origin from
|
|
43
|
+
* --registry > UNIWEB_REGISTER_URL > the local default (internal dev overrides;
|
|
44
|
+
* not the user-facing path — `uniweb login` determines the origin).
|
|
45
|
+
* Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
49
|
+
import { join, resolve, basename, dirname } from 'node:path'
|
|
50
|
+
import { spawnSync } from 'node:child_process'
|
|
51
|
+
import { scaffoldWorkspace, scaffoldSite } from '../utils/scaffold.js'
|
|
52
|
+
import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
|
|
53
|
+
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
54
|
+
import { addWorkspaceGlob } from '../utils/config.js'
|
|
55
|
+
import { detectWorkspacePm, installCmd } from '../utils/pm.js'
|
|
56
|
+
import { ensureRegistryAuth } from '../utils/registry-auth.js'
|
|
57
|
+
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
58
|
+
import { extractFoundationRef } from '../utils/site-content-refs.js'
|
|
59
|
+
|
|
60
|
+
const DEFAULT_BACKEND_ORIGIN = 'http://localhost:8080'
|
|
61
|
+
|
|
62
|
+
const colors = {
|
|
63
|
+
reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
|
|
64
|
+
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m', cyan: '\x1b[36m',
|
|
65
|
+
}
|
|
66
|
+
const log = console.log
|
|
67
|
+
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
68
|
+
const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
69
|
+
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
70
|
+
const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
71
|
+
|
|
72
|
+
function flagValue(args, name) {
|
|
73
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
74
|
+
if (eq) return eq.slice(name.length + 1)
|
|
75
|
+
const i = args.indexOf(name)
|
|
76
|
+
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function slugify(s) {
|
|
81
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Tolerant single-entity document extraction (mirrors pull.js; duplicated rather
|
|
85
|
+
// than imported because pull.js statically imports @uniweb/build).
|
|
86
|
+
export function extractDocument(payload) {
|
|
87
|
+
if (!payload || typeof payload !== 'object') return null
|
|
88
|
+
if (payload.$model || payload.$id || payload.info) return payload
|
|
89
|
+
return payload.document || payload.entity || null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Unwrap a possibly-localized scalar (a `{ <locale>: value }` map) to a plain string.
|
|
93
|
+
function unwrapScalar(v) {
|
|
94
|
+
if (typeof v === 'string') return v
|
|
95
|
+
if (v && typeof v === 'object') {
|
|
96
|
+
const first = Object.values(v).find((x) => typeof x === 'string')
|
|
97
|
+
if (first) return first
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the seeds clone needs out of a site-content `$`-document:
|
|
104
|
+
* - foundationRef: the `foundation` ref (a URL or our `@ns/name@ver`) — written
|
|
105
|
+
* verbatim into site.yml so the runtime loads it as a federated module;
|
|
106
|
+
* - name: a display name for the new project.
|
|
107
|
+
*
|
|
108
|
+
* No folder uuid is read: the site holds one identity (its site-content uuid), and
|
|
109
|
+
* the folder is pulled by that same uuid — the framework never holds a folder uuid.
|
|
110
|
+
*/
|
|
111
|
+
export function extractCloneSeeds(document) {
|
|
112
|
+
const info = document?.info || {}
|
|
113
|
+
return {
|
|
114
|
+
foundationRef: extractFoundationRef(info, document),
|
|
115
|
+
name: unwrapScalar(info.name) ?? unwrapScalar(document?.name) ?? null,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Insert/replace a top-level `$uuid:` scalar in a YAML file's text without
|
|
120
|
+
// disturbing the rest (the scaffolded site.yml is comment-heavy — don't round-trip
|
|
121
|
+
// through a YAML dumper). Inserts after the first `name:` line, else prepends.
|
|
122
|
+
function seedYamlUuid(filePath, uuid) {
|
|
123
|
+
let text = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
|
|
124
|
+
if (/^\$uuid:/m.test(text)) {
|
|
125
|
+
text = text.replace(/^\$uuid:.*$/m, `$uuid: ${uuid}`)
|
|
126
|
+
} else {
|
|
127
|
+
const nameMatch = text.match(/^name:.*$/m)
|
|
128
|
+
if (nameMatch) {
|
|
129
|
+
const idx = nameMatch.index + nameMatch[0].length
|
|
130
|
+
text = text.slice(0, idx) + `\n$uuid: ${uuid}` + text.slice(idx)
|
|
131
|
+
} else {
|
|
132
|
+
text = `$uuid: ${uuid}\n` + text
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
136
|
+
writeFileSync(filePath, text)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build the package-manager argv to run the project-local `uniweb pull`.
|
|
140
|
+
function pullExecArgv(pm, extra) {
|
|
141
|
+
// npm needs `exec --` to forward flags to the binary; pnpm/yarn don't.
|
|
142
|
+
if (pm === 'npm') return ['exec', '--', 'uniweb', 'pull', ...extra]
|
|
143
|
+
return ['exec', 'uniweb', 'pull', ...extra]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string[]} args
|
|
148
|
+
* @param {object} [deps] - injectable seams for testing:
|
|
149
|
+
* fetch, getToken, skipInstall, skipPull, runInstall(projectDir, pm),
|
|
150
|
+
* runPull(siteDir, pm, extraArgs).
|
|
151
|
+
*/
|
|
152
|
+
export async function clone(args = [], deps = {}) {
|
|
153
|
+
const fetchImpl = deps.fetch || globalThis.fetch
|
|
154
|
+
|
|
155
|
+
const positionals = args.filter((a) => !a.startsWith('-'))
|
|
156
|
+
const siteUuid = positionals[0]
|
|
157
|
+
const target = positionals[1] || null // [name|.]
|
|
158
|
+
|
|
159
|
+
if (!siteUuid) {
|
|
160
|
+
error('Missing site uuid.')
|
|
161
|
+
log(`\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-collections]`)
|
|
162
|
+
log(`${colors.dim}Sites are private — run \`uniweb login\` first.${colors.reset}`)
|
|
163
|
+
return { exitCode: 2 }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const noCollections = args.includes('--no-collections') || args.includes('--content-only')
|
|
167
|
+
const pathFlag = flagValue(args, '--path')
|
|
168
|
+
const projectFlag = flagValue(args, '--project')
|
|
169
|
+
const tokenFlag = flagValue(args, '--token')
|
|
170
|
+
const registryFlag = flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_BACKEND_ORIGIN
|
|
171
|
+
|
|
172
|
+
let apiBase
|
|
173
|
+
try {
|
|
174
|
+
apiBase = new URL(registryFlag).origin
|
|
175
|
+
} catch {
|
|
176
|
+
error(`Invalid --registry / UNIWEB_REGISTER_URL: ${registryFlag}`)
|
|
177
|
+
return { exitCode: 2 }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let cachedToken = null
|
|
181
|
+
const getToken =
|
|
182
|
+
deps.getToken ||
|
|
183
|
+
(async () => {
|
|
184
|
+
if (cachedToken) return cachedToken
|
|
185
|
+
cachedToken = tokenFlag || process.env.UNIWEB_TOKEN || (await ensureRegistryAuth({ apiBase, command: 'Cloning', args }))
|
|
186
|
+
return cachedToken
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// 1. GET the site-content document (plain fetch — no @uniweb/build).
|
|
190
|
+
const url = `${apiBase}/dev/site/content/pull/${encodeURIComponent(siteUuid)}`
|
|
191
|
+
info(`Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${url}${colors.reset} …`)
|
|
192
|
+
let payload
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${await getToken()}` } })
|
|
195
|
+
if (res.status === 404) {
|
|
196
|
+
error(`Site not found (404) — check the uuid, or you lack access.`)
|
|
197
|
+
return { exitCode: 1 }
|
|
198
|
+
}
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
error(`Could not read the site: HTTP ${res.status} ${res.statusText}`)
|
|
201
|
+
if (res.status === 401 || res.status === 403) note('Run `uniweb login` first (or pass --token <bearer>).')
|
|
202
|
+
return { exitCode: 1 }
|
|
203
|
+
}
|
|
204
|
+
payload = await res.json()
|
|
205
|
+
} catch (err) {
|
|
206
|
+
error(`Could not reach the backend at ${url}: ${err.message}`)
|
|
207
|
+
return { exitCode: 1 }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const document = extractDocument(payload)
|
|
211
|
+
if (!document) {
|
|
212
|
+
error('The site-content response carried no recognizable document.')
|
|
213
|
+
return { exitCode: 1 }
|
|
214
|
+
}
|
|
215
|
+
const { foundationRef, name: siteDisplayName } = extractCloneSeeds(document)
|
|
216
|
+
if (!foundationRef) {
|
|
217
|
+
note('! The pulled site declares no foundation ref — set `foundation:` in site.yml after clone.')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 2. Resolve placement (one verb, context-aware).
|
|
221
|
+
const cwd = deps.cwd || process.cwd()
|
|
222
|
+
const inPlace = target === '.'
|
|
223
|
+
const existingRoot = inPlace ? null : findWorkspaceRoot(cwd)
|
|
224
|
+
|
|
225
|
+
let projectDir // the package-manager root (for install)
|
|
226
|
+
let siteDir // where the site package lands
|
|
227
|
+
let sitePkgName
|
|
228
|
+
let workspaceName
|
|
229
|
+
let isNewWorkspace
|
|
230
|
+
let placement = null
|
|
231
|
+
|
|
232
|
+
if (inPlace) {
|
|
233
|
+
isNewWorkspace = true
|
|
234
|
+
projectDir = cwd
|
|
235
|
+
workspaceName = slugify(basename(cwd)) || 'site'
|
|
236
|
+
siteDir = join(projectDir, 'site')
|
|
237
|
+
sitePkgName = 'site'
|
|
238
|
+
} else if (existingRoot) {
|
|
239
|
+
isNewWorkspace = false
|
|
240
|
+
projectDir = existingRoot
|
|
241
|
+
placement = resolvePlacement(existingRoot, target, { path: pathFlag, project: projectFlag }, SITE_KIND)
|
|
242
|
+
siteDir = join(existingRoot, placement.relativePath)
|
|
243
|
+
sitePkgName = placement.packageName
|
|
244
|
+
workspaceName = sitePkgName
|
|
245
|
+
} else {
|
|
246
|
+
isNewWorkspace = true
|
|
247
|
+
workspaceName = target || slugify(siteDisplayName) || null
|
|
248
|
+
if (!workspaceName) {
|
|
249
|
+
error('Could not derive a project name from the site. Pass one: `uniweb clone <uuid> <name>`.')
|
|
250
|
+
return { exitCode: 2 }
|
|
251
|
+
}
|
|
252
|
+
if (!/^[a-z0-9-]+$/.test(workspaceName)) {
|
|
253
|
+
error(`Invalid project name "${workspaceName}" — use lowercase letters, numbers, and hyphens.`)
|
|
254
|
+
return { exitCode: 2 }
|
|
255
|
+
}
|
|
256
|
+
projectDir = resolve(cwd, workspaceName)
|
|
257
|
+
siteDir = join(projectDir, 'site')
|
|
258
|
+
sitePkgName = 'site'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Conflict guards.
|
|
262
|
+
if (isNewWorkspace && !inPlace && existsSync(projectDir)) {
|
|
263
|
+
error(`Directory already exists: ${workspaceName}`)
|
|
264
|
+
return { exitCode: 1 }
|
|
265
|
+
}
|
|
266
|
+
if (existsSync(join(siteDir, 'site.yml'))) {
|
|
267
|
+
error(`A site already exists at ${siteDir} — refusing to overwrite.`)
|
|
268
|
+
return { exitCode: 1 }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 3. Scaffold the harness (ref-only site: foundationRef, no foundationPath).
|
|
272
|
+
const onProgress = (m) => note(m)
|
|
273
|
+
const siteContext = { name: sitePkgName, projectName: workspaceName, ...(foundationRef ? { foundationRef } : {}) }
|
|
274
|
+
|
|
275
|
+
if (isNewWorkspace) {
|
|
276
|
+
info(`Scaffolding ${colors.bright}${workspaceName}${colors.reset} …`)
|
|
277
|
+
await scaffoldWorkspace(
|
|
278
|
+
projectDir,
|
|
279
|
+
{ projectName: workspaceName, workspaceGlobs: ['site'], scripts: { dev: 'uniweb dev', build: 'uniweb build' } },
|
|
280
|
+
{ onProgress },
|
|
281
|
+
)
|
|
282
|
+
await scaffoldSite(siteDir, siteContext, { onProgress })
|
|
283
|
+
} else {
|
|
284
|
+
info(`Adding site ${colors.bright}${sitePkgName}${colors.reset} to the workspace at ${colors.dim}${placement.relativePath}/${colors.reset} …`)
|
|
285
|
+
await scaffoldSite(siteDir, siteContext, { onProgress })
|
|
286
|
+
await addWorkspaceGlob(existingRoot, placement.relativePath)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 4. Seed the site's one identity — site.yml::$uuid. The folder is pulled by this
|
|
290
|
+
// same uuid (the backend resolves the site's @uniweb/folder from it), so there is no
|
|
291
|
+
// separate folder uuid to seed.
|
|
292
|
+
seedYamlUuid(join(siteDir, 'site.yml'), siteUuid)
|
|
293
|
+
success(`Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`)
|
|
294
|
+
|
|
295
|
+
// 5. Install, then delegate the projection to the project-local `uniweb pull`.
|
|
296
|
+
const pm = detectWorkspacePm(projectDir) || 'pnpm'
|
|
297
|
+
|
|
298
|
+
if (deps.skipInstall) {
|
|
299
|
+
note('Skipping install (test mode).')
|
|
300
|
+
} else if (deps.runInstall) {
|
|
301
|
+
await deps.runInstall(projectDir, pm)
|
|
302
|
+
} else {
|
|
303
|
+
info(`Installing dependencies (${installCmd(pm)}) …`)
|
|
304
|
+
const r = spawnSync(pm, ['install'], { cwd: projectDir, stdio: 'inherit' })
|
|
305
|
+
if (r.status !== 0) {
|
|
306
|
+
error(`Install failed. Once it succeeds, run \`uniweb pull\` from ${siteDir} to fetch the content.`)
|
|
307
|
+
return { exitCode: 1 }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const pullExtra = []
|
|
312
|
+
if (flagValue(args, '--registry')) pullExtra.push('--registry', registryFlag)
|
|
313
|
+
if (tokenFlag) pullExtra.push('--token', tokenFlag)
|
|
314
|
+
if (noCollections) pullExtra.push('--no-collections')
|
|
315
|
+
|
|
316
|
+
if (deps.skipPull) {
|
|
317
|
+
note('Skipping pull (test mode).')
|
|
318
|
+
} else if (deps.runPull) {
|
|
319
|
+
await deps.runPull(siteDir, pm, pullExtra)
|
|
320
|
+
} else {
|
|
321
|
+
info('Pulling content …')
|
|
322
|
+
const r = spawnSync(pm, pullExecArgv(pm, pullExtra), { cwd: siteDir, stdio: 'inherit' })
|
|
323
|
+
if (r.status !== 0) {
|
|
324
|
+
error(`Content pull failed. Fix the issue, then run \`uniweb pull\` from ${siteDir}.`)
|
|
325
|
+
return { exitCode: 1 }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
log('')
|
|
330
|
+
success(`Cloned site into ${colors.bright}${isNewWorkspace && !inPlace ? workspaceName : siteDir}${colors.reset}`)
|
|
331
|
+
if (isNewWorkspace && !inPlace) {
|
|
332
|
+
log(`\nNext: ${colors.cyan}cd ${workspaceName} && uniweb dev${colors.reset}`)
|
|
333
|
+
} else {
|
|
334
|
+
log(`\nNext: ${colors.cyan}uniweb dev${colors.reset}`)
|
|
335
|
+
}
|
|
336
|
+
return { exitCode: 0 }
|
|
337
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Command — `uniweb content export`
|
|
3
|
+
*
|
|
4
|
+
* Packages a site project (or a built foundation's schema) as a `.uwx`
|
|
5
|
+
* entity package — the Uniweb exchange format — for import into Uniweb.
|
|
6
|
+
* No JS/HTML: pure content / declarative data.
|
|
7
|
+
*
|
|
8
|
+
* uniweb content export Package the site in the cwd
|
|
9
|
+
* uniweb content export <dir> Package a specific site/foundation
|
|
10
|
+
* uniweb content export -o team.uwx Choose the output filename
|
|
11
|
+
* uniweb content export --no-sidecar Mint fresh ids (submit-once;
|
|
12
|
+
* default is the syncable round trip,
|
|
13
|
+
* persisting ids in .uniweb/uwx-ids.json)
|
|
14
|
+
* uniweb content export --dry-run Print a summary; write nothing
|
|
15
|
+
* uniweb content export --source-locale fr Wrap single-language fields under
|
|
16
|
+
* this locale (default: en)
|
|
17
|
+
*
|
|
18
|
+
* A site dir is detected by `site.yml`; a foundation by a built
|
|
19
|
+
* `dist/meta/schema.json` (run `uniweb build` first for a foundation).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { join, basename, resolve } from 'node:path'
|
|
24
|
+
|
|
25
|
+
const c = {
|
|
26
|
+
reset: '\x1b[0m',
|
|
27
|
+
dim: '\x1b[2m',
|
|
28
|
+
cyan: '\x1b[36m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
red: '\x1b[31m',
|
|
31
|
+
yellow: '\x1b[33m',
|
|
32
|
+
}
|
|
33
|
+
const say = {
|
|
34
|
+
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
35
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
36
|
+
err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
|
|
37
|
+
warn: (m) => console.log(`${c.yellow}!${c.reset} ${m}`),
|
|
38
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function usage() {
|
|
42
|
+
console.log(`
|
|
43
|
+
${c.cyan}uniweb content export${c.reset} [dir] [options]
|
|
44
|
+
|
|
45
|
+
Package a site project (or built foundation schema) as a .uwx entity
|
|
46
|
+
package for import into Uniweb.
|
|
47
|
+
|
|
48
|
+
${c.dim}-o, --output <file>${c.reset} Output path (default: <name>.uwx)
|
|
49
|
+
${c.dim}--no-sidecar${c.reset} Mint fresh ids (submit-once) instead of
|
|
50
|
+
the syncable round trip
|
|
51
|
+
${c.dim}--dry-run${c.reset} Print a summary; write nothing
|
|
52
|
+
${c.dim}--source-locale <code>${c.reset} Locale for single-language fields (default: en)
|
|
53
|
+
`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function content(args = []) {
|
|
57
|
+
const sub = args[0]
|
|
58
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
59
|
+
usage()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (sub === 'export') {
|
|
63
|
+
await contentExport(args.slice(1))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
say.err(`Unknown subcommand: content ${sub}`)
|
|
67
|
+
usage()
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function contentExport(args) {
|
|
72
|
+
const { readFlagValue } = await import('../utils/args.js')
|
|
73
|
+
const dryRun = args.includes('--dry-run')
|
|
74
|
+
const noSidecar = args.includes('--no-sidecar')
|
|
75
|
+
const sourceLocale = readFlagValue(args, '--source-locale') || undefined
|
|
76
|
+
let output = readFlagValue(args, '-o')
|
|
77
|
+
if (!output || output === true) output = readFlagValue(args, '--output')
|
|
78
|
+
const dir = resolve(
|
|
79
|
+
args.find((a) => !a.startsWith('-') && a !== output) || process.cwd()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const isSite = existsSync(join(dir, 'site.yml'))
|
|
83
|
+
const schemaPath = join(dir, 'dist', 'meta', 'schema.json')
|
|
84
|
+
const isFoundation = !isSite && existsSync(schemaPath)
|
|
85
|
+
|
|
86
|
+
if (!isSite && !isFoundation) {
|
|
87
|
+
say.err(`No site.yml or built dist/meta/schema.json found in ${dir}`)
|
|
88
|
+
say.dim('Point at a site directory, or `uniweb build` a foundation first.')
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const uwx = await import('@uniweb/build/uwx')
|
|
93
|
+
// --dry-run must have zero side effects: mint ids (no sidecar file).
|
|
94
|
+
const useSidecar = !dryRun && !noSidecar
|
|
95
|
+
|
|
96
|
+
let buf
|
|
97
|
+
let defaultName
|
|
98
|
+
try {
|
|
99
|
+
if (isSite) {
|
|
100
|
+
say.info(`Packaging site → @uniweb/site-content (.uwx)…`)
|
|
101
|
+
// Nested $-document on the sync lane. The sidecar is read-only here: it
|
|
102
|
+
// supplies $uuids a prior sync recorded (the backend mints, never this
|
|
103
|
+
// export), so a fresh project exports a uuid-less document.
|
|
104
|
+
buf = await uwx.emitSiteSyncPackage(dir, {
|
|
105
|
+
sidecar: useSidecar, // read <dir>/.uniweb/uwx-ids.json if present
|
|
106
|
+
sourceLocale,
|
|
107
|
+
})
|
|
108
|
+
defaultName = basename(dir)
|
|
109
|
+
} else {
|
|
110
|
+
const schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
|
|
111
|
+
say.info(`Packaging foundation schema → @uniweb/foundation-schema (.uwx)…`)
|
|
112
|
+
buf = uwx.emitFoundationSchemaPackage(schema, {
|
|
113
|
+
sidecar: useSidecar ? join(dir, uwx.SIDECAR_RELPATH) : undefined,
|
|
114
|
+
foundationDir: dir,
|
|
115
|
+
sourceLocale,
|
|
116
|
+
})
|
|
117
|
+
defaultName = (schema?._self?.name || basename(dir)).replace(
|
|
118
|
+
/[^a-z0-9._-]+/gi,
|
|
119
|
+
'-'
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
say.err(err.message)
|
|
124
|
+
process.exit(1)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Structural summary (not an import simulation). The entity file is located
|
|
128
|
+
// via the manifest index, which works for both lanes: the register lane
|
|
129
|
+
// (foundation schema, flat `items[]`) and the sync lane (site, nested
|
|
130
|
+
// `$`-document).
|
|
131
|
+
const files = uwx.readZip(buf)
|
|
132
|
+
const manifest = JSON.parse(files.get('manifest.json').toString('utf8'))
|
|
133
|
+
const entityFile = manifest.entries?.[0]?.file
|
|
134
|
+
const entity = JSON.parse(files.get(entityFile).toString('utf8'))
|
|
135
|
+
const counts = {}
|
|
136
|
+
if (Array.isArray(entity.items)) {
|
|
137
|
+
// Flat register lane: one item per section occurrence.
|
|
138
|
+
for (const it of entity.items) {
|
|
139
|
+
counts[it.section] = (counts[it.section] || 0) + 1
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Nested sync lane: count records per top-level section, recursing into
|
|
143
|
+
// self-nested `$children` and inline `page_sections`.
|
|
144
|
+
const countPages = (pages) => {
|
|
145
|
+
let p = 0
|
|
146
|
+
let s = 0
|
|
147
|
+
for (const page of pages || []) {
|
|
148
|
+
p++
|
|
149
|
+
s += (page.page_sections || []).length
|
|
150
|
+
const sub = countPages(page.$children)
|
|
151
|
+
p += sub.p
|
|
152
|
+
s += sub.s
|
|
153
|
+
}
|
|
154
|
+
return { p, s }
|
|
155
|
+
}
|
|
156
|
+
if (entity.info) counts.info = 1
|
|
157
|
+
const pg = countPages(entity.pages)
|
|
158
|
+
if (pg.p) counts.pages = pg.p
|
|
159
|
+
if (pg.s) counts.page_sections = pg.s
|
|
160
|
+
if (entity.layout_sections?.length) counts.layout_sections = entity.layout_sections.length
|
|
161
|
+
if (entity.extensions?.length) counts.extensions = entity.extensions.length
|
|
162
|
+
if (entity.collections?.length) counts.collections = entity.collections.length
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log('')
|
|
166
|
+
say.dim(`subtype ${manifest.subtype} (format ${manifest.format})`)
|
|
167
|
+
say.dim(
|
|
168
|
+
`type ${manifest.models_required[0].name_at_export} ${manifest.models_required[0].uuid}`
|
|
169
|
+
)
|
|
170
|
+
say.dim(`entity ${entity.uuid || entity.$uuid || entity.$id}`)
|
|
171
|
+
say.dim(
|
|
172
|
+
`items ${Object.entries(counts)
|
|
173
|
+
.map(([s, n]) => `${s}:${n}`)
|
|
174
|
+
.join(' ')}`
|
|
175
|
+
)
|
|
176
|
+
say.dim(`package_sha256 ${manifest.package_sha256.slice(0, 16)}…`)
|
|
177
|
+
say.dim(`size ${(buf.length / 1024).toFixed(1)} KiB`)
|
|
178
|
+
console.log('')
|
|
179
|
+
|
|
180
|
+
if (dryRun) {
|
|
181
|
+
say.ok('Dry run — nothing written.')
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const outPath = resolve(output || `${defaultName}.uwx`)
|
|
186
|
+
writeFileSync(outPath, buf)
|
|
187
|
+
say.ok(`Wrote ${c.cyan}${outPath}${c.reset}`)
|
|
188
|
+
// Only the register lane (foundation schema) mints + persists ids locally. The
|
|
189
|
+
// site sync lane reads the sidecar read-only — the backend mints on sync — so
|
|
190
|
+
// there is nothing to persist here for a site.
|
|
191
|
+
if (useSidecar && isFoundation) {
|
|
192
|
+
say.dim(
|
|
193
|
+
`ids persisted in ${uwx.SIDECAR_RELPATH} — commit it so re-exports update, not duplicate.`
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
console.log('')
|
|
197
|
+
say.warn('v0 scope: media bytes, collection records, and @-nested section')
|
|
198
|
+
say.dim('hierarchy are not yet carried (documented).')
|
|
199
|
+
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - Static-host adapters (`s3-cloudfront`, `cloudflare-pages`,
|
|
12
12
|
* `github-pages`, `generic-static`, …): build dist/ in bundle-mode
|
|
13
13
|
* and hand it to a host adapter for upload + invalidation. No login,
|
|
14
|
-
* no edge.
|
|
14
|
+
* no edge.
|
|
15
15
|
*
|
|
16
16
|
* For static-host artifacts WITHOUT upload, see `uniweb export`.
|
|
17
17
|
*
|
|
@@ -50,8 +50,6 @@
|
|
|
50
50
|
* UNIWEB_SKIP_BILLING=1 Admin-only: bypass billing gate
|
|
51
51
|
* UNIWEB_FORCE_REVIEW=1 Force the browser review flow
|
|
52
52
|
* UNIWEB_ALLOW_DIRTY_FOUNDATION=1 Don't treat a dirty workspace as stale
|
|
53
|
-
*
|
|
54
|
-
* See kb/platform/plans/cli-site-deploy-decisions.md for the full design.
|
|
55
53
|
*/
|
|
56
54
|
|
|
57
55
|
import { createServer } from 'node:http'
|
|
@@ -453,7 +451,7 @@ export async function deploy(args = []) {
|
|
|
453
451
|
//
|
|
454
452
|
// The default flow (`uniweb`) requires a `foundation:` declaration;
|
|
455
453
|
// static-host deploys don't, so this branch comes BEFORE the foundation
|
|
456
|
-
// check.
|
|
454
|
+
// check.
|
|
457
455
|
const targetFromFlag = readFlagValue(args, '--target')
|
|
458
456
|
let hostFromFlag = readFlagValue(args, '--host')
|
|
459
457
|
const noSave = args.includes('--no-save')
|
|
@@ -907,6 +905,11 @@ export async function deploy(args = []) {
|
|
|
907
905
|
say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
|
|
908
906
|
}
|
|
909
907
|
|
|
908
|
+
const searchFiles = await collectSearchFiles(distDir)
|
|
909
|
+
if (Object.keys(searchFiles).length > 0) {
|
|
910
|
+
say.dim(`Search indexes : ${Object.keys(searchFiles).length} (_search/ JSON)`)
|
|
911
|
+
}
|
|
912
|
+
|
|
910
913
|
// Asset pipeline — upload dist/assets/* + favicon + fonts + content-scan
|
|
911
914
|
// hits (public/, data file refs) to S3, then rewrite each locale's
|
|
912
915
|
// siteContent + each parsed data file so the runtime resolves CDN URLs at
|
|
@@ -948,6 +951,7 @@ export async function deploy(args = []) {
|
|
|
948
951
|
// publish writes each to ${sitePrefix}/data/<key>; worker serve allows
|
|
949
952
|
// /data/* paths from R2 alongside _pages/*.
|
|
950
953
|
...(Object.keys(dataFiles).length > 0 ? { dataFiles } : {}),
|
|
954
|
+
...(Object.keys(searchFiles).length > 0 ? { searchFiles } : {}),
|
|
951
955
|
// Same shape as Editor publish — one entry per language. Single-locale
|
|
952
956
|
// sites end up with `{ [defaultLanguage]: siteContent }`; multi-locale
|
|
953
957
|
// sites carry per-locale translated content emitted by buildLocalizedContent.
|
|
@@ -1039,8 +1043,6 @@ export async function deploy(args = []) {
|
|
|
1039
1043
|
// registered in @uniweb/build/hosts. Always runs `uniweb build` (bundle
|
|
1040
1044
|
// mode + prerender) first, then hands dist/ to the adapter's deploy hook
|
|
1041
1045
|
// for upload + invalidation.
|
|
1042
|
-
//
|
|
1043
|
-
// See kb/framework/plans/static-host-deploy-adapters.md.
|
|
1044
1046
|
|
|
1045
1047
|
async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
|
|
1046
1048
|
let getAdapter
|
|
@@ -1324,6 +1326,25 @@ async function collectDataFiles(distDir) {
|
|
|
1324
1326
|
return files
|
|
1325
1327
|
}
|
|
1326
1328
|
|
|
1329
|
+
// Collect search index files from dist/_search/ recursively.
|
|
1330
|
+
// Returns `{ '<locale>/<name>.json': '<utf8-content>' }` so the worker can
|
|
1331
|
+
// write each to `${sitePrefix}/_search/<key>` in R2, gated by searchEnabled.
|
|
1332
|
+
// Empty object when the build emitted no search indexes.
|
|
1333
|
+
async function collectSearchFiles(distDir) {
|
|
1334
|
+
const searchDir = join(distDir, '_search')
|
|
1335
|
+
if (!existsSync(searchDir)) return {}
|
|
1336
|
+
const files = {}
|
|
1337
|
+
const entries = await readdir(searchDir, { withFileTypes: true, recursive: true })
|
|
1338
|
+
for (const entry of entries) {
|
|
1339
|
+
if (!entry.isFile()) continue
|
|
1340
|
+
if (!entry.name.endsWith('.json')) continue
|
|
1341
|
+
const fullPath = join(entry.parentPath || entry.path, entry.name)
|
|
1342
|
+
const relPath = relative(searchDir, fullPath)
|
|
1343
|
+
files[relPath] = await readFile(fullPath, 'utf8')
|
|
1344
|
+
}
|
|
1345
|
+
return files
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1327
1348
|
// Optional per-language labels from site.yml's object form. Returns null when
|
|
1328
1349
|
// site.yml uses the plain-string form (no labels declared) — server falls back
|
|
1329
1350
|
// to its own defaults in that case.
|
package/src/commands/docs.js
CHANGED
|
@@ -119,8 +119,7 @@ ${colors.bright}Child Page Ordering:${colors.reset}
|
|
|
119
119
|
|
|
120
120
|
${colors.bright}Navigation Visibility:${colors.reset}
|
|
121
121
|
${colors.cyan}hidden${colors.reset} Hide from all navigation (page still exists)
|
|
122
|
-
${colors.cyan}
|
|
123
|
-
${colors.cyan}hideInFooter${colors.reset} Hide from footer nav only
|
|
122
|
+
${colors.cyan}hideIn${colors.reset} Hide from named nav areas, e.g. [header] or [footer, sidebar]
|
|
124
123
|
|
|
125
124
|
${colors.bright}Layout Options:${colors.reset}
|
|
126
125
|
${colors.cyan}layout${colors.reset} Layout name or object with name, hide, params
|
|
@@ -148,7 +147,7 @@ ${colors.bright}Example:${colors.reset}
|
|
|
148
147
|
${colors.dim}title: About Us
|
|
149
148
|
description: Learn about our company
|
|
150
149
|
order: 2
|
|
151
|
-
|
|
150
|
+
hideIn: [footer]
|
|
152
151
|
layout:
|
|
153
152
|
hide: [right]
|
|
154
153
|
seo:
|