uniweb 0.55.0 → 0.56.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.55.0",
3
+ "version": "0.56.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.29.1",
45
- "@uniweb/kit": "^0.19.0",
46
- "@uniweb/runtime": "^0.26.0",
44
+ "@uniweb/core": "^0.29.2",
45
+ "@uniweb/kit": "^0.19.1",
46
+ "@uniweb/runtime": "^0.26.1",
47
47
  "@uniweb/semantic-parser": "^1.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.51.0",
51
- "@uniweb/semantic-parser": "^1.4.0",
52
- "@uniweb/content-reader": "^1.2.4"
50
+ "@uniweb/build": "^0.52.0",
51
+ "@uniweb/content-reader": "^1.2.5",
52
+ "@uniweb/semantic-parser": "^1.4.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -84,7 +84,7 @@ Documentation paths in this guide are given bare — `development/creating-compo
84
84
  |------|------|
85
85
  | Writing page content | `authoring/writing-content.md` |
86
86
  | Theming and styling | `authoring/theming.md` |
87
- | Authoring collections | `authoring/collections.md` |
87
+ | Working with records and queries | `authoring/collections.md` |
88
88
  | Where-object predicate format | `authoring/predicates.md` |
89
89
  | Data sources / custom transports | `development/data-sources.md` |
90
90
  | Building components | `development/creating-components.md` |
@@ -96,6 +96,7 @@ Documentation paths in this guide are given bare — `development/creating-compo
96
96
  | Content shape reference | `reference/content-structure.md` |
97
97
  | Component metadata (`meta.js`) | `reference/component-metadata.md` |
98
98
  | Site configuration | `reference/site-configuration.md` |
99
+ | Queries — everything a query can say | `reference/queries.md` |
99
100
  | Data fetching model | `reference/data-fetching.md` |
100
101
  | Navigation patterns | `reference/navigation-patterns.md` |
101
102
 
@@ -115,7 +116,7 @@ uniweb dev
115
116
 
116
117
  **Choosing a template.** `--template <name>` gives you a working site plus a foundation you can study and edit. `--template none` gives you the same two packages with no content — the right choice when you're building a foundation from scratch or porting a design. `--blank` gives you an empty workspace and assumes you'll add packages with `uniweb add`; use it only if you already know the framework.
117
118
 
118
- Official templates: `marketing` (tokens, insets, grids, multi-line headings), `docs` (sidebar nav, code highlighting), `dynamic` (live API data, loading states), `international` (i18n, collections, multi-locale routing), `store` (product grids, e-commerce), `academic` (publications, timeline, math), `extensions` (multi-foundation, runtime loading).
119
+ Official templates include `marketing` (tokens, insets, grids, multi-line headings), `docs` (sidebar nav, code highlighting), `blog` (records of the standard `@std/article` schema, a query, a parametric `[slug]` page), `dynamic` (live API data, loading states), `international` (i18n, records, multi-locale routing), `store` (product grids, e-commerce), `academic` (publications, timeline, math), `extensions` (multi-foundation, runtime loading). `uniweb template list` shows them all.
119
120
 
120
121
  **npm or pnpm.** Projects include both `pnpm-workspace.yaml` and npm workspaces. Replace `pnpm` with `npm` in any command in this guide.
121
122
 
@@ -131,7 +132,7 @@ my-project/
131
132
  A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site`.
132
133
 
133
134
  - **Foundation** (`src/`): React components. Those in `sections/` and `layouts/` are *section types* — selectable by authors via `type:`, or used for layout areas. Everything in `components/` and `utils/` is ordinary React and JS: the developer's workbench, not visible to authors.
134
- - **Site** (`site/`): markdown content and configuration, plus optional collections of structured content and references to external data sources.
135
+ - **Site** (`site/`): markdown content and configuration, plus optional records of structured content and the queries that reach them or an external data source.
135
136
 
136
137
  **The composition boundary.** Authors compose pages from finished section types — choosing types, writing content, setting params. Developers compose section types from building blocks — importing helpers, using libraries, writing JSX. Two different levels of composition, and the section type is the boundary between them. Don't expose building-block composition to authors; build complete, self-contained section types that handle their own internal structure.
137
138
 
@@ -777,7 +778,7 @@ Server-to-server callers are authenticated by other means, arranged with your ho
777
778
 
778
779
  **Set `seo.baseUrl` if you want absolute links** in the index — without it the links are root-relative, which still works for an agent that arrived via the index. `uniweb doctor` warns when it's unset.
