uniweb 0.78.0 → 0.79.1

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.78.0",
3
+ "version": "0.79.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,17 +41,17 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "^0.28.3",
45
44
  "@uniweb/kit": "^0.19.11",
45
+ "@uniweb/schemas": "^0.9.2",
46
46
  "@uniweb/core": "^0.35.0",
47
+ "@uniweb/runtime": "^0.28.3",
47
48
  "@uniweb/content-writer": "^0.3.4",
48
- "@uniweb/semantic-parser": "^1.4.1",
49
- "@uniweb/schemas": "^0.9.1"
49
+ "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@uniweb/content-reader": "^1.2.7",
53
53
  "@uniweb/semantic-parser": "^1.4.1",
54
- "@uniweb/build": "^0.72.0"
54
+ "@uniweb/build": "^0.73.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -866,6 +866,10 @@ site/
866
866
 
867
867
  Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records/folder.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
868
868
 
869
+ **Links are records too — `@uniweb/link`.** A file in `records/uniweb/link/` holding `url:` and nothing else is a link record (one per file; no body, no other fields). Its display text and tags go on its `records/folder.yml` entry (`- path: uniweb/link/launch-video.yml` with `label:` / `tags:`), like any record's. Select links with a query `schema: '@uniweb/link'` and declare them in `meta.js` as `data: { videos: '@uniweb/link' }`; each arrives as `{ url, $name, $label, $tags }`. A link's `url` is absolute (`https://…`), and `uniweb push` refuses a relative one and a `draft: true` link.
870
+
871
+ **Files are records too — `@uniweb/file`.** The file itself is the record: any file dropped into `records/uniweb/file/` (a PDF, a spreadsheet, any type) is a record named by its file name without the extension; nothing in it is read. Its label and tags go on its `records/folder.yml` entry (`- path: uniweb/file/brochure.pdf`). Query with `schema: '@uniweb/file'`, declare with `data: { downloads: '@uniweb/file' }`; each arrives as `{ file: { url, name, mime, size }, $name, $label, $tags }` — link to `file.url`. `uniweb push` uploads the file with the site's media; a pull fetches it back. Two files of one name with different extensions clash — rename one. On a site that goes to a backend, a component must declare the type (`@uniweb/link` or `@uniweb/file`) for a query over it to be accepted.
872
+
869
873
  **Four formats, one shape.** All of these produce the same records at runtime:
870
874
 
871
875
  | Format | Best for | Notes |
@@ -907,7 +911,7 @@ The body — the value of the schema's content field, `body.content`.
907
911
  - article/2025-*.md
