@uniweb/build 0.14.28 → 0.14.29

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.
@@ -17,11 +17,19 @@
17
17
  * meta-refresh HTML the prerender already emits for `redirect:` /
18
18
  * `rewrite:` directives.
19
19
  *
20
- * initCi: emits a `.github/workflows/deploy-github-pages.yml` that runs
21
- * `uniweb build --host=github-pages` and uploads the resulting `dist/`
22
- * via the official actions/deploy-pages flow. UNIWEB_BASE is decided
23
- * at workflow scaffold time based on the deploy target (per gotcha #15
24
- * for the base-path flow):
20
+ * Two ways to publish, both supported:
21
+ *
22
+ * - initCi (recommended) emits a `.github/workflows/…` that runs
23
+ * `uniweb build --host=github-pages` and uploads via the official
24
+ * actions/deploy-pages flow. One-time setup; every push deploys.
25
+ * - deploy — publishes an already-built `dist/` straight to the
26
+ * `gh-pages` branch from your machine. For repos without Actions
27
+ * minutes, or a one-off publish. Uses a detached worktree so your
28
+ * working tree is never touched, and a normal (non-force) commit so
29
+ * the branch history stays intact and revertible.
30
+ *
31
+ * UNIWEB_BASE is decided at workflow scaffold time based on the deploy
32
+ * target (per gotcha #15 for the base-path flow):
25
33
  *
26
34
  * - With `domain`: bakes `UNIWEB_BASE: /` and emits a CNAME file under
27
35
  * `<sitePath>/public/`. GitHub Pages serves the custom domain at
@@ -34,21 +42,252 @@
34
42
  * deploy target.
35
43
  */
36
44
 
37
- import { writeFile } from 'node:fs/promises'
38
- import { join } from 'node:path'
45
+ import { writeFile, mkdtemp, rm, cp, readdir } from 'node:fs/promises'
46
+ import { tmpdir } from 'node:os'
47
+ import { join, basename } from 'node:path'
48
+
49
+ import { DeployError, spawnTool } from './deploy-utils.js'
50
+ import { setupSteps, uniwebBuildCommand, pushTrigger, workflowHeader } from './ci-workflow.js'
51
+
52
+ const DEFAULT_PUBLISH_BRANCH = 'gh-pages'
53
+
54
+ // Only used to seed a brand-new publish branch (`git checkout --orphan`
55
+ // requires a name). Always deleted in the deploy's finally block.
56
+ const TEMP_BRANCH = 'uniweb-deploy-tmp'
57
+
58
+ const GIT_INSTALL = [
59
+ 'Install git:',
60
+ ' macOS: xcode-select --install (or brew install git)',
61
+ ' Linux: apt install git / dnf install git',
62
+ ' Windows: https://git-scm.com/download/win',
63
+ ].join('\n')
64
+
65
+ function git(args, opts) {
66
+ return spawnTool('git', args, { install: GIT_INSTALL, quiet: true, ...opts })
67
+ }
68
+
69
+ /**
70
+ * Publish `distDir` to the repo's publish branch (default `gh-pages`).
71
+ *
72
+ * Mechanics, and why each choice:
73
+ * - A detached worktree in a temp dir, never the user's working tree.
74
+ * A deploy must not be able to disturb uncommitted work.
75
+ * - `git rm -rf .` then copy, so the published tree is an exact mirror
76
+ * of dist/ and pages deleted locally actually disappear.
77
+ * - A normal commit + push, never `--force`. History on the publish
78
+ * branch stays intact, so a bad deploy is one `git revert` away.
79
+ */
80
+ async function deploy({ distDir, deployConfig = {}, env = process.env, log = () => {} }) {
81
+ const branch = deployConfig.branch || DEFAULT_PUBLISH_BRANCH
82
+ const remote = deployConfig.remote || 'origin'
83
+
84
+ // Must be inside a git repo with the named remote, or there's nothing
85
+ // to publish to.
86
+ let repoRoot
87
+ try {
88
+ const { stdout } = await git(['rev-parse', '--show-toplevel'], { env })
89
+ repoRoot = stdout.trim()
90
+ } catch {
91
+ throw new DeployError(
92
+ 'Not inside a git repository.',
93
+ {
94
+ hint: [
95
+ 'The github-pages deploy publishes to a branch of your repo, so it needs one.',
96
+ '',
97
+ ' git init && git remote add origin git@github.com:<user>/<repo>.git',
98
+ '',
99
+ 'Or use the CI path instead: `uniweb add ci --host=github-pages`.',
100
+ ].join('\n'),
101
+ }
102
+ )
103
+ }
104
+
105
+ try {
106
+ await git(['remote', 'get-url', remote], { env, cwd: repoRoot })
107
+ } catch {
108
+ throw new DeployError(
109
+ `This repository has no '${remote}' remote.`,
110
+ {
111
+ hint: [
112
+ `Add one, then retry:`,
113
+ ` git remote add ${remote} git@github.com:<user>/<repo>.git`,
114
+ ].join('\n'),
115
+ }
116
+ )
117
+ }
118
+
119
+ const entries = await readdir(distDir).catch(() => [])
120
+ if (!entries.length) {
121
+ throw new DeployError(
122
+ 'dist/ is empty — nothing to publish.',
123
+ { hint: 'Run `uniweb build --host=github-pages` first.' }
124
+ )
125
+ }
126
+
127
+ log(`\n→ Publishing dist/ to the '${branch}' branch of ${remote}`)
128
+
129
+ const worktree = await mkdtemp(join(tmpdir(), 'uniweb-ghpages-'))
130
+ let worktreeAdded = false
131
+ let tempBranchCreated = false
132
+ try {
133
+ // Does the publish branch already exist on the remote?
134
+ let branchExists = true
135
+ try {
136
+ await git(['fetch', remote, branch], { env, cwd: repoRoot })
137
+ } catch {
138
+ branchExists = false
139
+ }
140
+
141
+ if (branchExists) {
142
+ // Detached at the remote tip: we commit on top of it without ever
143
+ // creating a local branch ref, so a failed run leaves nothing to
144
+ // clean up and the next attempt isn't blocked by a stale branch.
145
+ await git(['worktree', 'add', '--detach', worktree, `${remote}/${branch}`], { env, cwd: repoRoot })
146
+ worktreeAdded = true
147
+ } else {
148
+ log(` '${branch}' does not exist yet — creating it.`)
149
+ await git(['worktree', 'add', '--detach', worktree], { env, cwd: repoRoot })
150
+ worktreeAdded = true
151
+ // `--orphan` is the only way to start a history with no parent, and
152
+ // it insists on a branch name. Clear any leftover from a crashed
153
+ // run, then delete ours in the finally block.
154
+ await git(['branch', '-D', TEMP_BRANCH], { env, cwd: repoRoot }).catch(() => {})
155
+ await git(['checkout', '--orphan', TEMP_BRANCH], { env, cwd: worktree })
156
+ tempBranchCreated = true
157
+ }
158
+
159
+ // Exact mirror: drop everything tracked, then lay down dist/. Without
160
+ // this, pages deleted locally would linger on the published site.
161
+ await git(['rm', '-rf', '--quiet', '.'], { env, cwd: worktree }).catch(() => {})
162
+
163
+ await cp(distDir, worktree, { recursive: true })
164
+ // Belt and braces: postBuild already wrote one, but a dist/ built for
165
+ // a different host wouldn't have it, and without it GH Pages eats
166
+ // every `_`-prefixed path.
167
+ await writeFile(join(worktree, '.nojekyll'), '')
168
+
169
+ await git(['add', '-A'], { env, cwd: worktree })
170
+
171
+ // Nothing changed → don't create an empty commit.
172
+ try {
173
+ await git(['diff', '--cached', '--quiet'], { env, cwd: worktree })
174
+ log('\n✓ Already up to date — nothing to publish.')
175
+ return { url: null, unchanged: true }
176
+ } catch {
177
+ // Non-zero exit from `diff --quiet` means there ARE staged changes.
178
+ }
179
+
180
+ const message = deployConfig.message || `deploy: site build ${new Date().toISOString()}`
181
+ await git(['commit', '-m', message], { env, cwd: worktree })
182
+ const { stdout: head } = await git(['rev-parse', 'HEAD'], { env, cwd: worktree })
183
+ const sha = head.trim()
184
+
185
+ // Push the commit BY SHA, from the repo root. Two reasons, both bugs
186
+ // found the hard way: the root is where `remote` is configured (a
187
+ // relative remote path like `../origin.git` resolves only there), and
188
+ // pushing `HEAD` from the root would push the root's HEAD — the
189
+ // developer's working branch — not what the worktree just built.
190
+ await git(['push', remote, `${sha}:refs/heads/${branch}`], { env, cwd: repoRoot, log })
191
+
192
+ const url = await inferPagesUrl(repoRoot, remote, env)
193
+ log('\n✓ Published.')
194
+ if (url) {
195
+ log(` ${url}`)
196
+ log(' (Settings → Pages → Source must be set to "Deploy from a branch")')
197
+ }
198
+ return { url }
199
+ } finally {
200
+ // Order matters: a branch checked out in a worktree can't be deleted.
201
+ if (worktreeAdded) {
202
+ await git(['worktree', 'remove', '--force', worktree], { env, cwd: repoRoot }).catch(() => {})
203
+ }
204
+ if (tempBranchCreated) {
205
+ await git(['branch', '-D', TEMP_BRANCH], { env, cwd: repoRoot }).catch(() => {})
206
+ }
207
+ await rm(worktree, { recursive: true, force: true }).catch(() => {})
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Derive the public Pages URL from the remote. Best-effort — a miss only
213
+ * costs the echoed link.
214
+ */
215
+ async function inferPagesUrl(repoRoot, remote, env) {
216
+ try {
217
+ const { stdout } = await git(['remote', 'get-url', remote], { env, cwd: repoRoot })
218
+ return pagesUrlFromRemote(stdout.trim())
219
+ } catch {
220
+ return null
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Map a GitHub remote URL to the site's public Pages URL.
226
+ *
227
+ * Two repo shapes, served differently: `<user>.github.io` is the profile
228
+ * repo and is served at the domain root; every other repo is served under
229
+ * `/<repo>/`. Non-GitHub remotes yield null — we don't guess.
230
+ *
231
+ * Exported for tests.
232
+ */
233
+ export function pagesUrlFromRemote(remoteUrl) {
234
+ const match = String(remoteUrl).match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i)
235
+ if (!match) return null
236
+ const [, owner, repo] = match
237
+ if (/\.github\.io$/i.test(repo)) return `https://${repo.toLowerCase()}/`
238
+ return `https://${owner.toLowerCase()}.github.io/${repo}/`
239
+ }
39
240
 
40
241
  const adapter = {
41
242
  name: 'github-pages',
42
243
 
244
+ display: {
245
+
246
+ order: 10,
247
+
248
+ pushWith: 'git — commits dist/ to the gh-pages branch',
249
+ title: 'GitHub Pages',
250
+ qualifier: 'free, CI on push',
251
+ summary: 'Free static hosting from your repo, with a custom domain over HTTPS. Set up a workflow once and every push deploys.',
252
+ ci: true,
253
+ // Can also publish FOUNDATIONS at permanent versioned URLs
254
+ // (`initCi({ target: 'foundation' })`). No other adapter implements
255
+ // that today, and the CLI gates on this flag rather than silently
256
+ // scaffolding a site workflow for a foundation request.
257
+ foundationCi: true,
258
+ previews: false,
259
+ },
260
+
43
261
  async postBuild({ distDir, onProgress = () => {} }) {
44
262
  await writeFile(join(distDir, '.nojekyll'), '')
45
263
  onProgress('Wrote .nojekyll (opts out of Jekyll on GitHub Pages)')
46
264
  },
47
265
 
266
+ deploy,
267
+
48
268
  // `pnpmVersion` is the pnpm major for the generated CI. The CLI passes the
49
269
  // authoritative value (versions.js::PNPM_VERSION); the default here is only a
50
270
  // fallback for direct/test callers.
51
- async initCi({ site, packageManager = 'pnpm', nodeVersion = '20', pnpmVersion = '11', domain = null }) {
271
+ //
272
+ // `target` picks what gets published:
273
+ // 'site' — the site's dist/ at the Pages root (the common case)
274
+ // 'foundation' — built foundations at versioned, permanent URLs
275
+ // (foundations/<name>/<version>/entry.js). The free
276
+ // distribution path for a foundation product; sites
277
+ // reference the URL from site.yml.
278
+ async initCi({
279
+ site,
280
+ foundations = [],
281
+ target = 'site',
282
+ packageManager = 'pnpm',
283
+ nodeVersion = '20',
284
+ pnpmVersion = '11',
285
+ domain = null,
286
+ }) {
287
+ if (target === 'foundation') {
288
+ return initFoundationCi({ foundations, packageManager, nodeVersion, pnpmVersion })
289
+ }
290
+
52
291
  const sitePath = site.path
53
292
  const workflowPath = `.github/workflows/deploy-github-pages.yml`
54
293
  const yaml = renderWorkflow({ sitePath, packageManager, nodeVersion, pnpmVersion, domain })
@@ -94,26 +333,144 @@ const adapter = {
94
333
  },
95
334
  }
96
335
 
336
+ /**
337
+ * Foundation distribution workflow.
338
+ *
339
+ * Publishes each built foundation at `foundations/<name>/<version>/` on
340
+ * the gh-pages branch. Versions accumulate: a bumped version creates a
341
+ * new directory next to the old ones, so every URL a site ever pinned
342
+ * keeps resolving. This is the free alternative to the Uniweb catalog —
343
+ * permanent stable URLs and GitHub's CDN, without propagation or
344
+ * license gating.
345
+ */
346
+ function initFoundationCi({ foundations, packageManager, nodeVersion, pnpmVersion }) {
347
+ if (!foundations.length) {
348
+ throw new Error('No foundation found to publish. Add one with `uniweb add foundation` first.')
349
+ }
350
+
351
+ const setup = setupSteps({ packageManager, nodeVersion, pnpmVersion })
352
+ const buildCmd = packageManager === 'pnpm' ? 'pnpm build' : 'npm run build'
353
+
354
+ // `<public-name>:<dir>` pairs, resolved by the CLI and baked in rather
355
+ // than derived with `basename` at CI time. The name becomes part of a
356
+ // permanent URL that sites pin, so it must not drift when a directory
357
+ // is renamed — and the CLI already printed these exact names in its
358
+ // next-steps output.
359
+ const pairs = foundations.map(f => `${f.name}:${f.path}`)
360
+
361
+ const content = `${workflowHeader({
362
+ title: 'Publish foundations to GitHub Pages',
363
+ command: 'uniweb add ci --host=github-pages --foundation',
364
+ notes: [
365
+ 'Each foundation is published at a permanent versioned URL:',
366
+ '',
367
+ ' https://<user>.github.io/<repo>/foundations/<name>/<version>/entry.js',
368
+ '',
369
+ 'Versions accumulate — bumping package.json version creates a new',
370
+ 'directory alongside the old ones, so URLs already pinned by a site',
371
+ 'keep resolving forever. Reference one from a site\'s site.yml:',
372
+ '',
373
+ ' foundation: \'https://<user>.github.io/<repo>/foundations/<name>/<version>/entry.js\'',
374
+ ],
375
+ })}
376
+
377
+ name: Publish Foundations
378
+
379
+ ${pushTrigger()}
380
+
381
+ permissions:
382
+ contents: write # writes to the gh-pages branch
383
+
384
+ concurrency:
385
+ group: publish-foundations
386
+ cancel-in-progress: false
387
+
388
+ jobs:
389
+ build-and-publish:
390
+ runs-on: ubuntu-latest
391
+ steps:
392
+ ${setup}
393
+ - name: Build and stage foundations
394
+ shell: bash
395
+ run: |
396
+ set -e
397
+ mkdir -p _staging/foundations
398
+ for pair in ${pairs.map(p => JSON.stringify(p)).join(' ')}; do
399
+ name="\${pair%%:*}"
400
+ dir="\${pair#*:}"
401
+ version=$(jq -r '.version' "$dir/package.json")
402
+ if [ -z "$version" ] || [ "$version" = "null" ]; then
403
+ echo "::warning::skipping $name — no version in package.json"
404
+ continue
405
+ fi
406
+
407
+ echo "Building $name@$version"
408
+ (cd "$dir" && ${buildCmd})
409
+
410
+ if [ ! -d "$dir/dist" ]; then
411
+ echo "::error::build produced no dist/ for $name"
412
+ exit 1
413
+ fi
414
+
415
+ mkdir -p "_staging/foundations/$name/$version"
416
+ cp -R "$dir/dist/." "_staging/foundations/$name/$version/"
417
+ echo "Staged $name@$version"
418
+ done
419
+
420
+ - name: Layer onto gh-pages
421
+ shell: bash
422
+ run: |
423
+ set -e
424
+ git config user.name "github-actions[bot]"
425
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
426
+
427
+ if git fetch origin gh-pages 2>/dev/null; then
428
+ git worktree add _gh-pages gh-pages
429
+ else
430
+ git worktree add --detach _gh-pages
431
+ (cd _gh-pages && git checkout --orphan gh-pages && git rm -rf . 2>/dev/null || true)
432
+ fi
433
+
434
+ # Layer, don't replace: older versions must survive so sites
435
+ # pinning them keep working.
436
+ mkdir -p _gh-pages/foundations
437
+ cp -R _staging/foundations/. _gh-pages/foundations/
438
+
439
+ # Without this, Jekyll silently 404s every _-prefixed chunk the
440
+ # foundation build emits.
441
+ touch _gh-pages/.nojekyll
442
+
443
+ cd _gh-pages
444
+ git add -A
445
+ if git diff --cached --quiet; then
446
+ echo "No foundation changes to publish."
447
+ exit 0
448
+ fi
449
+ git commit -m "publish: foundations from \${GITHUB_SHA::7}"
450
+ git push origin gh-pages
451
+ `
452
+
453
+ const names = foundations.map(f => f.name)
454
+ return {
455
+ files: [{ path: '.github/workflows/publish-foundations.yml', content }],
456
+ postInstructions: [
457
+ 'Commit and push .github/workflows/publish-foundations.yml.',
458
+ 'On GitHub: Settings → Pages → Source: "Deploy from a branch" → gh-pages.',
459
+ '',
460
+ 'After the first run, each foundation is served at:',
461
+ ...names.map(n => ` https://<user>.github.io/<repo>/foundations/${n}/<version>/entry.js`),
462
+ '',
463
+ 'Bump a foundation\'s package.json version to publish a new one; older',
464
+ 'versions stay reachable at their original URLs.',
465
+ ],
466
+ targetConfig: null,
467
+ }
468
+ }
469
+
97
470
  function renderWorkflow({ sitePath, packageManager, nodeVersion, pnpmVersion, domain }) {
98
471
  const isPnpm = packageManager === 'pnpm'
99
- const setupSteps = isPnpm
100
- ? ` - uses: pnpm/action-setup@v4
101
- with:
102
- version: ${pnpmVersion}
103
- - uses: actions/setup-node@v4
104
- with:
105
- node-version: '${nodeVersion}'
106
- cache: pnpm
107
- - run: pnpm install --frozen-lockfile`
108
- : ` - uses: actions/setup-node@v4
109
- with:
110
- node-version: '${nodeVersion}'
111
- cache: npm
112
- - run: npm ci`
113
-
114
- const buildCmd = isPnpm
115
- ? 'pnpm exec uniweb build --host=github-pages'
116
- : 'npx uniweb build --host=github-pages'
472
+ const setup = setupSteps({ packageManager, nodeVersion, pnpmVersion })
473
+ const buildCmd = uniwebBuildCommand({ packageManager, host: 'github-pages' })
117
474
 
118
475
  // Two shapes:
119
476
  // - Custom domain: UNIWEB_BASE is hardcoded to '/' (GH Pages serves
@@ -135,20 +492,14 @@ function renderWorkflow({ sitePath, packageManager, nodeVersion, pnpmVersion, do
135
492
  fi
136
493
  - run: ${buildCmd}`
137
494
 
138
- return `# Deploy to GitHub Pages
139
- # Generated by \`uniweb add ci --host=github-pages\`. Safe to edit.
495
+ return `${workflowHeader({
496
+ title: 'Deploy to GitHub Pages',
497
+ command: 'uniweb add ci --host=github-pages',
498
+ })}
140
499
 
141
500
  name: Deploy to GitHub Pages
142
501
 
143
- on:
144
- push:
145
- # Both names are listed so the workflow fires whether the repo
146
- # uses 'main' (GitHub's current default) or 'master' (older repos
147
- # and any not migrated). GHA only triggers on a branch that exists,
148
- # so the unused name is a harmless no-op. Users on a different
149
- # default (trunk, develop, release) edit this list directly.
150
- branches: [main, master]
151
- workflow_dispatch:
502
+ ${pushTrigger()}
152
503
 
153
504
  permissions:
154
505
  contents: read
@@ -166,8 +517,7 @@ jobs:
166
517
  name: github-pages
167
518
  url: \${{ steps.deployment.outputs.page_url }}
168
519
  steps:
169
- - uses: actions/checkout@v4
170
- ${setupSteps}
520
+ ${setup}
171
521
  ${baseStep}
172
522
  - uses: actions/configure-pages@v5
173
523
  - uses: actions/upload-pages-artifact@v3
@@ -43,6 +43,7 @@
43
43
  import cloudflarePages from './cloudflare-pages.js'
44
44
  import githubPages from './github-pages.js'
45
45
  import genericStatic from './generic-static.js'
46
+ import netlify from './netlify.js'
46
47
  import s3Cloudfront from './s3-cloudfront.js'
47
48
  import vercel from './vercel.js'
48
49
 
@@ -50,6 +51,7 @@ const builtins = new Map([
50
51
  [cloudflarePages.name, cloudflarePages],
51
52
  [githubPages.name, githubPages],
52
53
  [genericStatic.name, genericStatic],
54
+ [netlify.name, netlify],
53
55
  [s3Cloudfront.name, s3Cloudfront],
54
56
  [vercel.name, vercel],
55
57
  ])
@@ -57,18 +59,25 @@ const builtins = new Map([
57
59
  /**
58
60
  * Aliases mapping a user-facing host name to a canonical adapter that
59
61
  * already implements the right behavior. Aliases exist when two hosts
60
- * share an artifact contract (e.g., Netlify and Cloudflare Pages both
61
- * consume `_redirects` in the same format) — one tested code path,
62
- * multiple discoverable names.
62
+ * share an artifact contract one tested code path, multiple
63
+ * discoverable names.
63
64
  *
64
65
  * The returned adapter's `name` is rewritten to the *requested* name,
65
66
  * so the deploy manifest, dry-run output, and lastDeploy entry record
66
67
  * what the user picked. Adapters that need to *behave* differently per
67
68
  * name should become canonical entries in `builtins`, not aliases.
69
+ *
70
+ * Netlify used to alias cloudflare-pages (they share the `_redirects`
71
+ * contract, so one postBuild covers both). It was promoted to a
72
+ * canonical adapter when deploy hooks landed: `netlify deploy` and
73
+ * `wrangler pages deploy` are different tools with different auth, so
74
+ * the behavior genuinely diverges. `netlify.js` still imports
75
+ * cloudflare-pages' `emitRedirectsFile` — the shared part stays shared.
76
+ *
77
+ * Empty today. Kept because the lookup path below is the documented
78
+ * extension point and re-adding an alias should not require rewiring.
68
79
  */
69
- const aliases = new Map([
70
- ['netlify', 'cloudflare-pages'],
71
- ])
80
+ const aliases = new Map()
72
81
 
73
82
  /**
74
83
  * Look up an adapter by name. Throws with the list of known names if