779
780
 
780
- **What's excluded, and it's deliberate:** `seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, and dynamic route templates. An index *describes* pages rather than merely listing them, so an unlinked page would become both discoverable and summarized — which is why these exclusions are load-bearing rather than tidy-up. `noindex` or `hidden` on a **folder** takes the whole branch with it.
781
+ **What's excluded, and it's deliberate:** `seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, and a parametric page's URL pattern (`/blog/:slug`). An index *describes* pages rather than merely listing them, so an unlinked page would become both discoverable and summarized — which is why these exclusions are load-bearing rather than tidy-up. `noindex` or `hidden` on a **folder** takes the whole branch with it.
781
782
 
782
783
  **Declaring how your content may be used** is a separate axis from whether it may be fetched, and it goes in `seo.robots`:
783
784
 
@@ -793,7 +794,7 @@ seo:
793
794
 
794
795
  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.
795
796
 
796
- ### Records, queries and dynamic routes
797
+ ### Records, queries and parametric pages
797
798
 
798
799
  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.
799
800
 
@@ -845,7 +846,7 @@ Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `descript
845
846
 
846
847
  A bare string is a path under `entities/`, naming one file or matching many.
847
848
 
848
- ⚠️ **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.
849
+ ⚠️ **An empty `records.yml` is not the same as having none.** With no file, a build publishes every entity in `entities/`, and `uniweb push` leaves the published set alone. An empty file means "nothing is published": a build publishes none, and a push REMOVES what is published. The CLI asks before it does that.
849
850
 
850
851
  **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:
851
852
 
@@ -880,7 +881,7 @@ You can keep the same declarations under `queries:` in `site.yml` instead, if yo
880
881
 
881
882
  **A fetch always names a query.** A string in `fetch:` is a query name: `fetch: team` is `fetch: { query: team }`, and `fetch: [team, articles]` is a list of those. A fetch never names a file — `/data/<query>.json` is what the build generates from a query for a site with no backend, and writing that path in a `fetch:` stops the build. That is what keeps a site portable: name the query, debug locally against the generated data, publish, and the same page reads live records from the host with nothing changed.
882
883
 
883
- **A query selects a set of records; a `fetch:` takes from that set, never adding to it.** The query's `scope`, `where`, `sort` and `limit` decide which records it selects — its `limit` included: `recent` above is the 10 newest articles. A fetch's `where` keeps the ones that also match, its `sort` re-orders them, and its `limit` takes the first N — never more than the query selects. That is the whole list — `scope` belongs to the query, and the build stops on a `fetch:` that carries one; for another folder branch, declare another query. **Which records get a detail page is the query's to decide, never a fetch's:** each record `recent` selects gets its page, an 11th-newest article gets none, and a list's `limit: 3` still leaves all 10 their pages — so a condition or count that should decide which pages exist belongs on the query. Two entries under one key at one level: the first is used, and the build warns.
884
+ **A query selects a set of records; a `fetch:` takes from that set, never adding to it.** The query's `scope`, `where`, `sort` and `limit` decide which records it selects — its `limit` included: `recent` above is the 10 newest articles. A fetch's `where` keeps the ones that also match, its `sort` re-orders them, and its `limit` takes the first N — never more than the query selects. That is the whole list — `scope` belongs to the query, and the build stops on a `fetch:` that carries one; for another folder branch, declare another query. **Which records get a page is the query's to decide, never a fetch's:** each record `recent` selects gets its page, an 11th-newest article gets none, and a list's `limit: 3` still leaves all 10 their pages — so a condition or count that should decide which pages exist belongs on the query. Two fetches under one key at one level: the first is used, and the build warns.
884
885
 
885
886
  ```yaml
886
887
  # pages/blog/page.yml | # a section on the homepage
@@ -890,7 +891,7 @@ query: recent | type: ArticleTeaser
890
891
  | ---
891
892
  ```
892
893
 
893
- **Give each record its own page with a `[slug]/` folder** under the list page:
894
+ **Give each record its own page with a `[slug]/` folder** a *parametric page*, what other frameworks call a dynamic route — inside the page that names the query:
894
895
 
895
896
  ```
896
897
  pages/blog/
@@ -903,13 +904,13 @@ pages/blog/
903
904
 