908
912
  ```
909
913
 
910
- A path under a folder is relative to `records/`, naming one file or matching many. A record's entry can instead be `{ path, tags, label }` — what the folder says about the record: a component receives them beside it as `$tags` (in the order written) and `$label`, and a query reads them like any key (`where: { $tags: featured }`); such an entry may sit at the top level, where a bare path is refused. A folder takes `tags:` too. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
914
+ A path under a folder is relative to `records/`, naming one file or matching many. A record's entry can instead be `{ path, tags, label }` — what the folder says about the record: a component receives them beside it as `$tags` (in the order written) and `$label`, and a query reads them like any key (`where: { $tags: featured }`); such an entry may sit at the top level, where a bare path is refused. A `label` — a record entry's or a folder's — is one text in the site's language, or one per language (`label: { en: Launch video, fr: Vidéo de lancement }`); `$label` is the page's language, else its base language, else the site's. A folder takes `tags:` too. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
911
915
 
912
916
  ⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
913
917
 
@@ -171,3 +171,52 @@ export async function downloadMissingAssets({
171
171
  updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
172
172
  return out
173
173
  }
174
+
175
+ /**
176
+ * Fetch the files a pull's file records name (`@uniweb/file`) to where each lands in
177
+ * `records/uniweb/file/` — the list `recordsToProject` returns as `fileDownloads` — and record each
178
+ * one's asset under its key (`sync.json` `assets`), as a push records the upload, so the next pull
179
+ * knows the bytes on disk are that asset and fetches nothing.
180
+ *
181
+ * @param {object} opts
182
+ * @param {Array<{ url: string, path: string, ref: string, assetId?: string, assetExt?: string }>} opts.downloads
183
+ * @param {string} opts.siteDir
184
+ * @param {string} opts.origin - backend origin, to resolve an origin-relative URL
185
+ * @param {typeof fetch} [opts.fetchImpl]
186
+ * @param {(m: string) => void} [opts.onProgress]
187
+ * @param {(m: string) => void} [opts.warn]
188
+ * @returns {Promise<{ downloaded: string[], failed: string[] }>} paths
189
+ */
190
+ export async function downloadFileRecords({
191
+ downloads = [],
192
+ siteDir,
193
+ origin,
194
+ fetchImpl,
195
+ onProgress = () => {},
196
+ warn = () => {}
197
+ }) {
198
+ const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
199
+ const out = { downloaded: [], failed: [] }
200
+ const learned = {}
201
+ for (const d of downloads) {
202
+ try {
203
+ const res = await doFetch(new URL(d.url, origin).href)
204
+ if (!res.ok) {
205
+ warn(`file ${d.ref}: HTTP ${res.status} — not fetched`)
206
+ out.failed.push(d.path)
207
+ continue
208
+ }
209
+ const bytes = Buffer.from(await res.arrayBuffer())
210
+ mkdirSync(dirname(d.path), { recursive: true })
211
+ writeFileSync(d.path, bytes)
212
+ onProgress(`↓ ${d.ref}`)
213
+ out.downloaded.push(d.path)
214
+ if (d.assetId) learned[d.ref] = { id: d.assetId, ext: d.assetExt || '' }
215
+ } catch (err) {
216
+ warn(`file ${d.ref}: ${err.message} — not fetched`)
217
+ out.failed.push(d.path)
218
+ }
219
+ }
220
+ if (Object.keys(learned).length) updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
221
+ return out
222
+ }
@@ -24,7 +24,8 @@ import { humanBytes } from '../utils/bytes.js'
24
24
  * @param {object} client - BackendClient (uploadSiteAssets). No `discover` — this
25
25
  * lane stopped consulting the capability doc when `assetBase` was removed.
26
26
  * @param {string} siteDir - the site root (site-root refs resolve under public/)
27
- * @param {string[]} refs - site-root local asset refs (`/images/x.png`)
27
+ * @param {Array<string|{ ref: string, path: string, contentType?: string }>} refs - site-root local
28
+ * asset refs (`/images/x.png`), or files already located (a file record's)
28
29
  * @param {{ siteUuid?: string|null, onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
29
30
  * `siteUuid` is the owner the uploaded bytes are charged to. Callers create the
30
31
  * site before uploading precisely so this is set — an unowned upload is charged
@@ -47,8 +48,15 @@ export async function uploadSiteMedia(
47
48
 
48
49
  const files = []
49
50
  const missing = []
50
- for (const ref of refs) {
51
- const { resolved } = resolveAssetPath(ref, siteDir, siteDir)
51
+ // ⭐ A ref is a site-root media path (`/images/x.png`), resolved under the site — or a file the
52
+ // caller already located, `{ ref, path, contentType }`: a file record's file and its preview, which
53
+ // live in the records directory and declare their own type (`@uniweb/file`, 2026-09-28).
54
+ const keys = []
55
+ for (const item of refs) {
56
+ const ref = typeof item === 'string' ? item : item?.ref
57
+ if (typeof ref !== 'string' || !ref) continue
58
+ keys.push(ref)
59
+ const resolved = typeof item === 'string' ? resolveAssetPath(ref, siteDir, siteDir).resolved : item.path
52
60
  if (!resolved || !existsSync(resolved)) {
53
61
  warn?.(`local-media: ${ref} not found under the site (skipped)`)
54
62
  missing.push(ref)
@@ -57,7 +65,7 @@ export async function uploadSiteMedia(
57
65
  const bytes = readFileSync(resolved)
58
66
  files.push({
59
67
  path: ref.replace(/^\/+/, ''), // bookkeeping key into the plan (must be unique)
60
- content_type: contentTypeFor(basename(resolved)),
68
+ content_type: (typeof item === 'object' && item.contentType) || contentTypeFor(basename(resolved)),
61
69
  size: bytes.length,
62
70
  sha256: createHash('sha256').update(bytes).digest('hex'),
63
71
  localUrl: ref, // the rewrite key — the original content ref
@@ -77,7 +85,7 @@ export async function uploadSiteMedia(
77
85
  // dedup skip, so a re-push of unchanged media still records identity without
78
86
  // moving bytes — which is what makes the committed map cheap to keep accurate.
79
87
  const ids = {}
80
- for (const ref of refs) {
88
+ for (const ref of keys) {
81
89
  const entry = result.assetsByLocalUrl[ref]
82
90
  if (!entry) continue
83
91
  // `served` — a fingerprint of the address, never the address. It is how a pull
@@ -49,7 +49,10 @@ import {
49
49
  matchStoredRecords,
50
50
  parseCatalogRef,
51
51
  readRegisteredFoundation,
52
- writeRegisteredFoundation
52
+ writeRegisteredFoundation,
53
+ backfillLinkUuids,
54
+ LINK_MODEL,
55
+ bankedFileUuids
53
56
  } from '@uniweb/build/uwx'
54
57
 
55
58
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -1419,7 +1422,9 @@ export async function recoverUnbankedIdentity({ client, siteDir, pkg, note }) {
1419
1422
  }
1420
1423
  // The records the package sends, and the folder placing them. The emit reads the `records`
1421
1424
  // map from sync.json itself, so it needs no option — only the build again.
1422
- if (await recoverRecordsIdentity({ client, siteDir, index: pkg?.records?.index, note })) {
1425
+ // Link records are matched as records are — by Model and name (`@uniweb/link`, `link-records.js`).
1426
+ const links = (pkg?.records?.links || []).map((l) => ({ ownId: l.ownId, model: LINK_MODEL, slug: l.slug }))
1427
+ if (await recoverRecordsIdentity({ client, siteDir, index: [...(pkg?.records?.index || []), ...links], note })) {
1423
1428
  again = {
1424
1429
  ...again,
1425
1430
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
@@ -1754,7 +1759,7 @@ export async function pushSyncPackages({
1754
1759
  // Two unrelated conflicts share HTTP 409, so branch on the machine-readable
1755
1760
  // `reason` — never on `detail`, which is prose the backend may reword.
1756
1761
  let problem = null
1757
- if ((res.status === 409 || res.status === 400) && body) {
1762
+ if ((res.status === 409 || res.status === 400 || res.status === 422) && body) {
1758
1763
  try {
1759
1764
  problem = JSON.parse(body)
1760
1765
  } catch {
@@ -1934,6 +1939,15 @@ export async function pushSyncPackages({
1934
1939
  ` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
