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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.33.1",
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/core": "^0.13.1",
45
- "@uniweb/kit": "^0.14.0",
46
- "@uniweb/semantic-parser": "^1.3.1",
47
- "@uniweb/runtime": "^0.13.1"
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.29.1",
51
- "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/semantic-parser": "^1.3.1"
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": {
@@ -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
- ### Collections and dynamic routes
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. **Collections are the other kind: repeating content managed as a set of files**, one item per file, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
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
- They're delivered through the **same data pipeline as remote APIs**, so from a component's point of view a locally-authored collection and a backend-served one look identical. Whether the records live in files or behind an endpoint is a transport concern (see *Fetching from other sources* in Part 4).
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
- ├── collections/
783
- │ ├── articles/
782
+ ├── entities/ # your stored things. The folder names their data schema
783
+ │ ├── article/
784
784
  │ │ ├── getting-started.md
785
785
  │ │ └── design-tips.md
786
- │ └── team/
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
- **Declare each collection in `site.yml`:**
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
- collections:
810
- articles: collections/articles # simple form — just point at the folder
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
- team: # extended form
813
- path: collections/team # or `url:` for a remote source
814
- sort: order asc # `date desc`, `title asc`, …
815
- where: { published: { ne: false } } # a predicate — see authoring/predicates.md
816
- limit: 100
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
- **Show a collection on a page** with `data:` in `page.yml` (the whole collection), or `fetch:` in a section's frontmatter (a subset):
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: articles | type: ArticleTeaser
825
- | fetch: { collection: articles, limit: 3, sort: date desc }
859
+ data: recent | type: ArticleTeaser
860
+ | fetch: { query: recent, limit: 3 }
826
861
  | ---
827
862
  ```
828
863
 
829
- **Give each item its own page with a `[slug]/` folder** under the list page:
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: articles
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 item arrives automatically
872
+ └── article.md # just `type: Article` — the record arrives automatically
838
873
  ```
839
874
 
