uniweb 0.48.5 → 0.49.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.48.5",
3
+ "version": "0.49.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "^0.19.5",
45
- "@uniweb/core": "^0.24.4",
46
- "@uniweb/kit": "^0.18.2",
47
- "@uniweb/semantic-parser": "^1.4.0"
44
+ "@uniweb/core": "^0.25.0",
45
+ "@uniweb/kit": "^0.18.3",
46
+ "@uniweb/semantic-parser": "^1.4.0",
47
+ "@uniweb/runtime": "^0.20.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "^1.4.0",
50
+ "@uniweb/build": "^0.45.0",
51
51
  "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/build": "^0.44.4"
52
+ "@uniweb/semantic-parser": "^1.4.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -857,7 +857,7 @@ A bare string is a path under `entities/`, naming one file or matching many.
857
857
  - article/2025-*.md
858
858
  ```
859
859
 
860
- A query then slices it with `where: { path: { under: 'archive' } }`. An entity belongs to one folder; if you want a computed subset, that is a query, not a second placement.
860
+ A query reads one branch with `scope: archive` — the folder and everything inside it (the older `where: { path: { under: } }` is refused by the build). An entity belongs to one folder; if you want a computed subset, that is a query, not a second placement.
861
861
 
862
862
  **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the published records of that schema are its rows:
863
863
 
@@ -897,9 +897,11 @@ pages/blog/
897
897
 
898
898
  `entities/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
899
899
 
900
+ **Which query the URL narrows — the page's route query:** the `[slug]` page's own `data:`, else its parent page's (the usual shape, above), else `site.yml`'s; if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's handle (`$name`, which compiled records carry — equal to their `slug`), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, reading the record when `[slug]/page.yml` declares the query. `[dir]` and `[path]` are refused as folder names, and so is any folder inside `[...path]/`.
901
+
900
902
  > **The record arrives as a single-element array under the query key** — `content.data.recent[0]`, not `content.data.article`. The runtime never coerces it to an object and never synthesizes a singular key. See *Data* in Part 4.
901
903
 
902
- **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by `slug`, so the section reads `content.data.recent[0]` as before. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound variable drops its clause, so one saved query serves the list page and the detail page. Reference: `reference/dynamic-routes.md`.
904
+ **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the list page and the parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
903
905
 
904
906
  **Two options for bigger sets:**
905
907
 
@@ -1773,7 +1775,7 @@ Content-less containers appear as group nodes (`hasContent: false`) — use `nav
1773
1775
 
1774
1776
  A component on a page with a `data:` or `fetch:` declaration automatically receives that data in `content.data.{key}` — no opt-in in `meta.js`.
1775
1777
 
1776
- **Bound collections always arrive as arrays.** On a list page, `content.data.articles` is the full collection. On a template page (`[slug]/`), the matched record is delivered under the *same* key as a single-element array — the detail section reads `content.data.articles[0]`. When nothing matches, the key is `[]`. The runtime never coerces to a single object and never synthesizes a singular key.
1778
+ **Bound collections always arrive as arrays.** On a list page, `content.data.articles` is the full collection. On a parametric page (`[slug]/`), the matched record is delivered under the *same* key as a single-element array — the detail section reads `content.data.articles[0]`. When nothing matches, the key is `[]`. The runtime never coerces to a single object and never synthesizes a singular key.
1777
1779
 
1778
1780
  ```jsx
