uniweb 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,1214 @@
1
+ /**
2
+ * Build Command
3
+ *
4
+ * Builds foundations with schema generation, or sites.
5
+ *
6
+ * Two site build pipelines are available, but they're INTERNAL vocabulary
7
+ * after Phase 2 of the CLI ergonomics overhaul. Users see `uniweb publish`
8
+ * (Uniweb hosting — runtime-linked), `uniweb deploy --host` (third-party host)
9
+ * and `uniweb export` (self-contained bundle); the build command itself just
10
+ * dispatches to whichever pipeline the caller requested.
11
+ *
12
+ * --bundle (internal; called by `uniweb deploy --host` / `uniweb export`)
13
+ * Full vite + post-vite pipeline. Produces a static-host JS bundle
14
+ * (`dist/index.html`, `dist/entry.js`, `_importmap/*`, `_pages/*` for
15
+ * split mode, sitemap/robots/search-index, prerendered HTML when
16
+ * configured). Foundation is loaded by URL when site.yml's foundation
17
+ * is a registry ref; statically inlined when it's a workspace-local
18
+ * ref (the self-contained case).
19
+ *
20
+ * --link (internal; called by `uniweb publish`)
21
+ * Data-only pipeline. No vite. Emits ONLY what Uniweb hosting
22
+ * needs: `dist/site-content.json` (with full sections),
23
+ * `dist/<lang>/site-content.json` per non-default locale,
24
+ * `dist/data/*.json` (collections), and `dist/assets/<media>` (images,
25
+ * fonts, video posters). The backend stitches runtime + foundation per
26
+ * request — the site's JS bundle would be dead weight.
27
+ *
28
+ * Bare `uniweb build` for a site defaults to --bundle (the historical
29
+ * behavior). This is mostly useful for inspecting the build output during
30
+ * development; for shipping, use `uniweb publish` (Uniweb hosting) or
31
+ * `uniweb deploy --host` / `uniweb export` (third-party / self-contained).
32
+ *
33
+ * Usage:
34
+ * uniweb build # Build current directory (sites default to --bundle)
35
+ * uniweb build --target foundation # Explicitly build as foundation
36
+ * uniweb build --target site # Explicitly build as site
37
+ * uniweb build --prerender # Force pre-rendering
38
+ * uniweb build --no-prerender # Skip pre-rendering
39
+ * uniweb build --host <name> # Pick the host adapter for this build's
40
+ * postBuild step (e.g. cloudflare-pages,
41
+ * s3-cloudfront, github-pages,
42
+ * generic-static). Default: cloudflare-pages.
43
+ *
44
+ * Internal flags:
45
+ * --link # Data-only pipeline (Uniweb hosting; called by `uniweb publish`)
46
+ * --bundle # Full vite pipeline (third-party / self-contained; deploy --host / export)
47
+ */
48
+
49
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
50
+ import { resolve, join, dirname, basename, relative } from 'node:path'
51
+ import { spawn } from 'node:child_process'
52
+ import { writeFile, mkdir } from 'node:fs/promises'
53
+ import { createRequire } from 'node:module'
54
+
55
+ // Import build utilities from @uniweb/build
56
+ import {
57
+ generateEntryPoint,
58
+ discoverComponents,
59
+ resolveFoundationSrcPath,
60
+ classifyPackage,
61
+ isExtensionPackage
62
+ } from '@uniweb/build'
63
+ import {
64
+ resolveDefaultLocale,
65
+ normalizeLanguageList
66
+ } from '@uniweb/core/locale-config'
67
+ import { readSiteConfig } from '@uniweb/build/site'
68
+ import { readWorkspaceConfig, resolveGlob } from '../utils/config.js'
69
+
70
+ // Colors for terminal output
71
+ const colors = {
72
+ reset: '\x1b[0m',
73
+ bright: '\x1b[1m',
74
+ dim: '\x1b[2m',
75
+ cyan: '\x1b[36m',
76
+ green: '\x1b[32m',
77
+ yellow: '\x1b[33m',
78
+ red: '\x1b[31m'
79
+ }
80
+
81
+ function log(message) {
82
+ console.log(message)
83
+ }
84
+
85
+ function success(message) {
86
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
87
+ }
88
+
89
+ function error(message) {
90
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
91
+ }
92
+
93
+ function info(message) {
94
+ console.log(`${colors.cyan}→${colors.reset} ${message}`)
95
+ }
96
+
97
+ function warn(message) {
98
+ console.log(`${colors.yellow}⚠${colors.reset} ${message}`)
99
+ }
100
+
101
+ /**
102
+ * Detect project type based on directory contents
103
+ *
104
+ * Detection priority:
105
+ * 1. foundation.js → foundation
106
+ * 2. site.yml → site
107
+ * 3. src/components/ → foundation (fallback)
108
+ * 4. pages/ → site (fallback)
109
+ * 5. pnpm-workspace.yaml or package.json workspaces → workspace
110
+ */
111
+ function detectProjectType(projectDir) {
112
+ // Workspace check FIRST. A workspace root often co-exists with leaf-
113
+ // package signals (the `src/` foundation lives inside it), so asking
114
+ // the leaf classifier about a workspace root could yield 'foundation'
115
+ // by coincidence. Workspace markers (pnpm-workspace.yaml, or
116
+ // package.json::workspaces) are unambiguous.
117
+ if (existsSync(join(projectDir, 'pnpm-workspace.yaml'))) {
118
+ return 'workspace'
119
+ }
120
+
121
+ const packageJsonPath = join(projectDir, 'package.json')
122
+ if (existsSync(packageJsonPath)) {
123
+ try {
124
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
125
+ if (packageJson.workspaces) {
126
+ return 'workspace'
127
+ }
128
+ } catch {
129
+ // Ignore parse errors
130
+ }
131
+ }
132
+
133
+ // Foundation vs site: delegate to the canonical classifier in @uniweb/build.
134
+ const kind = classifyPackage(projectDir)
135
+ if (kind) return kind
136
+
137
+ return null
138
+ }
139
+
140
+ /**
141
+ * Run a command and return a promise
142
+ */
143
+ function runCommand(command, args, cwd) {
144
+ return new Promise((resolve, reject) => {
145
+ // No shell: the command is node and a script path, which needs none — and a shell
146
+ // splits a path with a space in it. (Node warns DEP0190 on args passed with one.)
147
+ const proc = spawn(command, args, {
148
+ cwd,
149
+ stdio: 'inherit'
150
+ })
151
+
152
+ proc.on('close', (code) => {
153
+ if (code === 0) {
154
+ resolve()
155
+ } else {
156
+ reject(new Error(`Command failed with exit code ${code}`))
157
+ }
158
+ })
159
+
160
+ proc.on('error', reject)
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Resolve the project's local Vite binary.
166
+ *
167
+ * We intentionally do NOT fall back to `npx vite`: npx resolves through npm's
168
+ * global cache, which may hold a stale Vite version (e.g. Vite 5 ignores
169
+ * lib.fileName for CSS and always emits style.css). Using the project's local
170
+ * Vite guarantees the version declared in package.json is the one that runs.
171
+ *
172
+ * @param {string} projectDir
173
+ * @returns {string} Absolute path to the vite CLI entry
174
+ */
175
+ function resolveLocalVite(projectDir) {
176
+ const require = createRequire(join(projectDir, 'package.json'))
177
+ let pkgJsonPath
178
+ try {
179
+ pkgJsonPath = require.resolve('vite/package.json')
180
+ } catch {
181
+ throw new Error(
182
+ `Vite is not installed in ${projectDir}.\n` +
183
+ `Run \`pnpm install\` (or npm/yarn install) in the project before building.`
184
+ )
185
+ }
186
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
187
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite
188
+ if (!binRel) {
189
+ throw new Error(`Could not find vite bin entry in ${pkgJsonPath}`)
190
+ }
191
+ return join(dirname(pkgJsonPath), binRel)
192
+ }
193
+
194
+ /**
195
+ * Run the project's local Vite with the given args.
196
+ * @param {string} projectDir
197
+ * @param {string[]} args - e.g. ['build']
198
+ */
199
+ async function runLocalVite(projectDir, args) {
200
+ const viteBin = resolveLocalVite(projectDir)
201
+ await runCommand(process.execPath, [viteBin, ...args], projectDir)
202
+ }
203
+
204
+ /**
205
+ * Build a foundation
206
+ */
207
+ async function buildFoundation(projectDir, options = {}) {
208
+ const srcDir = resolveFoundationSrcPath(projectDir)
209
+
210
+ info('Building foundation...')
211
+
212
+ // 1. Discover section types
213
+ log('')
214
+ info('Discovering section types...')
215
+ const components = await discoverComponents(srcDir)
216
+ const componentNames = Object.keys(components)
217
+
218
+ // ⭐ A foundation with no section types yet builds — the state
219
+ // `create --template none` makes on purpose, and one `uniweb dev` and
220
+ // @uniweb/build's entry generation already accept. ⛔ Until 2026-10-08 this
221
+ // stopped the build, saying section types belonged in `components/[Name]/`
222
+ // with a meta.js — where they lived in January — so a fresh `none` project
223
+ // failed its first build, pointed at the wrong folder.
224
+ if (componentNames.length === 0) {
225
+ const sectionsDir = relative(process.cwd(), join(srcDir, 'sections')) || 'sections'
226
+ warn(`No section types yet: ${sectionsDir}/ is empty.`)
227
+ log(
228
+ ` ${colors.dim}Add one: ${colors.reset}uniweb add section Hero${colors.dim} — a PascalCase file or folder there is one.${colors.reset}`
229
+ )
230
+ } else {
231
+ const n = componentNames.length
232
+ success(`Found ${n} section type${n === 1 ? '' : 's'}: ${componentNames.join(', ')}`)
233
+ }
234
+
235
+ // 2. Generate entry point
236
+ log('')
237
+ info('Generating entry point...')
238
+ const entryPath = join(srcDir, '_entry.generated.js')
239
+ await generateEntryPoint(srcDir, entryPath)
240
+ success('Generated _entry.generated.js')
241
+
242
+ // 3. Build with Vite
243
+ log('')
244
+ info('Running Vite build...')
245
+
246
+ // Check if vite.config.js uses the generated entry
247
+ // For now, just run the standard vite build
248
+ await runLocalVite(projectDir, ['build'])
249
+
250
+ // Vite's foundation plugin generates dist/meta/schema.json
251
+ // and processes preview images during the build.
252
+
253
+ success('Vite build complete')
254
+
255
+ // Summary
256
+ log('')
257
+ log(`${colors.green}${colors.bright}Build complete!${colors.reset}`)
258
+
259
+ // The next step is for someone who ran `uniweb build` — not for a push or publish
260
+ // that builds the foundation as one of its own steps (UNIWEB_BUILD_STEP).
261
+ if (process.env.UNIWEB_BUILD_STEP) return
262
+ log('')
263
+ log(`${colors.bright}Share with clients:${colors.reset}`)
264
+ log(
265
+ ` ${colors.bright}uniweb register${colors.reset} Release your foundation to the catalog (alias: uniweb release)`
266
+ )
267
+ }
268
+
269
+ /**
270
+ * Ensure a local foundation's `dist/entry.js` is current.
271
+ *
272
+ * Bundle mode reads a local foundation from disk (vite imports it, prerender
273
+ * loads dist/entry.js for SSG), so the foundation must be built and current.
274
+ * Otherwise the verb fails with "Foundation not found at .../dist/entry.js" or
275
+ * silently ships stale artifacts. (Link mode does NOT read the built foundation
276
+ * — it forwards only the `foundation:` ref to the backend and ships no
277
+ * foundation code — so buildSiteLink does not call this.)
278
+ *
279
+ * `buildWorkspace()` already cascades when invoked from a workspace root,
280
+ * but verbs invoked from a site directory (`uniweb build` in `sites/x/`,
281
+ * `uniweb deploy`, `uniweb export`) used to skip the cascade and rely on
282
+ * the user having pre-built the foundation. That broke fresh checkouts
283
+ * where the foundation has never been built locally.
284
+ *
285
+ * This helper is idempotent: when the workspace-root cascade has already
286
+ * run, the freshness check sees a current `dist/entry.js` and returns
287
+ * without rebuilding. So calling it inside `buildSite()` does not
288
+ * double-build under `buildWorkspace()`.
289
+ *
290
+ * Freshness rule: a built artifact (`dist/entry.js` or the legacy
291
+ * `dist/foundation.js`) exists AND its mtime is >= the newest mtime of
292
+ * any tracked source file. Tracked sources: every file under
293
+ * `<foundation>/src/`, plus root-level `package.json`, `foundation.js`,
294
+ * `meta.js` (the structural files that drive entry generation and
295
+ * schema). Build outputs and node_modules are skipped.
296
+ *
297
+ * Both artifact names are accepted because the foundation build emitter
298
+ * was renamed `dist/foundation.js → dist/entry.js` in `@uniweb/build`
299
+ * v0.14.3. A foundation built with an older @uniweb/build still produces
300
+ * the legacy name; we don't want the cascade to keep rebuilding such a
301
+ * foundation forever just because the new name is missing.
302
+ */
303
+ async function ensureFoundationFresh(foundationDir, label = 'foundation') {
304
+ const distArtifact = findFoundationDistArtifact(foundationDir)
305
+
306
+ if (!distArtifact) {
307
+ info(
308
+ `Local ${label} not built yet — building ${basename(foundationDir)} first`
309
+ )
310
+ log('')
311
+ await buildFoundation(foundationDir)
312
+ log('')
313
+ return { built: true, reason: 'missing' }
314
+ }
315
+
316
+ const distMtime = statSync(distArtifact).mtimeMs
317
+ const stale = isFoundationSourceNewerThan(foundationDir, distMtime)
318
+
319
+ if (stale) {
320
+ info(
321
+ `Local ${label} sources changed — rebuilding ${basename(foundationDir)}`
322
+ )
323
+ log('')
324
+ await buildFoundation(foundationDir)
325
+ log('')
326
+ return { built: true, reason: 'stale' }
327
+ }
328
+
329
+ return { built: false, reason: 'fresh' }
330
+ }
331
+
332
+ /**
333
+ * Locate the foundation's built entry artifact. Returns the path of the
334
+ * first match, or null when neither file exists. Prefers the current
335
+ * name (`dist/entry.js`) over the legacy one (`dist/foundation.js`).
336
+ */
337
+ function findFoundationDistArtifact(foundationDir) {
338
+ for (const name of ['entry.js', 'foundation.js']) {
339
+ const p = join(foundationDir, 'dist', name)
340
+ if (existsSync(p)) return p
341
+ }
342
+ return null
343
+ }
344
+
345
+ /**
346
+ * Returns true if any tracked foundation source file has an mtime newer
347
+ * than `referenceMtime`. Walks `<foundationDir>/src/` recursively (skipping
348
+ * dotfiles, node_modules, and dist). Also stats the root structural files.
349
+ */
350
+ function isFoundationSourceNewerThan(foundationDir, referenceMtime) {
351
+ const rootFiles = ['package.json', 'foundation.js', 'meta.js']
352
+ for (const f of rootFiles) {
353
+ const p = join(foundationDir, f)
354
+ if (existsSync(p) && statSync(p).mtimeMs > referenceMtime) return true
355
+ }
356
+
357
+ const srcDir = join(foundationDir, 'src')
358
+ if (!existsSync(srcDir)) return false
359
+
360
+ const stack = [srcDir]
361
+ while (stack.length) {
362
+ const dir = stack.pop()
363
+ let entries
364
+ try {
365
+ entries = readdirSync(dir, { withFileTypes: true })
366
+ } catch {
367
+ continue
368
+ }
369
+ for (const e of entries) {
370
+ if (
371
+ e.name === 'node_modules' ||
372
+ e.name === 'dist' ||
373
+ e.name.startsWith('.')
374
+ )
375
+ continue
376
+ const full = join(dir, e.name)
377
+ if (e.isDirectory()) {
378
+ stack.push(full)
379
+ continue
380
+ }
381
+ if (!e.isFile()) continue
382
+ try {
383
+ if (statSync(full).mtimeMs > referenceMtime) return true
384
+ } catch {
385
+ /* ignore */
386
+ }
387
+ }
388
+ }
389
+
390
+ return false
391
+ }
392
+
393
+ /**
394
+ * Load site i18n configuration
395
+ *
396
+ * Resolves locales from config:
397
+ * - undefined → all available locales (from locales/*.json)
398
+ * - '*' → explicitly all available locales
399
+ * - ['es', 'fr'] → only those specific locales
400
+ */
401
+ async function loadI18nConfig(projectDir, siteConfig = null) {
402
+ const config = siteConfig || readSiteConfig(projectDir)
403
+
404
+ const localesDir = config.i18n?.localesDir || 'locales'
405
+ const localesPath = join(projectDir, localesDir)
406
+
407
+ // Resolve locales (undefined/'*' → all available, array → specific)
408
+ const { resolveLocales } = await import('@uniweb/build/i18n')
409
+ let locales = await resolveLocales(config.languages, localesPath)
410
+
411
+ // Publish filter: build outputs are visitor artifacts. With an explicit
412
+ // `publishLanguages:`, only listed locales get dist/{locale}/ content,
413
+ // per-locale HTML, hreflang, and translated collections. Draft locales
414
+ // stay previewable in dev, which doesn't run this path. Intersecting the
415
+ // DISK-resolved list here also covers wildcard (`languages: '*'`) sites.
416
+ if (config.publishLanguages != null) {
417
+ const publishSet = new Set(normalizeLanguageList(config.publishLanguages))
418
+ const dropped = locales.filter((l) => !publishSet.has(l))
419
+ locales = locales.filter((l) => publishSet.has(l))
420
+ if (dropped.length > 0) {
421
+ info(
422
+ `Skipping non-publishable locale(s): ${dropped.join(', ')} (not in publishLanguages)`
423
+ )
424
+ }
425
+ }
426
+
427
+ // ⛔ The default locale is the root tree, not `dist/<default>/`. An explicit
428
+ // `languages: [en, fr]` resolves verbatim — default included — so the DECLARED
429
+ // form (the one the contract tells authors to use) minted a duplicate copy of
430
+ // every page, while the wildcard form did not. Dropping it here keeps content,
431
+ // per-locale HTML and translated records agreeing on one set.
432
+ const defaultLocale = resolveDefaultLocale(config)
433
+ locales = locales.filter((l) => l !== defaultLocale)
434
+
435
+ if (locales.length === 0) return null
436
+
437
+ return {
438
+ defaultLocale,
439
+ locales,
440
+ localesDir
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Build localized content for all configured locales
446
+ */
447
+ async function buildLocalizedContent(projectDir, i18nConfig) {
448
+ const { buildLocalizedContent } = await import('@uniweb/build/i18n')
449
+
450
+ const outputs = await buildLocalizedContent(projectDir, {
451
+ localesDir: i18nConfig.localesDir,
452
+ locales: i18nConfig.locales,
453
+ outputDir: join(projectDir, 'dist'),
454
+ fallbackToSource: true
455
+ })
456
+
457
+ return outputs
458
+ }
459
+
460
+ /**
461
+ * Generate index.html for each locale with hreflang tags
462
+ */
463
+ async function generateLocalizedHtml(projectDir, i18nConfig) {
464
+ const { readFile, writeFile, mkdir, copyFile } =
465
+ await import('node:fs/promises')
466
+ const distDir = join(projectDir, 'dist')
467
+ const baseHtmlPath = join(distDir, 'index.html')
468
+
469
+ if (!existsSync(baseHtmlPath)) {
470
+ return
471
+ }
472
+
473
+ let baseHtml = await readFile(baseHtmlPath, 'utf-8')
474
+
475
+ // Build hreflang tags
476
+ const hreflangTags = i18nConfig.locales
477
+ .map(
478
+ (locale) =>
479
+ `<link rel="alternate" hreflang="${locale}" href="/${locale}/" />`
480
+ )
481
+ .join('\n ')
482
+
483
+ const defaultHreflang = `<link rel="alternate" hreflang="x-default" href="/" />`
484
+ const allHreflangTags = `${hreflangTags}\n ${defaultHreflang}`
485
+
486
+ // Add hreflang to base HTML (for default locale)
487
+ if (!baseHtml.includes('hreflang')) {
488
+ baseHtml = baseHtml.replace('</head>', ` ${allHreflangTags}\n </head>`)
489
+ await writeFile(baseHtmlPath, baseHtml)
490
+ }
491
+
492
+ // Generate index.html for each locale
493
+ for (const locale of i18nConfig.locales) {
494
+ const localeDir = join(distDir, locale)
495
+ await mkdir(localeDir, { recursive: true })
496
+
497
+ // Read locale-specific site-content.json
498
+ const localeContentPath = join(localeDir, 'site-content.json')
499
+ let localeHtml = baseHtml
500
+
501
+ // Update html lang attribute
502
+ localeHtml = localeHtml.replace(
503
+ /<html[^>]*lang="[^"]*"/,
504
+ `<html lang="${locale}"`
505
+ )
506
+ if (!localeHtml.includes('lang=')) {
507
+ localeHtml = localeHtml.replace('<html', `<html lang="${locale}"`)
508
+ }
509
+
510
+ // Update script src for locale-specific content if inlined
511
+ if (existsSync(localeContentPath)) {
512
+ const localeContent = await readFile(localeContentPath, 'utf-8')
513
+ // Replace the inlined content if present
514
+ localeHtml = localeHtml.replace(
515
+ /<script[^>]*id="__SITE_CONTENT__"[^>]*>[\s\S]*?<\/script>/,
516
+ `<script type="application/json" id="__SITE_CONTENT__">${localeContent}</script>`
517
+ )
518
+ }
519
+
520
+ await writeFile(join(localeDir, 'index.html'), localeHtml)
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Resolve foundation directory based on site config and project structure
526
+ *
527
+ * Priority:
528
+ * 1. Read site's package.json to find foundation dependency path (most reliable)
529
+ * 2. Check foundations/{name} for multi-site projects
530
+ * 3. Check ../foundation for single-site projects
531
+ * 4. Check ../{name} as fallback
532
+ */
533
+ function resolveFoundationDir(projectDir, siteConfig) {
534
+ const foundationName = siteConfig?.foundation
535
+
536
+ // First, try to resolve from site's package.json dependencies
537
+ // This is the most reliable method as it matches how Vite resolves imports
538
+ if (foundationName) {
539
+ const pkgPath = join(projectDir, 'package.json')
540
+ if (existsSync(pkgPath)) {
541
+ try {
542
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
543
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
544
+ const depValue = deps[foundationName]
545
+
546
+ // Check for file: protocol (local dependency)
547
+ if (depValue && depValue.startsWith('file:')) {
548
+ const relativePath = depValue.slice(5) // Remove 'file:' prefix
549
+ const resolvedPath = join(projectDir, relativePath)
550
+ if (existsSync(resolvedPath)) {
551
+ return resolvedPath
552
+ }
553
+ }
554
+ } catch {
555
+ // Ignore JSON parse errors, fall through to other methods
556
+ }
557
+ }
558
+ }
559
+
560
+ // Check if we're in a multi-site structure (site is under sites/)
561
+ const parentDir = join(projectDir, '..')
562
+ const grandParentDir = join(projectDir, '..', '..')
563
+ const isMultiSite =
564
+ parentDir.endsWith('/sites') || parentDir.endsWith('\\sites')
565
+
566
+ if (isMultiSite && foundationName) {
567
+ // Multi-site: look for foundations/{name}
568
+ const multiFoundationDir = join(
569
+ grandParentDir,
570
+ 'foundations',
571
+ foundationName
572
+ )
573
+ if (existsSync(multiFoundationDir)) {
574
+ return multiFoundationDir
575
+ }
576
+ }
577
+
578
+ // Single site: ../foundation
579
+ const singleFoundationDir = join(projectDir, '..', 'foundation')
580
+ if (existsSync(singleFoundationDir)) {
581
+ return singleFoundationDir
582
+ }
583
+
584
+ // Fallback: try to find by foundation name in parent
585
+ if (foundationName) {
586
+ const namedFoundationDir = join(parentDir, foundationName)
587
+ if (existsSync(namedFoundationDir)) {
588
+ return namedFoundationDir
589
+ }
590
+ }
591
+
592
+ // Ultimate fallback
593
+ return singleFoundationDir
594
+ }
595
+
596
+ /**
597
+ * Build a site in link mode — data only, no vite.
598
+ *
599
+ * Emits exactly what `uniweb publish` ships to Uniweb hosting:
600
+ * dist/site-content.json (full sections inlined)
601
+ * dist/<lang>/site-content.json per non-default locale
602
+ * dist/data/<query>.json (+ a file per record, dist/data/<query>/<name>.json)
603
+ * dist/assets/<media> (processed images, video posters, PDF thumbnails)
604
+ *
605
+ * Does NOT emit HTML, JS, CSS, source maps, _importmap chunks, or
606
+ * static-host extras (sitemap, robots, search-index, _pages/*) — none
607
+ * of these are consumed on the link-mode deploy path. The worker
608
+ * generates HTML at request time and re-derives split-content per-page
609
+ * files from the full payload it receives.
610
+ *
611
+ * The `buildLocalizedContent` step is the same call bundle mode makes
612
+ * post-vite, so multi-locale sites get identical per-locale outputs in
613
+ * either mode. Collection translation (`buildLocalizedRecords`)
614
+ * also runs here so deploy ships translated collection JSONs.
615
+ *
616
+ * Bug surfaced + fixed by routing deploy through this path: the bundle
617
+ * pipeline's prerender step rewrites `dist/site-content.json` into a
618
+ * lightweight manifest (sections stripped) when split-content is active,
619
+ * and deploy was reading that stripped version, causing the worker to
620
+ * mis-detect split and serve blank pages. Link mode skips prerender
621
+ * entirely; `dist/site-content.json` keeps full sections; the worker
622
+ * splits correctly.
623
+ */
624
+ async function buildSiteLink(projectDir, options = {}) {
625
+ const { siteConfig = null } = options
626
+
627
+ info('Building site (link mode)...')
628
+
629
+ const { buildSiteData } = await import('@uniweb/build/site')
630
+ const distDir = join(projectDir, 'dist')
631
+
632
+ // Resolve the local foundation path so collectSiteContent can pick up
633
+ // theme variable defaults from `foundation.js::theme.vars`. When the
634
+ // foundation is purely a registry ref (no local sibling), this stays
635
+ // null and theme defaults come from theme.yml only.
636
+ const foundationDir = await resolveFoundationDirForSite(
637
+ projectDir,
638
+ siteConfig
639
+ ).catch(() => null)
640
+
641
+ // Link mode does NOT (re)build the foundation. It reads only the
642
+ // foundation's SOURCE config (foundation.js::theme.vars, passed as
643
+ // foundationPath below) for theme defaults, and ships NO foundation code —
644
+ // the backend serves the foundation from the registry by the `foundation:`
645
+ // ref. (Bundle mode is the one that needs a current dist/entry.js — see
646
+ // buildSite — so the ensureFoundationFresh cascade lives there, not here.)
647
+
648
+ await buildSiteData({
649
+ siteRoot: projectDir,
650
+ distDir,
651
+ foundationPath: foundationDir,
652
+ assets: siteConfig?.build?.assets || {}
653
+ })
654
+ success(`Wrote ${join('dist', 'site-content.json')}`)
655
+
656
+ // Per-locale variants — same call bundle mode makes post-vite. Both
657
+ // modes produce identical `dist/<lang>/site-content.json` outputs so
658
+ // the deploy CLI walks the same path shape regardless of mode.
659
+ const i18nConfig = await loadI18nConfig(projectDir, siteConfig)
660
+ if (i18nConfig && i18nConfig.locales.length > 0) {
661
+ log('')
662
+ info(`Building localized content for: ${i18nConfig.locales.join(', ')}`)
663
+ try {
664
+ const outputs = await buildLocalizedContent(projectDir, i18nConfig)
665
+ success(`Generated ${Object.keys(outputs).length} locale(s)`)
666
+ for (const [locale] of Object.entries(outputs)) {
667
+ log(` ${colors.dim}dist/${locale}/site-content.json${colors.reset}`)
668
+ }
669
+
670
+ // Collection translations — optional; don't fail the build if
671
+ // missing. Bundle mode does the same.
672
+ try {
673
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
674
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
675
+ locales: i18nConfig.locales,
676
+ outputDir: distDir,
677
+ recordLocalesDir: join(
678
+ projectDir,
679
+ i18nConfig.localesDir,
680
+ 'records'
681
+ ),
682
+ // Free-form record translations live under it — the default was `locales/`
683
+ // whatever the site said.
684
+ localesDir: join(projectDir, i18nConfig.localesDir)
685
+ })
686
+ const recordCount = Object.values(recordOutputs).reduce(
687
+ (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
688
+ 0
689
+ )
690
+ if (recordCount > 0) {
691
+ success(
692
+ `Translated records for ${Object.keys(recordOutputs).length} locale(s)`
693
+ )
694
+ }
695
+ } catch (err) {
696
+ if (process.env.UNIWEB_DEBUG)
697
+ console.error('Record translation:', err.message)
698
+ }
699
+ } catch (err) {
700
+ error(`i18n build failed: ${err.message}`)
701
+ if (process.env.UNIWEB_DEBUG) console.error(err.stack)
702
+ log(`${colors.yellow}Continuing without localized content${colors.reset}`)
703
+ }
704
+ }
705
+
706
+ log('')
707
+ log(
708
+ `${colors.green}${colors.bright}Build complete (link mode)${colors.reset}`
709
+ )
710
+ }
711
+
712
+ /**
713
+ * Best-effort resolution of the local foundation directory for a site,
714
+ * used by `buildSiteLink` to pass `foundationPath` to the data pipeline.
715
+ *
716
+ * Mirrors a subset of `@uniweb/build`'s `detectFoundationType` semantics:
717
+ * when the site declares `foundation: <name>` and a sibling/file: dep
718
+ * resolves to a local foundation, return its path. When the foundation
719
+ * is a registry ref or URL, return null (data pipeline still works
720
+ * without a local foundation; theme defaults just come from theme.yml).
721
+ */
722
+ async function resolveFoundationDirForSite(siteDir, siteConfig) {
723
+ const cfg = siteConfig || readSiteConfig(siteDir)
724
+ const foundation = cfg?.foundation
725
+ if (!foundation || typeof foundation !== 'string') return null
726
+ // Registry ref or URL — no local foundation.
727
+ if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundation)) return null
728
+ if (foundation.startsWith('http://') || foundation.startsWith('https://'))
729
+ return null
730
+
731
+ // Workspace sibling.
732
+ const sibling = resolve(siteDir, '..', foundation)
733
+ if (existsSync(sibling)) return sibling
734
+
735
+ // file: dep declared in package.json.
736
+ try {
737
+ const pkg = JSON.parse(readFileSync(join(siteDir, 'package.json'), 'utf-8'))
738
+ const dep = pkg.dependencies?.[foundation]
739
+ if (typeof dep === 'string' && dep.startsWith('file:')) {
740
+ const filePath = resolve(siteDir, dep.slice(5))
741
+ if (existsSync(filePath)) return filePath
742
+ }
743
+ } catch {
744
+ /* no package.json or malformed — fall through */
745
+ }
746
+
747
+ return null
748
+ }
749
+
750
+ /**
751
+ * Build a site
752
+ */
753
+ async function buildSite(projectDir, options = {}) {
754
+ const {
755
+ prerender = false,
756
+ foundationDir,
757
+ siteConfig = null,
758
+ host = null
759
+ } = options
760
+
761
+ info('Building site...')
762
+
763
+ // Cascade: bundle mode imports the foundation through vite, and (when
764
+ // prerender is on) loads dist/entry.js for SSG. A local foundation must
765
+ // therefore be current. Idempotent under buildWorkspace() — when the
766
+ // workspace cascade has already built it, the freshness check no-ops.
767
+ if (foundationDir) await ensureFoundationFresh(foundationDir)
768
+
769
+ // Run vite build for sites
770
+ await runLocalVite(projectDir, ['build'])
771
+
772
+ success('Site build complete')
773
+
774
+ // Check for i18n configuration
775
+ const i18nConfig = await loadI18nConfig(projectDir, siteConfig)
776
+
777
+ if (i18nConfig && i18nConfig.locales.length > 0) {
778
+ log('')
779
+ info(`Building localized content for: ${i18nConfig.locales.join(', ')}`)
780
+
781
+ try {
782
+ // Generate locale-specific site-content.json
783
+ const outputs = await buildLocalizedContent(projectDir, i18nConfig)
784
+
785
+ // Generate locale-specific index.html files
786
+ await generateLocalizedHtml(projectDir, i18nConfig)
787
+
788
+ success(`Generated ${Object.keys(outputs).length} locale(s)`)
789
+
790
+ for (const [locale, path] of Object.entries(outputs)) {
791
+ log(` ${colors.dim}dist/${locale}/site-content.json${colors.reset}`)
792
+ }
793
+
794
+ // Translate collections if they exist
795
+ try {
796
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
797
+
798
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
799
+ locales: i18nConfig.locales,
800
+ outputDir: join(projectDir, 'dist'),
801
+ recordLocalesDir: join(
802
+ projectDir,
803
+ i18nConfig.localesDir,
804
+ 'records'
805
+ ),
806
+ // Free-form record translations live under it — the default was `locales/`
807
+ // whatever the site said.
808
+ localesDir: join(projectDir, i18nConfig.localesDir)
809
+ })
810
+
811
+ // Count collections translated
812
+ const recordCount = Object.values(recordOutputs).reduce(
813
+ (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
814
+ 0
815
+ )
816
+
817
+ if (recordCount > 0) {
818
+ success(
819
+ `Translated records for ${Object.keys(recordOutputs).length} locale(s)`
820
+ )
821
+ }
822
+ } catch (err) {
823
+ // Collection translation is optional, don't fail build
824
+ if (process.env.UNIWEB_DEBUG) {
825
+ console.error('Record translation:', err.message)
826
+ }
827
+ }
828
+ } catch (err) {
829
+ error(`i18n build failed: ${err.message}`)
830
+ if (process.env.UNIWEB_DEBUG) {
831
+ console.error(err.stack)
832
+ }
833
+ // Don't fail the build, just warn
834
+ log(`${colors.yellow}Continuing without localized content${colors.reset}`)
835
+ }
836
+ }
837
+
838
+ // Pre-render if requested
839
+ if (prerender) {
840
+ log('')
841
+ info('Pre-rendering pages to static HTML (SSG)...')
842
+
843
+ try {
844
+ const { prerenderSite } = await import('@uniweb/build/prerender')
845
+
846
+ const result = await prerenderSite(projectDir, {
847
+ foundationDir:
848
+ foundationDir || resolveFoundationDir(projectDir, siteConfig),
849
+ host,
850
+ onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
851
+ })
852
+
853
+ success(
854
+ `Pre-rendered ${result.pages} page${result.pages !== 1 ? 's' : ''} to static HTML`
855
+ )
856
+
857
+ // Summary
858
+ log('')
859
+ log(`${colors.green}${colors.bright}SSG Build complete!${colors.reset}`)
860
+ log('')
861
+ log(`Output:`)
862
+ for (const file of result.files) {
863
+ const relativePath = file.replace(projectDir + '/', '')
864
+ log(` ${colors.dim}${relativePath}${colors.reset}`)
865
+ }
866
+ } catch (err) {
867
+ error(`Pre-rendering failed: ${err.message}`)
868
+
869
+ // Provide helpful guidance for common errors
870
+ if (err.message.includes('Foundation not found')) {
871
+ log('')
872
+ log(`${colors.yellow}This usually means:${colors.reset}`)
873
+ log(
874
+ ` 1. The foundation hasn't been built yet (run foundation build first)`
875
+ )
876
+ log(` 2. The foundation name in site.yml doesn't match your setup`)
877
+ log('')
878
+ log(`${colors.dim}Check that:${colors.reset}`)
879
+ log(
880
+ ` • site.yml 'foundation:' matches the package name in your foundation's package.json`
881
+ )
882
+ log(
883
+ ` • site's package.json has a dependency pointing to the correct foundation path`
884
+ )
885
+ log(
886
+ ` • The foundation's dist/entry.js exists (build the foundation first)`
887
+ )
888
+ }
889
+
890
+ if (process.env.UNIWEB_DEBUG) {
891
+ console.error(err.stack)
892
+ }
893
+ process.exit(1)
894
+ }
895
+ }
896
+
897
+ showNextSteps(false, true)
898
+ }
899
+
900
+ /**
901
+ * Check if a directory is a foundation
902
+ */
903
+ function isFoundation(dir) {
904
+ return classifyPackage(dir) === 'foundation'
905
+ }
906
+
907
+ /**
908
+ * Check if a foundation directory declares extension: true in its
909
+ * authored declarations file (main.js or legacy foundation.js).
910
+ */
911
+ function isExtensionDir(dir) {
912
+ return isExtensionPackage(dir)
913
+ }
914
+
915
+ /**
916
+ * Check if a directory is a site
917
+ */
918
+ function isSite(dir) {
919
+ return classifyPackage(dir) === 'site'
920
+ }
921
+
922
+ /**
923
+ * Discover workspace packages based on workspace config (pnpm-workspace.yaml globs)
924
+ */
925
+ async function discoverWorkspacePackages(workspaceDir) {
926
+ const foundations = []
927
+ const extensions = []
928
+ const sites = []
929
+
930
+ const { packages } = await readWorkspaceConfig(workspaceDir)
931
+
932
+ for (const pattern of packages) {
933
+ const dirs = await resolveGlob(workspaceDir, pattern)
934
+ for (const dir of dirs) {
935
+ const fullPath = join(workspaceDir, dir)
936
+ const name = dir.split('/').pop()
937
+
938
+ if (isFoundation(fullPath)) {
939
+ if (isExtensionDir(fullPath)) {
940
+ extensions.push({ name, path: fullPath })
941
+ } else {
942
+ foundations.push({ name, path: fullPath })
943
+ }
944
+ } else if (isSite(fullPath)) {
945
+ sites.push({ name, path: fullPath })
946
+ }
947
+ }
948
+ }
949
+
950
+ return { foundations, extensions, sites }
951
+ }
952
+
953
+ /**
954
+ * Build all packages in a workspace
955
+ */
956
+ async function buildWorkspace(workspaceDir, options = {}) {
957
+ const { prerenderFlag, noPrerenderFlag, host = null } = options
958
+
959
+ log(`${colors.cyan}${colors.bright}Building workspace...${colors.reset}`)
960
+ log('')
961
+
962
+ const { foundations, extensions, sites } =
963
+ await discoverWorkspacePackages(workspaceDir)
964
+
965
+ if (
966
+ foundations.length === 0 &&
967
+ extensions.length === 0 &&
968
+ sites.length === 0
969
+ ) {
970
+ error('No foundations, extensions, or sites found in workspace')
971
+ log('')
972
+ log('Packages are the folders pnpm-workspace.yaml lists. Add one with:')
973
+ log(' uniweb add foundation [folder] (src/ when none is named)')
974
+ log(' uniweb add site [folder] (site/ when none is named)')
975
+ log(' uniweb add extension <folder>')
976
+ process.exit(1)
977
+ }
978
+
979
+ // Build foundations first (sites depend on them)
980
+ for (const foundation of foundations) {
981
+ log(`${colors.bright}[${foundation.name}]${colors.reset}`)
982
+ await buildFoundation(foundation.path)
983
+ log('')
984
+ }
985
+
986
+ // Build extensions (they are foundations, but logged distinctly)
987
+ for (const extension of extensions) {
988
+ const label = isExtensionDir(extension.path) ? 'extension' : 'foundation'
989
+ log(
990
+ `${colors.bright}[${extension.name}]${colors.reset} ${colors.dim}(${label})${colors.reset}`
991
+ )
992
+ await buildFoundation(extension.path)
993
+ log('')
994
+ }
995
+
996
+ // Build sites
997
+ for (const site of sites) {
998
+ log(`${colors.bright}[${site.name}]${colors.reset}`)
999
+
1000
+ const siteConfig = readSiteConfig(site.path)
1001
+ const configPrerender = siteConfig.build?.prerender === true
1002
+
1003
+ let prerender = configPrerender
1004
+ if (prerenderFlag) prerender = true
1005
+ if (noPrerenderFlag) prerender = false
1006
+
1007
+ // Resolve foundation directory for this site
1008
+ const foundationDir = resolveFoundationDir(site.path, siteConfig)
1009
+
1010
+ await buildSite(site.path, { prerender, foundationDir, siteConfig, host })
1011
+ log('')
1012
+ }
1013
+
1014
+ // Summary
1015
+ const totalBuilt = foundations.length + extensions.length + sites.length
1016
+ const parts = []
1017
+ if (foundations.length > 0) parts.push(`${foundations.length} foundation(s)`)
1018
+ if (extensions.length > 0) parts.push(`${extensions.length} extension(s)`)
1019
+ if (sites.length > 0) parts.push(`${sites.length} site(s)`)
1020
+
1021
+ log(`${colors.green}${colors.bright}Workspace build complete!${colors.reset}`)
1022
+ log('')
1023
+ log(`Built ${parts.join(', ')}`)
1024
+
1025
+ showNextSteps(foundations.length > 0, sites.length > 0)
1026
+ }
1027
+
1028
+ /**
1029
+ * Show next-step hints after workspace build
1030
+ */
1031
+ function showNextSteps(hasFoundations, hasSites) {
1032
+ if (hasFoundations) {
1033
+ log('')
1034
+ log(`${colors.bright}Share with clients:${colors.reset}`)
1035
+ log(
1036
+ ` ${colors.bright}uniweb register${colors.reset} Release your foundation to the catalog (alias: uniweb release)`
1037
+ )
1038
+ }
1039
+ if (hasSites) {
1040
+ log('')
1041
+ log(`${colors.bright}Ship a site:${colors.reset}`)
1042
+ log(
1043
+ ` ${colors.bright}uniweb deploy${colors.reset} Pick a host and ship (asks where, then remembers)`
1044
+ )
1045
+ log(
1046
+ ` ${colors.bright}uniweb add ci --host${colors.reset}=… Deploy on every push (free hosts; adds PR previews)`
1047
+ )
1048
+ log(
1049
+ ` ${colors.bright}uniweb publish${colors.reset} Uniweb Cloud (brings the foundation along)`
1050
+ )
1051
+ log(
1052
+ ` Or upload ${colors.cyan}dist/${colors.reset} (\`uniweb export\`) to any static host`
1053
+ )
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * Main build command handler
1059
+ */
1060
+ export async function build(args = []) {
1061
+ const projectDir = process.cwd()
1062
+
1063
+ // Parse arguments
1064
+ let targetType = null
1065
+ const targetIndex = args.indexOf('--target')
1066
+ if (targetIndex !== -1 && args[targetIndex + 1]) {
1067
+ targetType = args[targetIndex + 1]
1068
+ if (!['foundation', 'site'].includes(targetType)) {
1069
+ error(`Invalid target: ${targetType}`)
1070
+ log('Valid targets: foundation, site')
1071
+ process.exit(1)
1072
+ }
1073
+ }
1074
+
1075
+ // Check for --prerender / --no-prerender flags
1076
+ const prerenderFlag = args.includes('--prerender')
1077
+ const noPrerenderFlag = args.includes('--no-prerender')
1078
+
1079
+ // Internal flags — called by `uniweb publish` (always --link) and
1080
+ // `uniweb deploy --host` / `uniweb export` (always --bundle). After Phase 2
1081
+ // of the CLI ergonomics overhaul, users don't see these flags directly; they
1082
+ // pick the verb (publish vs deploy vs export) and the corresponding
1083
+ // pipeline runs. Bare `uniweb build` for a site defaults to --bundle
1084
+ // (mostly used during development to inspect the vite output).
1085
+ const linkFlag = args.includes('--link')
1086
+ const bundleFlag = args.includes('--bundle')
1087
+ if (linkFlag && bundleFlag) {
1088
+ error(
1089
+ 'Cannot pass both --link and --bundle (they select different build pipelines)'
1090
+ )
1091
+ process.exit(1)
1092
+ }
1093
+
1094
+ // Check for --foundation-dir flag (for prerendering)
1095
+ let foundationDir = null
1096
+ const foundationDirIndex = args.indexOf('--foundation-dir')
1097
+ if (foundationDirIndex !== -1 && args[foundationDirIndex + 1]) {
1098
+ foundationDir = resolve(args[foundationDirIndex + 1])
1099
+ }
1100
+
1101
+ // --host names the host adapter for this build's prerender step.
1102
+ // Default = 'cloudflare-pages' (resolved inside prerender.js, via the
1103
+ // registry). Build does not read deploy.yml; that is the deploy
1104
+ // orchestrator's job.
1105
+ //
1106
+ // `--host` with no value → interactive picker (errors in CI / non-TTY).
1107
+ const { readFlagValue } = await import('../utils/args.js')
1108
+ const hostFlag = readFlagValue(args, '--host')
1109
+ let host = null
1110
+ if (hostFlag === null) {
1111
+ const { promptForHost } = await import('../utils/host-prompt.js')
1112
+ try {
1113
+ host = await promptForHost({ args })
1114
+ } catch (err) {
1115
+ error(err.message)
1116
+ process.exit(1)
1117
+ }
1118
+ } else if (typeof hostFlag === 'string') {
1119
+ host = hostFlag
1120
+ }
1121
+
1122
+ // Auto-detect project type if not specified
1123
+ if (!targetType) {
1124
+ targetType = detectProjectType(projectDir)
1125
+
1126
+ if (!targetType) {
1127
+ error('Could not detect project type')
1128
+ log(
1129
+ 'Use --target foundation or --target site, or run from workspace root'
1130
+ )
1131
+ process.exit(1)
1132
+ }
1133
+
1134
+ if (targetType !== 'workspace') {
1135
+ info(`Detected project type: ${targetType}`)
1136
+ }
1137
+ }
1138
+
1139
+ // Validate prerender flags are only used with site/workspace target
1140
+ if ((prerenderFlag || noPrerenderFlag) && targetType === 'foundation') {
1141
+ error(
1142
+ '--prerender/--no-prerender can only be used with site or workspace builds'
1143
+ )
1144
+ process.exit(1)
1145
+ }
1146
+
1147
+ // Validate --link / --bundle are only used with site target.
1148
+ // (Foundation builds have no equivalent split — they always produce
1149
+ // dist/entry.js + schema.json regardless of how a downstream
1150
+ // site consumes the result.)
1151
+ if ((linkFlag || bundleFlag) && targetType === 'foundation') {
1152
+ error('--link and --bundle apply to site builds only')
1153
+ process.exit(1)
1154
+ }
1155
+
1156
+ // Run appropriate build
1157
+ try {
1158
+ if (targetType === 'workspace') {
1159
+ await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag, host })
1160
+ } else if (targetType === 'foundation') {
1161
+ await buildFoundation(projectDir)
1162
+ } else {
1163
+ // For sites, read config to determine prerender default
1164
+ const siteConfig = readSiteConfig(projectDir)
1165
+
1166
+ // Link mode: data-only pipeline, no vite. The deployed Uniweb-edge
1167
+ // site never consumes the JS bundle, so we skip producing it.
1168
+ // Worker generates HTML at request time using its own runtime +
1169
+ // the foundation served from the registry. See
1170
+ // `framework/build/src/site/build-site-data.js` for what gets
1171
+ // emitted (and what doesn't).
1172
+ if (linkFlag) {
1173
+ if (prerenderFlag) {
1174
+ error(
1175
+ '--prerender does not apply to link mode (no static HTML is produced)'
1176
+ )
1177
+ process.exit(1)
1178
+ }
1179
+ await buildSiteLink(projectDir, { siteConfig })
1180
+ return
1181
+ }
1182
+
1183
+ // Bundle mode (default for sites, or explicit --bundle).
1184
+ const configPrerender = siteConfig.build?.prerender === true
1185
+
1186
+ // CLI flags override config: --prerender forces on, --no-prerender forces off
1187
+ let prerender = configPrerender
1188
+ if (prerenderFlag) prerender = true
1189
+ if (noPrerenderFlag) prerender = false
1190
+
1191
+ // If `--foundation-dir` wasn't passed, resolve the local foundation
1192
+ // from site.yml + package.json. Required so buildSite() can cascade
1193
+ // to the local foundation when the user runs `uniweb build` from a
1194
+ // site dir on a fresh checkout where dist/ doesn't exist yet.
1195
+ const resolvedFoundationDir =
1196
+ foundationDir ||
1197
+ (await resolveFoundationDirForSite(projectDir, siteConfig).catch(
1198
+ () => null
1199
+ ))
1200
+
1201
+ await buildSite(projectDir, {
1202
+ prerender,
1203
+ foundationDir: resolvedFoundationDir,
1204
+ siteConfig,
1205
+ host
1206
+ })
1207
+ }
1208
+ } catch (err) {
1209
+ error(err.message)
1210
+ process.exit(1)
1211
+ }
1212
+ }
1213
+
1214
+ export default build