904
905
  `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.
905
906
 
906
- **Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the list page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
907
+ **Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the query's own page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
907
908
 
908
909
  **Which query the URL narrows — the page's route query:** the `[slug]` page's own `query:`, else its parent page's (the usual shape, above), else `site.yml`'s (for a top-level `pages/[slug]/` only — the site's reaches no deeper page); if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's handle (`$name`, which compiled records carry — equal to their `slug`), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name — and when that field holds several values, **any member** matches (the record's own link is its first value). Routing by a field that is not unique picks one record and which one is not guaranteed; the build warns. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, about the same record: its route query is the `[slug]` page's, wherever that is declared, and a query `cv/` declares itself arrives under its own key. **A section chooses how it uses the page's record with `current:`** on its own `fetch:` — `only` (the default: the record, as a list of one), `exclude` (the others: "related"), `include` (all of them: a pager) — e.g. `fetch: { query: articles, as: related, current: exclude, limit: 3 }`, where `limit` counts the others, all from the query's set. **`current:` follows the query, not the key:** a fetch of the route query gets the record unless it says otherwise, whatever its `as`; a fetch of another query gets that query's records and reads `current:` only when written (`exclude` drops the page's record, `only` keeps just it). `refine: true` / `detail: false` are retired and stop the build. `[dir]` and `[path]` are refused as folder names, and so is any folder inside `[...path]/`.
909
910
 
910
911
  > **The record arrives as a single-element array under the key the component declares** — `content.data.recent[0]` for a component declaring `recent`, `content.data.article[0]` for one declaring `article: '@std/article'` over an `@std/article` query. The runtime never coerces it to an object. See *Data* in Part 4.
911
912
 
912
- **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the list page and the parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
913
+ **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
913
914
 
914
915
  **Two options for bigger sets:**
915
916
 
@@ -929,7 +930,7 @@ members:
929
930
 
930
931
  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.
931
932
 
932
- Full author guide: `authoring/collections.md`. Predicate operators: `authoring/predicates.md`.
933
+ Full author guide: `authoring/collections.md`. Every key a query takes: `reference/queries.md`. Predicate operators: `authoring/predicates.md`.
933
934
 
934
935
  ---
935
936
 
@@ -1783,7 +1784,7 @@ Content-less containers appear as group nodes (`hasContent: false`) — use `nav
1783
1784
 
1784
1785
  **A section receives the keys its component declares in `meta.js` `data:` — and nothing else** (plus any its foundation declares in `main.js` `data:`). A component that reads `content.data.articles` declares `articles`. Each declared key is filled by, in order: a tagged data block in the section; else the fetch that fills it, level by level from the section's own to the site's — a fetch whose `as` (its query's name by default) is the key, or else, **automatic `as`**, the first fetch under an undeclared key whose query's schema is the key's (`@/x` matches any scope's `x`); else `null`. So `data: { related: '@std/article' }` receives an `articles` query under `related`. `as:` on a fetch picks the key when two keys or two fetches share a schema. A fetch that fills none of a section's keys is not requested for it.
1785
1786
 
1786
- **Bound collections always arrive as arrays.** On a list page, `content.data.articles` is the full collection. On a parametric page (`[slug]/`), the matched record is delivered as a single-element array under the key the component declares — the detail section reads `content.data.articles[0]` (or `content.data.article[0]` with `data: { article: '@std/article' }`). When nothing matches, the key is `[]`; when nothing fills it, `null`. The runtime never coerces to a single object.
1787
+ **A query's records always arrive as an array.** On the query's own page, `content.data.articles` holds every record the fetch takes. On a parametric page (`[slug]/`), the record the URL names is delivered as a single-element array under the key the component declares — the section showing it reads `content.data.articles[0]` (or `content.data.article[0]` with `data: { article: '@std/article' }`). When nothing matches, the key is `[]`; when nothing fills it, `null`. The runtime never coerces to a single object.
1787
1788
 
1788
1789
  ```jsx
1789
1790
  function Article({ content, block }) {
@@ -1823,7 +1824,7 @@ fetch:
1823
1824
  limit: 3
1824
1825
  ```
1825
1826
 
1826
- **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`. (An external query declares no `deferred:` — its whole record comes from `record:`, below.) On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`. The hook is safe to call on any query: when the query has no separate source for the whole record it returns the record you passed in, because nothing was stripped from it.
1827
+ **Lean lists with `deferred:`.** A query over records with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]`. The list payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json`. (An external query declares no `deferred:` — its whole record comes from `record:`, below.) On a parametric page the record's full data is delivered automatically; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`. The hook is safe to call on any query: when the query has no separate source for the whole record it returns the record you passed in, because nothing was stripped from it.
1827
1828
 