1779
1781
  function Article({ content, block }) {
@@ -2447,6 +2449,7 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
2447
2449
  # (GitHub Pages → foundations/<name>/<version>/entry.js)
2448
2450
 
2449
2451
  uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
2452
+ uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2450
2453
  uniweb push --org @acme # First push/publish of a site: who owns it (see below)
2451
2454
  uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
2452
2455
  uniweb login / logout # Start or clear the backend session the verbs above reuse
@@ -2516,7 +2519,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2516
2519
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2517
2520
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2518
2521
 
2519
- **Conflicts behave like a collaborative document, not like git.** A developer's push arrives the way a live collaborator's edit would. Different sections never conflict, and different params of the same section never conflict — last edit wins. The app warns only when two content edits target the same section at the same time.
2522
+ **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2520
2523
 
2521
2524
  **The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
2522
2525
 
@@ -34,7 +34,11 @@
34
34
  import { mkdirSync, existsSync, writeFileSync } from 'node:fs'
35
35
  import { dirname, join } from 'node:path'
36
36
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
37
- import { readAssetMap, updateAssetMap } from '@uniweb/build/uwx'
37
+ import {
38
+ readAssetMap,
39
+ updateAssetMap,
40
+ servedFingerprint
41
+ } from '@uniweb/build/uwx'
38
42
 
39
43
  /**
40
44
  * Collect every `{ id, ext, url }` an entity document references.
@@ -43,7 +47,7 @@ import { readAssetMap, updateAssetMap } from '@uniweb/build/uwx'
43
47
  * carrying the reference — so a ProseMirror image node's attrs and a section
44
48
  * background's media object are both found by one walk.
45
49
  */
46
- export function collectAssetRefs(document) {
50
+ export function collectAssetRefs(document, map = {}) {
47
51
  const found = new Map() // id → { id, ext, url }
48
52
  const visit = (node) => {
49
53
  if (Array.isArray(node)) return node.forEach(visit)
@@ -59,6 +63,28 @@ export function collectAssetRefs(document) {
59
63
  for (const v of Object.values(node)) visit(v)
60
64
  }
61
65
  visit(document)
66
+ // A BARE-STRING reference (`info.preview`, `seo.image`, a section param) has no
67
+ // object to carry identity beside it. The map recognizes it by the fingerprint the
68
+ // push recorded for its serve URL — see `servedFingerprint` in @uniweb/build/uwx.
69
+ const byServed = new Map()
70
+ for (const v of Object.values(map || {})) {
71
+ if (v?.served && v.id && !byServed.has(v.served)) byServed.set(v.served, v)
72
+ }
73
+ if (byServed.size) {
74
+ const visitStrings = (node) => {
75
+ if (typeof node === 'string') {
76
+ if (!node.startsWith('/') && !/^https?:\/\//i.test(node)) return
77
+ const entry = byServed.get(servedFingerprint(node))
78
+ if (entry && !found.has(entry.id))
79
+ found.set(entry.id, { id: entry.id, ext: entry.ext || '', url: node })
80
+ return
81
+ }
82
+ if (Array.isArray(node)) return node.forEach(visitStrings)
83
+ if (node && typeof node === 'object')
84
+ for (const v of Object.values(node)) visitStrings(v)
85
+ }
86
+ visitStrings(document)
87
+ }
62
88
  return [...found.values()]
63
89
  }
64
90
 
@@ -89,11 +115,12 @@ export async function downloadMissingAssets({
89
115
  warn = () => {}
90
116
  }) {
91
117
  const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
92
- const refs = collectAssetRefs(document)
118
+ // Read first: a bare-string reference is only recognizable through the map.
119
+ const map = readAssetMap(siteDir)
120
+ const refs = collectAssetRefs(document, map)
93
121
  const out = { downloaded: [], present: [], failed: [], skipped: [] }
94
122
  if (!refs.length) return out
95
123
 
96
- const map = readAssetMap(siteDir)
97
124
  const byId = new Map()
98
125
  for (const [ref, v] of Object.entries(map)) if (v?.id) byId.set(v.id, ref)
99
126
 
@@ -16,6 +16,7 @@ import { createHash } from 'node:crypto'
16
16
  import { existsSync, readFileSync } from 'node:fs'
17
17
  import { basename } from 'node:path'
18
18
  import { resolveAssetPath } from '@uniweb/build/site'
19
+ import { servedFingerprint } from '@uniweb/build/uwx'
19
20
  import { contentTypeFor } from '../utils/code-upload.js'
20
21
  import { humanBytes } from '../utils/bytes.js'
21
22
 
@@ -79,7 +80,17 @@ export async function uploadSiteMedia(
79
80
  for (const ref of refs) {
80
81
  const entry = result.assetsByLocalUrl[ref]
81
82
  if (!entry) continue
82
- if (entry.id) ids[ref] = { id: entry.id, ext: entry.ext || '' }
83
+ // `served` a fingerprint of the address, never the address. It is how a pull
84
+ // recognizes this asset where the reference is a bare string with no room for an
85
+ // id beside it (`info.preview`, `seo.image`, a section param); `assets.json`'s
86
+ // header says why a hash and not the URL.
87
+ if (entry.id) {
88
+ ids[ref] = {
89
+ id: entry.id,
90
+ ext: entry.ext || '',
91
+ ...(entry.serveUrl ? { served: servedFingerprint(entry.serveUrl) } : {})
92
+ }
93
+ }
83
94
  // The backend's canonical serve URL, READ — never composed. An entry without
84
95
  // one is an asset we cannot address, and inventing a location for it is the
85
96
  // exact failure this lane exists to avoid: a guessed host is SILENTLY wrong,
@@ -29,7 +29,8 @@ import {
29
29
  collectUnitUuids,
30
30
  collectFolderItemUuids,
31
31
  collectQueryUuids,
32
- readAssetMap
32
+ readAssetMap,
33
+ removeYamlScalar
33
34
  } from '@uniweb/build/uwx'
34
35
 
35
36
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -323,6 +324,51 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
323
324
  return stale
324
325
  }
325
326
 
327
+ /**
328
+ * Drop the `site.yml` values that describe ONE PARTICULAR backend site, when this
329
+ * project is bound to none (no `$uuid`) — a brand-new site, or the state our own
330
+ * "clear `$uuid` to re-publish as a new site" recovery puts you in:
331
+ *
332
+ * · `$url` — where the PREVIOUS site was live. The new one is not live anywhere
333
+ * yet; its first publish records its own.
334
+ * · `preview` in the APP's form — a timestamp naming the previous site's generated
335
+ * card image, which is keyed by that site's uuid and would dangle on this one.
336
+ *
337
+ * ⛔ An AUTHOR's preview is theirs and stays: a URL, or a path to an image in the
338
+ * project, is as good for the new site as for the old.
339
+ *
340
+ * The site.yml sibling of `clearRemoteSyncStateIfUnbound`, called beside it for the
341
+ * same reason — before the create mints a uuid and makes the project look bound.
342
+ *
343
+ * @param {string} siteDir
344
+ * @returns {string[]} the keys removed
345
+ */
346
+ export function dropSiteBoundValues(siteDir) {
347
+ const file = join(siteDir, 'site.yml')
348
+ let y
349
+ try {
350
+ y = yaml.load(readFileSync(file, 'utf8'))
351
+ } catch {
352
+ return []
353
+ }
354
+ if (!y || typeof y !== 'object' || typeof y.$uuid === 'string') return []
355
+ const dropped = []
356
+ if (y.$url !== undefined && removeYamlScalar(file, '$url')) dropped.push('$url')
357
+ if (
358
+ y.preview !== undefined &&
359
+ !isAuthoredPreview(y.preview) &&
360
+ removeYamlScalar(file, 'preview')
361
+ ) {
362
+ dropped.push('preview')
363
+ }
364
+ return dropped
365
+ }
366
+
367
+ // An author's preview is an address — a URL, or a site-root / relative path. Anything
368
+ // else is the app's generated-image token.
369
+ const isAuthoredPreview = (v) =>
370
+ typeof v === 'string' && (/^https?:\/\//i.test(v) || /^\.{0,2}\//.test(v))
371
+
326
372
  export function readSyncCache(siteDir) {
327
373
  return readMap(siteDir, 'hashes')
328
374
  }
@@ -87,6 +87,7 @@ import {
87
87
  readFolderItemUuids,
88
88
  ensureSiteExists,
89
89
  clearRemoteSyncStateIfUnbound,
90
+ dropSiteBoundValues,
90
91
  pushSyncPackages,
91
92
  resolveSiteOrgForCreate
92
93
  } from '../backend/site-sync.js'
@@ -578,6 +579,12 @@ export async function publish(args = []) {
578
579
  `Cleared stale sync state from a previous site (${droppedState.join(', ')}).`
579
580
  )
580
581
  }
582
+ const droppedValues = dropSiteBoundValues(siteDir)
583
+ if (droppedValues.length) {
584
+ say.dim(
585
+ `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
586
+ )
587
+ }
581
588
 
582
589
  // 3b. Make sure the SITE EXISTS before a single byte is uploaded.
583
590
  //
@@ -1106,6 +1113,11 @@ export async function publish(args = []) {
1106
1113
  say.dim(` site.yml lists ${unserved.join(', ')}, and ${unserved.length === 1 ? 'it was' : 'they were'} not published.`)
1107
1114
  }
1108
1115
  if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
1116
+ // ⛔ No site.yml write for where it went live. The backend records the reply's
1117
+ // `url` on `info.url` at every publish, and pull brings it into `site.yml::$url`
1118
+ // like any other key. A copy written here was a second writer that had to match
1119
+ // the backend's byte for byte (the address printed above is made absolute, so it
1120
+ // did not), and it left site.yml modified — which a plain `pull` refuses over.
1109
1121
  if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
1110
1122
  return { exitCode: 0 }
1111
1123
  }
@@ -89,6 +89,7 @@ import {
89
89
  ensureItemUuids,
90
90
  ensureSiteExists,
91
91
  clearRemoteSyncStateIfUnbound,
92
+ dropSiteBoundValues,
92
93
  pushSyncPackages,
93
94
  resolveSiteOrgForCreate
94
95
  } from '../backend/site-sync.js'
@@ -303,6 +304,10 @@ export async function push(args = [], deps = {}) {
303
304
  `Cleared stale sync state from a previous site (${dropped.join(', ')}).`
304
305
  )
305
306
  }
307
+ const stale = dropSiteBoundValues(siteDir)
308
+ if (stale.length) {
309
+ note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
310
+ }
306
311
  }
307
312
  const priorHashes = readSyncCache(siteDir)
308
313
  // Per-item identity, without which the backend reads every record as new and
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-10T16:24:13.926Z",
3
+ "generatedAt": "2026-09-11T18:41:11.718Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.3.1",
6
+ "version": "0.3.2",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.44.4",
13
+ "version": "0.45.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -34,7 +34,7 @@
34
34
  "deps": []
35
35
  },
36
36
  "@uniweb/core": {
37
- "version": "0.24.4",
37
+ "version": "0.25.0",
38
38
  "path": "framework/core",
39
39
  "deps": [
40
40
  "@uniweb/semantic-parser",
@@ -47,14 +47,14 @@
47
47
  "deps": []
48
48
  },
49
49
  "@uniweb/icons": {
50
- "version": "0.4.15",
50
+ "version": "0.4.16",
51
51
  "path": "framework/icons",
52
52
  "deps": [
53
53
  "@uniweb/core"
54
54
  ]
55
55
  },
56
56
  "@uniweb/kit": {
57
- "version": "0.18.2",
57
+ "version": "0.18.3",
58
58
  "path": "framework/kit",
59
59
  "deps": [
60
60
  "@uniweb/core",
@@ -74,7 +74,7 @@
74
74
  "deps": []
75
75
  },
76
76
  "@uniweb/projections": {
77
- "version": "0.6.0",
77
+ "version": "0.6.1",
78
78
  "path": "framework/projections",
79
79
  "deps": [
80
80
  "@uniweb/content-writer",
@@ -82,7 +82,7 @@
82
82
  ]
83
83
  },
84
84
  "@uniweb/runtime": {
85
- "version": "0.19.5",
85
+ "version": "0.20.0",
86
86
  "path": "framework/runtime",
87
87
  "deps": [
88
88
  "@uniweb/core",
@@ -110,7 +110,7 @@
110
110
  "deps": []
111
111
  },
112
112
  "@uniweb/templates": {
113
- "version": "0.12.4",
113
+ "version": "0.12.5",
114
114
  "path": "framework/templates",
115
115
  "deps": []
116
116
  },