uniweb 0.12.33 → 0.12.35
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 +7 -7
- package/package.json +4 -4
- package/partials/agents.md +29 -16
- package/src/backend/client.js +36 -0
- package/src/backend/data-bundle.js +43 -0
- package/src/backend/site-media.js +61 -0
- package/src/backend/site-sync.js +267 -0
- package/src/commands/deploy.js +137 -201
- package/src/commands/publish.js +38 -0
- package/src/commands/push.js +26 -212
- package/src/commands/rename.js +5 -3
- package/src/commands/status.js +174 -0
- package/src/framework-index.json +3 -3
- package/src/index.js +8 -0
- package/src/utils/asset-upload.js +22 -8
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ pnpm build # Build foundation + site for production
|
|
|
62
62
|
pnpm preview # Preview the production build
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere. For the actual deploy step (and the `uniweb
|
|
65
|
+
The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere. For the actual deploy step (and the `uniweb deploy` / `uniweb register` commands), see [Deployment](#deployment) below.
|
|
66
66
|
|
|
67
67
|
## What You Get
|
|
68
68
|
|
|
@@ -258,7 +258,7 @@ The `src/` folder (your project's foundation) ships with your project as a conve
|
|
|
258
258
|
| **Local folder** | Foundation lives in your workspace | Developing site and components together |
|
|
259
259
|
| **Runtime link** | Foundation loads from a URL | Independent release cycles, platform-managed sites |
|
|
260
260
|
|
|
261
|
-
You can delete the `src/` folder entirely and point your site at a
|
|
261
|
+
You can delete the `src/` folder entirely and point your site at a registered foundation. Or develop a foundation locally, then register it for other sites to consume. The site doesn't care where its components come from.
|
|
262
262
|
|
|
263
263
|
**This enables two development patterns:**
|
|
264
264
|
|
|
@@ -327,20 +327,20 @@ uniweb add ci --host=github-pages
|
|
|
327
327
|
|
|
328
328
|
### Path 2 — Someone else manages the content
|
|
329
329
|
|
|
330
|
-
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
|
|
330
|
+
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 register a foundation:
|
|
331
331
|
|
|
332
332
|
```bash
|
|
333
333
|
cd src
|
|
334
|
-
uniweb
|
|
334
|
+
uniweb register --scope @your-org
|
|
335
335
|
```
|
|
336
336
|
|
|
337
337
|
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.
|
|
338
338
|
|
|
339
339
|
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.
|
|
340
340
|
|
|
341
|
-
###
|
|
341
|
+
### Hybrid
|
|
342
342
|
|
|
343
|
-
|
|
343
|
+
Markdown in a git repo and content in the Uniweb apps can share the same site. Directional sync is available today: `uniweb push` sends local content to the backend, `uniweb pull` brings the backend's content back to local files, and `uniweb clone` bootstraps a local project from a site that already lives in the backend. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. The advanced two-way-merge experience — conflict resolution, branch isolation, and review flows when both surfaces change the same content concurrently — is still evolving.
|
|
344
344
|
|
|
345
345
|
### Commands at a glance
|
|
346
346
|
|
|
@@ -349,7 +349,7 @@ A future version will let markdown in a git repo and content in the Uniweb apps
|
|
|
349
349
|
| `uniweb add ci --host=<adapter>` | Scaffold a CI workflow in your repo (today: `github-pages`). The host runs `uniweb build` on each push. |
|
|
350
350
|
| `uniweb deploy` | Deploy to Uniweb hosting (default). With `--host=<adapter>`, push directly to a static host — builds, uploads, invalidates in one step. |
|
|
351
351
|
| `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
|
|
352
|
-
| `uniweb
|
|
352
|
+
| `uniweb register --scope @org` | Register a foundation to the registry (path 2). |
|
|
353
353
|
| `uniweb build` | Inspect a build locally. For shipping, use `deploy` or `export`. |
|
|
354
354
|
| `uniweb update` | Align this project with the CLI you're running: bump `@uniweb/*` deps in every `package.json` to the CLI's matrix (then install), and refresh `AGENTS.md`. Pins to *this* CLI's matrix — run `npx uniweb@latest update` to align to the latest release. Updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`). |
|
|
355
355
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.35",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/kit": "0.9.18",
|
|
45
44
|
"@uniweb/core": "0.7.14",
|
|
45
|
+
"@uniweb/kit": "0.9.18",
|
|
46
46
|
"@uniweb/runtime": "0.8.20"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
+
"@uniweb/content-reader": "1.1.12",
|
|
49
50
|
"@uniweb/semantic-parser": "1.1.17",
|
|
50
|
-
"@uniweb/build": "0.14.
|
|
51
|
-
"@uniweb/content-reader": "1.1.12"
|
|
51
|
+
"@uniweb/build": "0.14.18"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -142,28 +142,39 @@ pnpm install # Install dependencies
|
|
|
142
142
|
pnpm build # Build for production
|
|
143
143
|
pnpm preview # Preview production build (SSG + SPA)
|
|
144
144
|
|
|
145
|
-
# Ship
|
|
146
|
-
uniweb deploy #
|
|
147
|
-
uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, netlify,
|
|
145
|
+
# Ship a site to Uniweb hosting (or a static host)
|
|
146
|
+
uniweb deploy # Build + push + publish in one shot (default; needs `uniweb login`)
|
|
147
|
+
uniweb deploy --host=<adapter> # Deploy to a static host instead: cloudflare-pages, netlify,
|
|
148
148
|
# vercel, github-pages, s3-cloudfront, generic-static
|
|
149
149
|
uniweb deploy --dry-run # Resolve foundation/runtime + print summary; no writes
|
|
150
150
|
uniweb export # Build dist/ for any static host (no Uniweb account)
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
|
|
152
|
+
# Sync a site with the Uniweb backend (git-style; needs `uniweb login`)
|
|
153
|
+
uniweb push # Push local content to the backend (creates the site on first push)
|
|
154
|
+
uniweb pull # Pull backend content back to local files
|
|
155
|
+
uniweb clone <site-uuid> # Start a new local project from a site already in the backend
|
|
156
|
+
uniweb status # Show what's unpushed / out of sync (local, offline)
|
|
157
|
+
uniweb publish # Make the synced site's current backend state live —
|
|
158
|
+
# does NOT push; run `uniweb push` first, or just `uniweb deploy`
|
|
159
|
+
|
|
160
|
+
# Register a foundation + its data schemas to the Uniweb registry
|
|
161
|
+
uniweb register # Register this foundation + the data schemas it defines
|
|
162
|
+
uniweb register --scope @org # Register under @org (also works from a schemas-only package)
|
|
163
|
+
|
|
164
|
+
# Project health + maintenance
|
|
153
165
|
uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
|
|
154
|
-
uniweb validate # Check
|
|
155
|
-
uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix
|
|
156
|
-
#
|
|
157
|
-
# `npx uniweb@latest update` pins to the latest release.
|
|
166
|
+
uniweb validate # Check file-based data against your declared schemas (--strict for CI)
|
|
167
|
+
uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix
|
|
168
|
+
# (--dry-run to preview, --yes for non-interactive)
|
|
158
169
|
|
|
159
170
|
# Help
|
|
160
171
|
uniweb --help # Top-level help
|
|
161
172
|
uniweb <command> --help # Per-command help (no side effects)
|
|
162
173
|
```
|
|
163
174
|
|
|
164
|
-
`uniweb deploy` auto-
|
|
175
|
+
`uniweb deploy` auto-registers a workspace-local foundation as part of the deploy under a site-scoped slot — no separate `uniweb register` step needed for site-bound foundations.
|
|
165
176
|
|
|
166
|
-
**Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the
|
|
177
|
+
**Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the org scope with `--scope @org` (default: the foundation's `package.json` `uniweb.scope`).
|
|
167
178
|
|
|
168
179
|
**Registering standard or shared schemas (no foundation).** Data schemas can also be registered on their own — without a foundation — straight from a schemas package. Run `uniweb register` from a package that *exports* schemas (`@uniweb/schemas`, or any `@org/schemas` you maintain), or from a bare folder of `schemas/*.{yml,json,js}` files: it detects the schemas-only package automatically and submits just the data schemas (no foundation) under `--scope`. This is how the standard schemas are published under `@std`, and how an org publishes its own shared `@org/schemas` once for many foundations to reference. The same flags apply (`--scope`, `--dry-run`, `-o`, `--token`, `--registry`); `--dry-run` (or `-o <file>`) previews the exact submission without authenticating.
|
|
169
180
|
|
|
@@ -192,16 +203,16 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
|
|
|
192
203
|
|
|
193
204
|
| Field | Where used | Default | Purpose |
|
|
194
205
|
|---|---|---|---|
|
|
195
|
-
| `id` | `uniweb
|
|
196
|
-
| `namespace` | `uniweb
|
|
206
|
+
| `id` | `uniweb register` | (the bare segment of a scoped `package.json::name`, or `uniweb.id`) | The foundation's registered id — the bare-name segment in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming the foundation on the registry doesn't ripple through site dependencies. Only relevant for catalog-registered foundations; site-bound foundations skip this. |
|
|
207
|
+
| `namespace` | `uniweb register` | (none — see scope resolution) | Legacy explicit org-namespace override. Equivalent to using a scoped `package.json::name` (`"@myorg/foundation"`). Rarely needed in modern foundations. |
|
|
197
208
|
| `runtimePolicy` | `dist/runtime-pin.json` (foundation build) | `"auto-minor"` | Controls how sites using this foundation receive runtime updates. Three values: `"exact"`, `"auto-patch"`, `"auto-minor"`. See "Foundation runtime policy" below. |
|
|
198
209
|
|
|
199
210
|
**Catalog vs site-bound foundations.** Two distribution intents share the same `dist/foundation.js` artifact:
|
|
200
211
|
|
|
201
|
-
- A **catalog foundation** is a deliberate product — named, versioned, listed in the catalog, consumable by other developers' sites. Use `uniweb
|
|
202
|
-
- A **site-bound foundation** powers exactly one site. Don't run `uniweb
|
|
212
|
+
- A **catalog foundation** is a deliberate product — named, versioned, listed in the catalog, consumable by other developers' sites. Use `uniweb register --scope @org` for these — cataloging is a deliberate, explicitly-scoped step, so you don't accidentally catalog a foundation that was meant to be site-bound.
|
|
213
|
+
- A **site-bound foundation** powers exactly one site. Don't run `uniweb register` for it. Just run `uniweb deploy` from the site directory — the CLI auto-registers your local foundation as part of the deploy, **uploaded with the site's other assets** (per-site storage, never to the catalog). With no naming ceremony, no catalog visibility, and no developer-vs-site ownership confusion. To later promote the foundation to a catalog product, run `uniweb register --scope @org` from the foundation directory and update the site's `site.yml` to a versioned ref (`foundation: '@org/name@1.2.3'`).
|
|
203
214
|
|
|
204
|
-
**On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means
|
|
215
|
+
**On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means a workspace rename (e.g. `marketing` → `marketing-pro`) is a one-shot `uniweb rename foundation marketing marketing-pro` — it updates the package, folder, and every dependent site, while the foundation's registered id (`uniweb.id`) stays independent.
|
|
205
216
|
|
|
206
217
|
These are the only fields the platform consumes today. Future platform features that need static configuration will land here too.
|
|
207
218
|
|
|
@@ -1762,6 +1773,8 @@ export default {
|
|
|
1762
1773
|
|
|
1763
1774
|
The content handler receives `block.parsedContent.data` and reads raw ProseMirror from `block.rawContent`. It returns a new ProseMirror document — the framework re-parses it through the semantic parser. Returning `null` or the same reference as `block.rawContent` signals no change.
|
|
1764
1775
|
|
|
1776
|
+
> **`instantiateContent` resolves `{placeholders}` in text nodes only** — not in link `href`s or other node/mark attributes. So `[{email}](mailto:{email})` fills the visible label but leaves the `mailto:` URL literal. For dynamic URLs, emit the value as plain text and let the component linkify it, or build the href in the handler yourself.
|
|
1777
|
+
|
|
1765
1778
|
### Reserved frontmatter fields
|
|
1766
1779
|
|
|
1767
1780
|
`source` and `where` are convention-level reserved fields — they flow through to both `block.properties` (for handler access) and `params` (visible to components). Components can ignore them. This is consistent with how `background` and `theme` work. List them in `meta.js` params with descriptions so the editor and schema recognize them.
|
package/src/backend/client.js
CHANGED
|
@@ -207,6 +207,25 @@ export class BackendClient {
|
|
|
207
207
|
return res.json()
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* ASSUMED endpoint (not built yet — kb/framework/plans/shipping-verbs-and-freshness.md §6.5).
|
|
212
|
+
* GET /dev/registry/foundation/{scope}/{name} → { latest_version } for a foundation's
|
|
213
|
+
* latest registered version, or null on 404 / any failure (callers degrade gracefully).
|
|
214
|
+
* A bare/unscoped name → null (only `@org/name` can be looked up).
|
|
215
|
+
* @param {string} scopedName
|
|
216
|
+
* @returns {Promise<{ latest_version: string }|null>}
|
|
217
|
+
*/
|
|
218
|
+
async readFoundationLatest(scopedName) {
|
|
219
|
+
const m = /^@([^/]+)\/([^@/]+)/.exec(String(scopedName || ''))
|
|
220
|
+
if (!m) return null
|
|
221
|
+
try {
|
|
222
|
+
const res = await this.request(`/dev/registry/foundation/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`)
|
|
223
|
+
return res.ok ? await res.json().catch(() => null) : null
|
|
224
|
+
} catch {
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
210
229
|
// ── Orgs ──────────────────────────────────────────────────────────────────────
|
|
211
230
|
|
|
212
231
|
/** GET /dev/orgs → { account_handle, personal_org_exists, orgs[] }. */
|
|
@@ -311,6 +330,23 @@ export class BackendClient {
|
|
|
311
330
|
return this.request(`/dev/site/unpublish/${encodeURIComponent(uuid)}`, { method: 'POST' })
|
|
312
331
|
}
|
|
313
332
|
|
|
333
|
+
/**
|
|
334
|
+
* ASSUMED endpoint (not built yet — kb/framework/plans/shipping-verbs-and-freshness.md §6.5).
|
|
335
|
+
* GET /dev/site/{uuid}/status → the site's publish lifecycle:
|
|
336
|
+
* { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
|
|
337
|
+
* `draft_dirty` = the synced draft differs from what's currently live. null on 404 / any failure.
|
|
338
|
+
* @param {string} uuid - the site-content uuid
|
|
339
|
+
* @returns {Promise<object|null>}
|
|
340
|
+
*/
|
|
341
|
+
async siteStatus(uuid) {
|
|
342
|
+
try {
|
|
343
|
+
const res = await this.request(`/dev/site/${encodeURIComponent(uuid)}/status`)
|
|
344
|
+
return res.ok ? await res.json().catch(() => null) : null
|
|
345
|
+
} catch {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
314
350
|
/**
|
|
315
351
|
* Deliver a site's processed assets (plan → PUT-per-file) to the backend's
|
|
316
352
|
* content-addressed store. Thin pass-through to utils/asset-upload.js with this
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload the static-data ball (assembleDataBall's `{ data, search }` doc) to the
|
|
3
|
+
* backend's content-addressed asset store via the SAME asset lane deploy uses for
|
|
4
|
+
* media, and return its durable serve URL — the `info.data_bundle` the composite push
|
|
5
|
+
* stamps on the site-content entity. The backend unwraps the ball into the `/data/*`
|
|
6
|
+
* + `/_search/*` bytes the gateway serves.
|
|
7
|
+
*
|
|
8
|
+
* The ball is in-memory (not a built file on disk), so it rides as `bytes` on the
|
|
9
|
+
* single upload entry — `uploadSiteAssets` PUTs `bytes` when present, else reads a
|
|
10
|
+
* `diskPath` (its media path). Content-addressed like every asset: identical ball →
|
|
11
|
+
* same id → a re-deploy of unchanged data is a cheap no-op PUT.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { buildAssetUrl } from '../utils/asset-upload.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
|
|
19
|
+
* @param {{ data: object, search: object }} ball - the assembled data ball
|
|
20
|
+
* @param {{ onProgress?: (m: string) => void }} [opts]
|
|
21
|
+
* @returns {Promise<string>} the content-addressed serve URL (→ `info.data_bundle`)
|
|
22
|
+
*/
|
|
23
|
+
export async function uploadDataBundle(client, ball, { onProgress } = {}) {
|
|
24
|
+
const bytes = Buffer.from(JSON.stringify(ball))
|
|
25
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
26
|
+
const localUrl = '/data-bundle/base.json' // bookkeeping key into assetsByLocalUrl
|
|
27
|
+
|
|
28
|
+
const result = await client.uploadSiteAssets({
|
|
29
|
+
files: [
|
|
30
|
+
{ path: 'data-bundle/base.json', content_type: 'application/json', size: bytes.length, sha256, localUrl, bytes },
|
|
31
|
+
],
|
|
32
|
+
onProgress,
|
|
33
|
+
})
|
|
34
|
+
if (result.failed?.length) {
|
|
35
|
+
const f = result.failed[0]
|
|
36
|
+
throw new Error(`data-bundle upload failed: HTTP ${f.status} ${f.detail}`)
|
|
37
|
+
}
|
|
38
|
+
const entry = result.assetsByLocalUrl[localUrl]
|
|
39
|
+
if (!entry) throw new Error('data-bundle upload returned no asset id')
|
|
40
|
+
|
|
41
|
+
const config = await client.discover()
|
|
42
|
+
return buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload a site's local media to the backend's content-addressed asset store via the
|
|
3
|
+
* SAME asset lane the data bundle rides, and return a `{ ref → serveUrl }` map for the
|
|
4
|
+
* deploy's second emit (`assetRewrite`) to swap the entity content refs for.
|
|
5
|
+
*
|
|
6
|
+
* Input is the site-root asset refs the producer surfaced in
|
|
7
|
+
* `emitSyncPackages().localAssets` (`/images/hero.png`); `resolveAssetPath` finds the
|
|
8
|
+
* file under the site's `public/` (or `assets/`). A ref whose file is missing is
|
|
9
|
+
* skipped (warned), never a broken serve URL. The serve URL is the backend's canonical
|
|
10
|
+
* `serve_url` when present, else reconstructed from `id`+`assetBase` (the dev fallback).
|
|
11
|
+
* Content-addressed like every asset: identical bytes → same id → a re-deploy of
|
|
12
|
+
* unchanged media is a cheap no-op PUT (the lane's `present` skip-list).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { basename } from 'node:path'
|
|
18
|
+
import { resolveAssetPath } from '@uniweb/build/site'
|
|
19
|
+
import { buildAssetUrl } from '../utils/asset-upload.js'
|
|
20
|
+
import { contentTypeFor } from '../utils/code-upload.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
|
|
24
|
+
* @param {string} siteDir - the site root (site-root refs resolve under public/)
|
|
25
|
+
* @param {string[]} refs - site-root local asset refs (`/images/x.png`)
|
|
26
|
+
* @param {{ onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
|
|
27
|
+
* @returns {Promise<Record<string,string>>} ref → serve URL (only resolved + uploaded refs)
|
|
28
|
+
*/
|
|
29
|
+
export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn } = {}) {
|
|
30
|
+
if (!refs?.length) return {}
|
|
31
|
+
|
|
32
|
+
const files = []
|
|
33
|
+
for (const ref of refs) {
|
|
34
|
+
const { resolved } = resolveAssetPath(ref, siteDir, siteDir)
|
|
35
|
+
if (!resolved || !existsSync(resolved)) {
|
|
36
|
+
warn?.(`local-media: ${ref} not found under the site (skipped)`)
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
const bytes = readFileSync(resolved)
|
|
40
|
+
files.push({
|
|
41
|
+
path: ref.replace(/^\/+/, ''), // bookkeeping key into the plan (must be unique)
|
|
42
|
+
content_type: contentTypeFor(basename(resolved)),
|
|
43
|
+
size: bytes.length,
|
|
44
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
45
|
+
localUrl: ref, // the rewrite key — the original content ref
|
|
46
|
+
diskPath: resolved,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
if (!files.length) return {}
|
|
50
|
+
|
|
51
|
+
const result = await client.uploadSiteAssets({ files, onProgress })
|
|
52
|
+
for (const f of result.failed || []) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
|
|
53
|
+
|
|
54
|
+
const config = await client.discover()
|
|
55
|
+
const map = {}
|
|
56
|
+
for (const ref of refs) {
|
|
57
|
+
const entry = result.assetsByLocalUrl[ref]
|
|
58
|
+
if (entry) map[ref] = entry.serveUrl || buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
|
|
59
|
+
}
|
|
60
|
+
return map
|
|
61
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* site-sync — the reusable core of `uniweb push`: given a site's emitted sync
|
|
3
|
+
* packages, submit them over the two directional lanes (site-content first, then the
|
|
4
|
+
* folder keyed by the site's uuid), back-fill the minted uuids into the source files,
|
|
5
|
+
* and persist the send-only-changed cache. Extracted from the push command so
|
|
6
|
+
* `uniweb deploy` (the composite path) reuses the exact same lane submission.
|
|
7
|
+
*
|
|
8
|
+
* The command keeps flag parsing, the emit, and the `-o`/`--dry-run` preview;
|
|
9
|
+
* everything from "the packages are built, now POST them" lives here. Logging is
|
|
10
|
+
* injected via `report` ({ info, note, error, dim }) so each caller styles output its
|
|
11
|
+
* own way.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
|
|
15
|
+
import { join, dirname } from 'node:path'
|
|
16
|
+
import { backfillEntityUuids, writeSiteEntityUuid, emitSyncPackages } from '@uniweb/build/uwx'
|
|
17
|
+
|
|
18
|
+
// Pull the finalized entities out of the restore response. The backend returns
|
|
19
|
+
// `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
|
|
20
|
+
// carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
|
|
21
|
+
// is not echoed), the minted entity `uuid`, a `changed` flag, and the full `document`
|
|
22
|
+
// (verbatim stored content with every `$uuid` filled in). A couple of shapes are
|
|
23
|
+
// tolerated; only entries with a valid `index` + `uuid` are usable.
|
|
24
|
+
export function extractFinalized(payload) {
|
|
25
|
+
const list = Array.isArray(payload?.report?.finalized)
|
|
26
|
+
? payload.report.finalized
|
|
27
|
+
: Array.isArray(payload?.finalized)
|
|
28
|
+
? payload.finalized
|
|
29
|
+
: Array.isArray(payload)
|
|
30
|
+
? payload
|
|
31
|
+
: null
|
|
32
|
+
if (!list) return null
|
|
33
|
+
return list
|
|
34
|
+
.map((d) => ({
|
|
35
|
+
index: d?.index,
|
|
36
|
+
uuid: d?.uuid ?? d?.document?.$uuid ?? null,
|
|
37
|
+
changed: d?.changed,
|
|
38
|
+
document: d?.document ?? null,
|
|
39
|
+
}))
|
|
40
|
+
.filter((e) => Number.isInteger(e.index) && e.uuid)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pull the minted site-content uuid out of a CREATE response. The exact shape is an
|
|
44
|
+
// open backend item, so the extractor is deliberately tolerant of a bare
|
|
45
|
+
// `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
|
|
46
|
+
// envelope the update/folder lanes return (the site entity is submitted alone, so its
|
|
47
|
+
// minted uuid is the first finalized entry). Returns null if none is present.
|
|
48
|
+
export function extractMintedSiteUuid(payload) {
|
|
49
|
+
if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
|
|
50
|
+
if (typeof payload?.$uuid === 'string') return payload.$uuid
|
|
51
|
+
if (typeof payload?.uuid === 'string') return payload.uuid
|
|
52
|
+
const finalized = extractFinalized(payload)
|
|
53
|
+
if (finalized && finalized.length) {
|
|
54
|
+
const site = finalized.find((f) => f.index === 0) || finalized[0]
|
|
55
|
+
return site?.uuid ?? null
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One-line summary from the authoritative per-entity `changed` flag (`false` = a true
|
|
61
|
+
// no-op). Falls back silently when the backend omits it.
|
|
62
|
+
function changedSummary(finalized) {
|
|
63
|
+
const changed = finalized.filter((f) => f.changed === true).length
|
|
64
|
+
const unchanged = finalized.filter((f) => f.changed === false).length
|
|
65
|
+
const parts = []
|
|
66
|
+
if (changed) parts.push(`${changed} changed`)
|
|
67
|
+
if (unchanged) parts.push(`${unchanged} unchanged`)
|
|
68
|
+
return parts.length ? parts.join(', ') : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Resolve a Model NOT defined by the local foundation by reading its declaration (the
|
|
72
|
+
// `@uniweb/data-schema` form) from the backend via the client. Cached per run; HTTP
|
|
73
|
+
// 404 → null (the emitter then says "register it first"). The bearer is acquired lazily
|
|
74
|
+
// by the client, so a fully-local sync never authenticates.
|
|
75
|
+
//
|
|
76
|
+
// `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
|
|
77
|
+
// touching the backend — an offline emit must never authenticate.
|
|
78
|
+
export function makeModelResolver({ client, offline = false }) {
|
|
79
|
+
const cache = new Map()
|
|
80
|
+
return async (modelName) => {
|
|
81
|
+
if (cache.has(modelName)) return cache.get(modelName)
|
|
82
|
+
const decl = offline ? null : await client.readDataSchema(modelName)
|
|
83
|
+
cache.set(modelName, decl)
|
|
84
|
+
return decl
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// "Send only changed" cache: content hashes from the last successful sync, keyed
|
|
89
|
+
// `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one full
|
|
90
|
+
// re-sync, which the backend then no-ops). NOT identity — the minted `$uuid` lives in
|
|
91
|
+
// the source files; this is a pure wire-efficiency cache.
|
|
92
|
+
function syncCachePath(siteDir) {
|
|
93
|
+
return join(siteDir, '.uniweb', 'sync-cache.json')
|
|
94
|
+
}
|
|
95
|
+
export function readSyncCache(siteDir) {
|
|
96
|
+
try {
|
|
97
|
+
const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
|
|
98
|
+
return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
|
|
99
|
+
} catch {
|
|
100
|
+
return {} // missing / unreadable → treat everything as changed
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function writeSyncCache(siteDir, hashes) {
|
|
104
|
+
const p = syncCachePath(siteDir)
|
|
105
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
106
|
+
writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Offline-probe how many of a site's entities differ from the last successful push.
|
|
111
|
+
* Runs the SAME emit + send-only-changed diff `uniweb push` runs, but with an
|
|
112
|
+
* OFFLINE Model resolver — no auth, no submit, no backend round-trip. Used by
|
|
113
|
+
* `uniweb status` and the `uniweb publish` pre-flight. Throws if the producer
|
|
114
|
+
* can't build the sync packages (e.g. an unresolved data Model); callers report it.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} siteDir
|
|
117
|
+
* @returns {Promise<{ changed: number, unchanged: number, warnings: string[] }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
120
|
+
const priorHashes = readSyncCache(siteDir)
|
|
121
|
+
const pkg = await emitSyncPackages(siteDir, {
|
|
122
|
+
resolveModel: makeModelResolver({ client: null, offline: true }),
|
|
123
|
+
priorHashes,
|
|
124
|
+
sendAll,
|
|
125
|
+
})
|
|
126
|
+
const changed = (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
|
|
127
|
+
return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Submit a site's emitted sync packages over both directional lanes, back-fill the
|
|
132
|
+
* minted uuids, and persist the send-only-changed cache. The HTTP + file-write-back
|
|
133
|
+
* half that `emitSyncPackages` (producer-pure) deliberately omits.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} params
|
|
136
|
+
* @param {object} params.client - BackendClient (carries the origin + the lane methods)
|
|
137
|
+
* @param {string} params.siteDir - the site root (for $uuid write-back + the cache)
|
|
138
|
+
* @param {object} params.pkg - the `emitSyncPackages` result
|
|
139
|
+
* ({ siteContent, collections, siteContentUuid, hashes })
|
|
140
|
+
* @param {string|null} [params.asOrg] - act-as org (membership-gated), forwarded to each lane
|
|
141
|
+
* @param {{info,note,error,dim?:Function}} params.report - injected logging
|
|
142
|
+
* @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
|
|
143
|
+
* exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
|
|
144
|
+
*/
|
|
145
|
+
export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report }) {
|
|
146
|
+
const { siteContent, collections, siteContentUuid, hashes } = pkg
|
|
147
|
+
const { info, note, error } = report
|
|
148
|
+
const dim = report.dim || ((s) => s)
|
|
149
|
+
|
|
150
|
+
const wrote = []
|
|
151
|
+
let finalizedTotal = 0
|
|
152
|
+
|
|
153
|
+
// POST one lane via the client and parse the JSON response. `doRequest` is a thunk
|
|
154
|
+
// returning the client's Response promise (so the "Pushing …" line prints before the
|
|
155
|
+
// request fires). The client carries `collision=force` (last-push-wins) + the optional
|
|
156
|
+
// `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
|
|
157
|
+
// (already reported).
|
|
158
|
+
const postLane = async (label, doRequest) => {
|
|
159
|
+
info(`Pushing ${label} to ${dim(client.origin)} …`)
|
|
160
|
+
let res
|
|
161
|
+
try {
|
|
162
|
+
res = await doRequest()
|
|
163
|
+
} catch (err) {
|
|
164
|
+
error(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
165
|
+
note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
|
|
170
|
+
if (res.status === 401 || res.status === 403) {
|
|
171
|
+
note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
|
|
172
|
+
} else if (res.status === 409) {
|
|
173
|
+
// The site's @uniweb/folder is genesis-owned: its structure is fixed on first
|
|
174
|
+
// deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
|
|
175
|
+
note(
|
|
176
|
+
"This site's collection structure is already established on the backend and can't be changed " +
|
|
177
|
+
'in place — e.g. adding or removing a schema-backed collection, or switching one between ' +
|
|
178
|
+
'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
|
|
179
|
+
'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
const body = await res.text().catch(() => '')
|
|
183
|
+
if (body) note(body.slice(0, 800))
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
return await res.json()
|
|
188
|
+
} catch (err) {
|
|
189
|
+
error(`Could not parse the ${label} response as JSON: ${err.message}`)
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
|
|
195
|
+
// finalized list (for record back-fill + the changed summary). Returns the finalized
|
|
196
|
+
// array, or null on failure (already reported).
|
|
197
|
+
const pushLane = async (label, doRequest) => {
|
|
198
|
+
const payload = await postLane(label, doRequest)
|
|
199
|
+
if (payload === null) return null
|
|
200
|
+
const finalized = extractFinalized(payload)
|
|
201
|
+
if (!finalized) {
|
|
202
|
+
error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
|
|
203
|
+
note(JSON.stringify(payload).slice(0, 800))
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
const summary = changedSummary(finalized)
|
|
207
|
+
if (summary) note(`${label}: ${summary}`)
|
|
208
|
+
return finalized
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Lane 1 — site-content (the site is born here; it must exist before its folder). A
|
|
212
|
+
// known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the site
|
|
213
|
+
// and returns its uuid, which we record into site.yml). `boundSiteUuid` carries the
|
|
214
|
+
// minted/known uuid forward to key the folder push.
|
|
215
|
+
let boundSiteUuid = siteContentUuid
|
|
216
|
+
if (siteContent) {
|
|
217
|
+
if (siteContentUuid) {
|
|
218
|
+
const finalized = await pushLane(
|
|
219
|
+
'site-content',
|
|
220
|
+
() => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
|
|
221
|
+
)
|
|
222
|
+
if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
|
|
223
|
+
finalizedTotal += finalized.length
|
|
224
|
+
} else {
|
|
225
|
+
const payload = await postLane(
|
|
226
|
+
'site-content',
|
|
227
|
+
() => client.createSiteContent(siteContent.buffer, { asOrg })
|
|
228
|
+
)
|
|
229
|
+
if (payload === null) return { exitCode: 1, finalizedTotal, wrote }
|
|
230
|
+
const minted = extractMintedSiteUuid(payload)
|
|
231
|
+
if (!minted) {
|
|
232
|
+
error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
|
|
233
|
+
note(JSON.stringify(payload).slice(0, 800))
|
|
234
|
+
return { exitCode: 1, finalizedTotal, wrote }
|
|
235
|
+
}
|
|
236
|
+
writeSiteEntityUuid(siteDir, minted)
|
|
237
|
+
boundSiteUuid = minted
|
|
238
|
+
wrote.push('recorded site $uuid in site.yml')
|
|
239
|
+
finalizedTotal += extractFinalized(payload)?.length ?? 1
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
|
|
244
|
+
// site-content uuid. On a brand-new site the backend creates the folder on this first
|
|
245
|
+
// push. Records round-trip their own $uuid (back-filled into source files); the folder
|
|
246
|
+
// itself has no uuid (the backend owns it).
|
|
247
|
+
if (collections) {
|
|
248
|
+
if (!boundSiteUuid) {
|
|
249
|
+
error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
|
|
250
|
+
return { exitCode: 1, finalizedTotal, wrote }
|
|
251
|
+
}
|
|
252
|
+
const finalized = await pushLane(
|
|
253
|
+
'collections',
|
|
254
|
+
() => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
|
|
255
|
+
)
|
|
256
|
+
if (!finalized) return { exitCode: 1, finalizedTotal, wrote }
|
|
257
|
+
const bf = backfillEntityUuids({ index: collections.index, finalized })
|
|
258
|
+
for (const w of bf.warnings) note(`! ${w}`)
|
|
259
|
+
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
260
|
+
if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
261
|
+
finalizedTotal += finalized.length
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Persist the full content-hash map so the next push skips unchanged entities.
|
|
265
|
+
writeSyncCache(siteDir, hashes)
|
|
266
|
+
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
267
|
+
}
|