uniweb 0.33.0 → 0.34.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 +8 -8
- package/partials/agents.md +73 -38
- package/src/backend/site-sync.js +144 -22
- package/src/commands/build.js +17 -17
- package/src/commands/clone.js +7 -7
- package/src/commands/content.js +2 -3
- package/src/commands/doctor.js +34 -10
- package/src/commands/i18n.js +53 -39
- package/src/commands/publish.js +28 -6
- package/src/commands/pull.js +57 -22
- package/src/commands/push.js +25 -11
- package/src/commands/validate.js +1 -1
- package/src/framework-index.json +8 -8
- package/src/utils/flag-guard.js +2 -2
- package/src/utils/git.js +8 -2
- package/src/utils/records-guard.js +80 -0
- package/src/utils/schemaless-report.js +4 -4
- package/src/utils/site-data-upload.js +2 -2
- package/templates/site/site.yml.hbs +24 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/core": "^0.
|
|
47
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/kit": "^0.15.0",
|
|
45
|
+
"@uniweb/runtime": "^0.13.2",
|
|
46
|
+
"@uniweb/core": "^0.14.0",
|
|
47
|
+
"@uniweb/semantic-parser": "^1.3.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "^0.30.0",
|
|
51
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
52
|
+
"@uniweb/content-reader": "^1.2.4"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -770,24 +770,36 @@ seo:
|
|
|
770
770
|
|
|
771
771
|
That emits a `Content-Signal:` line in `robots.txt`. Declare only what you mean — an omitted signal says nothing, which is not the same as saying no.
|
|
772
772
|
|
|
773
|
-
###
|
|
773
|
+
### Records, queries and dynamic routes
|
|
774
774
|
|
|
775
|
-
Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **
|
|
775
|
+
Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **The other kind is a set of records: repeating content managed as one file per item**, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
|
|
776
776
|
|
|
777
|
-
|
|
777
|
+
It is a small database, not a page tree. Three things, deliberately separate:
|
|
778
778
|
|
|
779
779
|
```
|
|
780
780
|
site/
|
|
781
781
|
├── pages/
|
|
782
|
-
├──
|
|
783
|
-
│ ├──
|
|
782
|
+
├── entities/ # your stored things. The folder names their data schema
|
|
783
|
+
│ ├── article/
|
|
784
784
|
│ │ ├── getting-started.md
|
|
785
785
|
│ │ └── design-tips.md
|
|
786
|
-
│ └──
|
|
786
|
+
│ └── person/
|
|
787
787
|
│ └── alice.yml
|
|
788
|
+
├── records.yml # what is PUBLISHED
|
|
789
|
+
├── queries.yml # how content is REACHED
|
|
788
790
|
└── site.yml
|
|
789
791
|
```
|
|
790
792
|
|
|
793
|
+
**`entities/{schema}/` — the pool.** The folder names the data schema and nothing else:
|
|
794
|
+
|
|
795
|
+
| on disk | schema |
|
|
796
|
+
|---|---|
|
|
797
|
+
| `entities/article/…` | `@/article` — your foundation's own |
|
|
798
|
+
| `entities/std/person/…` | `@std/person` — the shared standard set |
|
|
799
|
+
| `entities/acme/project/…` | `@acme/project` — an org's |
|
|
800
|
+
|
|
801
|
+
Keep those folders flat: `entities/article/design-tips.md` works; `entities/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead.
|
|
802
|
+
|
|
791
803
|
**Four formats, one shape.** All of these produce the same records at runtime:
|
|
792
804
|
|
|
793
805
|
| Format | Best for | Notes |
|
|
@@ -799,62 +811,85 @@ site/
|
|
|
799
811
|
|
|
800
812
|
**One record per file, or many.** A single mapping at the top of a file makes one record and the filename stem becomes its `slug`. A top-level array (YAML/JSON) or a multi-entry `.bib` makes many, each carrying its own `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
|
|
801
813
|
|
|
802
|
-
**Keep collection folders flat.** `collections/articles/design-tips.md` works; `collections/articles/2025/design-tips.md` does not.
|
|
803
|
-
|
|
804
814
|
Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `published`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. `published: false` hides an item without deleting it; items with no `published` field are included.
|
|
805
815
|
|
|
806
|
-
|
|
816
|
+
**`records.yml` — listing an entity is what publishes it.** A file in `entities/` exists; listing it here makes it a record. Anything you leave out is a draft — no flag to set. **The common case is three lines:**
|
|
807
817
|
|
|
808
818
|
```yaml
|
|
809
|
-
|
|
810
|
-
|
|
819
|
+
- article/*.md
|
|
820
|
+
- person/*.yml
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
A bare string is a path under `entities/`, naming one file or matching many.
|
|
824
|
+
|
|
825
|
+
⚠️ **An empty `records.yml` is not the same as having none.** No file means "leave the published set alone". An empty file means "the folder holds nothing", which REMOVES what is published. The CLI asks before it does that.
|
|
826
|
+
|
|
827
|
+
**Structure is for querying, not for navigation.** Add a `folder:` only when a query needs to ask for a *slice* of the pool rather than all of it — most sites never do:
|
|
811
828
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
829
|
+
```yaml
|
|
830
|
+
- article/2026-*.md
|
|
831
|
+
- folder: archive
|
|
832
|
+
label: The Archive
|
|
833
|
+
records:
|
|
834
|
+
- article/2025-*.md
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
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.
|
|
838
|
+
|
|
839
|
+
**`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:
|
|
840
|
+
|
|
841
|
+
```yaml
|
|
842
|
+
recent:
|
|
843
|
+
schema: '@/article'
|
|
844
|
+
sort: date desc # `date desc`, `title asc`, …
|
|
845
|
+
limit: 10
|
|
846
|
+
|
|
847
|
+
team:
|
|
848
|
+
schema: '@std/person'
|
|
849
|
+
where: { published: { ne: false } } # a predicate — see authoring/predicates.md
|
|
817
850
|
```
|
|
818
851
|
|
|
819
|
-
|
|
852
|
+
You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
|
|
853
|
+
|
|
854
|
+
**Show a query on a page** with `data:` in `page.yml` (the whole result), or `fetch:` in a section's frontmatter (a subset):
|
|
820
855
|
|
|
821
856
|
```yaml
|
|
822
857
|
# pages/blog/page.yml | # a section on the homepage
|
|
823
858
|
title: Blog | ---
|
|
824
|
-
data:
|
|
825
|
-
| fetch: {
|
|
859
|
+
data: recent | type: ArticleTeaser
|
|
860
|
+
| fetch: { query: recent, limit: 3 }
|
|
826
861
|
| ---
|
|
827
862
|
```
|
|
828
863
|
|
|
829
|
-
**Give each
|
|
864
|
+
**Give each record its own page with a `[slug]/` folder** under the list page:
|
|
830
865
|
|
|
831
866
|
```
|
|
832
867
|
pages/blog/
|
|
833
|
-
├── page.yml # title: Blog / data:
|
|
868
|
+
├── page.yml # title: Blog / data: recent
|
|
834
869
|
├── list.md
|
|
835
870
|
└── [slug]/
|
|
836
871
|
├── page.yml
|
|
837
|
-
└── article.md # just `type: Article` — the
|
|
872
|
+
└── article.md # just `type: Article` — the record arrives automatically
|
|
838
873
|
```
|
|
839
874
|
|
|
840
|
-
`
|
|
875
|
+
`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.
|
|
841
876
|
|
|
842
|
-
> **The record arrives as a single-element array under the
|
|
877
|
+
> **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.
|
|
843
878
|
|
|
844
|
-
**Two options for bigger
|
|
879
|
+
**Two options for bigger sets:**
|
|
845
880
|
|
|
846
|
-
`deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote
|
|
881
|
+
`deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote source, add `detailUrl: /api/articles/{slug}` so the framework knows how to fetch one full record; file-based records emit per-record files at `/data/<name>/<slug>.json` and need no configuration.
|
|
847
882
|
|
|
848
883
|
`queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
|
|
849
884
|
|
|
850
885
|
```yaml
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
886
|
+
# queries.yml
|
|
887
|
+
members:
|
|
888
|
+
schema: '@std/person'
|
|
889
|
+
queryable:
|
|
890
|
+
department: { type: enum, label: Department, options: [biology, physics, chemistry] }
|
|
891
|
+
tenured: { type: boolean, label: Tenured }
|
|
892
|
+
start_year: { type: range, label: Start year, min: 1800, max: 2025 }
|
|
858
893
|
```
|
|
859
894
|
|
|
860
895
|
The site declares the *surface*; the foundation reads the metadata, renders matching controls (dropdown, toggle, slider), and composes the predicate when the reader picks values.
|
|
@@ -1745,13 +1780,13 @@ A foundation can route a scope to a plain folder of schema files instead of a pa
|
|
|
1745
1780
|
```yaml
|
|
1746
1781
|
# pages/blog/page.yml
|
|
1747
1782
|
fetch:
|
|
1748
|
-
|
|
1783
|
+
query: recent
|
|
1749
1784
|
where: { published: true, tags: featured }
|
|
1750
1785
|
sort: date desc
|
|
1751
1786
|
limit: 3
|
|
1752
1787
|
```
|
|
1753
1788
|
|
|
1754
|
-
**Lean lists with `deferred:`.**
|
|
1789
|
+
**Lean lists with `deferred:`.** A query over records with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based records) or fetched from an author-declared `detailUrl:` (API-backed). On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch on demand via `useEntityDetail`. The hook is safe to call on any query: when there is no separate detail source it returns the record you passed in, because nothing was stripped from it.
|
|
1755
1790
|
|
|
1756
1791
|
**Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useEntityDetail`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
|
|
1757
1792
|
|
|
@@ -1766,7 +1801,7 @@ A site isn't limited to file-based collections. The `fetcher:` block in `site.ym
|
|
|
1766
1801
|
fetcher:
|
|
1767
1802
|
baseUrl: https://api.example.com
|
|
1768
1803
|
headers: { X-Tenant: acme }
|
|
1769
|
-
envelope: {
|
|
1804
|
+
envelope: { list: data.items, item: data.article, error: errors.0.message }
|
|
1770
1805
|
|
|
1771
1806
|
supports: [where, limit, sort] # which operators the source evaluates natively
|
|
1772
1807
|
|
|
@@ -2383,12 +2418,12 @@ Running `extract` before a build is the usual first mistake — it reads the com
|
|
|
2383
2418
|
|
|
2384
2419
|
```
|
|
2385
2420
|
locales/freeform/es/pages/about/hero.md # by page route
|
|
2386
|
-
locales/freeform/es/
|
|
2421
|
+
locales/freeform/es/entities/article/x.md # records work too
|
|
2387
2422
|
```
|
|
2388
2423
|
|
|
2389
2424
|
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.
|
|
2390
2425
|
|
|
2391
|
-
**
|
|
2426
|
+
**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.
|
|
2392
2427
|
|
|
2393
2428
|
**Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
|
|
2394
2429
|
|
package/src/backend/site-sync.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
computeUnitHashes,
|
|
28
28
|
collectUnitUuids,
|
|
29
29
|
collectFolderItemUuids,
|
|
30
|
+
collectQueryUuids,
|
|
30
31
|
readAssetMap
|
|
31
32
|
} from '@uniweb/build/uwx'
|
|
32
33
|
|
|
@@ -241,6 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
241
242
|
const prior = readSyncCacheFile(siteDir)
|
|
242
243
|
const dropped = [
|
|
243
244
|
'itemUuids',
|
|
245
|
+
'queryUuids',
|
|
244
246
|
'hashes',
|
|
245
247
|
'baseVersions',
|
|
246
248
|
'unitBases',
|
|
@@ -248,6 +250,9 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
248
250
|
].filter((k) => prior[k] && Object.keys(prior[k]).length)
|
|
249
251
|
updateSyncCache(siteDir, {
|
|
250
252
|
itemUuids: {},
|
|
253
|
+
// Remote-derived exactly like itemUuids — it holds the OLD site's collection
|
|
254
|
+
// ids, and surviving the drop it would offer them for the new site's sections.
|
|
255
|
+
queryUuids: {},
|
|
251
256
|
hashes: {},
|
|
252
257
|
baseVersions: {},
|
|
253
258
|
unitBases: {},
|
|
@@ -468,6 +473,32 @@ export function readItemUuids(siteDir) {
|
|
|
468
473
|
export function readFolderItemUuids(siteDir) {
|
|
469
474
|
return readMap(siteDir, 'folderItemUuids')
|
|
470
475
|
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Collection-declaration identity: `{ <collection name>: <backend $uuid> }`.
|
|
479
|
+
*
|
|
480
|
+
* ⛔ THE THIRD MAP, AND EACH ONE EXISTS FOR THE SAME REASON. `itemUuids` is keyed by
|
|
481
|
+
* the file an item projects to. Two kinds of item have no file of their own — a
|
|
482
|
+
* folder's placements, and a collection DECLARATION (they all live in one
|
|
483
|
+
* `collections/collections.yml`) — so a path-keyed map has no shape either could
|
|
484
|
+
* occupy, and a push re-sends that whole section uuid-less.
|
|
485
|
+
*
|
|
486
|
+
* The backend refuses an all-blank section over stored items rather than applying it,
|
|
487
|
+
* because applying it would insert every record fresh and delete every stored row —
|
|
488
|
+
* content survives, identity does not. So `push` worked once and every push after was
|
|
489
|
+
* refused. Measured 2026-08-29; collab framework-backend-812b.
|
|
490
|
+
*
|
|
491
|
+
* ⭐ Keyed by NAME, which the backend enforces unique within the section and uses as
|
|
492
|
+
* its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
|
|
493
|
+
* handle the backend skips on parse and never stores.
|
|
494
|
+
*/
|
|
495
|
+
export function readQueryUuids(siteDir) {
|
|
496
|
+
return readMap(siteDir, 'queryUuids')
|
|
497
|
+
}
|
|
498
|
+
export function writeQueryUuids(siteDir, map) {
|
|
499
|
+
if (!map || !Object.keys(map).length) return
|
|
500
|
+
updateSyncCache(siteDir, { queryUuids: map })
|
|
501
|
+
}
|
|
471
502
|
export function writeFolderItemUuids(siteDir, map) {
|
|
472
503
|
if (!map || !Object.keys(map).length) return
|
|
473
504
|
updateSyncCache(siteDir, { folderItemUuids: map })
|
|
@@ -937,31 +968,83 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
937
968
|
//
|
|
938
969
|
// ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
|
|
939
970
|
// a foundation-relative `@/member` into the `@org/member` the push shipped and
|
|
940
|
-
// keyed its hashes by. Without it the emit does not fail — `
|
|
971
|
+
// keyed its hashes by. Without it the emit does not fail — `buildRecordEntities`
|
|
941
972
|
// WARNS and ships the model unresolved, deliberately, so an org-less export still
|
|
942
973
|
// works — so every record of a `@/`-scoped collection is emitted under a key that
|
|
943
974
|
// can never match its banked one, and reads as changed forever.
|
|
944
975
|
//
|
|
945
|
-
// ⚠️ It hides in plain sight because `@std/…`
|
|
976
|
+
// ⚠️ It hides in plain sight because `@std/…` queries are unaffected: their
|
|
946
977
|
// scope is already absolute, so they match. A site mixing both — the marketing
|
|
947
978
|
// fixture has `@std/person` AND `@proximify/member` — shows some records settling
|
|
948
979
|
// and others never settling, which reads like a content problem rather than a
|
|
949
980
|
// resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
|
|
950
981
|
// immediately after a successful push; passing the org took it to 1.
|
|
982
|
+
const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
|
|
983
|
+
const changed =
|
|
984
|
+
(pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
|
|
985
|
+
return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* The emit whose hashes are comparable with the banked ones — ONE definition.
|
|
990
|
+
*
|
|
991
|
+
* ⛔ EVERY DEFECT IN THIS AREA HAS BEEN A WRITER AND A READER DISAGREEING ABOUT
|
|
992
|
+
* WHICH DOCUMENT A HASH DESCRIBES, so the option set that makes them agree must
|
|
993
|
+
* have a single home. Assembled from three sources, each for a stated reason:
|
|
994
|
+
*
|
|
995
|
+
* · BANKED the injections the push applied before hashing — serve URLs and
|
|
996
|
+
* the pinned foundation ref, which only a round trip produces.
|
|
997
|
+
* · RE-DERIVED asset identity, from the COMMITTED `assets.json`, so a moved map
|
|
998
|
+
* reads as changed rather than matching a copy of itself.
|
|
999
|
+
* · RECORDED the site's org, which resolves a foundation-relative `@/x` into
|
|
1000
|
+
* the `@org/x` the push keyed its hashes by, and the collection
|
|
1001
|
+
* identity a push stamps — so `status` hashes the same document a
|
|
1002
|
+
* push would send rather than one missing a section's `$uuid`s.
|
|
1003
|
+
*
|
|
1004
|
+
* Offline by design — measured at zero HTTP requests, a property the cross-client
|
|
1005
|
+
* flows rely on.
|
|
1006
|
+
*/
|
|
1007
|
+
async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {}) {
|
|
951
1008
|
const applied = readAppliedInjections(siteDir)
|
|
952
1009
|
const assetIds = readAssetMap(siteDir)
|
|
953
1010
|
const org = readSiteOrg(siteDir)
|
|
954
|
-
const
|
|
1011
|
+
const queryUuids = readQueryUuids(siteDir)
|
|
1012
|
+
return emitSyncPackages(siteDir, {
|
|
955
1013
|
resolveModel: makeModelResolver({ client: null, offline: true }),
|
|
956
1014
|
priorHashes,
|
|
957
1015
|
sendAll,
|
|
958
1016
|
...applied,
|
|
1017
|
+
...(Object.keys(queryUuids).length ? { queryUuids } : {}),
|
|
959
1018
|
...(Object.keys(assetIds).length ? { assetIds } : {}),
|
|
960
1019
|
...(org ? { org } : {})
|
|
961
1020
|
})
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Re-bank the send-only-changed hashes over the files as they NOW stand.
|
|
1025
|
+
*
|
|
1026
|
+
* ⛔ FOR A WRITER THAT REWRITES THE WORKING TREE — today, `uniweb pull`.
|
|
1027
|
+
*
|
|
1028
|
+
* A pull projects the backend's document into source files, and that projection is
|
|
1029
|
+
* canonical rather than byte-identical to what was there: it moves section ordering
|
|
1030
|
+
* out of filename prefixes (`1-hero.md` → `hero.md` plus an explicit `sections:`
|
|
1031
|
+
* list) and stamps each section's `id`. Lossless, and a different document.
|
|
1032
|
+
*
|
|
1033
|
+
* ⚠️ `pull` already knew this for the OTHER map — it clears the `local` unit base
|
|
1034
|
+
* because "what we would emit from them is not byte-identical to it, so the old
|
|
1035
|
+
* local base no longer describes anything". That reasoning was never carried to the
|
|
1036
|
+
* hashes, which were left STALE rather than unknown: the next `uniweb status`
|
|
1037
|
+
* reported the site as having unpushed content immediately after a pull, forever.
|
|
1038
|
+
* Measured on matinee 2026-08-29 — `push → pull` reported 1 changed of 8.
|
|
1039
|
+
*
|
|
1040
|
+
* ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
|
|
1041
|
+
* on-disk state IS the agreed state: it came from the backend. A push with no edits
|
|
1042
|
+
* in between should send nothing, and clearing would make it send everything.
|
|
1043
|
+
*/
|
|
1044
|
+
export async function rebankSyncHashes(siteDir) {
|
|
1045
|
+
const pkg = await comparisonEmit(siteDir, { sendAll: true })
|
|
1046
|
+
writeSyncCache(siteDir, pkg.hashes || {}, pkg.applied || {})
|
|
1047
|
+
return Object.keys(pkg.hashes || {}).length
|
|
965
1048
|
}
|
|
966
1049
|
|
|
967
1050
|
/**
|
|
@@ -986,7 +1069,7 @@ export async function pushSyncPackages({
|
|
|
986
1069
|
asOrg,
|
|
987
1070
|
report
|
|
988
1071
|
}) {
|
|
989
|
-
const { siteContent,
|
|
1072
|
+
const { siteContent, records, siteContentUuid, hashes, applied } = pkg
|
|
990
1073
|
const { info, note, error } = report
|
|
991
1074
|
const dim = report.dim || ((s) => s)
|
|
992
1075
|
|
|
@@ -1063,18 +1146,47 @@ export async function pushSyncPackages({
|
|
|
1063
1146
|
return null
|
|
1064
1147
|
}
|
|
1065
1148
|
if (problem?.reason === 'identity_required') {
|
|
1149
|
+
// ⛔ THE REFUSAL IS PER-SECTION, NOT PER-PUSH, AND WE USED TO SAY OTHERWISE.
|
|
1150
|
+
//
|
|
1151
|
+
// The backend asks, of each section independently: is this section's ENTIRE
|
|
1152
|
+
// incoming record set uuid-less while the stored entity already has items in
|
|
1153
|
+
// it? So a push can carry correct identity for most of the document and still
|
|
1154
|
+
// be refused over one section it stamps none of — and the old wording ("this
|
|
1155
|
+
// copy has no record of the site's item identity", "the recovery … could not
|
|
1156
|
+
// reach the backend", "`uniweb pull` also restores it") was wrong on all
|
|
1157
|
+
// three counts in exactly that case: the copy has identity, no recovery was
|
|
1158
|
+
// attempted, and a pull changes nothing.
|
|
1159
|
+
//
|
|
1160
|
+
// ⭐ THE BACKEND ALREADY SENDS WHAT LOCATES IT — `section_id`,
|
|
1161
|
+
// `records_without_uuid`, `stored_items` — and this branch discarded every
|
|
1162
|
+
// one of them, so every refusal anyone collected was missing the only fields
|
|
1163
|
+
// that say WHERE. (Named by backend in collab `framework-backend-812b`,
|
|
1164
|
+
// 2026-08-28: "the offending section and both counts have been in the body of
|
|
1165
|
+
// every refusal you have collected".)
|
|
1166
|
+
const n = problem.records_without_uuid
|
|
1167
|
+
const stored = problem.stored_items
|
|
1168
|
+
// `section_name` landed backend-side 2026-08-28 (`90e7cd7e`), replacing an
|
|
1169
|
+
// i64 row id we could not resolve. The fallback stays for an older backend,
|
|
1170
|
+
// not because the swap is pending — it is done.
|
|
1171
|
+
const where = problem.section_name ?? problem.section_id
|
|
1066
1172
|
error(
|
|
1067
|
-
`${label} push refused —
|
|
1068
|
-
|
|
1069
|
-
note(
|
|
1070
|
-
'Nothing was written. Pushing without it would have replaced the identity of every stored item.'
|
|
1071
|
-
)
|
|
1072
|
-
note(
|
|
1073
|
-
'The recovery normally runs automatically, so it likely could not reach the backend.'
|
|
1173
|
+
`${label} push refused — one section carries no item identity` +
|
|
1174
|
+
(where === undefined ? '.' : ` (section ${where}).`)
|
|
1074
1175
|
)
|
|
1176
|
+
if (Number.isInteger(n) && Number.isInteger(stored)) {
|
|
1177
|
+
note(
|
|
1178
|
+
`That section sent ${n} record(s), none carrying a \`$uuid\`, while ${stored} item(s) are already stored there.`
|
|
1179
|
+
)
|
|
1180
|
+
}
|
|
1075
1181
|
note(
|
|
1076
|
-
'
|
|
1182
|
+
'Nothing was written. Applying it would have replaced the identity of every stored item in that section.'
|
|
1077
1183
|
)
|
|
1184
|
+
// ⚠️ Deliberately NOT "run `uniweb pull`". A pull re-harvests identity from
|
|
1185
|
+
// the backend's own document, so it fixes a LOST cache — and does nothing at
|
|
1186
|
+
// all when the cache is intact and one section simply has no entry in it,
|
|
1187
|
+
// which is the case this message now names. Suggesting it there sends the
|
|
1188
|
+
// user to re-fetch a map they already have.
|
|
1189
|
+
if (problem.detail) note(String(problem.detail))
|
|
1078
1190
|
return null
|
|
1079
1191
|
}
|
|
1080
1192
|
if (problem?.reason === 'stale_base') {
|
|
@@ -1160,8 +1272,8 @@ export async function pushSyncPackages({
|
|
|
1160
1272
|
// The site's @uniweb/folder is genesis-owned: its structure is fixed on first
|
|
1161
1273
|
// deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
|
|
1162
1274
|
note(
|
|
1163
|
-
"This site's
|
|
1164
|
-
'in place — e.g. adding or removing a schema-backed
|
|
1275
|
+
"This site's record structure is already established on the backend and can't be changed " +
|
|
1276
|
+
'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
|
|
1165
1277
|
'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
|
|
1166
1278
|
'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
|
|
1167
1279
|
)
|
|
@@ -1264,6 +1376,16 @@ export async function pushSyncPackages({
|
|
|
1264
1376
|
}
|
|
1265
1377
|
harvest(finalized)
|
|
1266
1378
|
siteFinalizedDoc = finalized[0]?.document || null
|
|
1379
|
+
// ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
|
|
1380
|
+
// placements below. These items have no file to back-fill into — they all
|
|
1381
|
+
// come from one `collections/collections.yml` — so the only place their
|
|
1382
|
+
// `$uuid` can live is the cache, keyed by the name the backend enforces
|
|
1383
|
+
// unique. Without it every push after the first re-sends the whole
|
|
1384
|
+
// `collections` section uuid-less and is refused.
|
|
1385
|
+
if (siteFinalizedDoc) {
|
|
1386
|
+
const recordIds = collectQueryUuids(siteFinalizedDoc)
|
|
1387
|
+
if (Object.keys(recordIds).length) writeQueryUuids(siteDir, recordIds)
|
|
1388
|
+
}
|
|
1267
1389
|
finalizedTotal += finalized.length
|
|
1268
1390
|
} else {
|
|
1269
1391
|
const payload = await postLane('site-content', () =>
|
|
@@ -1307,16 +1429,16 @@ export async function pushSyncPackages({
|
|
|
1307
1429
|
// site-content uuid. On a brand-new site the backend creates the folder on this first
|
|
1308
1430
|
// push. Records round-trip their own $uuid (back-filled into source files); the folder
|
|
1309
1431
|
// itself has no uuid (the backend owns it).
|
|
1310
|
-
if (
|
|
1432
|
+
if (records) {
|
|
1311
1433
|
if (!boundSiteUuid) {
|
|
1312
1434
|
error(
|
|
1313
|
-
'Cannot push
|
|
1435
|
+
'Cannot push records — the site has no uuid yet. Push the site-content lane first.'
|
|
1314
1436
|
)
|
|
1315
1437
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1316
1438
|
}
|
|
1317
1439
|
const finalized = await pushLane(
|
|
1318
|
-
'
|
|
1319
|
-
() => client.pushFolder(boundSiteUuid,
|
|
1440
|
+
'records',
|
|
1441
|
+
() => client.pushFolder(boundSiteUuid, records.buffer, { asOrg }),
|
|
1320
1442
|
undefined,
|
|
1321
1443
|
{ boundUuid: boundSiteUuid }
|
|
1322
1444
|
)
|
|
@@ -1325,7 +1447,7 @@ export async function pushSyncPackages({
|
|
|
1325
1447
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1326
1448
|
}
|
|
1327
1449
|
harvest(finalized)
|
|
1328
|
-
const bf = backfillEntityUuids({ index:
|
|
1450
|
+
const bf = backfillEntityUuids({ index: records.index, finalized })
|
|
1329
1451
|
for (const w of bf.warnings) note(`! ${w}`)
|
|
1330
1452
|
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
1331
1453
|
if (bf.updated.length)
|
package/src/commands/build.js
CHANGED
|
@@ -592,7 +592,7 @@ function resolveFoundationDir(projectDir, siteConfig) {
|
|
|
592
592
|
*
|
|
593
593
|
* The `buildLocalizedContent` step is the same call bundle mode makes
|
|
594
594
|
* post-vite, so multi-locale sites get identical per-locale outputs in
|
|
595
|
-
* either mode. Collection translation (`
|
|
595
|
+
* either mode. Collection translation (`buildLocalizedRecords`)
|
|
596
596
|
* also runs here so deploy ships translated collection JSONs.
|
|
597
597
|
*
|
|
598
598
|
* Bug surfaced + fixed by routing deploy through this path: the bundle
|
|
@@ -652,28 +652,28 @@ async function buildSiteLink(projectDir, options = {}) {
|
|
|
652
652
|
// Collection translations — optional; don't fail the build if
|
|
653
653
|
// missing. Bundle mode does the same.
|
|
654
654
|
try {
|
|
655
|
-
const {
|
|
656
|
-
const
|
|
655
|
+
const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
|
|
656
|
+
const recordOutputs = await buildLocalizedRecords(projectDir, {
|
|
657
657
|
locales: i18nConfig.locales,
|
|
658
658
|
outputDir: distDir,
|
|
659
|
-
|
|
659
|
+
recordLocalesDir: join(
|
|
660
660
|
projectDir,
|
|
661
661
|
i18nConfig.localesDir,
|
|
662
|
-
'
|
|
662
|
+
'records'
|
|
663
663
|
)
|
|
664
664
|
})
|
|
665
|
-
const
|
|
665
|
+
const recordCount = Object.values(recordOutputs).reduce(
|
|
666
666
|
(sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
|
|
667
667
|
0
|
|
668
668
|
)
|
|
669
|
-
if (
|
|
669
|
+
if (recordCount > 0) {
|
|
670
670
|
success(
|
|
671
|
-
`Translated
|
|
671
|
+
`Translated records for ${Object.keys(recordOutputs).length} locale(s)`
|
|
672
672
|
)
|
|
673
673
|
}
|
|
674
674
|
} catch (err) {
|
|
675
675
|
if (process.env.UNIWEB_DEBUG)
|
|
676
|
-
console.error('
|
|
676
|
+
console.error('Record translation:', err.message)
|
|
677
677
|
}
|
|
678
678
|
} catch (err) {
|
|
679
679
|
error(`i18n build failed: ${err.message}`)
|
|
@@ -772,33 +772,33 @@ async function buildSite(projectDir, options = {}) {
|
|
|
772
772
|
|
|
773
773
|
// Translate collections if they exist
|
|
774
774
|
try {
|
|
775
|
-
const {
|
|
775
|
+
const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
|
|
776
776
|
|
|
777
|
-
const
|
|
777
|
+
const recordOutputs = await buildLocalizedRecords(projectDir, {
|
|
778
778
|
locales: i18nConfig.locales,
|
|
779
779
|
outputDir: join(projectDir, 'dist'),
|
|
780
|
-
|
|
780
|
+
recordLocalesDir: join(
|
|
781
781
|
projectDir,
|
|
782
782
|
i18nConfig.localesDir,
|
|
783
|
-
'
|
|
783
|
+
'records'
|
|
784
784
|
)
|
|
785
785
|
})
|
|
786
786
|
|
|
787
787
|
// Count collections translated
|
|
788
|
-
const
|
|
788
|
+
const recordCount = Object.values(recordOutputs).reduce(
|
|
789
789
|
(sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
|
|
790
790
|
0
|
|
791
791
|
)
|
|
792
792
|
|
|
793
|
-
if (
|
|
793
|
+
if (recordCount > 0) {
|
|
794
794
|
success(
|
|
795
|
-
`Translated
|
|
795
|
+
`Translated records for ${Object.keys(recordOutputs).length} locale(s)`
|
|
796
796
|
)
|
|
797
797
|
}
|
|
798
798
|
} catch (err) {
|
|
799
799
|
// Collection translation is optional, don't fail build
|
|
800
800
|
if (process.env.UNIWEB_DEBUG) {
|
|
801
|
-
console.error('
|
|
801
|
+
console.error('Record translation:', err.message)
|
|
802
802
|
}
|
|
803
803
|
}
|
|
804
804
|
} catch (err) {
|
package/src/commands/clone.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* seed;
|
|
25
25
|
* 4. install, then delegate the projection to the project-local `uniweb pull` (which
|
|
26
26
|
* resolves the now-installed project-local `@uniweb/build`; clone forwards
|
|
27
|
-
* `--no-
|
|
27
|
+
* `--no-records` to it when set).
|
|
28
28
|
*
|
|
29
29
|
* Sites are private — authenticate with `uniweb login` first; the session carries
|
|
30
30
|
* identity + the backend origin. There is no `--foundation` flag: the site carries
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* the current workspace when run inside one)
|
|
38
38
|
* uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
|
|
39
39
|
* uniweb clone <uuid> --project docs Co-located docs/site
|
|
40
|
-
* uniweb clone <uuid> --no-
|
|
40
|
+
* uniweb clone <uuid> --no-records Pull pages only; skip records
|
|
41
41
|
*
|
|
42
42
|
* Backend: via BackendClient (the site-content pull lane). Origin from
|
|
43
43
|
* --registry > UNIWEB_REGISTER_URL > the local default (internal dev overrides;
|
|
@@ -176,7 +176,7 @@ export async function clone(args = [], deps = {}) {
|
|
|
176
176
|
if (!siteUuid) {
|
|
177
177
|
error('Missing site uuid.')
|
|
178
178
|
log(
|
|
179
|
-
`\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-
|
|
179
|
+
`\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-records]`
|
|
180
180
|
)
|
|
181
181
|
log(
|
|
182
182
|
`${colors.dim}Sites are private — run \`uniweb login\` first.${colors.reset}`
|
|
@@ -184,8 +184,8 @@ export async function clone(args = [], deps = {}) {
|
|
|
184
184
|
return { exitCode: 2 }
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
const
|
|
188
|
-
args.includes('--no-
|
|
187
|
+
const noRecords =
|
|
188
|
+
args.includes('--no-records') || args.includes('--content-only')
|
|
189
189
|
const pathFlag = flagValue(args, '--path')
|
|
190
190
|
const projectFlag = flagValue(args, '--project')
|
|
191
191
|
const tokenFlag = flagValue(args, '--token')
|
|
@@ -386,7 +386,7 @@ export async function clone(args = [], deps = {}) {
|
|
|
386
386
|
// bytes on disk, not of what the scaffolder first wrote.
|
|
387
387
|
recordWritten(
|
|
388
388
|
siteDir,
|
|
389
|
-
['site.yml', 'theme.yml', 'head.html', '
|
|
389
|
+
['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml']
|
|
390
390
|
.map((f) => join(siteDir, f))
|
|
391
391
|
.filter((p) => existsSync(p))
|
|
392
392
|
)
|
|
@@ -394,7 +394,7 @@ export async function clone(args = [], deps = {}) {
|
|
|
394
394
|
const pullExtra = []
|
|
395
395
|
if (explicitBackend) pullExtra.push('--backend', explicitBackend)
|
|
396
396
|
if (tokenFlag) pullExtra.push('--token', tokenFlag)
|
|
397
|
-
if (
|
|
397
|
+
if (noRecords) pullExtra.push('--no-records')
|
|
398
398
|
|
|
399
399
|
if (deps.skipPull) {
|
|
400
400
|
note('Skipping pull (test mode).')
|