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 +46 -19
- package/package.json +3 -3
- package/partials/agents.md +5 -0
- package/src/commands/add.js +224 -7
- package/src/commands/build.js +2 -2
- package/src/commands/doctor.js +155 -1
- package/src/commands/update.js +352 -54
- package/src/framework-index.json +3 -3
- package/src/index.js +25 -14
- package/src/utils/config.js +32 -32
- package/src/utils/pm.js +28 -2
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
|
-
##
|
|
297
|
+
## Deployment
|
|
298
298
|
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
+
### Path 1 — You manage the content
|
|
304
305
|
|
|
305
|
-
|
|
306
|
+
You (or your dev team) write the markdown. Deploy site + foundation together.
|
|
306
307
|
|
|
307
|
-
|
|
308
|
+
The shortest path to a live site is free, on GitHub Pages, with a custom domain:
|
|
308
309
|
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
### Roadmap — Hybrid
|
|
319
337
|
|
|
320
|
-
|
|
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
|
-
|
|
340
|
+
### Commands at a glance
|
|
323
341
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"@uniweb/build": "0.14.3",
|
|
50
50
|
"@uniweb/content-reader": "1.1.10",
|
|
51
51
|
"@uniweb/semantic-parser": "1.1.17"
|
|
52
52
|
},
|
package/partials/agents.md
CHANGED
|
@@ -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
|
package/src/commands/add.js
CHANGED
|
@@ -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
|
-
//
|
|
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/
|
|
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,
|
|
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
|
}
|
package/src/commands/build.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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')
|
package/src/commands/doctor.js
CHANGED
|
@@ -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')
|