1828
1829
  **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`, `useWholeRecord`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1829
1830
 
@@ -1844,7 +1845,7 @@ posts:
1844
1845
 
1845
1846
  It also takes `method: POST` with a `body`, `where` / `sort` / `limit` (evaluated over what arrived, after `transform`) and `queryable`. `record:` takes `url`, `method`, `body` and `transform`: `url` and `method` default to the query's, `body` and `transform` never carry over, and a placeholder named by the page's folder (`{id}` for `[id]`, `{slug}` for `[slug]`) is its URL segment. `schema`, `scope`, `deferred`, `excerpt` and `path` are refused beside `url:` — they describe the site's own records. The visitor's browser fetches an external query (a `fetch:` with `prerender: true` makes the build fetch it instead); it is never asked of a host's records service and compiles no `/data` file. On a `[id]` page the list still decides which records exist — `record:` fetches the one it found, whole. A site published to a Uniweb host reads the host's records with no configuration at all.
1846
1847
 
1847
- ⛔ A `fetch:` never carries a source: `path`, `url`, `method`, `body`, `transform`, `detail` and `scope` on a binding stop the build. The `detail:` forms (`rest`, `query`, a URL pattern) are now an external query's `record:`, and `detailUrl:` is `record.url`.
1848
+ ⛔ A `fetch:` never carries a source: `path`, `url`, `method`, `body`, `transform`, `detail` and `scope` on a fetch stop the build. The `detail:` forms (`rest`, `query`, a URL pattern) are now an external query's `record:`, and `detailUrl:` is `record.url`.
1848
1849
 
1849
1850
  A backend that needs a key, headers, paging, its own wire or query language is a **transport**: a named `{ resolve, cacheKey? }` exported by the foundation (or an extension), which the site selects per data key in `fetcher:` — the only thing that block is for:
1850
1851
 
@@ -1883,7 +1884,7 @@ search:
1883
1884
 
1884
1885
  | Provider | Answers with | Trade-off |
1885
1886
  |---|---|---|
1886
- | `index` (default) | `search-index.json` + Fuse.js in the browser | Free, works on **any** host including a plain static one, tolerates typos. Contains only what existed at build time. |
1887
+ | `index` (default) | `search-index.json`, ranked in the browser | Free, works on **any** host including a plain static one, tolerates typos. Contains only what existed at build time. |
1887
1888
  | `endpoint` | A server-side search API | Can cover records fetched from an API, and can be re-indexed without rebuilding the site. Requires a host that serves one. |
1888
1889
  | *any other name* | A foundation-supplied search transport | Fully open — Typesense, Meilisearch, Pagefind, a vendor API |
1889
1890
 
@@ -1895,7 +1896,7 @@ search:
1895
1896
 
1896
1897
  `endpoint:` is **required** with `provider: endpoint`; omit it and the provider refuses the query rather than guessing a path. It resolves against the site's base path — `/` → `/_search`, `base: /docs/` → `/docs/_search`, a subpath-served site follows its subpath. An absolute `https://…` URL points at another origin. A host that serves the site may offer search itself, supplying the address so the site declares none.
1897
1898
 
1898
- **Results have one shape, whatever the provider.** Always present: `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml`. Provider-optional (`null` when absent): `sectionId`, `anchor`, `description`, `component`, `snippetText`, `matches`, `collection`, `item`. Whether an optional field arrives is a deployment fact, not a content fact — the same site yields `item` from a server provider and `null` from the local index — so guard them: `result.item?.image`.
1899
+ **Results have one shape, whatever the provider.** Always present: `id`, `type`, `route`, `href`, `title`, `pageTitle`, `excerpt`, `snippetHtml`. Provider-optional (`null` when absent): `sectionId`, `anchor`, `description`, `component`, `snippetText`, `matches`, `group`, `item`. `type` is `page`, `section` or `record`; on a record hit, `item` holds the record's fields and `group` names the set it came from. Whether an optional field arrives is a deployment fact, not a content fact — the same site yields `item` from a server provider and `null` from the local index — so guard them: `result.item?.image`.
1899
1900
 
1900
1901
  `snippetHtml` is HTML with `<mark>`. Render it through `SafeHtml`, never as text.
1901
1902
 
