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.
@@ -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
+ }
@@ -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. See kb/framework/plans/static-host-deploy-adapters.md.
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. See kb/framework/plans/static-host-deploy-adapters.md.
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.
@@ -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}hideInHeader${colors.reset} Hide from header nav only
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
- hideInFooter: true
150
+ hideIn: [footer]
152
151
  layout:
153
152
  hide: [right]
154
153
  seo: