uniweb 0.69.4 → 0.70.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.69.4",
3
+ "version": "0.70.0",
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.27.4",
45
- "@uniweb/schemas": "^0.7.0",
46
44
  "@uniweb/content-writer": "^0.3.4",
47
- "@uniweb/core": "^0.29.8",
48
- "@uniweb/kit": "^0.19.4",
45
+ "@uniweb/core": "^0.30.0",
46
+ "@uniweb/runtime": "^0.27.5",
47
+ "@uniweb/kit": "^0.19.5",
48
+ "@uniweb/schemas": "^0.7.0",
49
49
  "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
+ "@uniweb/build": "^0.64.0",
52
53
  "@uniweb/content-reader": "^1.2.5",
53
- "@uniweb/semantic-parser": "^1.4.1",
54
- "@uniweb/build": "^0.63.3"
54
+ "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -2717,6 +2717,8 @@ Running `extract` before a build is the usual first mistake — it reads the com
2717
2717
  { "e5f6g7h8": { "default": "Learn More", "overrides": { "/pricing:cta": "See Pricing" } } }
2718
2718
  ```
2719
2719
 
2720
+ An override key is the page's route and the section's id (`cta` for `2-cta.md`); a page's own title, label, description and keywords are `/pricing:_meta`, and in `locales/records/` a record is named by its manifest context (`std/article/launch`).
2721
+
2720
2722
  *Free-form bodies* — when a language needs different structure, different images, or copy rewritten rather than translated. The file replaces one section's content:
2721
2723
 
2722
2724
  ```
@@ -2726,7 +2728,7 @@ locales/freeform/es/records/article/x.md # records work too
2726
2728
 
2727
2729
  These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
2728
2730
 
2729
- **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it.
2731
+ **Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it. A record whose query has a data schema translates exactly the fields the schema says are text — never an enum, a URL, a date, a number, a file, a reference or a `translatable: false` field — which are the fields a push carries in each language; mark a string that is a code or a key `translatable: false` and no translator is asked for it. An excerpt the build derives is re-derived from each language's body.
2730
2732
 
2731
2733
  **Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
2732
2734
 
@@ -45,7 +45,8 @@ import {
45
45
  removeYamlScalar,
46
46
  harvestRecordItems,
47
47
  storedRecordItems,
48
- reprintRecordItems
48
+ reprintRecordItems,
49
+ matchStoredRecords
49
50
  } from '@uniweb/build/uwx'
50
51
 
51
52
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -878,6 +879,64 @@ export async function recoverRecordItemUuids({ client, siteDir, uuids, note }) {
878
879
  }
879
880
  }
880
881
 
882
+ /**
883
+ * Recover the records lane's identity this copy lacks for the site it is bound to — which stored
884
+ * record each of its records is (`records`), each placement's in the folder (`folders`), and the
885
+ * list items of the records it matches (`recordItems`) — from one read of the backend's folder
886
+ * lane. What the copy already holds is kept.
887
+ *
888
+ * ⛔ Until 2026-09-25 nothing recovered these, and a copy whose `sync.json` had lost them — a
889
+ * blog site whose push was refused after its content landed, with an older CLI — was refused on
890
+ * every push after: its folder sent three placements with no `$uuid` while three were stored
891
+ * ("one section carries no item identity (section contents)"). Unrefused, its records would have
892
+ * gone up with none, and the backend would have made a second entity of each.
893
+ *
894
+ * Reads only when the package names a record the map does not, or the folder map is empty; a
895
+ * record new to the backend costs that read and finds nothing (`matchStoredRecords`).
896
+ *
897
+ * @param {object} params
898
+ * @param {object[]} params.index - the records the package sends (`pkg.records.index`)
899
+ * @returns {Promise<number>} how many records and placements were recovered
900
+ */
901
+ export async function recoverRecordsIdentity({ client, siteDir, index, note }) {
902
+ const state = readBackendState(siteDir, client.origin)
903
+ const siteUuid = state.site?.uuid || null
904
+ const recordMap = state.records || {}
905
+ const records = (index || []).filter((e) => e?.ownId && e.model && e.slug)
906
+ const folders = readFolderItemUuids(siteDir, client.origin)
907
+ const unmapped = records.some((e) => !recordMap[e.ownId])
908
+ if (!siteUuid || !records.length || (!unmapped && Object.keys(folders).length)) return 0
909
+ try {
910
+ const res = await client.pullFolder(siteUuid)
911
+ if (!res?.ok) return 0
912
+ const docs = entityDocsFromUwx(Buffer.from(await res.arrayBuffer()))
913
+ const folderDoc = docs.find((d) => d?.$schema === '@uniweb/folder')
914
+ if (!folderDoc) return 0
915
+ const learned = matchStoredRecords({ index: records, recordMap, folderDoc })
916
+ const matched = Object.keys(learned).length
917
+ if (matched) {
918
+ updateBackendMap(siteDir, client.origin, 'records', learned)
919
+ const stored = new Map(docs.map((d) => [d?.$uuid, d]))
920
+ const items = {}
921
+ for (const theirs of Object.values(learned)) {
922
+ const doc = stored.get(theirs)
923
+ if (doc) items[theirs] = storedRecordItems(doc)
924
+ }
925
+ writeRecordItemUuids(siteDir, client.origin, items)
926
+ }
927
+ const placements = collectFolderItemUuids(folderDoc)
928
+ const missing = Object.fromEntries(Object.entries(placements).filter(([k]) => !folders[k]))
929
+ writeFolderItemUuids(siteDir, client.origin, missing)
930
+ const placed = Object.keys(missing).length
931
+ if (matched || placed) {
932
+ note?.(`Recovered the identity of ${matched} record(s) and ${placed} folder placement(s) from the backend.`)
933
+ }
934
+ return matched + placed
935
+ } catch {
936
+ return 0
937
+ }
938
+ }
939
+
881
940
  /**
882
941
  * The workspace this site was created in on `backend` — its owner, as the create
883
942
  * answered it — in the header's form: `@handle`, a unit's bare uuid when that unit has
@@ -1263,8 +1322,9 @@ export async function recoverItemUuids({ client, siteDir, note }) {
1263
1322
 
1264
1323
  /**
1265
1324
  * Recover the identity a package just built went out without, when the backend may hold it —
1266
- * a page or section (`itemIdentity.unknown`), or a record's list items
1267
- * (`recordItemIdentity.unbanked`) — and say what to build the package again with.
1325
+ * a page or section (`itemIdentity.unknown`), a record or a folder placement
1326
+ * (`recoverRecordsIdentity`), or a record's list items (`recordItemIdentity.unbanked`) — and say
1327
+ * what to build the package again with.
1268
1328
  *
1269
1329
  * A page added since the last push is also without identity, and costs one read that finds
1270
1330
  * nothing. That is the price of never sending as new an item the backend already holds.
@@ -1277,6 +1337,15 @@ export async function recoverUnbankedIdentity({ client, siteDir, pkg, note }) {
1277
1337
  if (pkg?.itemIdentity?.unknown > 0 && (await recoverItemUuids({ client, siteDir, note }))) {
1278
1338
  again = { ...again, itemUuids: readItemUuids(siteDir, client.origin) }
1279
1339
  }
1340
+ // The records the package sends, and the folder placing them. The emit reads the `records`
1341
+ // map from sync.json itself, so it needs no option — only the build again.
1342
+ if (await recoverRecordsIdentity({ client, siteDir, index: pkg?.records?.index, note })) {
1343
+ again = {
1344
+ ...again,
1345
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
1346
+ recordItemUuids: readRecordItemUuids(siteDir, client.origin)
1347
+ }
1348
+ }
1280
1349
  const unbanked = pkg?.recordItemIdentity?.unbanked
1281
1350
  if (unbanked?.length && (await recoverRecordItemUuids({ client, siteDir, uuids: unbanked, note }))) {
1282
1351
  again = { ...again, recordItemUuids: readRecordItemUuids(siteDir, client.origin) }
@@ -668,7 +668,10 @@ async function buildSiteLink(projectDir, options = {}) {
668
668
  projectDir,
669
669
  i18nConfig.localesDir,
670
670
  'records'
671
- )
671
+ ),
672
+ // Free-form record translations live under it — the default was `locales/`
673
+ // whatever the site said.
674
+ localesDir: join(projectDir, i18nConfig.localesDir)
672
675
  })
673
676
  const recordCount = Object.values(recordOutputs).reduce(
674
677
  (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
@@ -789,7 +792,10 @@ async function buildSite(projectDir, options = {}) {
789
792
  projectDir,
790
793
  i18nConfig.localesDir,
791
794
  'records'
792
- )
795
+ ),
796
+ // Free-form record translations live under it — the default was `locales/`
797
+ // whatever the site said.
798
+ localesDir: join(projectDir, i18nConfig.localesDir)
793
799
  })
794
800
 
795
801
  // Count collections translated
@@ -37,9 +37,9 @@
37
37
  * uniweb publish --no-save Do not record this publish in deploy.yml
38
38
  */
39
39
 
40
- import { existsSync, readFileSync } from 'node:fs'
40
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
41
41
  import { readFile } from 'node:fs/promises'
42
- import { join } from 'node:path'
42
+ import { join, dirname } from 'node:path'
43
43
  import { execSync } from 'node:child_process'
44
44
  import yaml from 'js-yaml'
45
45
 
@@ -143,14 +143,27 @@ function readSiteYml(path) {
143
143
  }
144
144
  }
145
145
 
146
- // Languages from the BUILT site-content.json (config.languages) — the authority
147
- // after a build. Three accepted shapes: 'en', { value, label }, { code, label }.
148
- function languagesFromContent(siteContent) {
146
+ // Languages from the BUILT site — the authority after a build: `config.languages` where
147
+ // the site declares them (three accepted shapes: 'en', { value, label }, { code, label }),
148
+ // else its default and each language the build produced (`dist/<locale>/site-content.json`).
149
+ // ⛔ Until 2026-09-26 a site that declares no `languages:` — its languages made by its
150
+ // translation files — went live as `['en']`, though the build had produced the others.
151
+ export function languagesFromContent(siteContent, distDir = null) {
149
152
  const langs = siteContent?.config?.languages
150
- if (!Array.isArray(langs) || langs.length === 0) return ['en']
151
- return langs
152
- .map((l) => (typeof l === 'string' ? l : l?.value || l?.code))
153
- .filter(Boolean)
153
+ if (Array.isArray(langs) && langs.length > 0) {
154
+ return langs
155
+ .map((l) => (typeof l === 'string' ? l : l?.value || l?.code))
156
+ .filter(Boolean)
157
+ }
158
+ const source = siteContent?.config?.defaultLanguage || 'en'
159
+ const built =
160
+ distDir && existsSync(distDir)
161
+ ? readdirSync(distDir, { withFileTypes: true })
162
+ .filter((e) => e.isDirectory() && e.name !== source && existsSync(join(distDir, e.name, 'site-content.json')))
163
+ .map((e) => e.name)
164
+ .sort()
165
+ : []
166
+ return [source, ...built]
154
167
  }
155
168
 
156
169
  // Languages from site.yml — used only for the dry-run summary (no build yet).
@@ -1005,7 +1018,7 @@ export async function publish(args = []) {
1005
1018
 
1006
1019
  // 7. Go live — make the just-pushed composite live (its current backend state).
1007
1020
  const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
1008
- const languages = languagesFromContent(siteContent)
1021
+ const languages = languagesFromContent(siteContent, dirname(contentPath))
1009
1022
  say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
1010
1023
  let pubRes
1011
1024
  try {
@@ -684,6 +684,8 @@ export async function pull(args = [], deps = {}) {
684
684
  args,
685
685
  command: 'Pulling'
686
686
  })
687
+ // One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
688
+ const readModel = makeModelResolver({ client })
687
689
 
688
690
  // ⭐ No scope check any more — and none is needed. This project's identity on
689
691
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -942,9 +944,23 @@ export async function pull(args = [], deps = {}) {
942
944
  // which is the pre-existing behaviour and right for a fresh clone.
943
945
  }
944
946
 
947
+ // ⭐ The Models the site's queries name — what a clone, with no foundation to read them from,
948
+ // tells a query's derived `deferred:` from its author's by. ⛔ Until 2026-09-26 a clone wrote
949
+ // the derivation into its queries as though authored. One that cannot be read is judged
950
+ // without it, which keeps the value: the safe direction.
951
+ const models = {}
952
+ for (const model of pulledQueryModels) {
953
+ try {
954
+ const declaration = await readModel(model)
955
+ if (declaration) models[model] = declaration
956
+ } catch {
957
+ // unreadable here
958
+ }
959
+ }
945
960
  const report = siteContentDocumentToProject({
946
961
  document: siteDoc,
947
962
  siteRoot: siteDir,
963
+ models,
948
964
  // Which backend's asset ids to read back as the author's own paths. Without
949
965
  // it a pull leaves every image pointing at a backend route.
950
966
  backend: client.origin,
@@ -985,7 +1001,7 @@ export async function pull(args = [], deps = {}) {
985
1001
  if (folder && !folder.notModified && folder.docs?.length) {
986
1002
  const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
987
1003
  pulledRecordDocs = recordDocs
988
- const resolveModel = makeModelResolver({ client })
1004
+ const resolveModel = readModel
989
1005
  const declByModel = new Map()
990
1006
  pulledDeclarations = declByModel
991
1007
  for (const model of [
@@ -1102,7 +1118,6 @@ export async function pull(args = [], deps = {}) {
1102
1118
  // in the project (a clone's) resolves none of them otherwise: every template clone
1103
1119
  // said "could not re-bank" until 2026-09-25, and banked nothing.
1104
1120
  const declarations = pulledDeclarations || new Map()
1105
- const readModel = makeModelResolver({ client })
1106
1121
  for (const model of pulledQueryModels) {
1107
1122
  if (declarations.has(model)) continue
1108
1123
  try {
@@ -835,10 +835,18 @@ async function runRegister(args = []) {
835
835
  if (!res.ok) {
836
836
  // Resume path: a registered version is immutable, so re-running after a
837
837
  // partial code delivery hits the duplicate rejection here — a STRUCTURED
838
- // 409 (problem+json, title "Conflict") — and proceeds to phase 2 (the
839
- // code-uploads plan authorizes against the REGISTERED version; files it
840
- // already stores come back `present` and are skipped).
841
- const isDuplicate = !standalone && res.status === 409
838
+ // 409 (problem+json, title "Conflict", naming the `version` it refused) — and
839
+ // proceeds to phase 2 (the code-uploads plan authorizes against the REGISTERED
840
+ // version; files it already stores come back `present` and are skipped).
841
+ //
842
+ // ⛔ ONE STATUS, MORE THAN ONE MEANING. A 409 that names no version is another
843
+ // refusal — a data schema changed where the backend holds records of it
844
+ // (`reason: "destructive_republish"`) — and until 2026-09-26 it was read as a
845
+ // resume too: "already registered — resuming code delivery", then a 404 for a
846
+ // version that was never registered, and the refusal's own sentence never shown.
847
+ // A body with no shape to read resumes, as it always did.
848
+ const isDuplicate =
849
+ !standalone && res.status === 409 && (!parsedBody || typeof parsedBody !== 'object' || parsedBody.version !== undefined)
842
850
  if (isDuplicate) {
843
851
  alreadyRegistered = true
844
852
  info(
@@ -869,8 +877,9 @@ async function runRegister(args = []) {
869
877
  // because a 422 the CLI did not know about arrived exactly that way.
870
878
  if (parsedBody?.detail) {
871
879
  log(` ${parsedBody.detail}`)
872
- if (parsedBody.code) {
873
- log(` ${colors.dim}(${parsedBody.code})${colors.reset}`)
880
+ const code = parsedBody.code ?? parsedBody.reason
881
+ if (code) {
882
+ log(` ${colors.dim}(${code})${colors.reset}`)
874
883
  }
875
884
  } else if (rawBody) {
876
885
  log(` ${colors.dim}${rawBody.slice(0, 500)}${colors.reset}`)
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-25T16:13:29.075Z",
3
+ "generatedAt": "2026-09-26T17:05:22.846Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.6.2",
6
+ "version": "0.6.3",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.63.3",
13
+ "version": "0.64.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.29.8",
38
+ "version": "0.30.0",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -48,14 +48,14 @@
48
48
  "deps": []
49
49
  },
50
50
  "@uniweb/icons": {
51
- "version": "0.4.20",
51
+ "version": "0.4.21",
52
52
  "path": "framework/icons",
53
53
  "deps": [
54
54
  "@uniweb/core"
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.4",
58
+ "version": "0.19.5",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -75,7 +75,7 @@
75
75
  "deps": []
76
76
  },
77
77
  "@uniweb/projections": {
78
- "version": "0.7.3",
78
+ "version": "0.7.4",
79
79
  "path": "framework/projections",
80
80
  "deps": [
81
81
  "@uniweb/content-writer",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.4",
86
+ "version": "0.27.5",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.17.2",
119
+ "version": "0.17.3",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.10.6",
129
+ "version": "0.10.7",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",