uniweb 0.33.1 → 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 +22 -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 +26 -25
- package/src/commands/push.js +24 -14
- package/src/commands/validate.js +1 -1
- package/src/framework-index.json +7 -7
- 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/
|
|
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,7 +27,7 @@ import {
|
|
|
27
27
|
computeUnitHashes,
|
|
28
28
|
collectUnitUuids,
|
|
29
29
|
collectFolderItemUuids,
|
|
30
|
-
|
|
30
|
+
collectQueryUuids,
|
|
31
31
|
readAssetMap
|
|
32
32
|
} from '@uniweb/build/uwx'
|
|
33
33
|
|
|
@@ -242,7 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
242
242
|
const prior = readSyncCacheFile(siteDir)
|
|
243
243
|
const dropped = [
|
|
244
244
|
'itemUuids',
|
|
245
|
-
'
|
|
245
|
+
'queryUuids',
|
|
246
246
|
'hashes',
|
|
247
247
|
'baseVersions',
|
|
248
248
|
'unitBases',
|
|
@@ -252,7 +252,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
252
252
|
itemUuids: {},
|
|
253
253
|
// Remote-derived exactly like itemUuids — it holds the OLD site's collection
|
|
254
254
|
// ids, and surviving the drop it would offer them for the new site's sections.
|
|
255
|
-
|
|
255
|
+
queryUuids: {},
|
|
256
256
|
hashes: {},
|
|
257
257
|
baseVersions: {},
|
|
258
258
|
unitBases: {},
|
|
@@ -492,12 +492,12 @@ export function readFolderItemUuids(siteDir) {
|
|
|
492
492
|
* its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
|
|
493
493
|
* handle the backend skips on parse and never stores.
|
|
494
494
|
*/
|
|
495
|
-
export function
|
|
496
|
-
return readMap(siteDir, '
|
|
495
|
+
export function readQueryUuids(siteDir) {
|
|
496
|
+
return readMap(siteDir, 'queryUuids')
|
|
497
497
|
}
|
|
498
|
-
export function
|
|
498
|
+
export function writeQueryUuids(siteDir, map) {
|
|
499
499
|
if (!map || !Object.keys(map).length) return
|
|
500
|
-
updateSyncCache(siteDir, {
|
|
500
|
+
updateSyncCache(siteDir, { queryUuids: map })
|
|
501
501
|
}
|
|
502
502
|
export function writeFolderItemUuids(siteDir, map) {
|
|
503
503
|
if (!map || !Object.keys(map).length) return
|
|
@@ -968,12 +968,12 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
968
968
|
//
|
|
969
969
|
// ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
|
|
970
970
|
// a foundation-relative `@/member` into the `@org/member` the push shipped and
|
|
971
|
-
// keyed its hashes by. Without it the emit does not fail — `
|
|
971
|
+
// keyed its hashes by. Without it the emit does not fail — `buildRecordEntities`
|
|
972
972
|
// WARNS and ships the model unresolved, deliberately, so an org-less export still
|
|
973
973
|
// works — so every record of a `@/`-scoped collection is emitted under a key that
|
|
974
974
|
// can never match its banked one, and reads as changed forever.
|
|
975
975
|
//
|
|
976
|
-
// ⚠️ It hides in plain sight because `@std/…`
|
|
976
|
+
// ⚠️ It hides in plain sight because `@std/…` queries are unaffected: their
|
|
977
977
|
// scope is already absolute, so they match. A site mixing both — the marketing
|
|
978
978
|
// fixture has `@std/person` AND `@proximify/member` — shows some records settling
|
|
979
979
|
// and others never settling, which reads like a content problem rather than a
|
|
@@ -981,7 +981,7 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
981
981
|
// immediately after a successful push; passing the org took it to 1.
|
|
982
982
|
const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
|
|
983
983
|
const changed =
|
|
984
|
-
(pkg.siteContent?.entityCount || 0) + (pkg.
|
|
984
|
+
(pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
|
|
985
985
|
return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
|
|
986
986
|
}
|
|
987
987
|
|
|
@@ -1008,13 +1008,13 @@ async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {
|
|
|
1008
1008
|
const applied = readAppliedInjections(siteDir)
|
|
1009
1009
|
const assetIds = readAssetMap(siteDir)
|
|
1010
1010
|
const org = readSiteOrg(siteDir)
|
|
1011
|
-
const
|
|
1011
|
+
const queryUuids = readQueryUuids(siteDir)
|
|
1012
1012
|
return emitSyncPackages(siteDir, {
|
|
1013
1013
|
resolveModel: makeModelResolver({ client: null, offline: true }),
|
|
1014
1014
|
priorHashes,
|
|
1015
1015
|
sendAll,
|
|
1016
1016
|
...applied,
|
|
1017
|
-
...(Object.keys(
|
|
1017
|
+
...(Object.keys(queryUuids).length ? { queryUuids } : {}),
|
|
1018
1018
|
...(Object.keys(assetIds).length ? { assetIds } : {}),
|
|
1019
1019
|
...(org ? { org } : {})
|
|
1020
1020
|
})
|
|
@@ -1069,7 +1069,7 @@ export async function pushSyncPackages({
|
|
|
1069
1069
|
asOrg,
|
|
1070
1070
|
report
|
|
1071
1071
|
}) {
|
|
1072
|
-
const { siteContent,
|
|
1072
|
+
const { siteContent, records, siteContentUuid, hashes, applied } = pkg
|
|
1073
1073
|
const { info, note, error } = report
|
|
1074
1074
|
const dim = report.dim || ((s) => s)
|
|
1075
1075
|
|
|
@@ -1272,8 +1272,8 @@ export async function pushSyncPackages({
|
|
|
1272
1272
|
// The site's @uniweb/folder is genesis-owned: its structure is fixed on first
|
|
1273
1273
|
// deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
|
|
1274
1274
|
note(
|
|
1275
|
-
"This site's
|
|
1276
|
-
'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 ' +
|
|
1277
1277
|
'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
|
|
1278
1278
|
'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
|
|
1279
1279
|
)
|
|
@@ -1383,8 +1383,8 @@ export async function pushSyncPackages({
|
|
|
1383
1383
|
// unique. Without it every push after the first re-sends the whole
|
|
1384
1384
|
// `collections` section uuid-less and is refused.
|
|
1385
1385
|
if (siteFinalizedDoc) {
|
|
1386
|
-
const
|
|
1387
|
-
if (Object.keys(
|
|
1386
|
+
const recordIds = collectQueryUuids(siteFinalizedDoc)
|
|
1387
|
+
if (Object.keys(recordIds).length) writeQueryUuids(siteDir, recordIds)
|
|
1388
1388
|
}
|
|
1389
1389
|
finalizedTotal += finalized.length
|
|
1390
1390
|
} else {
|
|
@@ -1429,16 +1429,16 @@ export async function pushSyncPackages({
|
|
|
1429
1429
|
// site-content uuid. On a brand-new site the backend creates the folder on this first
|
|
1430
1430
|
// push. Records round-trip their own $uuid (back-filled into source files); the folder
|
|
1431
1431
|
// itself has no uuid (the backend owns it).
|
|
1432
|
-
if (
|
|
1432
|
+
if (records) {
|
|
1433
1433
|
if (!boundSiteUuid) {
|
|
1434
1434
|
error(
|
|
1435
|
-
'Cannot push
|
|
1435
|
+
'Cannot push records — the site has no uuid yet. Push the site-content lane first.'
|
|
1436
1436
|
)
|
|
1437
1437
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1438
1438
|
}
|
|
1439
1439
|
const finalized = await pushLane(
|
|
1440
|
-
'
|
|
1441
|
-
() => client.pushFolder(boundSiteUuid,
|
|
1440
|
+
'records',
|
|
1441
|
+
() => client.pushFolder(boundSiteUuid, records.buffer, { asOrg }),
|
|
1442
1442
|
undefined,
|
|
1443
1443
|
{ boundUuid: boundSiteUuid }
|
|
1444
1444
|
)
|
|
@@ -1447,7 +1447,7 @@ export async function pushSyncPackages({
|
|
|
1447
1447
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1448
1448
|
}
|
|
1449
1449
|
harvest(finalized)
|
|
1450
|
-
const bf = backfillEntityUuids({ index:
|
|
1450
|
+
const bf = backfillEntityUuids({ index: records.index, finalized })
|
|
1451
1451
|
for (const w of bf.warnings) note(`! ${w}`)
|
|
1452
1452
|
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
1453
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).')
|
package/src/commands/content.js
CHANGED
|
@@ -162,8 +162,7 @@ async function contentExport(args) {
|
|
|
162
162
|
if (entity.layout_sections?.length)
|
|
163
163
|
counts.layout_sections = entity.layout_sections.length
|
|
164
164
|
if (entity.extensions?.length) counts.extensions = entity.extensions.length
|
|
165
|
-
if (entity.
|
|
166
|
-
counts.collections = entity.collections.length
|
|
165
|
+
if (entity.queries?.length) counts.queries = entity.queries.length
|
|
167
166
|
}
|
|
168
167
|
|
|
169
168
|
console.log('')
|
|
@@ -198,6 +197,6 @@ async function contentExport(args) {
|
|
|
198
197
|
)
|
|
199
198
|
}
|
|
200
199
|
console.log('')
|
|
201
|
-
say.warn('v0 scope: media bytes,
|
|
200
|
+
say.warn('v0 scope: media bytes, records, and @-nested section')
|
|
202
201
|
say.dim('hierarchy are not yet carried (documented).')
|
|
203
202
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -153,8 +153,8 @@ function loadSiteYml(dir) {
|
|
|
153
153
|
/**
|
|
154
154
|
* Diagnose the compiled-collection output directory.
|
|
155
155
|
*
|
|
156
|
-
* `public/<DATA_DIR>/` holds what the build compiles from `
|
|
157
|
-
* nothing else — `
|
|
156
|
+
* `public/<DATA_DIR>/` holds what the build compiles from `entities/`, and
|
|
157
|
+
* nothing else — `entities/` + `records.yml` is the only supported way to provide
|
|
158
158
|
* structured data. Two consequences, both checked here:
|
|
159
159
|
*
|
|
160
160
|
* 1. **The mapping is a bijection.** Every entry should be backed by a
|
|
@@ -548,9 +548,33 @@ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issue
|
|
|
548
548
|
)
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
+
/** The bare map in `queries.yml`, or `{}` when there is none. */
|
|
552
|
+
function readQueriesYml(sitePath) {
|
|
553
|
+
try {
|
|
554
|
+
const doc = yaml.load(readFileSync(join(sitePath, 'queries.yml'), 'utf8'))
|
|
555
|
+
return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {}
|
|
556
|
+
} catch {
|
|
557
|
+
return {}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
551
561
|
function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix, fixed }) {
|
|
552
562
|
const dataDir = join(sitePath, 'public', DATA_DIR)
|
|
553
|
-
|
|
563
|
+
// ⚠️ THE QUERIES, not the pool. `public/<DATA_DIR>/x.json` is a query's
|
|
564
|
+
// MATERIALIZATION — one file per named query — so the bijection is with the
|
|
565
|
+
// declared queries, never with the schema folders under `entities/`.
|
|
566
|
+
//
|
|
567
|
+
// ⛔ BOTH HOMES, or this reports every compiled file as an orphan. A site that
|
|
568
|
+
// keeps its queries in `queries.yml` has none in `site.yml`, and reading one
|
|
569
|
+
// file would turn the whole check into a false positive — the loudest possible
|
|
570
|
+
// failure for a check whose job is to find stale output.
|
|
571
|
+
//
|
|
572
|
+
// Read directly rather than through `resolveQueriesConfig`: this pass is
|
|
573
|
+
// synchronous, and it needs the NAMES rather than resolved declarations.
|
|
574
|
+
const declared = new Set([
|
|
575
|
+
...Object.keys(siteYml.queries || {}),
|
|
576
|
+
...Object.keys(readQueriesYml(sitePath))
|
|
577
|
+
])
|
|
554
578
|
|
|
555
579
|
if (existsSync(dataDir)) {
|
|
556
580
|
// A collection `x` owns `x.json` (the cascade) and `x/` (per-record files
|
|
@@ -565,16 +589,16 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
|
|
|
565
589
|
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
|
|
566
590
|
|
|
567
591
|
if (orphans.length > 0) {
|
|
568
|
-
const id = 'orphaned-
|
|
592
|
+
const id = 'orphaned-query-output'
|
|
569
593
|
issues.push({
|
|
570
594
|
id,
|
|
571
595
|
type: 'warning',
|
|
572
596
|
site: siteName,
|
|
573
|
-
message: `${orphans.length} entr${orphans.length === 1 ? 'y' : 'ies'} in public/${DATA_DIR}/ with no declared
|
|
597
|
+
message: `${orphans.length} entr${orphans.length === 1 ? 'y' : 'ies'} in public/${DATA_DIR}/ with no declared query`
|
|
574
598
|
})
|
|
575
599
|
warn(`[${id}] Stale output in public/${DATA_DIR}/: ${orphans.join(', ')}`)
|
|
576
600
|
log(
|
|
577
|
-
` No
|
|
601
|
+
` No query produces ${orphans.length === 1 ? 'it' : 'these'}. ` +
|
|
578
602
|
`${orphans.length === 1 ? 'It is' : 'They are'} still served and deployed.`
|
|
579
603
|
)
|
|
580
604
|
if (shouldFix(id)) {
|
|
@@ -614,7 +638,7 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
|
|
|
614
638
|
const body = existing === null ? '' : existing.replace(/\n*$/, '\n')
|
|
615
639
|
writeFileSync(
|
|
616
640
|
gitignorePath,
|
|
617
|
-
`${body}\n# Compiled
|
|
641
|
+
`${body}\n# Compiled query results — generated from entities/ + queries.yml\n${rule}\n`
|
|
618
642
|
)
|
|
619
643
|
fixed(`added ${rule} to ${gitignorePath}`)
|
|
620
644
|
if (existsSync(dataDir)) {
|
|
@@ -891,9 +915,9 @@ export async function doctor(args = []) {
|
|
|
891
915
|
}
|
|
892
916
|
|
|
893
917
|
// `public/<DATA_DIR>/` is the build's output directory and nothing else —
|
|
894
|
-
// `
|
|
895
|
-
// makes the mapping a bijection: every entry there should be backed
|
|
896
|
-
// declared
|
|
918
|
+
// `entities/` + `records.yml` is the only supported way to provide structured
|
|
919
|
+
// data. That makes the mapping a bijection: every entry there should be backed
|
|
920
|
+
// by a declared QUERY, so anything else is stale, and identifiable.
|
|
897
921
|
//
|
|
898
922
|
// It matters because the directory is written into the source tree rather
|
|
899
923
|
// than dist/, so what lands there persists and gets deployed. A collection
|