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 +7 -7
- package/partials/agents.md +19 -18
- package/src/commands/i18n.js +54 -75
- package/src/framework-index.json +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "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.
|
|
45
|
-
"@uniweb/kit": "^0.19.
|
|
46
|
-
"@uniweb/runtime": "^0.26.
|
|
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
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
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": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
-
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.**
|
|
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
|
|
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**
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
|
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`, `
|
|
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,
|
|
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.
|
package/src/commands/i18n.js
CHANGED
|
@@ -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,
|
|
516
|
+
const localesPath = join(siteRoot, scope.dir)
|
|
501
517
|
const manifestPath = join(localesPath, 'manifest.json')
|
|
502
518
|
if (!existsSync(manifestPath)) {
|
|
503
|
-
error(
|
|
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:
|
|
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 ${
|
|
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
|
-
|
|
619
|
-
computeSourceHash,
|
|
634
|
+
freeformSourceIndex,
|
|
620
635
|
getStaleTranslations,
|
|
621
636
|
getOrphanedTranslations
|
|
622
637
|
} = await import('@uniweb/build/i18n')
|
|
623
638
|
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
900
|
+
const localesPath = join(siteRoot, scope.dir)
|
|
893
901
|
const manifestPath = join(localesPath, 'manifest.json')
|
|
894
902
|
if (!existsSync(manifestPath)) {
|
|
895
|
-
error(
|
|
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
|
-
|
|
1241
|
+
freeformSourceIndex,
|
|
1235
1242
|
getStaleTranslations
|
|
1236
1243
|
} = await import('@uniweb/build/i18n')
|
|
1237
1244
|
|
|
1238
|
-
//
|
|
1239
|
-
const sourceHashes =
|
|
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
|
-
|
|
1477
|
+
freeformSourceIndex,
|
|
1495
1478
|
getOrphanedTranslations,
|
|
1496
|
-
removeManifestEntries
|
|
1497
|
-
discoverFreeformTranslations
|
|
1479
|
+
removeManifestEntries
|
|
1498
1480
|
} = await import('@uniweb/build/i18n')
|
|
1499
1481
|
|
|
1500
|
-
//
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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
|
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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",
|