@@ -2739,8 +2740,8 @@ Source repo (public, cloneable): **https://github.com/uniweb/docs** · any page
2739
2740
  |---------|--------|
2740
2741
  | `architecture/` | Component Content Architecture — the why behind the patterns in this file |
2741
2742
  | `getting-started/` | What is Uniweb, quickstart, templates |
2742
- | `authoring/` | Writing content, site setup, collections, theming, translations, predicates |
2743
+ | `authoring/` | Writing content, site setup, records, theming, translations, predicates |
2743
2744
  | `development/` | Foundations, component patterns, project structures, data, layouts, i18n, migration, schemas |
2744
- | `reference/` | site.yml, page.yml, content structure, meta.js, kit API, navigation, data fetching, CLI, deployment |
2745
+ | `reference/` | site.yml, page.yml, content structure, meta.js, kit API, navigation, records, queries, data fetching, parametric pages, CLI, deployment |
2745
2746
 
2746
2747
  The by-task table is in Part 0. For CLI flags, prefer `uniweb <command> --help` over this file — it's always current.
@@ -486,6 +486,21 @@ async function runInit(siteRoot, config, args) {
486
486
  log(` 3. Run 'uniweb i18n status' to check coverage${colors.reset}`)
487
487
  }
488
488
 
489
+ /**
490
+ * Which translations `status` and `audit` read: the page strings, or — with
491
+ * `--records-only` (or `--records`) — the record strings, in `<localesDir>/records/`,
492
+ * where `extract` writes the record manifest. Both directories hold the same shapes
493
+ * (`manifest.json` and one `<locale>.json` per locale), so every reader takes either.
494
+ *
495
+ * ⛔ Both commands ignored the flag until 2026-09-14 and reported the page strings.
496
+ */
497
+ function translationScope(config, args) {
498
+ const records = args.includes('--records-only') || args.includes('--records')
499
+ return records
500
+ ? { records: true, dir: join(config.localesDir, 'records'), noun: 'record manifest', extract: 'uniweb i18n extract --records-only' }
501
+ : { records: false, dir: config.localesDir, noun: 'manifest', extract: 'uniweb i18n extract' }
502
+ }
503
+
489
504
  /**
490
505
  * Status command - show translation coverage
491
506
  */