840
- `collections/articles/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.
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 collection key** — `content.data.articles[0]`, not `content.data.article`. The runtime never coerces it to an object and never synthesizes a singular key. See *Data* in Part 4.
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 collections:**
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 collection, add `detailUrl: /api/articles/{slug}` so the framework knows how to fetch one full record; file-based collections emit per-record files at `/data/<name>/<slug>.json` and need no configuration.
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
- collections:
852
- members:
853
- path: collections/members
854
- queryable:
855
- department: { type: enum, label: Department, options: [biology, physics, chemistry] }
856
- tenured: { type: boolean, label: Tenured }
857
- start_year: { type: range, label: Start year, min: 1800, max: 2025 }
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
- collection: articles
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:`.** Collections with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]` in `site.yml`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based collections) 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 collection: when there is no separate detail source it returns the record you passed in, because nothing was stripped from it.
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: { collection: data.items, item: data.article, error: errors.0.message }
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/collections/articles/x.md # collections work too
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
- **Collections translate in the same `extract` run**, into their own manifest at `locales/collections/manifest.json`.
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
 
@@ -27,7 +27,7 @@ import {
27
27
  computeUnitHashes,
28
28
  collectUnitUuids,
29
29
  collectFolderItemUuids,
30
- collectCollectionUuids,
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
- 'collectionUuids',
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
- collectionUuids: {},
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 readCollectionUuids(siteDir) {
496
- return readMap(siteDir, 'collectionUuids')
495
+ export function readQueryUuids(siteDir) {
496
+ return readMap(siteDir, 'queryUuids')
497
497
  }
498
- export function writeCollectionUuids(siteDir, map) {
498
+ export function writeQueryUuids(siteDir, map) {
499
499
  if (!map || !Object.keys(map).length) return
500
- updateSyncCache(siteDir, { collectionUuids: map })
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 — `buildCollectionEntities`
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/…` collections are unaffected: their
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.collections?.entityCount || 0)
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 collectionUuids = readCollectionUuids(siteDir)
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(collectionUuids).length ? { collectionUuids } : {}),
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, collections, siteContentUuid, hashes, applied } = pkg
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 collection structure is already established on the backend and can't be changed " +
1276
- 'in place — e.g. adding or removing a schema-backed collection, or switching one between ' +
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 collectionIds = collectCollectionUuids(siteFinalizedDoc)
1387
- if (Object.keys(collectionIds).length) writeCollectionUuids(siteDir, collectionIds)
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 (collections) {
1432
+ if (records) {
1433
1433
  if (!boundSiteUuid) {
1434
1434
  error(
1435
- 'Cannot push collections — the site has no uuid yet. Push the site-content lane first.'
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
- 'collections',
1441
- () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg }),
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: collections.index, finalized })
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)
@@ -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 (`buildLocalizedCollections`)
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 { buildLocalizedCollections } = await import('@uniweb/build/i18n')
656
- const collectionOutputs = await buildLocalizedCollections(projectDir, {
655
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
656
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
657
657
  locales: i18nConfig.locales,
658
658
  outputDir: distDir,
659
- collectionsLocalesDir: join(
659
+ recordLocalesDir: join(
660
660
  projectDir,
661
661
  i18nConfig.localesDir,
662
- 'collections'
662
+ 'records'
663
663
  )
664
664
  })
665
- const collectionCount = Object.values(collectionOutputs).reduce(
665
+ const recordCount = Object.values(recordOutputs).reduce(
666
666
  (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
667
667
  0
668
668
  )
669
- if (collectionCount > 0) {
669
+ if (recordCount > 0) {
670
670
  success(
671
- `Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`
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('Collection translation:', err.message)
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 { buildLocalizedCollections } = await import('@uniweb/build/i18n')
775
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
776
776
 
777
- const collectionOutputs = await buildLocalizedCollections(projectDir, {
777
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
778
778
  locales: i18nConfig.locales,
779
779
  outputDir: join(projectDir, 'dist'),
780
- collectionsLocalesDir: join(
780
+ recordLocalesDir: join(
781
781
  projectDir,
782
782
  i18nConfig.localesDir,
783
- 'collections'
783
+ 'records'
784
784
  )
785
785
  })
786
786
 
787
787
  // Count collections translated
788
- const collectionCount = Object.values(collectionOutputs).reduce(
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 (collectionCount > 0) {
793
+ if (recordCount > 0) {
794
794
  success(
795
- `Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`
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('Collection translation:', err.message)
801
+ console.error('Record translation:', err.message)
802
802
  }
803
803
  }
804
804
  } catch (err) {
@@ -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-collections` to it when set).
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-collections Pull pages only; skip collection records
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-collections]`
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 noCollections =
188
- args.includes('--no-collections') || args.includes('--content-only')
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', 'collections.yml']
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 (noCollections) pullExtra.push('--no-collections')
397
+ if (noRecords) pullExtra.push('--no-records')
398
398
 
399
399
  if (deps.skipPull) {
400
400
  note('Skipping pull (test mode).')
@@ -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.collections?.length)
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, collection records, and @-nested section')
200
+ say.warn('v0 scope: media bytes, records, and @-nested section')
202
201
  say.dim('hierarchy are not yet carried (documented).')
203
202
  }
@@ -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 `collections/`, and
157
- * nothing else — `collections/` is the only supported way to provide
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
- const declared = new Set(Object.keys(siteYml.collections || {}))
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-collection-output'
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 collection`
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 collection in site.yml produces ${orphans.length === 1 ? 'it' : 'these'}. ` +
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 collections — generated from collections/\n${rule}\n`
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
- // `collections/` is the only supported way to provide structured data. That
895
- // makes the mapping a bijection: every entry there should be backed by a
896
- // declared collection, so anything else is stale, and identifiable.
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