1935
1940
  )
1936
1941
  note('Deleting this folder removes only your local copy, either way.')
1942
+ } else if (problem?.reason === 'template_records_not_copyable' && Array.isArray(problem.records)) {
1943
+ // ⭐ A push asking to designate the site a template is refused whole when a record in its
1944
+ // folder cannot be copied into the sites made from it — each named, with a `detail` written
1945
+ // to be shown. Until 2026-09-28 this printed the raw problem document, cut at 800 characters.
1946
+ note(problem.title || 'These records cannot be copied into a template:')
1947
+ for (const r of problem.records) {
1948
+ note(` ${r?.name ?? '(unnamed)'} — ${r?.detail || r?.reason || 'no reason given'}`)
1949
+ }
1950
+ return null
1937
1951
  } else if (res.status === 409 && (problem?.title || problem?.detail)) {
1938
1952
  // ⛔ A 409 names its own cause — a reference the store refuses, a rule of the record's type,
1939
1953
  // an append-only section — so it is shown as the backend put it, not explained by us. Until
@@ -2240,6 +2254,20 @@ export async function pushSyncPackages({
2240
2254
  const placements = collectFolderItemUuids(folderDoc)
2241
2255
  if (Object.keys(placements).length)
2242
2256
  writeFolderItemUuids(siteDir, client.origin, placements)
2257
+ // ⭐ A LINK RECORD'S IDENTITY is its folder entry's `$uuid`, banked as a record's is: into
2258
+ // its file the first time, then mapped per backend (`link-records.js`, 2026-09-28).
2259
+ if (records.links?.length) {
2260
+ const lb = backfillLinkUuids({ links: records.links, folderDoc })
2261
+ if (Object.keys(lb.mapped).length) updateBackendMap(siteDir, client.origin, 'records', lb.mapped)
2262
+ for (const w of lb.warnings) note(`! ${w}`)
2263
+ if (lb.updated.length) wrote.push(`wrote ${lb.updated.length} link file(s)`)
2264
+ }
2265
+ // ⭐ …and a FILE RECORD's: a file cannot carry it, so it is banked per backend by the file's
2266
+ // path (`sync.json` `files`; `file-records.js`, 2026-09-28).
2267
+ if (records.files?.length) {
2268
+ const fb = bankedFileUuids({ files: records.files, folderDoc })
2269
+ if (Object.keys(fb).length) updateBackendMap(siteDir, client.origin, 'files', fb)
2270
+ }
2243
2271
  }
2244
2272
  finalizedTotal += finalized.length
2245
2273
  }
@@ -656,7 +656,8 @@ export async function publish(args = []) {
656
656
  // the ref→serveUrl map; rewrite the entity content AND the ball with it.
657
657
  let assetRewrite = null
658
658
  let assetIds = null
659
- const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
659
+ // …and each file record's file (`@uniweb/file` — `{ ref, path, contentType }`, already located).
660
+ const mediaRefs = [...new Set([...localAssets, ...ballAssets]), ...(probe.localFiles || [])]
660
661
  if (mediaRefs.length) {
661
662
  say.info('Uploading media…')
662
663
  try {
@@ -69,7 +69,7 @@ import { createHash } from 'node:crypto'
69
69
  import { createInterface } from 'node:readline/promises'
70
70
  import { join, dirname, relative, resolve } from 'node:path'
71
71
  import yaml from 'js-yaml'
72
- import { downloadMissingAssets } from '../backend/asset-download.js'
72
+ import { downloadMissingAssets, downloadFileRecords } from '../backend/asset-download.js'
73
73
  import {
74
74
  readBackendState,
75
75
  siteContentDocumentToProject,
@@ -1068,6 +1068,25 @@ export async function pull(args = [], deps = {}) {
1068
1068
  removed.push(resolve(siteDir, report.recordsFile))
1069
1069
  }
1070
1070
  records += report.placed.length + report.updated.length
1071
+ // ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
1072
+ // places each one, as media are fetched. A file that did not arrive is a record not taken —
1073
+ // reported, and the pull fails below as it does for any, so the next one fetches it again.
1074
+ if (report.fileDownloads?.length) {
1075
+ const got = await downloadFileRecords({
1076
+ downloads: report.fileDownloads,
1077
+ siteDir,
1078
+ origin: client.origin,
1079
+ onProgress: (m) => note(` ${m}`),
1080
+ warn: (m) => note(`! ${m}`)
1081
+ })
1082
+ records += got.downloaded.length
1083
+ wrote.push(...got.downloaded)
1084
+ if (got.failed.length) {
1085
+ report.skipped.push(
1086
+ ...got.failed.map((path) => ({ slug: relative(siteDir, path), reason: 'its file could not be fetched' }))
1087
+ )
1088
+ }
1089
+ }
1071
1090
  // ⛔ A PULL THAT DID NOT PLACE A RECORD HAS NOT TAKEN THE BACKEND'S RECORDS.
1072
1091
  // Until 2026-09-23 each skip was a dim note, the pull said "✓ Pulled … 0
1073
1092
  // record(s)" and exited 0, and it banked the lane's ETag and tokens. So the next
@@ -409,7 +409,8 @@ export async function push(args = [], deps = {}) {
409
409
  ...(foundationDir ? { foundationDir } : {}),
410
410
  resolveModel: makeModelResolver({ client, offline: false, siteDir })
411
411
  })
412
- mediaRefs = probe.localAssets || []
412
+ // Site-root media, and each file record's file (`@uniweb/file` — `{ ref, path, contentType }`).
413
+ mediaRefs = [...(probe.localAssets || []), ...(probe.localFiles || [])]
413
414
  refusals = probe.refusals || []
414
415
  } catch (err) {
415
416
  error(`Could not scan the site for local media: ${err.message}`)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-28T12:54:31.686Z",
3
+ "generatedAt": "2026-09-28T21:05:39.664Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.8",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.72.0",
13
+ "version": "0.73.1",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -97,7 +97,7 @@
97
97
  "deps": []
98
98
  },
99
99
  "@uniweb/schemas": {
100
- "version": "0.9.1",
100
+ "version": "0.9.2",
101
101
  "path": "framework/schemas",
102
102
  "deps": []
103
103
  },
@@ -127,7 +127,7 @@
127
127
  "deps": []
128
128
  },
129
129
  "@uniweb/unipress": {
130
- "version": "0.10.15",
130
+ "version": "0.10.16",
131
131
  "path": "framework/unipress",
132
132
  "deps": [
133
133
  "@uniweb/build",
@@ -4,7 +4,9 @@ dist
4
4
  .DS_Store
5
5
  *.local
6
6
 
7
- # Compiled collections. Generated from collections/ on every build — it lands
8
- # in the source tree rather than dist/, so without this it looks permanent,
9
- # gets committed, and then looks like something you may edit. It isn't.
7
+ # Generated from records/ on every build — compiled queries in public/data/, and
8
+ # each record's files, published in public/records/. Both land in the source tree
9
+ # rather than dist/, so without this they look permanent, get committed, and then
10
+ # look like something you may edit. They aren't: records live in records/.
10
11
  public/data/
12
+ public/records/