@@ -495,18 +510,19 @@ async function runStatus(siteRoot, config, args) {
495
510
  const showFreeform = args.includes('--freeform')
496
511
  const outputJson = args.includes('--json')
497
512
  const byPage = args.includes('--by-page')
513
+ const scope = translationScope(config, args)
498
514
 
499
515
  // Check if manifest exists
500
- const localesPath = join(siteRoot, config.localesDir)
516
+ const localesPath = join(siteRoot, scope.dir)
501
517
  const manifestPath = join(localesPath, 'manifest.json')
502
518
  if (!existsSync(manifestPath)) {
503
- error('No manifest found. Run "uniweb i18n extract" first.')
519
+ error(`No ${scope.noun} found. Run "${scope.extract}" first.`)
504
520
  process.exit(1)
505
521
  }
506
522
 
507
523
  // For --missing mode, use auditLocale which returns detailed missing info
508
524
  if (showMissing) {
509
- await runStatusMissing(siteRoot, config, locale, { outputJson, byPage })
525
+ await runStatusMissing(siteRoot, config, locale, { outputJson, byPage, scope })
510
526
  return
511
527
  }
512
528
 
@@ -518,7 +534,7 @@ async function runStatus(siteRoot, config, args) {
518
534
 
519
535
  // Standard status mode
520
536
  if (!outputJson) {
521
- log(`\n${colors.cyan}Translation Status${colors.reset}\n`)
537
+ log(`\n${colors.cyan}${scope.records ? 'Record ' : ''}Translation Status${colors.reset}\n`)
522
538
  }
523
539
 
524
540
  if (config.locales.length === 0) {
@@ -546,7 +562,7 @@ async function runStatus(siteRoot, config, args) {
546
562
  const localesToCheck = locale ? [locale] : config.locales
547
563
 
548
564
  const status = await getTranslationStatus(siteRoot, {
549
- localesDir: config.localesDir,
565
+ localesDir: scope.dir,
550
566
  locales: localesToCheck
551
567
  })
552
568
 
@@ -561,10 +577,10 @@ async function runStatus(siteRoot, config, args) {
561
577
  const hasMissing = Object.values(status.locales).some((l) => l.missing > 0)
562
578
  if (hasMissing) {
563
579
  log(
564
- `\n${colors.dim}To translate missing strings, edit the locale files in ${config.localesDir}/`
580
+ `\n${colors.dim}To translate missing strings, edit the locale files in ${scope.dir}/`
565
581
  )
566
582
  log(
567
- `Or use: uniweb i18n status --missing --json > missing.json${colors.reset}`
583
+ `Or use: uniweb i18n status${scope.records ? ' --records-only' : ''} --missing --json > missing.json${colors.reset}`
568
584
  )
569
585
  }
570
586
  } catch (err) {
@@ -615,26 +631,15 @@ async function runStatusFreeform(siteRoot, config, locale, options = {}) {
615
631
 
616
632
  const {
617
633
  discoverFreeformTranslations,
618
- buildFreeformPath,
619
- computeSourceHash,
634
+ freeformSourceIndex,
620
635
  getStaleTranslations,
621
636
  getOrphanedTranslations
622
637
  } = await import('@uniweb/build/i18n')
623
638
 
624
- // Build source hashes
625
- const sourceHashes = {}
626
- const validPaths = new Set()
627
- for (const page of siteContent.pages || []) {
628
- for (const section of page.sections || []) {
629
- if (section.stableId && section.content) {
630
- const path = buildFreeformPath(section, page)
631
- if (path) {
632
- validPaths.add(path)
633
- sourceHashes[path] = computeSourceHash(section.content)
634
- }
635
- }
636
- }
637
- }
639
+ // What the built content says about its free-form translations — every path the
640
+ // renderer reads one from, with its source's hash. The build's own check uses this
641
+ // index, so the two cannot disagree about which files are orphaned or stale.
642
+ const { validPaths, sourceHashes, canJudge } = freeformSourceIndex(siteContent)
638
643
 
639
644
  // Find all locales
640
645
  const entries = await readdir(freeformPath, { withFileTypes: true })
@@ -655,9 +660,10 @@ async function runStatusFreeform(siteRoot, config, locale, options = {}) {
655
660
  ...discovered.records
656
661
  ]
657
662
 
658
- // Check staleness
663
+ // Check staleness. Orphaned only when the content can judge it — never a record's
664
+ // translation, nor one for a page whose sections the built content does not carry.
659
665
  const stale = await getStaleTranslations(localeDir, sourceHashes)
660
- const orphaned = await getOrphanedTranslations(localeDir, validPaths)
666
+ const orphaned = (await getOrphanedTranslations(localeDir, validPaths)).filter((o) => canJudge(o.path))
661
667
 
662
668
  const upToDate = allPaths.filter(
663
669
  (p) =>
@@ -743,8 +749,8 @@ async function runStatusFreeform(siteRoot, config, locale, options = {}) {
743
749
  * Status --missing mode - show detailed missing strings
744
750
  */
745
751
  async function runStatusMissing(siteRoot, config, locale, options = {}) {
746
- const { outputJson = false, byPage = false } = options
747
- const localesPath = join(siteRoot, config.localesDir)
752
+ const { outputJson = false, byPage = false, scope = translationScope(config, []) } = options
753
+ const localesPath = join(siteRoot, scope.dir)
748
754
 
749
755
  if (config.locales.length === 0) {
750
756
  if (outputJson) {
@@ -834,7 +840,8 @@ async function runStatusMissing(siteRoot, config, locale, options = {}) {
834
840
  for (const entry of allMissing.slice(0, 20)) {
835
841
  const preview = truncateString(entry.source, 60)
836
842
  const context = entry.contexts?.[0]
837
- const location = context ? `${context.page}:${context.section}` : ''
843
+ // A page string's context names its page and section; a record string's, its record
844
+ const location = context ? context.record ?? `${context.page}:${context.section}` : ''
838
845
  log(` ${colors.dim}${entry.hash}${colors.reset} "${preview}"`)
839
846
  if (location) {
840
847
  log(` ${colors.dim}→ ${location}${colors.reset}`)
@@ -863,7 +870,7 @@ async function runStatusMissing(siteRoot, config, locale, options = {}) {
863
870
  function groupByPage(entries) {
864
871
  const grouped = {}
865
872
  for (const entry of entries) {
866
- const page = entry.contexts?.[0]?.page || 'unknown'
873
+ const page = entry.contexts?.[0]?.page || entry.contexts?.[0]?.record || 'unknown'
867
874
  if (!grouped[page]) grouped[page] = []
868
875
  grouped[page].push(entry)
869
876
  }
@@ -885,14 +892,15 @@ async function runAudit(siteRoot, config, args) {
885
892
  const locale = args.find((a) => !a.startsWith('-'))
886
893
  const clean = args.includes('--clean')
887
894
  const verbose = args.includes('--verbose') || args.includes('-v')
895
+ const scope = translationScope(config, args)
888
896
 
889
- log(`\n${colors.cyan}Translation Audit${colors.reset}\n`)
897
+ log(`\n${colors.cyan}${scope.records ? 'Record ' : ''}Translation Audit${colors.reset}\n`)
890
898
 
891
899
  // Check if manifest exists
892
- const localesPath = join(siteRoot, config.localesDir)
900
+ const localesPath = join(siteRoot, scope.dir)
893
901
  const manifestPath = join(localesPath, 'manifest.json')
894
902
  if (!existsSync(manifestPath)) {
895
- error('No manifest found. Run "uniweb i18n extract" first.')
903
+ error(`No ${scope.noun} found. Run "${scope.extract}" first.`)
896
904
  process.exit(1)
897
905
  }
898
906
 
@@ -1229,18 +1237,13 @@ async function runUpdateHash(siteRoot, config, args) {
1229
1237
  const siteContent = JSON.parse(siteContentRaw)
1230
1238
 
1231
1239
  const {
1232
- computeSourceHash,
1233
1240
  updateHash,
1234
- buildFreeformPath,
1241
+ freeformSourceIndex,
1235
1242
  getStaleTranslations
1236
1243
  } = await import('@uniweb/build/i18n')
1237
1244
 
1238
- // Build source hashes map
1239
- const sourceHashes = buildSourceHashMap(
1240
- siteContent,
1241
- buildFreeformPath,
1242
- computeSourceHash
1243
- )
1245
+ // The hash of each section's source, at every path the renderer reads its translation from
1246
+ const { sourceHashes } = freeformSourceIndex(siteContent)
1244
1247
 
1245
1248
  if (allStale) {
1246
1249
  // Update all stale translations
@@ -1284,26 +1287,6 @@ async function runUpdateHash(siteRoot, config, args) {
1284
1287
  }
1285
1288
  }
1286
1289
 
1287
- /**
1288
- * Build a map of relative paths to source hashes
1289
- */
1290
- function buildSourceHashMap(siteContent, buildFreeformPath, computeSourceHash) {
1291
- const sourceHashes = {}
1292
-
1293
- for (const page of siteContent.pages || []) {
1294
- for (const section of page.sections || []) {
1295
- if (section.stableId && section.content) {
1296
- const path = buildFreeformPath(section, page)
1297
- if (path) {
1298
- sourceHashes[path] = computeSourceHash(section.content)
1299
- }
1300
- }
1301
- }
1302
- }
1303
-
1304
- return sourceHashes
1305
- }
1306
-
1307
1290
  /**
1308
1291
  * Move free-form translations when pages are reorganized
1309
1292
  *
@@ -1491,22 +1474,18 @@ async function runPrune(siteRoot, config, args) {
1491
1474
  const siteContent = JSON.parse(siteContentRaw)
1492
1475
 
1493
1476
  const {
1494
- buildFreeformPath,
1477
+ freeformSourceIndex,
1495
1478
  getOrphanedTranslations,
1496
- removeManifestEntries,
1497
- discoverFreeformTranslations
1479
+ removeManifestEntries
1498
1480
  } = await import('@uniweb/build/i18n')
1499
1481
 
1500
- // Build set of valid paths
1501
- const validPaths = new Set()
1502
- for (const page of siteContent.pages || []) {
1503
- for (const section of page.sections || []) {
1504
- if (section.stableId) {
1505
- const path = buildFreeformPath(section, page)
1506
- if (path) validPaths.add(path)
1507
- }
1508
- }
1509
- }
1482
+ // THIS DELETES WHAT IT CALLS ORPHANED, so it judges a file exactly as the renderer
1483
+ // reads one (`freeformSourceIndex`, the build's own index): every path a section's
1484
+ // translation is read from — ⛔ until 2026-09-14 only `page-ids/<id>/…` on a page with
1485
+ // an `id`, so a route-addressed translation the page rendered was deleted — and only
1486
+ // what the built content can see: never a record's translation (`entities/…`), and
1487
+ // never one for a page whose sections the content does not carry.
1488
+ const { validPaths, canJudge } = freeformSourceIndex(siteContent)
1510
1489
 
1511
1490
  // Find all locales
1512
1491
  const entries = await readdir(freeformPath, { withFileTypes: true })
@@ -1517,8 +1496,8 @@ async function runPrune(siteRoot, config, args) {
1517
1496
  for (const locale of locales) {
1518
1497
  const localeDir = join(freeformPath, locale)
1519
1498
 
1520
- // Get orphaned translations
1521
- const orphaned = await getOrphanedTranslations(localeDir, validPaths)
1499
+ // Get orphaned translations — those the content can judge
1500
+ const orphaned = (await getOrphanedTranslations(localeDir, validPaths)).filter((o) => canJudge(o.path))
1522
1501
 
1523
1502
  if (orphaned.length === 0) continue
1524
1503
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-14T21:22:47.224Z",
3
+ "generatedAt": "2026-09-14T22:44:27.395Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.3.6",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.51.0",
13
+ "version": "0.52.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -24,7 +24,7 @@
24
24
  ]
25
25
  },
26
26
  "@uniweb/content-reader": {
27
- "version": "1.2.4",
27
+ "version": "1.2.5",
28
28
  "path": "framework/content-reader",
29
29
  "deps": []
30
30
  },
@@ -34,7 +34,7 @@
34
34
  "deps": []
35
35
  },
36
36
  "@uniweb/core": {
37
- "version": "0.29.1",
37
+ "version": "0.29.2",
38
38
  "path": "framework/core",
39
39
  "deps": [
40
40
  "@uniweb/semantic-parser",
@@ -54,7 +54,7 @@
54
54
  ]
55
55
  },
56
56
  "@uniweb/kit": {
57
- "version": "0.19.0",
57
+ "version": "0.19.1",
58
58
  "path": "framework/kit",
59
59
  "deps": [
60
60
  "@uniweb/core",
@@ -82,7 +82,7 @@
82
82
  ]
83
83
  },
84
84
  "@uniweb/runtime": {
85
- "version": "0.26.0",
85
+ "version": "0.26.1",
86
86
  "path": "framework/runtime",
87
87
  "deps": [
88
88
  "@uniweb/core",
@@ -110,7 +110,7 @@
110
110
  "deps": []
111
111
  },
112
112
  "@uniweb/templates": {
113
- "version": "0.14.1",
113
+ "version": "0.14.2",
114
114
  "path": "framework/templates",
115
115
  "deps": []
116
116
  },
@@ -120,7 +120,7 @@
120
120
  "deps": []
121
121
  },
122
122
  "@uniweb/unipress": {
123
- "version": "0.9.20",
123
+ "version": "0.9.21",
124
124
  "path": "framework/unipress",
125
125
  "deps": [
126
126
  "@uniweb/build",
@@ -172,7 +172,7 @@
172
172
  },
173
173
  "blog": {
174
174
  "name": "Blog",
175
- "description": "A blog built on the @std/article standard schema. Markdown articles render as a card grid and as full posts via dynamic [slug] routes. The first template to use a shared standard data schema — no custom schema file needed.",
175
+ "description": "A blog built on the @std/article standard schema. Markdown articles render as a card grid and as full posts on a parametric [slug] page. The first template to use a shared standard data schema — no custom schema file needed.",
176
176
  "tags": [
177
177
  "blog",
178
178
  "articles",
@@ -194,7 +194,7 @@
194
194
  },
195
195
  "store": {
196
196
  "name": "Store",
197
- "description": "Artisan e-commerce with product collections, Shopify Buy Button, journal blog, and stone-amber design",
197
+ "description": "Artisan e-commerce with a product catalog, Shopify Buy Button, journal blog, and stone-amber design",
198
198
  "tags": [
199
199
  "store",
200
200
  "e-commerce",
@@ -266,7 +266,7 @@
266
266
  },
267
267
  "business-docs": {
268
268
  "name": "Business Documents",
269
- "description": "A complete business-document site: invoice and SOW collections, per-record preview pages, and a filtered engagement report with XLSX export. Built on @uniweb/business-docs and demonstrates the same foundation that ships the unipress invoice template.",
269
+ "description": "A complete business-document site: invoice and SOW records, pages listing them, and a filtered engagement report with XLSX export. Built on @uniweb/business-docs and demonstrates the same foundation that ships the unipress invoice template.",
270
270
  "tags": [
271
271
  "business-docs",
272
272
  "invoice",