uniweb 0.12.14 → 0.12.16

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 CHANGED
@@ -294,38 +294,65 @@ pnpm dev # or npm run dev
294
294
 
295
295
  The workspace grows organically. `add` handles placement, wires dependencies, updates workspace globs, and generates root scripts. The name you provide becomes both the directory name and the package name. Use `--path` to override default placement, or `--project` for explicit co-located layouts.
296
296
 
297
- ## The Bigger Picture
297
+ ## Deployment
298
298
 
299
- The structure you start with scales without rewrites:
299
+ A Uniweb project produces two artifacts a **site** (content) and a **foundation** (code). **Who manages the content** decides what you ship:
300
300
 
301
- 1. **Single project** One site, one foundation. Develop and deploy together. Most projects stay here.
301
+ - If you (or your dev team) manage the content, you ship them together — the site and its foundation, bundled or linked.
302
+ - If someone else manages the content, you ship only the foundation. Content authors compose sites in the Uniweb apps; there's no site to deploy from your repo.
302
303
 
303
- 2. **Published foundation**Release your foundation as a dynamically linked module to [uniweb.app](https://uniweb.app). Other sites can use it without copying code.
304
+ ### Path 1You manage the content
304
305
 
305
- 3. **Multiple sites** Several sites share one foundation. Update components once, every site benefits.
306
+ You (or your dev team) write the markdown. Deploy site + foundation together.
306
307
 
307
- 4. **Platform-managed sites** Sites built on [uniweb.app](https://uniweb.app) with visual editing tools can use your foundation. You develop components locally; content teams work in the browser.
308
+ The shortest path to a live site is free, on GitHub Pages, with a custom domain:
308
309
 
309
- Start with local files deployed anywhere. The same foundation works across all these scenarios.
310
+ ```bash
311
+ npm create uniweb my-site
312
+ cd my-site
313
+ uniweb add ci --host=github-pages
314
+ # Commit, push to GitHub, enable Pages in repo settings → live site
315
+ ```
310
316
 
311
- ## Deployment
317
+ `uniweb add ci` scaffolds a GitHub Actions workflow that runs `uniweb build` on each push. Pre-rendering is on by default — static HTML, fast first paint, SEO out of the box. Use `--domain=<domain>` for a custom domain.
318
+
319
+ **Other free static hosts.** Cloudflare Pages, Netlify, and Vercel auto-build your site when you connect the repo through their dashboard — no scaffolded workflow needed.
320
+
321
+ **When to choose Uniweb hosting instead** (paid): when you need dynamic-page prerender at the edge (for collections fetched at runtime, not just at build time), foundation/runtime version propagation without redeploying every site, or edge SSR. If your site's content lives in markdown and updates ship via git, free CI is the right call.
322
+
323
+ ### Path 2 — Someone else manages the content
324
+
325
+ You're building a foundation for clients, content authors, or any team that won't write markdown. The foundation is your product; the repo's `site/` is a test harness for the code (run `pnpm dev` to preview your components against sample content). You don't deploy a site — you publish a foundation:
312
326
 
313
- A Uniweb project produces two artifacts — a **site** (content) and a **foundation** (code) — and they don't have to ship together. Two top-level modes:
327
+ ```bash
328
+ cd src
329
+ uniweb publish @your-org/foundation-name
330
+ ```
331
+
332
+ Real sites built on your foundation are managed in the **Uniweb apps** (web + desktop) — visual editors designed for non-technical authors. They never see git, markdown, yaml, or React. They see *your* components, with live previews and visual controls for the params you defined. The foundation becomes the editor's native vocabulary for that site: you keep creative control of the design system, they get an editor that feels custom-built for them.
333
+
334
+ This is the best path when site content has a life independent of the foundation's release cycle — agencies, design studios, multi-client teams, or any project where content authors aren't the same people as the developers.
314
335
 
315
- - **Standalone mode** site and foundation built into one self-contained `dist/`, deployed to any static host.
316
- - **Linked mode** — the foundation is a separate file the site loads at runtime, with two flavours:
317
- - **Site-bound** — the foundation belongs to one site and rides with it (`foundation: ~self/<name>@<version>` in `site.yml`).
318
- - **Cataloged** — the foundation is a private catalog product, published once and licensed to consuming sites (`foundation: '@<org>/<name>@<version>'`).
336
+ ### RoadmapHybrid
319
337
 
320
- `uniweb publish` ships a cataloged foundation; `uniweb deploy` ships a site (and, for site-bound, the foundation along with it). Most projects start standalone or site-bound and grow into cataloged when a foundation needs to serve more than one site.
338
+ A future version will let markdown in a git repo and content in the Uniweb apps stay in two-way sync. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. On the roadmap; not available today.
321
339
 
322
- Where can you deploy?
340
+ ### Commands at a glance
323
341
 
324
- - **Free static hosts** Vercel, Cloudflare Pages, Netlify, GitHub Pages — work great when you have a site to publish. Built-in adapters: `vercel`, `cloudflare-pages`, `netlify`, `github-pages`. Lifecycle is Git-driven: connect your repo, the host runs `uniweb build` on each push, serves `dist/`. The framework auto-detects the CI host and emits the right helper files.
325
- - **AWS S3 + CloudFront** — `uniweb deploy --host=s3-cloudfront` builds, syncs, and invalidates in one command.
326
- - **Uniweb hosting** paid (starts at $14/month per site). Always serves linked sites with JIT prerender, edge SSR, locale-aware routing, foundation/runtime version propagation, and the multi-tenant CMS for non-technical content authors via the visual editor. The right choice when foundation developers or agencies build for clients who manage their own content. The catalog is private and access-segregated — foundations are commercial products licensed by site, not packages on a public registry.
342
+ | Command | What it does |
343
+ | --- | --- |
344
+ | `uniweb add ci --host=<adapter>` | Scaffold a CI workflow in your repo (today: `github-pages`). The host runs `uniweb build` on each push. |
345
+ | `uniweb deploy` | Deploy to Uniweb hosting (default). With `--host=<adapter>`, push directly to a static host — builds, uploads, invalidates in one step. |
346
+ | `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
347
+ | `uniweb publish @org/name` | Publish a foundation to the registry (path 2). |
348
+ | `uniweb build` | Inspect a build locally. For shipping, use `deploy` or `export`. |
349
+ | `uniweb update` | Reconcile workspace state with the CLI: self-update the global install, align `@uniweb/*` deps in every `package.json` to the CLI's matrix, refresh `AGENTS.md`. Refuses to outpace declared deps with a stale doc. |
350
+
351
+ `--host=<adapter>` is the same option across `deploy`, `export`, and `add ci`. Built-in adapters: `cloudflare-pages`, `netlify`, `github-pages`, `vercel`, `s3-cloudfront`, `generic-static`. Each adapter implements only the operations it supports — `add ci` is currently `github-pages`-only because it's the only one that needs a workflow file in the repo.
352
+
353
+ ---
327
354
 
328
- **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)** the full menu: picking a deploy path (free vs paid), standalone vs linked, site-bound vs cataloged, the two-verb model, CI-detection, and per-host recipes.
355
+ Both paths use the same framework. The difference is who edits content, where it lives, and what gets deployed. For the deeper menu — site-bound vs cataloged foundations, S3+CloudFront, manual exports, per-host recipes, foundation propagation — see → **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)**.
329
356
 
330
357
  ---
331
358
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.14",
3
+ "version": "0.12.16",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,12 +41,12 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "0.8.13",
45
44
  "@uniweb/kit": "0.9.11",
45
+ "@uniweb/runtime": "0.8.13",
46
46
  "@uniweb/core": "0.7.11"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.2",
49
+ "@uniweb/build": "0.14.3",
50
50
  "@uniweb/content-reader": "1.1.10",
51
51
  "@uniweb/semantic-parser": "1.1.17"
52
52
  },
@@ -150,6 +150,9 @@ uniweb deploy --dry-run # Resolve foundation/runtime + print summary;
150
150
  uniweb export # Build dist/ for any static host (no Uniweb account)
151
151
  uniweb publish # Publish a foundation to the Uniweb registry
152
152
  uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
153
+ uniweb update # Reconcile workspace state with the CLI: align @uniweb/*
154
+ # deps in every package.json + refresh this AGENTS.md.
155
+ # Use --dry-run to preview, --deps-only to skip the doc.
153
156
 
154
157
  # Help
155
158
  uniweb --help # Top-level help
@@ -158,6 +161,8 @@ uniweb <command> --help # Per-command help (no side effects)
158
161
 
159
162
  `uniweb deploy` auto-publishes a workspace-local foundation as part of the deploy under a site-scoped slot — no separate `uniweb publish` step needed for site-bound foundations.
160
163
 
164
+ If this AGENTS.md was stamped against an older CLI than the workspace's installed `@uniweb/*` packages, run `uniweb update --dry-run` to see the gap. The verb refuses to refresh the doc while declared deps lag the CLI — a stale doc that documents features the installed code doesn't have is worse than no refresh.
165
+
161
166
  ---
162
167
 
163
168
  ## `package.json` `uniweb` configuration
@@ -54,19 +54,26 @@ function info(message) { console.log(`${colors.dim}${message}${colors.reset}`) }
54
54
  */
55
55
  function parseArgs(args) {
56
56
  const result = {
57
- subcommand: args[0], // foundation, site, extension
57
+ subcommand: args[0], // foundation, site, extension, ci, …
58
58
  name: null,
59
59
  path: null,
60
60
  project: null,
61
61
  foundation: null,
62
62
  site: null,
63
63
  from: null,
64
+ host: null,
65
+ force: false,
66
+ domain: null,
64
67
  }
65
68
 
66
- // Find positional name (first arg after subcommand that's not a flag)
69
+ // Booleans (no value) consumed up-front so the value-flag loop below
70
+ // doesn't accidentally swallow the next positional.
71
+ const BOOLEAN_FLAGS = new Set(['--force'])
72
+
73
+ // Find positional name (first arg after subcommand that's not a flag).
67
74
  for (let i = 1; i < args.length; i++) {
68
75
  if (args[i].startsWith('--')) {
69
- i++ // skip flag value
76
+ if (!BOOLEAN_FLAGS.has(args[i])) i++ // skip flag value
70
77
  continue
71
78
  }
72
79
  if (!result.name) {
@@ -86,6 +93,12 @@ function parseArgs(args) {
86
93
  result.site = args[++i]
87
94
  } else if (args[i] === '--from' && args[i + 1]) {
88
95
  result.from = args[++i]
96
+ } else if (args[i] === '--host' && args[i + 1]) {
97
+ result.host = args[++i]
98
+ } else if (args[i] === '--domain' && args[i + 1]) {
99
+ result.domain = args[++i]
100
+ } else if (args[i] === '--force') {
101
+ result.force = true
89
102
  }
90
103
  }
91
104
 
@@ -126,9 +139,10 @@ export async function add(rawArgs) {
126
139
  { label: 'site', description: 'Content site' },
127
140
  { label: 'extension', description: 'Additional component package' },
128
141
  { label: 'section', description: 'Section type in a foundation' },
142
+ { label: 'ci', description: 'CI deploy workflow for a host (e.g., GitHub Pages)' },
129
143
  ]))
130
144
  log('')
131
- log(`Usage: ${prefix} add <project|foundation|site|extension|section> [name]`)
145
+ log(`Usage: ${prefix} add <project|foundation|site|extension|section|ci> [name]`)
132
146
  process.exit(1)
133
147
  }
134
148
 
@@ -142,6 +156,7 @@ export async function add(rawArgs) {
142
156
  { title: 'Site', value: 'site', description: 'Content site' },
143
157
  { title: 'Extension', value: 'extension', description: 'Additional component package' },
144
158
  { title: 'Section', value: 'section', description: 'Section type in a foundation' },
159
+ { title: 'CI workflow', value: 'ci', description: 'Deploy workflow for a host (e.g., GitHub Pages)' },
145
160
  ],
146
161
  }, {
147
162
  onCancel: () => {
@@ -176,9 +191,12 @@ export async function add(rawArgs) {
176
191
  case 'section':
177
192
  await addSection(rootDir, parsed)
178
193
  break
194
+ case 'ci':
195
+ await addCi(rootDir, parsed, pm)
196
+ break
179
197
  default:
180
198
  error(`Unknown subcommand: ${parsed.subcommand}`)
181
- log(`Valid subcommands: project, foundation, site, extension, section`)
199
+ log(`Valid subcommands: project, foundation, site, extension, section, ci`)
182
200
  process.exit(1)
183
201
  }
184
202
  }
@@ -865,7 +883,7 @@ async function wireExtensionToSite(rootDir, siteName, extensionName, extensionPa
865
883
  const config = yaml.load(content) || {}
866
884
 
867
885
  // Add extension URL
868
- const extensionUrl = `/${extensionPath}/dist/foundation.js`
886
+ const extensionUrl = `/${extensionPath}/dist/entry.js`
869
887
  if (!config.extensions) {
870
888
  config.extensions = []
871
889
  }
@@ -1017,6 +1035,195 @@ export default function ${name}({ content, params }) {
1017
1035
  }
1018
1036
  }
1019
1037
 
1038
+ /**
1039
+ * Add a CI deploy workflow for a host adapter.
1040
+ *
1041
+ * Wires through the host-adapter registry: each adapter optionally
1042
+ * exports `initCi({ rootDir, site, packageManager, nodeVersion })`
1043
+ * returning `{ files, postInstructions }`. The CLI handles file writes,
1044
+ * --force overwrite, and consistent output. Today only github-pages
1045
+ * implements initCi; the registry's other adapters (cloudflare-pages,
1046
+ * vercel, s3-cloudfront, generic-static) error out with a clear message
1047
+ * and can plug in later.
1048
+ */
1049
+ async function addCi(rootDir, opts, pm = 'pnpm') {
1050
+ // Lazy-load the host registry so the CLI doesn't pay this import on
1051
+ // every add invocation.
1052
+ let getAdapter
1053
+ try {
1054
+ ({ getAdapter } = await import('@uniweb/build/hosts'))
1055
+ } catch {
1056
+ error('Failed to load host adapter registry from @uniweb/build/hosts.')
1057
+ process.exit(1)
1058
+ }
1059
+
1060
+ // Resolve host. With one CI-capable adapter today (github-pages),
1061
+ // default silently when --host isn't passed. If more adapters add
1062
+ // initCi later, this becomes a picker.
1063
+ const host = opts.host || 'github-pages'
1064
+
1065
+ let adapter
1066
+ try {
1067
+ adapter = getAdapter(host)
1068
+ } catch (err) {
1069
+ error(err.message)
1070
+ process.exit(1)
1071
+ }
1072
+
1073
+ if (typeof adapter.initCi !== 'function') {
1074
+ error(`Host '${host}' does not provide a CI workflow yet.`)
1075
+ log(`Currently supported: github-pages.`)
1076
+ log(`Other hosts may use platform-side integrations (e.g., Vercel/Netlify connect via dashboard).`)
1077
+ process.exit(1)
1078
+ }
1079
+
1080
+ // Validate --domain (lightweight: must look like a hostname). The
1081
+ // adapter decides what to do with it; today only github-pages uses
1082
+ // it (writes a CNAME, switches UNIWEB_BASE to root).
1083
+ if (opts.domain && !isLikelyDomain(opts.domain)) {
1084
+ error(`Invalid --domain value: '${opts.domain}'`)
1085
+ log(`Expected a bare hostname (e.g., 'mysite.com' or 'docs.mysite.com'). No scheme, no path.`)
1086
+ process.exit(1)
1087
+ }
1088
+
1089
+ // If --domain wasn't passed, fall back to whatever's already in
1090
+ // deploy.yml's targets.<host>.domain. Lets re-running `add ci` (e.g.,
1091
+ // to refresh the workflow after a CLI upgrade) keep the domain
1092
+ // without the user re-typing it.
1093
+ const { loadDeployYml } = await import('@uniweb/build/site')
1094
+ let resolvedDomain = opts.domain
1095
+
1096
+ // Resolve site: --site flag, single site auto, prompt, or error.
1097
+ const sites = await discoverSites(rootDir)
1098
+ if (sites.length === 0) {
1099
+ error('No site found in this workspace. Add one with `uniweb add site` first.')
1100
+ process.exit(1)
1101
+ }
1102
+
1103
+ let site
1104
+ if (opts.site) {
1105
+ site = sites.find(s => s.name === opts.site)
1106
+ if (!site) {
1107
+ error(`Site '${opts.site}' not found.`)
1108
+ log(`Available sites: ${sites.map(s => s.name).join(', ')}`)
1109
+ process.exit(1)
1110
+ }
1111
+ } else if (sites.length === 1) {
1112
+ site = sites[0]
1113
+ } else if (isNonInteractive(process.argv)) {
1114
+ error(`Multiple sites in workspace. Specify --site <name>.`)
1115
+ log(`Available sites: ${sites.map(s => s.name).join(', ')}`)
1116
+ process.exit(1)
1117
+ } else {
1118
+ const sortedSites = [...sites].sort((a, b) => a.name.localeCompare(b.name))
1119
+ const response = await prompts({
1120
+ type: 'select',
1121
+ name: 'site',
1122
+ message: 'Which site should the workflow build?',
1123
+ choices: sortedSites.map(s => ({ title: s.name, description: s.path, value: s })),
1124
+ }, {
1125
+ onCancel: () => {
1126
+ log('\nCancelled.')
1127
+ process.exit(0)
1128
+ },
1129
+ })
1130
+ site = response.site
1131
+ }
1132
+
1133
+ // Read node version from root package.json engines.node if pinned;
1134
+ // otherwise default to 20 (matches the workspace template's >=20.19).
1135
+ const rootPkg = JSON.parse(
1136
+ await readFile(join(rootDir, 'package.json'), 'utf-8').catch(() => '{}')
1137
+ )
1138
+ const nodeVersion = parseNodeMajor(rootPkg.engines?.node) || '20'
1139
+
1140
+ const siteDir = join(rootDir, site.path)
1141
+ if (!resolvedDomain) {
1142
+ try {
1143
+ const deployYml = await loadDeployYml(siteDir)
1144
+ const remembered = deployYml?.targets?.[host]?.domain
1145
+ if (remembered && isLikelyDomain(remembered)) {
1146
+ resolvedDomain = remembered
1147
+ info(`Using domain '${remembered}' from deploy.yml.`)
1148
+ }
1149
+ } catch {
1150
+ // Malformed deploy.yml — surface elsewhere; don't block add ci.
1151
+ }
1152
+ }
1153
+
1154
+ const result = await adapter.initCi({
1155
+ rootDir,
1156
+ site,
1157
+ packageManager: pm,
1158
+ nodeVersion,
1159
+ domain: resolvedDomain,
1160
+ })
1161
+
1162
+ // Write files. Refuse to overwrite without --force so re-running
1163
+ // doesn't silently clobber edits the user made to the workflow.
1164
+ for (const file of result.files) {
1165
+ const fullPath = join(rootDir, file.path)
1166
+ if (existsSync(fullPath) && !opts.force) {
1167
+ error(`File already exists: ${file.path}`)
1168
+ log(`Re-run with --force to overwrite.`)
1169
+ process.exit(1)
1170
+ }
1171
+ await mkdir(join(fullPath, '..'), { recursive: true })
1172
+ await writeFile(fullPath, file.content)
1173
+ success(`Wrote ${file.path}`)
1174
+ }
1175
+
1176
+ // Persist the adapter's target config into deploy.yml so the user's
1177
+ // intent (host + adapter-specific fields like `domain`) is remembered
1178
+ // across CLI upgrades and re-runs. github-pages deploys via GHA, not
1179
+ // via `uniweb deploy`, so without this its config would never reach
1180
+ // deploy.yml. The writer:
1181
+ // - scaffolds a fresh deploy.yml on first call (this target is the
1182
+ // default), or
1183
+ // - merges into an existing targets.<targetName> without touching
1184
+ // `default`, `autoSave`, or other targets.
1185
+ if (result.targetConfig) {
1186
+ try {
1187
+ const { recordTarget } = await import('@uniweb/build/site')
1188
+ const writeResult = await recordTarget(siteDir, {
1189
+ targetName: host,
1190
+ targetConfig: result.targetConfig,
1191
+ })
1192
+ success(
1193
+ writeResult.action === 'scaffold'
1194
+ ? `Wrote ${relative(rootDir, writeResult.path)} (default target: ${host})`
1195
+ : `Updated ${relative(rootDir, writeResult.path)} (target: ${host})`
1196
+ )
1197
+ } catch (err) {
1198
+ // deploy.yml persistence is best-effort: the workflow + CNAME
1199
+ // are the load-bearing artifacts. Print a warning and continue.
1200
+ info(`Warning: could not update deploy.yml: ${err.message}`)
1201
+ }
1202
+ }
1203
+
1204
+ if (result.postInstructions?.length) {
1205
+ log('')
1206
+ log(`${colors.bright}Next steps:${colors.reset}`)
1207
+ for (const line of result.postInstructions) {
1208
+ log(line ? ` ${line}` : '')
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ function isLikelyDomain(value) {
1214
+ if (typeof value !== 'string' || value.length === 0 || value.length > 253) return false
1215
+ // Reject schemes, paths, ports, whitespace, leading/trailing dots/hyphens.
1216
+ // Each label is 1–63 chars of [a-z0-9-], no leading/trailing hyphen.
1217
+ // The TLD must be at least 2 characters of letters.
1218
+ return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(value)
1219
+ }
1220
+
1221
+ function parseNodeMajor(engines) {
1222
+ if (!engines) return null
1223
+ const match = String(engines).match(/(\d+)/)
1224
+ return match ? match[1] : null
1225
+ }
1226
+
1020
1227
  /**
1021
1228
  * Show help for the add command
1022
1229
  */
@@ -1024,7 +1231,7 @@ function showAddHelp() {
1024
1231
  log(`
1025
1232
  ${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
1026
1233
 
1027
- Add projects, foundations, sites, extensions, or section types to your workspace.
1234
+ Add projects, foundations, sites, extensions, section types, or CI workflows to your workspace.
1028
1235
 
1029
1236
  ${colors.bright}Usage:${colors.reset}
1030
1237
  uniweb add project [name] [options]
@@ -1032,6 +1239,7 @@ ${colors.bright}Usage:${colors.reset}
1032
1239
  uniweb add site [name] [options]
1033
1240
  uniweb add extension <name> [options]
1034
1241
  uniweb add section <name> [options]
1242
+ uniweb add ci [options]
1035
1243
 
1036
1244
  ${colors.bright}Common Options:${colors.reset}
1037
1245
  --from <template> Apply content from a template after scaffolding
@@ -1050,6 +1258,12 @@ ${colors.bright}Extension Options:${colors.reset}
1050
1258
  ${colors.bright}Section Options:${colors.reset}
1051
1259
  --foundation <n> Foundation to add section to (prompted if multiple exist)
1052
1260
 
1261
+ ${colors.bright}CI Options:${colors.reset}
1262
+ --host <name> Host adapter (default: github-pages)
1263
+ --site <name> Site the workflow builds (prompted if multiple exist)
1264
+ --domain <host> Custom domain (writes CNAME, serves at root)
1265
+ --force Overwrite an existing workflow file
1266
+
1053
1267
  ${colors.bright}Examples:${colors.reset}
1054
1268
  uniweb add project docs # Create docs/foundation/ + docs/site/
1055
1269
  uniweb add project docs --from academic # Co-located pair + academic content
@@ -1062,5 +1276,8 @@ ${colors.bright}Examples:${colors.reset}
1062
1276
  uniweb add section Hero --foundation ui # Target specific foundation
1063
1277
  uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
1064
1278
  uniweb add site --project docs # Create ./docs/site/ (co-located)
1279
+ uniweb add ci # Add GitHub Pages deploy workflow
1280
+ uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
1281
+ uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
1065
1282
  `)
1066
1283
  }
@@ -645,7 +645,7 @@ async function buildSite(projectDir, options = {}) {
645
645
  log(`${colors.dim}Check that:${colors.reset}`)
646
646
  log(` • site.yml 'foundation:' matches the package name in your foundation's package.json`)
647
647
  log(` • site's package.json has a dependency pointing to the correct foundation path`)
648
- log(` • The foundation's dist/foundation.js exists (build the foundation first)`)
648
+ log(` • The foundation's dist/entry.js exists (build the foundation first)`)
649
649
  }
650
650
 
651
651
  if (process.env.UNIWEB_DEBUG) {
@@ -884,7 +884,7 @@ export async function build(args = []) {
884
884
 
885
885
  // Validate --link / --bundle are only used with site target.
886
886
  // (Foundation builds have no equivalent split — they always produce
887
- // dist/foundation.js + schema.json regardless of how a downstream
887
+ // dist/entry.js + schema.json regardless of how a downstream
888
888
  // site consumes the result.)
889
889
  if ((linkFlag || bundleFlag) && targetType === 'foundation') {
890
890
  error('--link and --bundle apply to site builds only')
@@ -2,10 +2,12 @@
2
2
  * uniweb doctor - Diagnose project configuration issues
3
3
  */
4
4
 
5
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
5
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'
6
6
  import { join, resolve, basename, dirname, relative } from 'node:path'
7
7
  import yaml from 'js-yaml'
8
8
  import { resolveFoundationSrcPath, classifyPackage, isExtensionPackage as buildIsExtensionPackage } from '@uniweb/build'
9
+ import { loadDeployYml } from '@uniweb/build/site'
10
+ import { listAdapters } from '@uniweb/build/hosts'
9
11
  import { getCliVersion } from '../versions.js'
10
12
  import { readAgentsVersion } from '../utils/agents-stamp.js'
11
13
  import { discoverFoundations, discoverSites } from '../utils/config.js'
@@ -518,6 +520,158 @@ export async function doctor(args = []) {
518
520
  }
519
521
  }
520
522
 
523
+ // ─── Deploy configuration ────────────────────────────────────
524
+ // Sanity-check deploy.yml shape and surface common drift between
525
+ // deploy.yml, site.yml, and the artifacts the CLI generates.
526
+ // Failures here would otherwise only surface at deploy / first GHA
527
+ // run, where the feedback loop is slower.
528
+ log('')
529
+ info('Checking deploy configuration...')
530
+ const knownAdapters = new Set(listAdapters())
531
+ for (const site of sites) {
532
+ const sitePath = site.path
533
+ const siteName = site.name
534
+ const deployYmlPath = join(sitePath, 'deploy.yml')
535
+ if (!existsSync(deployYmlPath)) continue
536
+
537
+ let deployYml
538
+ try {
539
+ deployYml = await loadDeployYml(sitePath)
540
+ } catch (err) {
541
+ issues.push({
542
+ id: 'deploy-yml-malformed',
543
+ type: 'error',
544
+ site: siteName,
545
+ message: `deploy.yml is malformed: ${err.message}`,
546
+ })
547
+ error(`[deploy-yml-malformed] ${relative(workspaceDir, deployYmlPath)}: ${err.message}`)
548
+ continue
549
+ }
550
+ if (!deployYml) continue
551
+
552
+ const targets = deployYml.targets || {}
553
+ const targetNames = Object.keys(targets)
554
+
555
+ // default: must reference a real target.
556
+ if (deployYml.default && !targets[deployYml.default]) {
557
+ issues.push({
558
+ id: 'deploy-yml-default-unknown-target',
559
+ type: 'error',
560
+ site: siteName,
561
+ message: `deploy.yml: default '${deployYml.default}' is not in targets (known: ${targetNames.join(', ') || '(none)'})`,
562
+ })
563
+ error(`[deploy-yml-default-unknown-target] ${siteName}: default '${deployYml.default}' is not declared under \`targets:\`.`)
564
+ log(` ${colors.dim}Add it to targets: or change default: to one of: ${targetNames.join(', ') || '(none)'}.${colors.reset}`)
565
+ }
566
+
567
+ // Each target must have a host, and host should be a known adapter.
568
+ for (const [name, cfg] of Object.entries(targets)) {
569
+ if (!cfg || typeof cfg !== 'object') continue
570
+ if (!cfg.host) {
571
+ issues.push({
572
+ id: 'deploy-yml-target-missing-host',
573
+ type: 'error',
574
+ site: siteName,
575
+ message: `deploy.yml: targets.${name} is missing \`host\``,
576
+ })
577
+ error(`[deploy-yml-target-missing-host] ${siteName}: targets.${name} has no \`host\` field.`)
578
+ continue
579
+ }
580
+ // 'uniweb' is the platform default and isn't in the static-host
581
+ // adapter registry; treat it as known.
582
+ if (cfg.host !== 'uniweb' && !knownAdapters.has(cfg.host)) {
583
+ issues.push({
584
+ id: 'deploy-yml-unknown-host',
585
+ type: 'warn',
586
+ site: siteName,
587
+ message: `deploy.yml: targets.${name}.host '${cfg.host}' is not a known adapter`,
588
+ })
589
+ warn(`[deploy-yml-unknown-host] ${siteName}: targets.${name}.host: '${cfg.host}' is not a known built-in adapter.`)
590
+ log(` ${colors.dim}Known: ${[...knownAdapters].sort().join(', ')}, plus 'uniweb'.${colors.reset}`)
591
+ }
592
+ }
593
+
594
+ // github-pages-specific: if a target has `domain`, `site/public/CNAME`
595
+ // should exist with that value. Vite copies public/ into dist/, and
596
+ // GH Pages reads dist/CNAME on each deploy to keep the custom domain
597
+ // configured. A drift here means a deploy will silently lose the
598
+ // custom domain.
599
+ const cnamePath = join(sitePath, 'public', 'CNAME')
600
+ const ghTarget = Object.entries(targets).find(([, c]) => c?.host === 'github-pages')
601
+ if (ghTarget) {
602
+ const [ghName, ghCfg] = ghTarget
603
+ if (ghCfg.domain) {
604
+ const expected = String(ghCfg.domain).trim()
605
+ let actual = null
606
+ if (existsSync(cnamePath)) {
607
+ try {
608
+ actual = readFileSync(cnamePath, 'utf8').trim()
609
+ } catch {
610
+ actual = null
611
+ }
612
+ }
613
+ if (actual !== expected) {
614
+ const issue = {
615
+ id: 'github-pages-cname-mismatch',
616
+ type: 'warn',
617
+ site: siteName,
618
+ message: actual === null
619
+ ? `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} is missing`
620
+ : `deploy.yml: targets.${ghName}.domain is '${expected}' but ${relative(workspaceDir, cnamePath)} contains '${actual}'`,
621
+ }
622
+ issues.push(issue)
623
+ warn(`[github-pages-cname-mismatch] ${siteName}: ${issue.message}`)
624
+ if (shouldFix('github-pages-cname-mismatch')) {
625
+ const dir = dirname(cnamePath)
626
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
627
+ writeFileSync(cnamePath, expected + '\n')
628
+ issue.fixed = true
629
+ fixed(`wrote ${relative(workspaceDir, cnamePath)} = ${expected}`)
630
+ } else {
631
+ log(` ${colors.dim}Re-run \`uniweb add ci --domain ${expected} --force\` (or pass --fix to write CNAME from deploy.yml).${colors.reset}`)
632
+ }
633
+ }
634
+ } else if (existsSync(cnamePath)) {
635
+ // CNAME exists but the github-pages target has no `domain` set —
636
+ // either deploy.yml was edited and lost the domain, or someone
637
+ // hand-authored CNAME without setting the target.
638
+ let actual = ''
639
+ try {
640
+ actual = readFileSync(cnamePath, 'utf8').trim()
641
+ } catch {
642
+ // ignore
643
+ }
644
+ issues.push({
645
+ id: 'github-pages-cname-orphan',
646
+ type: 'warn',
647
+ site: siteName,
648
+ message: `${relative(workspaceDir, cnamePath)} exists ('${actual}') but deploy.yml's github-pages target has no \`domain\``,
649
+ })
650
+ warn(`[github-pages-cname-orphan] ${siteName}: ${relative(workspaceDir, cnamePath)} exists ('${actual}') but the github-pages target has no \`domain\` set.`)
651
+ log(` ${colors.dim}Either add \`domain: ${actual}\` to targets.${ghName}, or delete the CNAME if you no longer want a custom domain.${colors.reset}`)
652
+ }
653
+ }
654
+
655
+ // site.yml::base + a generated GH Pages workflow → the workflow
656
+ // sets UNIWEB_BASE at build time, which beats site.yml::base
657
+ // (site/config.js precedence: explicit > UNIWEB_BASE > site.yml).
658
+ // Not an error — site.yml's value is just dormant for that target —
659
+ // but worth flagging so the user knows the value isn't taking effect.
660
+ const siteYml = loadSiteYml(sitePath)
661
+ const workflowPath = join(workspaceDir, '.github/workflows/deploy-github-pages.yml')
662
+ if (siteYml?.base && existsSync(workflowPath) && ghTarget) {
663
+ issues.push({
664
+ id: 'site-base-overridden-by-workflow',
665
+ type: 'warn',
666
+ site: siteName,
667
+ message: `site.yml::base ('${siteYml.base}') is dormant — the GH Pages workflow sets UNIWEB_BASE at build time, which takes precedence`,
668
+ })
669
+ warn(`[site-base-overridden-by-workflow] ${siteName}: site.yml has \`base: ${siteYml.base}\`, but the GH Pages workflow sets UNIWEB_BASE.`)
670
+ log(` ${colors.dim}site.yml::base is the fallback when no UNIWEB_BASE env var is set. The workflow always sets it, so this value is ignored on GH Pages deploys.${colors.reset}`)
671
+ log(` ${colors.dim}If the GH Pages deploy is the only target, you can remove \`base:\` from site.yml. Otherwise leave it — it still applies to other deploy targets.${colors.reset}`)
672
+ }
673
+ }
674
+
521
675
  // Check AGENTS.md freshness
522
676
  log('')
523
677
  const agentsPath = join(workspaceDir, 'AGENTS.md')