diff --git a/apps/docs/content/docs/dev/content-engine/caching.mdx b/apps/docs/content/docs/dev/content-engine/caching.mdx
index 0fb6ffa93..8d838e3ce 100644
--- a/apps/docs/content/docs/dev/content-engine/caching.mdx
+++ b/apps/docs/content/docs/dev/content-engine/caching.mdx
@@ -26,9 +26,29 @@ contentPublicSlugTag("example.article", "hello-world");
// "content:example.article:slug:hello-world"
```
-Format: `content:{contentTypeId}:{scope}[:{key}]`. No plugin id - a content type
-id is already globally unique (`validateContentTypes` enforces it) and already
-namespaced, as in `example.article`.
+Format: `content:{contentTypeId}:{scope}[:{locale}][:{key}]`. No plugin id - a
+content type id is already globally unique (`validateContentTypes` enforces it)
+and already namespaced, as in `example.article`.
+
+On a [localized](/docs/dev/content-engine/localized-public-api) content type every
+builder takes a locale, and the segment sits **after** the scope so the two forms
+can never collide - `content:x:list` is three segments and `content:x:list:pl` is
+four, whatever the locale happens to spell:
+
+```ts
+contentPublicListTag("example.article", "pl");
+// "content:example.article:list:pl"
+
+contentPublicSlugTag("example.article", "witaj", "pl");
+// "content:example.article:slug:pl:witaj"
+```
+
+The locale is load-bearing on the slug tag rather than merely tidy: two languages
+routinely answer to the *same* slug (`/en/about` and `/pl/about`), so a
+locale-less slug tag would make one language's edit expire the other's page.
+
+A content type that is not localized produces exactly the tags it always did,
+byte for byte, so nothing existing has to be re-tagged.
These are pure strings and they are **public API**. Tag your own `fetch` calls
and your own `"use cache"` functions with them and your pages get expired at the
@@ -305,6 +325,29 @@ enforces rather than by a second, drifting rule:
isContentPubliclyVisible({ publishedAt: row.publishedAt, status: row.status });
```
+`isContentTranslationPubliclyVisible` is the localized counterpart, and it is
+stated as an `&&` of that same predicate rather than as a second set of clauses -
+so the two cannot drift into disagreeing about what "published" means:
+
+```ts
+isContentTranslationPubliclyVisible({ base: row, translation });
+```
+
+### Which locales a mutation reaches
+
+`contentLocaleInvalidations` answers that, and it is pure:
+
+| What changed | Reaches |
+| --- | --- |
+| A **shared** field, or the record's publication state | **every** locale |
+| A translation in a **non-default** locale | that locale |
+| A translation in the **default** locale, `fallback: "default"` | that locale, plus every locale with no translation of its own |
+| A translation in the **default** locale, `fallback: "none"` | that locale |
+
+Nothing falls back to Polish, whatever the fallback setting is - so a Polish edit
+never throws away the English cache. See
+[Localized public API](/docs/dev/content-engine/localized-public-api#caching).
+
## Where the Next imports live
Exactly one place: `@vitnode/core/content/next`.
diff --git a/apps/docs/content/docs/dev/content-engine/index.mdx b/apps/docs/content/docs/dev/content-engine/index.mdx
index 9d3ef8c07..77b4b354b 100644
--- a/apps/docs/content/docs/dev/content-engine/index.mdx
+++ b/apps/docs/content/docs/dev/content-engine/index.mdx
@@ -61,9 +61,11 @@ Four more declarations, each opt-in:
Publication alone exposes nothing. Public exposure requires both of the first
two; `editorial` works with or without either. `localization` combines with
-`publication` and `editorial`, and is still
-[exclusive of `publicApi` and `search`](/docs/dev/content-engine/localization#stage-5b-boundaries) -
-both arrive in a later stage.
+`publication`, `editorial` and `publicApi` - a public read then
+[resolves one language](/docs/dev/content-engine/localized-public-api) - and is
+still
+[exclusive of `search`](/docs/dev/content-engine/localization#stage-5c-boundaries),
+which arrives in a later stage.
## What it is not
diff --git a/apps/docs/content/docs/dev/content-engine/limitations.mdx b/apps/docs/content/docs/dev/content-engine/limitations.mdx
index 59f3ef2f2..84de2d6d9 100644
--- a/apps/docs/content/docs/dev/content-engine/limitations.mdx
+++ b/apps/docs/content/docs/dev/content-engine/limitations.mdx
@@ -52,22 +52,29 @@ whose *reading* half is not built yet:
| Combination | Refused until |
| --- | --- |
-| `localization` + `publicApi` | Stage 5C |
| `localization` + `search` | Stage 5D |
-Each is a definition-time error naming the stage. A localized content type that
-silently ran Stage 1-4 logic against its base table while ignoring its localized
-fields would be worse than one that refuses to be declared.
+It is a definition-time error naming the stage. A localized content type that
+silently indexed one language while ranking every other one as a miss would be
+worse than one that refuses to be declared.
-Because preview projects through `publicApi.fields`, a **locale preview link
-cannot be minted** until Stage 5C either - the
-[token format](/docs/dev/content-engine/translation-preview) is in place and
-tested, but the routes that mint and read one are not.
+Stage 5C lifted the `publicApi` refusal, so
+[locale-aware public reads](/docs/dev/content-engine/localized-public-api),
+fallback resolution, strict-locale slugs, locale-aware cache tags and
+[locale preview links](/docs/dev/content-engine/translation-preview) all work now.
-Also outside Stage 5B: fallback resolution, locale-aware cache tags, per-locale
-search documents, an `Outdated` badge (its honest definition needs a comparison
-two timestamps cannot make), a locale selector on the AdminCP *list*, and
-locale-specific scheduling.
+Still outside Stage 5C: per-locale search documents, an `Outdated` badge (its
+honest definition needs a comparison two timestamps cannot make), a locale
+selector on the AdminCP *list*, and locale-specific scheduling.
+
+## A public list cannot be ordered by a localized field
+
+`publicApi.orderableFields` refuses one at definition time. A list ordered by a
+localized title would reshuffle itself for every language, and a fallback set
+would interleave two collations - so one cursor would mean two different
+positions depending on the language. Order by a column the record has one of.
+`filterableFields` and `searchableFields` *may* name a localized field: both are
+evaluated against the single translation the reader is being served.
## Localized field names cannot appear on base-table surfaces
@@ -79,7 +86,7 @@ six are compile errors and runtime errors.
`admin.titleField` therefore falls back to `null` on a content type whose only
text fields are localized. The locale tabs show the localized title inside each
tab; the list still has no locale-aware title column, which arrives with the
-locale selector in Stage 5C.
+locale selector in Stage 5D.
## Foreign key names on a long translation table are truncated by Postgres
diff --git a/apps/docs/content/docs/dev/content-engine/localization.mdx b/apps/docs/content/docs/dev/content-engine/localization.mdx
index b1e7f2615..36b73b1d5 100644
--- a/apps/docs/content/docs/dev/content-engine/localization.mdx
+++ b/apps/docs/content/docs/dev/content-engine/localization.mdx
@@ -49,10 +49,10 @@ A content type without the block generates the same single table, the same
routes and the same wire shapes it always did.
- Localization works with `publication` and `editorial` from Stage 5B on, but
+ Localization works with `publication`, `editorial` and `publicApi`, but
cannot yet be combined with `publicApi` or `search` - each is refused at
- definition time with a message naming the stage that lifts it. See [Stage 5B
- boundaries](#stage-5b-boundaries).
+ definition time with a message naming the stage that lifts it. See [Stage 5C
+ boundaries](#stage-5c-boundaries).
## This is not UI translation
@@ -102,7 +102,7 @@ localization: {
| --- | --- | --- | --- |
| `enabled` | `true` | - | Literal `true`. Omit the block to stay non-localized |
| `defaultLocale` | `string` | - | The locale every record is created in, and the one translation it can never lose |
-| `fallback` | `"none" \| "default"` | `"none"` | Reserved for Stage 5C. Resolved now so the configuration is stable before anything reads through it |
+| `fallback` | `"none" \| "default"` | `"none"` | What a [public read](/docs/dev/content-engine/localized-public-api) does for a locale with no published translation |
`defaultLocale` is checked twice. Its **shape** is checked at definition time -
it has to look like a locale code and fit `varchar(32)`. Whether it names a real,
@@ -111,7 +111,7 @@ checked [once at boot](#the-boot-check) instead.
`fallback` does nothing yet, and this page will not pretend otherwise. Nothing in
Stage 5A reads through it; it exists so that a content type declared today does
-not change public behaviour the moment Stage 5C lands. `"none"` is the default
+not change public behaviour the moment the public read layer landed. `"none"` is the default
because it is the only answer that cannot silently publish the wrong language.
## Which fields can be localized
@@ -274,25 +274,24 @@ offender at once rather than failing on the first, and it is skipped entirely
when no content type is localized - an install with none never touches the
languages table because of it.
-## Stage 5B boundaries
+## Stage 5C boundaries
-Stage 5A landed the infrastructure; Stage 5B landed the editorial layer on top of
-it. What is still missing is everything that reads *outwards*, and the honest
-failure for that is a refused definition rather than a content type that quietly
-runs Stage 1-4 logic against the base table while pretending its localized fields
-do not exist.
+Stage 5A landed the infrastructure, Stage 5B the editorial layer and Stage 5C the
+[public read](/docs/dev/content-engine/localized-public-api). One thing still
+reads outwards without knowing about languages, and the honest failure for that is
+a refused definition rather than a content type that quietly indexes one language
+and ranks every other one as a miss.
| Combination | Refused until | Why |
| --- | --- | --- |
-| `localization` + `publicApi` | Stage 5C | A public read has to resolve a locale and decide what to do when a translation is missing |
| `localization` + `search` | Stage 5D | One document per record would index a single language and rank every other one as a miss |
-Each is a `ContentEngineError` at definition time, with the stage in the message.
+It is a `ContentEngineError` at definition time, with the stage in the message.
-Because `editorial.preview` requires `publicApi`, a **locale preview link cannot
-be minted yet** either - the token format is in place and tested, and the routes
-land with Stage 5C. See
-[Locale preview](/docs/dev/content-engine/translation-preview).
+`localization` + `publicApi` is **no longer refused**: locale precedence, fallback,
+strict-locale slugs, locale-aware cache tags and the
+[locale preview link](/docs/dev/content-engine/translation-preview) all landed with
+Stage 5C.
## The roadmap
diff --git a/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx
new file mode 100644
index 000000000..65e6bfd31
--- /dev/null
+++ b/apps/docs/content/docs/dev/content-engine/localized-public-api.mdx
@@ -0,0 +1,248 @@
+---
+title: Localized public API
+description: How a public read picks a language, what it does when a translation is missing, and why a slug never falls back.
+icon: Globe
+---
+
+A [public API](/docs/dev/content-engine/public-api) on a
+[localized](/docs/dev/content-engine/localization) content type has to answer one
+question the ordinary one never asks: *which language?*
+
+Everything on this page follows from one sentence.
+
+
+ A public localized response is **one base row joined to one translation, and
+ both halves have to be published.** Fallback chooses *which* translation the
+ predicate runs against. It never relaxes the predicate.
+
+
+## Turning it on
+
+```ts title="src/content/article.ts"
+export const articleContentType = defineContentType({
+ id: "example.localized-article",
+ tableName: "example_localized_articles",
+
+ localization: { enabled: true, defaultLocale: "en", fallback: "default" },
+ publication: { enabled: true },
+
+ fields: {
+ title: field.text({ localized: true, required: true }),
+ slug: field.slug({ localized: true, source: "title" }),
+ body: field.textarea({ localized: true, required: true }),
+ featured: field.boolean({ defaultValue: false }),
+ },
+
+ publicApi: {
+ enabled: true,
+ path: "localized-articles",
+ fields: ["title", "slug", "body", "featured", "publishedAt"],
+ searchableFields: ["title", "body"],
+ // Shared columns only - see "What you cannot order by" below.
+ orderableFields: ["publishedAt"],
+ filterableFields: ["featured", "slug"],
+ },
+});
+```
+
+That gives you the same two routes a non-localized public content type gets, plus
+a `locale` query parameter on each:
+
+```http
+GET /api/{pluginId}/content/localized-articles/?locale=pl
+GET /api/{pluginId}/content/localized-articles/{slug}?locale=pl
+```
+
+## Which language a request is for
+
+Three sources, in this order, and the order is the whole point:
+
+| Source | Where from | Unmatched |
+| --- | --- | --- |
+| **Explicit** | `?locale=pl` | **404** |
+| **Negotiated** | `Accept-Language` | falls through |
+| **Default** | `localization.defaultLocale` | - |
+
+An **explicit** locale is a deliberate request for one language, so it is never
+quietly replaced. `?locale=de` on an install that does not serve German is the
+same 404 as a slug that does not exist - substituting the default would answer a
+German URL with English content, and locale-aware caching would then store it
+under the German tag and keep doing it.
+
+A **negotiated** locale is a preference rather than an instruction, so an
+unmatched `Accept-Language` falls through to the default. A visitor whose browser
+asks for Icelandic should get the site, not a 404.
+
+A locale the app has switched off in `i18n.locales` is not available to either.
+It stays readable in the AdminCP - hiding it would make the content
+unrecoverable - and unreachable in public, which is the read-side half of the rule
+that already stops content being *written* into one.
+
+### What comes back
+
+```json
+{
+ "title": "Witaj",
+ "slug": "witaj",
+ "body": "…",
+ "featured": false,
+ "publishedAt": "2026-01-01T00:00:00.000Z",
+ "locale": "pl"
+}
+```
+
+`locale` is the language the row **actually is in**, which with a fallback is not
+always the one that was asked for. Without it a reader cannot tell a Polish
+article from an English one served through the fallback - and `hreflang`, a
+language switcher and a "not translated yet" notice all need exactly that
+distinction. A localized content type may not expose a field called `locale`;
+`defineContentType` refuses it by name.
+
+The response also carries `Content-Language`, and `Vary: Accept-Language` when the
+header is what decided. A response chosen by an explicit `?locale=` is keyed by
+its URL, so varying on a header that decided nothing would fragment every shared
+cache for free.
+
+## Fallback
+
+| `fallback` | A locale with no published translation |
+| --- | --- |
+| `"none"` (default) | is not public in that language at all |
+| `"default"` | is served the **default** language's translation |
+
+Fallback is deliberately narrow, and these are the places it does **not** apply:
+
+- **Slug lookup.** See below.
+- **The AdminCP.** A locale tab shows that locale, or shows `Missing`.
+- **Preview.** A [locale preview](/docs/dev/content-engine/translation-preview) is
+ bound to one language and refuses every other.
+- **History and mutations.** A revision belongs to a locale; a write names one.
+- **Search.** Per-locale documents land in Stage 5D.
+
+And one thing it can never do:
+
+```text
+pl translation exists but is a draft → falls back to en ✅
+pl translation exists and is published → serves pl ✅
+en translation is a draft → nothing is public ✅
+```
+
+A draft is never served, in any language, through any path. The fallback picks
+which translation the published predicate is evaluated against.
+
+## A slug never falls back
+
+`GET /{slug}` is **strict-locale**, whatever `fallback` says:
+
+```text
+/localized-articles/witaj?locale=pl → 200, the Polish article
+/localized-articles/witaj?locale=en → 404
+/localized-articles/hello?locale=pl → 404, even though `hello` is reachable
+ in Polish through the fallback
+```
+
+A URL belongs to a language. Answering `/pl/witaj` from the English row would be
+the wrong article, in the wrong language - and then the wrong thing cached under
+the Polish tag. Two languages routinely answer to the *same* slug (`/en/about`
+and `/pl/about`), which is exactly why the locale is part of the lookup rather
+than a hint.
+
+The rule holds whether the slug field is localized or shared. A shared slug is
+matched on the base row and the translation is still required to be published in
+the requested language.
+
+## Filtering and searching
+
+Both are evaluated against **the one translation the reader is being served**, so
+they can never match a language nobody will see:
+
+```text
+?slug=filtr&locale=pl → matches, the Polish translation's slug is `filtr`
+?slug=filtr&locale=en → no match, the English one's slug is `filter`
+```
+
+A localized field may appear in `searchableFields` and `filterableFields`. Shared
+fields work exactly as before, and the two can be combined in one request.
+
+### What you cannot order by
+
+`publicApi.orderableFields` refuses a localized field, at definition time:
+
+> `publicApi.orderableFields` includes the localized field "title". A public list
+> is ordered by a column of the record, not of one of its translations.
+
+A list ordered by a localized title would reshuffle itself for every language, and
+a fallback set would interleave two collations - so the same cursor would mean two
+different positions depending on the language, which is not a pagination bug you
+can fix later. Order by something the record has one of: `publishedAt`,
+`createdAt`, a shared enum.
+
+## Reading it from a page
+
+```tsx title="src/app/[locale]/articles/[slug]/page.tsx"
+const { locale, slug } = await params;
+
+const { data } = await contentPublicFetch({
+ definition: articleContentType,
+ locale,
+ pluginId: "@vitnode/example",
+ schema: articleContentType.schemas.publicSelect,
+ slug,
+});
+
+if (!data) notFound();
+```
+
+Passing `locale` does two things that have to happen together, which is why it is
+one argument rather than a query parameter you add yourself: it goes to the API as
+`?locale=`, **and** it goes into the cache tags. Omitting it is not an error - the
+API falls back to the default locale - but the response is then tagged as though
+it were locale-less, so a translation publish will not expire it.
+
+## Caching
+
+Every tag gains a locale segment, after the scope:
+
+```text
+content:example.article:list:pl
+content:example.article:item:pl:7
+content:example.article:slug:pl:witaj
+```
+
+A content type that is not localized produces exactly the tags it always did -
+byte for byte - so nothing existing has to be re-tagged.
+
+### Which locales a mutation expires
+
+Two rules, and both come straight from what a response is made of:
+
+| What changed | Reaches |
+| --- | --- |
+| A **shared** field, or the record's publication state | **every** locale |
+| A translation in a **non-default** locale | that locale |
+| A translation in the **default** locale, `fallback: "default"` | that locale, plus every locale with no translation of its own |
+| A translation in the **default** locale, `fallback: "none"` | that locale |
+
+A shared field is in every language's response and the base row's publication
+state gates all of them, so a shared edit reaches everything. A Polish edit
+reaches Polish: nothing falls back to Polish, whatever the fallback setting is.
+
+The AdminCP works this out by taking a snapshot on each side of the mutation
+(`GET /{id}/public-locales`) rather than by reasoning about it in the browser -
+whether a locale has a page depends on the base row, that locale's translation,
+the fallback and the language registry, and a second copy of that rule is the
+classic pair that drifts, with a stale page in one language as the symptom.
+
+Scheduled publishing works the same way. A scheduled transition moves the
+*record*, so it expires every locale that had a page or has one now, over the
+[revalidation bridge](/docs/dev/content-engine/scheduling).
+
+## Related
+
+- [Public API](/docs/dev/content-engine/public-api) - the non-localized shape this
+ extends
+- [Caching](/docs/dev/content-engine/caching) - the tags, and how to reuse them
+- [Locale preview](/docs/dev/content-engine/translation-preview) - previewing one
+ language of an unpublished record
+- [Translation editorial workflow](/docs/dev/content-engine/translation-editorial) -
+ what "published in this language" means
diff --git a/apps/docs/content/docs/dev/content-engine/meta.json b/apps/docs/content/docs/dev/content-engine/meta.json
index b2d952c5d..2460b58b8 100644
--- a/apps/docs/content/docs/dev/content-engine/meta.json
+++ b/apps/docs/content/docs/dev/content-engine/meta.json
@@ -26,6 +26,7 @@
"translation-editorial",
"translation-revisions",
"translation-preview",
+ "localized-public-api",
"localization-migrations",
"admincp",
"permissions",
diff --git a/apps/docs/content/docs/dev/content-engine/public-api.mdx b/apps/docs/content/docs/dev/content-engine/public-api.mdx
index 174c4405e..31bf5b72a 100644
--- a/apps/docs/content/docs/dev/content-engine/public-api.mdx
+++ b/apps/docs/content/docs/dev/content-engine/public-api.mdx
@@ -282,6 +282,17 @@ flag that would add one.
must not surface through a result snippet, a highlighted match or an exact-match
probe either.
+## Localized content types
+
+A [localized](/docs/dev/content-engine/localization) content type gets the same two
+routes plus a `locale` query parameter, and the response carries the language it
+resolved to. Everything on this page still applies - the allowlist, the published
+predicate, the 404-for-everything rule - with one addition and one restriction:
+`filterableFields` and `searchableFields` may name a localized field, and
+`orderableFields` may not.
+
+See [Localized public API](/docs/dev/content-engine/localized-public-api).
+
## What this is not
No public writes, ever - under any configuration, including
diff --git a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx
index 601bf90a3..9ffdde4c3 100644
--- a/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx
+++ b/apps/docs/content/docs/dev/content-engine/translation-editorial.mdx
@@ -238,7 +238,8 @@ counters, so they never conflict with each other - only with another edit of the
Localization still cannot be combined with:
- **`publicApi`** — a public read has to resolve a locale and decide what to do
- when a translation is missing. Stage 5C.
+ when a translation is missing. Landed in Stage 5C - see
+ [Localized public API](/docs/dev/content-engine/localized-public-api).
- **`search`** — one document per record would index a single language and rank
every other one as a miss. Stage 5D.
@@ -246,7 +247,7 @@ Because preview projects through `publicApi.fields`, **locale-bound preview link
cannot be minted yet** either. The token format already carries the locale and
both frozen revisions - see
[Translation preview](/docs/dev/content-engine/translation-preview) - and the
-route that mints one lands with Stage 5C.
+route that mints one landed with Stage 5C.
Locale-specific *scheduling* stays outside Stage 5 entirely. A scheduled global
publish exposes the languages already marked published and publishes no drafts; a
diff --git a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx
index 30995ffe8..5cfa3741c 100644
--- a/apps/docs/content/docs/dev/content-engine/translation-preview.mdx
+++ b/apps/docs/content/docs/dev/content-engine/translation-preview.mdx
@@ -106,17 +106,39 @@ const payload = verifyContentPreviewToken({
});
```
-## Stage 5B boundary
+## The routes
-`editorial.preview` requires `publicApi`, and `publicApi` is still refused
-alongside `localization` until Stage 5C - so **no locale preview link can be
-minted yet**. What Stage 5B lands is the token format above and the rules that
-make it safe: the locale binding, the two frozen revisions, the symmetric check
-and the tamper rejection, all covered by tests.
+```http
+POST /api/{pluginId}/admin/content/{module}/{id}/translations/{locale}/preview
+GET /api/{pluginId}/content/{publicApi.path}/preview/{token}?locale={locale}
+```
-The route that mints one (`POST /{id}/translations/{locale}/preview`) and the
-public route that reads one arrive with the locale-aware public API in Stage 5C.
-Shipping the route now would ship a button that cannot be pressed.
+The mint route is behind `can_view`, exactly like the base one: a preview shows
+what the public route would show, so anyone allowed to read the record in the
+AdminCP is already allowed to see it. The link is the credential from there on.
+
+It freezes the record's newest **shared** revision and that locale's newest
+**translation** revision, and returns both ids alongside the link. A locale with
+no translation is a 404 rather than a link to the fallback - the button is on a
+language tab, and a link that quietly previewed a different language would be
+worse than no link.
+
+`?locale=` is a query parameter rather than a second placeholder in
+`editorial.preview.pathTemplate`, and that is deliberate: a new placeholder would
+have made every existing template wrong the day localization landed, and a locale
+that lived only in the path could not be honoured by the API form of the link at
+all. The reading side takes it through the same precedence every other public read
+uses, so a page passes it straight to `contentPreviewFetch`:
+
+```tsx title="src/app/[locale]/articles/preview/[token]/page.tsx"
+const { data } = await contentPreviewFetch({
+ definition: articleContentType,
+ locale: (await params).locale,
+ pluginId: "@vitnode/example",
+ token: (await params).token,
+});
+if (!data) notFound();
+```
## Related
diff --git a/packages/vitnode/src/content/cache.locale.test.ts b/packages/vitnode/src/content/cache.locale.test.ts
new file mode 100644
index 000000000..7f92922db
--- /dev/null
+++ b/packages/vitnode/src/content/cache.locale.test.ts
@@ -0,0 +1,379 @@
+import { describe, expect, it } from "vitest";
+
+import type {
+ ContentLocaleInvalidation,
+ ContentLocaleState,
+ ContentPublicLocaleState,
+} from "./cache";
+
+import {
+ contentInvalidationTags,
+ contentLocaleInvalidationMode,
+ contentLocaleInvalidations,
+ contentPublicItemTag,
+ contentPublicListTag,
+ contentPublicSlugTag,
+ diffContentPublicLocaleStates,
+ isContentTranslationPubliclyVisible,
+} from "./cache";
+
+const ID = "example.article";
+
+describe("locale-aware cache tags", () => {
+ it("leaves a locale-less tag byte-identical to what it always was", () => {
+ // Every Stage 1-4 content type has to keep the tags it already produced, or
+ // one deploy invalidates nothing anybody was holding.
+ expect(contentPublicListTag(ID)).toBe("content:example.article:list");
+ expect(contentPublicItemTag(ID, 7)).toBe("content:example.article:item:7");
+ expect(contentPublicSlugTag(ID, "hello")).toBe(
+ "content:example.article:slug:hello",
+ );
+ });
+
+ it("puts the locale after the scope, so the two forms cannot collide", () => {
+ expect(contentPublicListTag(ID, "pl")).toBe(
+ "content:example.article:list:pl",
+ );
+ expect(contentPublicItemTag(ID, 7, "pl")).toBe(
+ "content:example.article:item:pl:7",
+ );
+ expect(contentPublicSlugTag(ID, "hello", "pl")).toBe(
+ "content:example.article:slug:pl:hello",
+ );
+ });
+
+ it("normalizes the locale, so `PL` and `pl` expire together", () => {
+ expect(contentPublicListTag(ID, " PL ")).toBe(
+ contentPublicListTag(ID, "pl"),
+ );
+ });
+
+ it("treats an empty locale as absent rather than as a segment", () => {
+ expect(contentPublicListTag(ID, "")).toBe(contentPublicListTag(ID));
+ });
+
+ it("keeps two languages on the same slug apart", () => {
+ // `/en/about` and `/pl/about` are different pages that happen to share a
+ // slug. One tag for both would make one language's edit expire the other's.
+ expect(contentPublicSlugTag(ID, "about", "en")).not.toBe(
+ contentPublicSlugTag(ID, "about", "pl"),
+ );
+ });
+});
+
+describe("contentInvalidationTags with locales", () => {
+ const base = { contentTypeId: ID, id: 7, isPublic: true, wasPublic: true };
+
+ it("emits per-locale tags and no locale-less ones", () => {
+ // A localized content type has no locale-less public URL, so a tag without a
+ // locale segment would name a page that does not exist.
+ expect(
+ contentInvalidationTags({
+ ...base,
+ locales: [
+ { isPublic: true, locale: "pl", slugs: ["witaj"], wasPublic: true },
+ ],
+ slugs: ["hello"],
+ }),
+ ).toEqual([
+ "content:example.article:list:pl",
+ "content:example.article:item:pl:7",
+ "content:example.article:slug:pl:witaj",
+ ]);
+ });
+
+ it("skips a locale that was private and stayed private", () => {
+ expect(
+ contentInvalidationTags({
+ ...base,
+ locales: [
+ { isPublic: false, locale: "de", slugs: [""], wasPublic: false },
+ { isPublic: true, locale: "pl", slugs: ["witaj"], wasPublic: false },
+ ],
+ slugs: [],
+ }),
+ ).not.toContain("content:example.article:list:de");
+ });
+
+ it("expires both URLs when a locale moved its slug", () => {
+ const tags = contentInvalidationTags({
+ ...base,
+ locales: [
+ {
+ isPublic: true,
+ locale: "pl",
+ slugs: ["stary", "nowy"],
+ wasPublic: true,
+ },
+ ],
+ slugs: [],
+ });
+
+ expect(tags).toContain("content:example.article:slug:pl:stary");
+ expect(tags).toContain("content:example.article:slug:pl:nowy");
+ });
+
+ it("returns nothing when no locale was or is public", () => {
+ expect(
+ contentInvalidationTags({
+ ...base,
+ isPublic: false,
+ locales: [
+ { isPublic: false, locale: "pl", slugs: [""], wasPublic: false },
+ ],
+ slugs: [],
+ wasPublic: false,
+ }),
+ ).toEqual([]);
+ });
+
+ it("ignores the flat fields entirely when locales are present", () => {
+ // The flat pair describes a record; a localized record's pages are per
+ // language, and mixing the two would expire a tag nothing is stored under.
+ expect(
+ contentInvalidationTags({
+ ...base,
+ locales: [],
+ slugs: ["hello"],
+ }),
+ ).toEqual([]);
+ });
+});
+
+describe("contentLocaleInvalidations", () => {
+ const states: ContentLocaleState[] = [
+ {
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: "en",
+ previousSlug: "hello",
+ slug: "hello",
+ wasPublic: true,
+ },
+ {
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: "pl",
+ previousSlug: "witaj",
+ slug: "witaj",
+ wasPublic: true,
+ },
+ {
+ hasOwnTranslation: false,
+ isPublic: true,
+ locale: "de",
+ previousSlug: "hello",
+ slug: "hello",
+ wasPublic: true,
+ },
+ ];
+
+ const locales = (
+ input: Partial[0]>,
+ ) =>
+ contentLocaleInvalidations({
+ changed: "shared",
+ defaultLocale: "en",
+ fallback: "default",
+ states,
+ ...input,
+ }).map(entry => entry.locale);
+
+ it("reaches every locale for a shared change", () => {
+ // A shared field is in every language's response, and the base row's
+ // publication state gates all of them.
+ expect(locales({ changed: "shared" })).toEqual(["en", "pl", "de"]);
+ });
+
+ it("reaches only its own locale for a non-default translation", () => {
+ // Nothing falls back to Polish, whatever the fallback setting is.
+ expect(locales({ changed: "translation", locale: "pl" })).toEqual(["pl"]);
+ });
+
+ it("reaches the fallback consumers when the default translation moves", () => {
+ // `de` has no translation of its own, so its page was built from the row
+ // that just changed. `pl` has one and is untouched.
+ expect(locales({ changed: "translation", locale: "en" })).toEqual([
+ "en",
+ "de",
+ ]);
+ });
+
+ it("reaches only the default locale when the fallback is `none`", () => {
+ expect(
+ locales({ changed: "translation", fallback: "none", locale: "en" }),
+ ).toEqual(["en"]);
+ });
+
+ it("matches the locale case-insensitively", () => {
+ expect(locales({ changed: "translation", locale: "PL" })).toEqual(["pl"]);
+ });
+
+ it("reaches nothing when a translation change names no locale", () => {
+ expect(locales({ changed: "translation" })).toEqual([]);
+ });
+
+ it("carries both slugs, so a moved URL stops resolving", () => {
+ const [entry] = contentLocaleInvalidations({
+ changed: "translation",
+ defaultLocale: "en",
+ fallback: "none",
+ locale: "pl",
+ states: [
+ {
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: "pl",
+ previousSlug: "stary",
+ slug: "nowy",
+ wasPublic: true,
+ },
+ ],
+ });
+
+ expect(entry.slugs).toEqual(["stary", "nowy"]);
+ });
+});
+
+describe("contentLocaleInvalidationMode", () => {
+ const entry = (
+ over: Partial = {},
+ ): ContentLocaleInvalidation => ({
+ isPublic: true,
+ locale: "pl",
+ slugs: ["witaj", "witaj"],
+ wasPublic: true,
+ ...over,
+ });
+
+ it("keeps the cache warm when nothing was removed", () => {
+ expect(contentLocaleInvalidationMode([entry()])).toBe(
+ "stale-while-revalidate",
+ );
+ });
+
+ it("expires immediately when one locale lost its page", () => {
+ // One withdrawn page makes the whole invalidation immediate: serving it once
+ // more is exactly the failure being prevented.
+ expect(
+ contentLocaleInvalidationMode([
+ entry(),
+ entry({ isPublic: false, locale: "de" }),
+ ]),
+ ).toBe("immediate");
+ });
+
+ it("expires immediately when a URL moved", () => {
+ expect(
+ contentLocaleInvalidationMode([entry({ slugs: ["stary", "nowy"] })]),
+ ).toBe("immediate");
+ });
+
+ it("keeps the cache warm for a locale that was never public", () => {
+ expect(
+ contentLocaleInvalidationMode([
+ entry({ isPublic: false, slugs: [""], wasPublic: false }),
+ ]),
+ ).toBe("stale-while-revalidate");
+ });
+});
+
+describe("diffContentPublicLocaleStates", () => {
+ const state = (
+ over: Partial,
+ ): ContentPublicLocaleState => ({
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: "pl",
+ slug: "witaj",
+ ...over,
+ });
+
+ it("pairs the two sides by locale", () => {
+ expect(
+ diffContentPublicLocaleStates(
+ [state({ slug: "stary" })],
+ [state({ slug: "nowy" })],
+ ),
+ ).toEqual([
+ {
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: "pl",
+ previousSlug: "stary",
+ slug: "nowy",
+ wasPublic: true,
+ },
+ ]);
+ });
+
+ it("reports a locale that only existed before", () => {
+ const [entry] = diffContentPublicLocaleStates([state({})], []);
+
+ expect(entry).toMatchObject({ isPublic: false, wasPublic: true });
+ });
+
+ it("reports a locale that only exists after", () => {
+ const [entry] = diffContentPublicLocaleStates([], [state({})]);
+
+ expect(entry).toMatchObject({ isPublic: true, wasPublic: false });
+ });
+
+ it("pairs two spellings of the same locale", () => {
+ expect(
+ diffContentPublicLocaleStates(
+ [state({ locale: "PL" })],
+ [state({ locale: "pl" })],
+ ),
+ ).toHaveLength(1);
+ });
+});
+
+describe("isContentTranslationPubliclyVisible", () => {
+ const published = {
+ publishedAt: new Date("2020-01-01T00:00:00.000Z"),
+ status: "published",
+ };
+ const draft = { publishedAt: null, status: "draft" };
+
+ it("needs both halves published", () => {
+ expect(
+ isContentTranslationPubliclyVisible({
+ base: published,
+ translation: published,
+ }),
+ ).toBe(true);
+ });
+
+ it("refuses a published translation of a draft record", () => {
+ expect(
+ isContentTranslationPubliclyVisible({
+ base: draft,
+ translation: published,
+ }),
+ ).toBe(false);
+ });
+
+ it("refuses a draft translation of a published record", () => {
+ expect(
+ isContentTranslationPubliclyVisible({
+ base: published,
+ translation: draft,
+ }),
+ ).toBe(false);
+ });
+
+ it("refuses a future publication date on either half", () => {
+ const future = {
+ publishedAt: new Date(Date.now() + 60_000),
+ status: "published",
+ };
+
+ expect(
+ isContentTranslationPubliclyVisible({
+ base: published,
+ translation: future,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts
index 6be317b9c..c7a33d73a 100644
--- a/packages/vitnode/src/content/cache.ts
+++ b/packages/vitnode/src/content/cache.ts
@@ -1,5 +1,8 @@
+import type { ContentLocalizationFallback } from "./types";
+
import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const";
import { clampWithFingerprint } from "./fingerprint";
+import { contentLocalesMatch, normalizeContentLocale } from "./locale";
/**
* Cache tags for the generated public API.
@@ -8,9 +11,15 @@ import { clampWithFingerprint } from "./fingerprint";
* and its own `"use cache"` functions with exactly the same values, which is
* the only way its pages get invalidated alongside the generated ones.
*
- * Format: `content:{contentTypeId}:{scope}[:{key}]`. No plugin id - a content
- * type id is already globally unique (`validateContentTypes` enforces it) and
- * already namespaced, as in `example.article`.
+ * Format: `content:{contentTypeId}:{scope}[:{locale}][:{key}]`. No plugin id - a
+ * content type id is already globally unique (`validateContentTypes` enforces it)
+ * and already namespaced, as in `example.article`.
+ *
+ * The locale segment is present **only for a localized content type**, so every
+ * tag a Stage 1-4 content type has ever produced is byte-identical to what it
+ * produced before. It sits after the scope rather than before it so the two forms
+ * can never collide: `content:x:list` is three segments and
+ * `content:x:list:pl` is four, whatever the locale happens to spell.
*
* Next caps a tag at 256 characters and a slug can be 160, so every builder
* runs its result through the same fingerprint clamp the index names use.
@@ -22,21 +31,53 @@ const tag = (...parts: (number | string)[]): string =>
CONTENT_CACHE_TAG_MAX_LENGTH,
);
-/** Every public list page of one content type. */
-export const contentPublicListTag = (contentTypeId: string): string =>
- tag(contentTypeId, "list");
+/**
+ * The locale segment, normalized, or nothing at all.
+ *
+ * Normalized because `PL` and `pl` address the same page and must therefore
+ * expire together - a tag is a string comparison, so the casing has to be settled
+ * here rather than hoped for at every call site.
+ */
+const localeParts = (locale: string | undefined): string[] => {
+ if (locale === undefined) return [];
+
+ const normalized = normalizeContentLocale(locale);
+
+ return normalized === "" ? [] : [normalized];
+};
+
+/**
+ * Every public list page of one content type, in one locale.
+ *
+ * Per locale rather than global: publishing a Polish translation changes no
+ * English list page, and throwing that cache away would be a cost with no
+ * correctness to show for it.
+ */
+export const contentPublicListTag = (
+ contentTypeId: string,
+ locale?: string,
+): string => tag(contentTypeId, "list", ...localeParts(locale));
-/** One row, by identifier. */
+/** One row, by identifier, in one locale. */
export const contentPublicItemTag = (
contentTypeId: string,
id: number,
-): string => tag(contentTypeId, "item", id);
+ locale?: string,
+): string => tag(contentTypeId, "item", ...localeParts(locale), id);
-/** One row, by the URL it answers to. */
+/**
+ * One row, by the URL it answers to, in one locale.
+ *
+ * The locale is load-bearing here and not merely tidy: two languages routinely
+ * answer to the *same* slug (`/en/about` and `/pl/about`), so a locale-less slug
+ * tag would make one language's edit expire the other's page - and, worse, make
+ * one language's publish appear to expire a page it never touched.
+ */
export const contentPublicSlugTag = (
contentTypeId: string,
slug: string,
-): string => tag(contentTypeId, "slug", slug);
+ locale?: string,
+): string => tag(contentTypeId, "slug", ...localeParts(locale), slug);
/**
* How hard a mutation expires the tags it touched.
@@ -48,11 +89,39 @@ export const contentPublicSlugTag = (
*/
export type ContentInvalidationMode = "immediate" | "stale-while-revalidate";
+/**
+ * One locale's share of a mutation.
+ *
+ * `isPublic` means **reachable through the public API in this locale**, which for
+ * a content type with `fallback: "default"` includes a locale that has no
+ * translation of its own and is being served the default one. That is the whole
+ * reason this is a flag rather than something derived from the translation row:
+ * the question a cache tag answers is "was there a page here", not "was there a
+ * row here".
+ */
+export interface ContentLocaleInvalidation {
+ isPublic: boolean;
+ locale: string;
+ /** Every slug this locale answered to across the mutation. */
+ slugs: readonly string[];
+ wasPublic: boolean;
+}
+
export interface ContentInvalidationInput {
contentTypeId: string;
id: number;
/** Whether the row is publicly reachable *after* the mutation. */
isPublic: boolean;
+ /**
+ * The locales this mutation affected, for a **localized** content type.
+ *
+ * When present it is authoritative and the three flat fields above are not
+ * consulted: a localized content type has no locale-less public URL, so a tag
+ * without a locale segment would name a page that does not exist. Absent - which
+ * is every Stage 1-4 content type - the flat fields are the whole input and the
+ * tags are exactly what they have always been.
+ */
+ locales?: readonly ContentLocaleInvalidation[];
/**
* Every slug the row answered to across the mutation. On a slug change that
* is two: the old URL has to stop resolving, and the new one has to start.
@@ -62,6 +131,15 @@ export interface ContentInvalidationInput {
wasPublic: boolean;
}
+const slugTags = (
+ contentTypeId: string,
+ slugs: readonly string[],
+ locale?: string,
+): string[] =>
+ [...new Set(slugs)]
+ .filter(slug => slug !== "")
+ .map(slug => contentPublicSlugTag(contentTypeId, slug, locale));
+
/**
* The exact tags one mutation should invalidate - and no others.
*
@@ -69,7 +147,8 @@ export interface ContentInvalidationInput {
* touches another's tags. A row that was private before and is private after
* touches nothing at all: creating a draft, or editing one, changes no public
* response, so invalidating a public list for it would just throw away a warm
- * cache for free.
+ * cache for free. The same rule applies per locale, which is what keeps a Polish
+ * publish from expiring every English page.
*
* Pure, so the whole matrix is a table test rather than a mocking exercise.
*/
@@ -77,20 +156,177 @@ export const contentInvalidationTags = ({
contentTypeId,
id,
isPublic,
+ locales,
slugs,
wasPublic,
}: ContentInvalidationInput): string[] => {
+ if (locales !== undefined) {
+ return locales
+ .filter(entry => entry.wasPublic || entry.isPublic)
+ .flatMap(entry => [
+ contentPublicListTag(contentTypeId, entry.locale),
+ contentPublicItemTag(contentTypeId, id, entry.locale),
+ ...slugTags(contentTypeId, entry.slugs, entry.locale),
+ ]);
+ }
+
if (!wasPublic && !isPublic) return [];
return [
contentPublicListTag(contentTypeId),
contentPublicItemTag(contentTypeId, id),
- ...[...new Set(slugs)]
- .filter(slug => slug !== "")
- .map(slug => contentPublicSlugTag(contentTypeId, slug)),
+ ...slugTags(contentTypeId, slugs),
];
};
+/**
+ * What one locale looks like around a mutation, as the fan-out reads it.
+ *
+ * `hasOwnTranslation` is the one that decides fan-out, and it is deliberately
+ * separate from `isPublic`: a locale served by the default translation is public
+ * *and* has no translation of its own, and it is exactly that combination which
+ * makes it a downstream consumer of the default locale's cache.
+ */
+export interface ContentLocaleState {
+ /** Whether this locale is served by a translation of its own. */
+ hasOwnTranslation: boolean;
+ /** Reachable in this locale after the mutation, fallback included. */
+ isPublic: boolean;
+ locale: string;
+ /** The slug it answered to before, when the mutation moved it. */
+ previousSlug?: string;
+ /** The slug this locale answers to now, or `""` when it answers to none. */
+ slug: string;
+ /** Reachable in this locale before the mutation, fallback included. */
+ wasPublic: boolean;
+}
+
+/**
+ * Which locales one mutation actually reaches.
+ *
+ * Two rules, and both come straight from what a public response is made of:
+ *
+ * 1. **A shared field is in every language's response.** So is the base row's
+ * publication state, which gates all of them. A change to either reaches every
+ * locale, and pretending otherwise would leave a withdrawn record readable in
+ * every language but the one it was withdrawn from.
+ * 2. **A translation reaches its own locale** - and, when the content type falls
+ * back to the default *and the translation that moved is the default one*, every
+ * locale that has no translation of its own. Those are precisely the locales
+ * whose pages were built from the row that just changed.
+ *
+ * A translation in a non-default locale reaches nothing else, whatever the
+ * fallback setting: nothing falls back to it.
+ *
+ * Pure, and separate from {@link contentInvalidationTags}, because "which locales"
+ * and "which tags" are two rules that fail in different ways - and the first one
+ * is the one worth a table test.
+ */
+export const contentLocaleInvalidations = ({
+ changed,
+ defaultLocale,
+ fallback,
+ locale,
+ states,
+}: {
+ changed: "shared" | "translation";
+ defaultLocale: string;
+ fallback: ContentLocalizationFallback;
+ /** The locale that moved. Required for `"translation"`, ignored otherwise. */
+ locale?: string;
+ states: readonly ContentLocaleState[];
+}): ContentLocaleInvalidation[] => {
+ const reaches = (state: ContentLocaleState): boolean => {
+ if (changed === "shared") return true;
+ if (locale === undefined) return false;
+ if (contentLocalesMatch(state.locale, locale)) return true;
+
+ return (
+ fallback === "default" &&
+ contentLocalesMatch(locale, defaultLocale) &&
+ !state.hasOwnTranslation
+ );
+ };
+
+ return states.filter(reaches).map(state => ({
+ isPublic: state.isPublic,
+ locale: state.locale,
+ slugs: [state.previousSlug ?? "", state.slug],
+ wasPublic: state.wasPublic,
+ }));
+};
+
+/**
+ * One locale as it stands right now, without the "before" half.
+ *
+ * What `contentPublicLocaleStates` reads out of the database, and what a caller
+ * takes twice - once on each side of a mutation. Client-safe, because the AdminCP
+ * Server Action holds a pair of these and diffs them without ever touching
+ * Drizzle.
+ */
+export type ContentPublicLocaleState = Omit<
+ ContentLocaleState,
+ "previousSlug" | "wasPublic"
+>;
+
+/**
+ * Folds a before-and-after pair of locale snapshots into invalidation states.
+ *
+ * Pure, and separate from the reads, so the "what moved" arithmetic is a table
+ * test rather than a database fixture. A locale present on one side only is still
+ * reported: that is exactly a language that gained or lost its public page.
+ */
+export const diffContentPublicLocaleStates = (
+ before: readonly ContentPublicLocaleState[],
+ after: readonly ContentPublicLocaleState[],
+): ContentLocaleState[] => {
+ const previous = new Map(
+ before.map(state => [normalizeContentLocale(state.locale), state]),
+ );
+ const current = new Map(
+ after.map(state => [normalizeContentLocale(state.locale), state]),
+ );
+
+ return [...new Set([...previous.keys(), ...current.keys()])].map(key => {
+ const was = previous.get(key);
+ const is = current.get(key);
+
+ return {
+ hasOwnTranslation: is?.hasOwnTranslation ?? false,
+ isPublic: is?.isPublic ?? false,
+ locale: (is ?? was)?.locale ?? key,
+ // Both slugs, so a translation that moved its URL stops answering to the
+ // old one and starts answering to the new one.
+ previousSlug: was?.slug ?? "",
+ slug: is?.slug ?? "",
+ wasPublic: was?.isPublic ?? false,
+ };
+ });
+};
+
+/**
+ * How hard a localized mutation should expire the tags it touched.
+ *
+ * The locale-aware half of the same rule the base row follows:
+ * stale-while-revalidate is safe only when *every* locale this mutation reached
+ * was public before, is public after, and still answers to the same URL. Anything
+ * else removed public reachability somewhere, and a withdrawn page must not be
+ * served even once more - so one locale losing its page makes the whole
+ * invalidation immediate rather than only its own.
+ */
+export const contentLocaleInvalidationMode = (
+ locales: readonly ContentLocaleInvalidation[],
+): ContentInvalidationMode => {
+ const unchanged = locales.every(entry => {
+ if (entry.wasPublic !== entry.isPublic) return false;
+ if (!entry.isPublic) return true;
+
+ return new Set(entry.slugs.filter(slug => slug !== "")).size <= 1;
+ });
+
+ return unchanged ? "stale-while-revalidate" : "immediate";
+};
+
/**
* Whether a row is reachable through the public API right now.
*
@@ -114,3 +350,26 @@ export const isContentPubliclyVisible = ({
return !Number.isNaN(date.getTime()) && date.getTime() <= Date.now();
};
+
+/**
+ * Whether one *translation* is reachable through the public API right now.
+ *
+ * Subordination, in JavaScript: **the base row and the translation must both be
+ * published**. It is the same rule `contentPublicCondition` enforces in SQL, and
+ * it is stated as an `&&` of the existing predicate rather than as a second set of
+ * clauses so the two cannot drift into disagreeing about what "published" means.
+ */
+export const isContentTranslationPubliclyVisible = ({
+ base,
+ translation,
+}: {
+ base: {
+ publishedAt: Date | null | string | undefined;
+ status: string | undefined;
+ };
+ translation: {
+ publishedAt: Date | null | string | undefined;
+ status: string | undefined;
+ };
+}): boolean =>
+ isContentPubliclyVisible(base) && isContentPubliclyVisible(translation);
diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts
index bbf2f94e7..dc4e79102 100644
--- a/packages/vitnode/src/content/define.ts
+++ b/packages/vitnode/src/content/define.ts
@@ -492,6 +492,7 @@ const resolvePublicApi = (
fields: ContentFieldMap,
publicApi: ContentPublicApiConfig | undefined,
publication: boolean,
+ localizedFields: ContentFieldMap,
): ResolvedContentPublicApiConfig => {
if (!publicApi?.enabled) {
return {
@@ -534,6 +535,17 @@ const resolvePublicApi = (
);
}
+ // The localized public response carries the language it was actually served
+ // in, under `locale`. A declared field of that name would shadow it, and the
+ // reader would have no way to tell a Polish article from an English one served
+ // through the fallback - which is the one thing that response has to say.
+ if (Object.keys(localizedFields).length > 0 && exposed.includes("locale")) {
+ throw new ContentEngineError(
+ 'publicApi.fields includes "locale", which a localized content type reserves: every public localized response carries the language it resolved to under that name. Rename the field.',
+ { contentTypeId: id },
+ );
+ }
+
for (const name of exposed) {
if (publicExposableColumns.includes(name)) continue;
@@ -604,6 +616,20 @@ const resolvePublicApi = (
const declaredOrderable = (publicApi.orderableFields ?? []).map(String);
assertExposed("publicApi.orderableFields", declaredOrderable);
+ // A localized column is not on the base table, and ordering by one would not
+ // just be awkward to generate - it would be wrong. The list a reader pages
+ // through would reshuffle itself for every language, and a fallback set would
+ // interleave two collations, so the same cursor would mean two different
+ // positions. Order by something the record has one of.
+ const localizedOrderable = declaredOrderable.find(
+ name => localizedFields[name] !== undefined,
+ );
+ if (localizedOrderable !== undefined) {
+ throw new ContentEngineError(
+ `publicApi.orderableFields includes the localized field "${localizedOrderable}". A public list is ordered by a column of the record, not of one of its translations - ordering by a localized field would reorder the list per language and make a cursor mean two different positions across a fallback.`,
+ { contentTypeId: id },
+ );
+ }
const orderableFields = [
...new Set([...declaredOrderable, CONTENT_PUBLIC_ALWAYS_ORDERABLE]),
];
@@ -1232,6 +1258,7 @@ export const defineContentType = <
// disabled config for anything that is not `enabled: true`.
publicApi as ContentPublicApiConfig | undefined,
publicationEnabled,
+ localizedFields,
);
const resolvedSearch = resolveSearch(
@@ -1253,7 +1280,7 @@ export const defineContentType = <
publicationEnabled,
);
- // Last, because the Stage 5A boundaries it enforces are stated in terms of
+ // Last, because the Stage 5C boundary it enforces is stated in terms of
// everything the other resolvers have already settled.
const resolvedLocalization = resolveContentLocalization({
fields: fieldMap,
@@ -1261,7 +1288,6 @@ export const defineContentType = <
// The `{ enabled: false }` arm exists only so an explicit literal
// typechecks - the same widening `publicApi`, `search` and `editorial` do.
localization: localization as ContentLocalizationConfig | undefined,
- publicApi: resolvedPublicApi,
publication: publicationEnabled,
search: resolvedSearch.enabled,
tableName,
diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts
index fdfeb7deb..9a47c2cf2 100644
--- a/packages/vitnode/src/content/index.ts
+++ b/packages/vitnode/src/content/index.ts
@@ -29,14 +29,21 @@ export type {
} from "./admin/spec";
export {
contentInvalidationTags,
+ contentLocaleInvalidationMode,
+ contentLocaleInvalidations,
contentPublicItemTag,
contentPublicListTag,
contentPublicSlugTag,
+ diffContentPublicLocaleStates,
isContentPubliclyVisible,
+ isContentTranslationPubliclyVisible,
} from "./cache";
export type {
ContentInvalidationInput,
ContentInvalidationMode,
+ ContentLocaleInvalidation,
+ ContentLocaleState,
+ ContentPublicLocaleState,
} from "./cache";
export {
parseContentConflict,
@@ -147,6 +154,15 @@ export {
resolveContentTranslationIndexes,
toSnakeCase,
} from "./indexes";
+export {
+ contentLocalesMatch,
+ isContentLocaleShaped,
+ negotiateContentLocale,
+ normalizeContentLocale,
+ parseAcceptLanguage,
+ resolveContentPublicLocale,
+} from "./locale";
+export type { ContentLocaleResolution, ContentLocaleSource } from "./locale";
export {
contentLocalizationDisabled,
contentTranslationTableName,
@@ -269,6 +285,7 @@ export type {
FilterableContentFieldName,
LocalizedContentTypeDefinition,
PreviewableContentTypeDefinition,
+ PublicContentTypeDefinition,
ResolvedContentAdminConfig,
ResolvedContentEditorialConfig,
ResolvedContentIndex,
diff --git a/packages/vitnode/src/content/locale.test.ts b/packages/vitnode/src/content/locale.test.ts
new file mode 100644
index 000000000..f1fb87498
--- /dev/null
+++ b/packages/vitnode/src/content/locale.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ contentLocalesMatch,
+ isContentLocaleShaped,
+ negotiateContentLocale,
+ normalizeContentLocale,
+ parseAcceptLanguage,
+ resolveContentPublicLocale,
+} from "./locale";
+
+const AVAILABLE = ["en", "pl", "pt-BR"];
+
+describe("normalizeContentLocale", () => {
+ it("trims and lower-cases, because a locale travels in a URL", () => {
+ expect(normalizeContentLocale(" PL ")).toBe("pl");
+ });
+
+ it("matches two spellings of the same language", () => {
+ expect(contentLocalesMatch("pt-BR", "pt-br")).toBe(true);
+ expect(contentLocalesMatch("pt", "pt-BR")).toBe(false);
+ });
+});
+
+describe("isContentLocaleShaped", () => {
+ it.each([["en"], ["pl"], ["pt-BR"], ["zh-Hans"], ["en_GB"]])(
+ "accepts %s",
+ value => {
+ expect(isContentLocaleShaped(value)).toBe(true);
+ },
+ );
+
+ it.each([[""], [" "], ["e"], ["../../etc/passwd"], ["en; DROP TABLE"]])(
+ "rejects %s",
+ value => {
+ expect(isContentLocaleShaped(value)).toBe(false);
+ },
+ );
+
+ it("rejects a value longer than `core_languages.code`", () => {
+ expect(isContentLocaleShaped("a".repeat(64))).toBe(false);
+ });
+});
+
+describe("parseAcceptLanguage", () => {
+ it("orders by quality, best first", () => {
+ expect(parseAcceptLanguage("en;q=0.4, pl;q=0.9, de;q=0.1")).toEqual([
+ "pl",
+ "en",
+ "de",
+ ]);
+ });
+
+ it("treats a missing q as 1", () => {
+ expect(parseAcceptLanguage("pl, en;q=0.9")).toEqual(["pl", "en"]);
+ });
+
+ it("keeps the sent order for equal quality", () => {
+ expect(parseAcceptLanguage("de, pl, en")).toEqual(["de", "pl", "en"]);
+ });
+
+ it("drops `q=0`, which is a refusal rather than a low preference", () => {
+ expect(parseAcceptLanguage("pl;q=0, en")).toEqual(["en"]);
+ });
+
+ it("drops the wildcard, so it never turns a request into a negotiated one", () => {
+ expect(parseAcceptLanguage("*")).toEqual([]);
+ });
+
+ it("skips garbage instead of throwing on a header anyone can send", () => {
+ expect(parseAcceptLanguage(",,;q=;,pl")).toEqual(["pl"]);
+ });
+});
+
+describe("negotiateContentLocale", () => {
+ it("prefers an exact match over a prefix one", () => {
+ // `pt, pt-BR` must not resolve to `pt-BR` when `pt` is available.
+ expect(negotiateContentLocale("pt, pt-BR", ["pt", "pt-BR"])).toBe("pt");
+ });
+
+ it("falls back to a regional variant of the same language", () => {
+ expect(negotiateContentLocale("pt", AVAILABLE)).toBe("pt-BR");
+ });
+
+ it("returns null when nothing matches", () => {
+ expect(negotiateContentLocale("is, fo", AVAILABLE)).toBeNull();
+ });
+
+ it("returns the canonical spelling, not the caller's", () => {
+ expect(negotiateContentLocale("PT-br", AVAILABLE)).toBe("pt-BR");
+ });
+});
+
+describe("resolveContentPublicLocale", () => {
+ const resolve = (
+ input: Partial[0]>,
+ ) =>
+ resolveContentPublicLocale({
+ available: AVAILABLE,
+ defaultLocale: "en",
+ ...input,
+ });
+
+ it("prefers an explicit locale over everything else", () => {
+ expect(resolve({ acceptLanguage: "pl", explicit: "pt-BR" })).toEqual({
+ locale: "pt-BR",
+ source: "explicit",
+ });
+ });
+
+ it("refuses an explicit locale that names no available language", () => {
+ // Not a substitution: answering a `/de/` URL with English would be the
+ // wrong page, cached under the German tag.
+ expect(resolve({ explicit: "de" })).toBeNull();
+ });
+
+ it("refuses an explicit locale that is not locale-shaped", () => {
+ expect(resolve({ explicit: "../en" })).toBeNull();
+ });
+
+ it("negotiates when there is no explicit locale", () => {
+ expect(resolve({ acceptLanguage: "pl;q=0.9, en;q=0.4" })).toEqual({
+ locale: "pl",
+ source: "negotiated",
+ });
+ });
+
+ it("falls through to the default when nothing negotiates", () => {
+ // A preference, not an instruction: a visitor whose browser asks for
+ // Icelandic gets the site rather than a 404.
+ expect(resolve({ acceptLanguage: "is" })).toEqual({
+ locale: "en",
+ source: "default",
+ });
+ });
+
+ it("uses the default with no explicit locale and no header", () => {
+ expect(resolve({})).toEqual({ locale: "en", source: "default" });
+ });
+
+ it("treats an empty explicit locale as absent", () => {
+ expect(resolve({ explicit: " " })).toEqual({
+ locale: "en",
+ source: "default",
+ });
+ });
+
+ it("returns the canonical spelling of an explicit locale", () => {
+ expect(resolve({ explicit: "PL" })).toEqual({
+ locale: "pl",
+ source: "explicit",
+ });
+ });
+
+ it("still answers when the default itself is not in the available set", () => {
+ // A misconfigured install is the boot guard's problem to report, not a
+ // reason for every public URL to 404.
+ expect(
+ resolveContentPublicLocale({
+ available: ["pl"],
+ defaultLocale: "en",
+ }),
+ ).toEqual({ locale: "en", source: "default" });
+ });
+});
diff --git a/packages/vitnode/src/content/locale.ts b/packages/vitnode/src/content/locale.ts
new file mode 100644
index 000000000..3621e1c60
--- /dev/null
+++ b/packages/vitnode/src/content/locale.ts
@@ -0,0 +1,183 @@
+import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_LOCALE_PATTERN } from "./const";
+
+/**
+ * A locale reduced to the form two codes are compared in.
+ *
+ * Trimmed and lower-cased, because a locale travels in a URL and in an
+ * `Accept-Language` header, and `PL`, `pl` and ` pl ` all name the same language.
+ * The **canonical** spelling always comes back off `core_languages.code` - this is
+ * only ever the comparison key, never a value that gets stored or returned.
+ */
+export const normalizeContentLocale = (value: string): string =>
+ value.trim().toLowerCase();
+
+/** Whether two locale codes name the same language. */
+export const contentLocalesMatch = (a: string, b: string): boolean =>
+ normalizeContentLocale(a) === normalizeContentLocale(b);
+
+/**
+ * Whether a string could be a locale at all.
+ *
+ * Cheap and deliberately in front of every lookup: an explicit `?locale=` is
+ * attacker-controlled, and a 200-character value has no business reaching the
+ * language registry, the cache-tag builder or a log line.
+ */
+export const isContentLocaleShaped = (value: string): boolean => {
+ const trimmed = value.trim();
+
+ return (
+ trimmed.length > 0 &&
+ trimmed.length <= CONTENT_LOCALE_MAX_LENGTH &&
+ CONTENT_LOCALE_PATTERN.test(trimmed)
+ );
+};
+
+/**
+ * Where the locale of a public read came from.
+ *
+ * Reported rather than inferred, because the three sources have different cache
+ * consequences: an `explicit` locale is part of the URL and needs no `Vary`, a
+ * `negotiated` one depends on a request header and does, and `default` depends on
+ * nothing at all.
+ */
+export type ContentLocaleSource = "default" | "explicit" | "negotiated";
+
+export interface ContentLocaleResolution {
+ /** The canonical `core_languages.code`, never the caller's casing. */
+ locale: string;
+ source: ContentLocaleSource;
+}
+
+/**
+ * One `Accept-Language` header, best language first.
+ *
+ * Quality values are honoured because that is what they are for; `q=0` is a
+ * refusal and is dropped rather than ranked last. `*` is dropped too - it means
+ * "anything", which is what the default locale already is, so keeping it would
+ * turn every request into a negotiated one and make `Vary: Accept-Language`
+ * unavoidable for no benefit.
+ *
+ * Malformed input is skipped, never thrown on: this parses a header that anybody
+ * can send.
+ */
+export const parseAcceptLanguage = (header: string): string[] =>
+ header
+ .split(",")
+ .flatMap(part => {
+ const [rawTag, ...parameters] = part.split(";");
+ const tag = normalizeContentLocale(rawTag ?? "");
+ if (tag === "" || tag === "*") return [];
+
+ const quality = parameters
+ .map(parameter => parameter.trim())
+ .find(parameter => parameter.startsWith("q="));
+ if (quality === undefined) return [{ q: 1, tag }];
+
+ const parsed = Number.parseFloat(quality.slice(2));
+ if (!Number.isFinite(parsed) || parsed <= 0) return [];
+
+ return [{ q: parsed, tag }];
+ })
+ // A stable sort, so two tags with the same `q` keep the order they were sent
+ // in - which is the order the client meant.
+ .sort((a, b) => b.q - a.q)
+ .map(entry => entry.tag);
+
+/**
+ * The best available language for one `Accept-Language` header, or `null`.
+ *
+ * Two passes, and the order matters: an exact match wins outright, and only then
+ * is `pt-BR` allowed to satisfy a request for `pt`. Doing it in one pass would let
+ * a header of `pt, pt-BR` resolve to `pt-BR` when `pt` is right there.
+ */
+export const negotiateContentLocale = (
+ header: string,
+ available: readonly string[],
+): null | string => {
+ const wanted = parseAcceptLanguage(header);
+ if (wanted.length === 0 || available.length === 0) return null;
+
+ const byNormalized = new Map(
+ available.map(locale => [normalizeContentLocale(locale), locale]),
+ );
+
+ for (const tag of wanted) {
+ const exact = byNormalized.get(tag);
+ if (exact !== undefined) return exact;
+ }
+
+ for (const tag of wanted) {
+ const base = tag.split(/[-_]/)[0];
+ const prefixed = available.find(
+ locale => normalizeContentLocale(locale).split(/[-_]/)[0] === base,
+ );
+ if (prefixed !== undefined) return prefixed;
+ }
+
+ return null;
+};
+
+/**
+ * The one place that decides which language a public read is for.
+ *
+ * **Explicit, then negotiated, then default**, and the precedence is the whole
+ * point:
+ *
+ * - An **explicit** locale is a deliberate request for one language. It is never
+ * quietly replaced - an explicit locale that names no available language comes
+ * back as `null`, and the caller answers the same 404 it answers for a slug that
+ * does not exist. Substituting the default here would serve English to a URL
+ * that said `pl`, which is the exact accident locale-aware caching then makes
+ * permanent.
+ * - A **negotiated** locale is a preference, so an unmatched one falls through to
+ * the default rather than failing. A visitor whose browser asks for Icelandic
+ * should get the site, not a 404.
+ * - The **default** is the content type's own `localization.defaultLocale`, which
+ * is the one language every record is guaranteed to exist in.
+ *
+ * `available` is the set of locales this install actually serves. Passing the
+ * disabled ones in would let a public URL address a language the app has switched
+ * off, which is the read-side half of the rule that already stops content being
+ * *written* into one.
+ */
+export const resolveContentPublicLocale = ({
+ acceptLanguage,
+ available,
+ defaultLocale,
+ explicit,
+}: {
+ /** The request header, when the caller is serving an HTTP request. */
+ acceptLanguage?: string;
+ /** Every locale this install serves, canonically spelled. */
+ available: readonly string[];
+ defaultLocale: string;
+ /** `?locale=`, a path segment, or an argument. */
+ explicit?: string;
+}): ContentLocaleResolution | null => {
+ const byNormalized = new Map(
+ available.map(locale => [normalizeContentLocale(locale), locale]),
+ );
+
+ if (explicit !== undefined && explicit.trim() !== "") {
+ if (!isContentLocaleShaped(explicit)) return null;
+
+ const matched = byNormalized.get(normalizeContentLocale(explicit));
+
+ return matched === undefined
+ ? null
+ : { locale: matched, source: "explicit" };
+ }
+
+ if (acceptLanguage !== undefined && acceptLanguage.trim() !== "") {
+ const negotiated = negotiateContentLocale(acceptLanguage, available);
+ if (negotiated !== null)
+ return { locale: negotiated, source: "negotiated" };
+ }
+
+ // The default locale is not required to be in `available`: an install that
+ // switched its own default off is misconfigured, and the boot guard says so far
+ // more usefully than a 404 here would.
+ const matched = byNormalized.get(normalizeContentLocale(defaultLocale));
+
+ return { locale: matched ?? defaultLocale, source: "default" };
+};
diff --git a/packages/vitnode/src/content/localization.test.ts b/packages/vitnode/src/content/localization.test.ts
index 85cc6dd8a..52c977ba8 100644
--- a/packages/vitnode/src/content/localization.test.ts
+++ b/packages/vitnode/src/content/localization.test.ts
@@ -361,57 +361,56 @@ describe("Stage 5B capability boundaries", () => {
expect(definition.localization.enabled).toBe(true);
});
- it("refuses localization plus publicApi until Stage 5C", () => {
- expect(() =>
- withCapability({
- publication: { enabled: false },
- publicApi: { enabled: true, fields: ["slug"], path: "boundaries" },
- }),
- ).toThrow();
+ it("allows localization plus publicApi from Stage 5C", () => {
+ const definition = withCapability({
+ publication: { enabled: true },
+ publicApi: { enabled: true, fields: ["slug"], path: "boundaries" },
+ });
+
+ expect(definition.publicApi.enabled).toBe(true);
+ expect(definition.localization.enabled).toBe(true);
+ // The public response carries the language it resolved to, so a reader can
+ // tell a translation from a fallback.
+ expect(definition.schemas.publicSelectObject.shape).toHaveProperty(
+ "locale",
+ );
});
it("refuses localization plus search until Stage 5D", () => {
expect(() =>
withCapability({
publication: { enabled: true },
- publicApi: { enabled: true, fields: ["slug"], path: "boundaries" },
- search: { enabled: true, titleField: "title" },
+ publicApi: {
+ enabled: true,
+ fields: ["title", "slug"],
+ path: "boundaries",
+ },
+ search: {
+ contentFields: ["title"],
+ enabled: true,
+ pathTemplate: "/boundaries/{slug}",
+ titleField: "title",
+ },
}),
).toThrow();
});
- it("names the stage in every remaining boundary message", () => {
+ it("names the stage in the one remaining boundary message", () => {
// "Not yet" is only useful when it says how long.
- const messageOf = (extra: Record): string => {
- try {
- withCapability(extra);
- } catch (error) {
- return error instanceof Error ? error.message : "";
- }
-
- return "";
- };
-
- expect(
- messageOf({
- publication: { enabled: true },
- publicApi: { enabled: true, fields: ["slug"], path: "boundaries" },
- }),
- ).toMatch(/Stage 5C/);
- // `search` cannot be reached through `defineContentType` while `publicApi` is
- // still refused - a searchable content type has to be a public one - so the
- // 5D message is asserted against the resolver directly.
expect(() =>
- resolveContentLocalization({
- fields: {
- title: field.text({ localized: true, required: true }),
+ withCapability({
+ publication: { enabled: true },
+ publicApi: {
+ enabled: true,
+ fields: ["title", "slug"],
+ path: "boundaries",
+ },
+ search: {
+ contentFields: ["title"],
+ enabled: true,
+ pathTemplate: "/boundaries/{slug}",
+ titleField: "title",
},
- id: "test.boundary",
- localization: { defaultLocale: "en", enabled: true },
- publicApi: { ...disabledPublicApi },
- publication: true,
- search: true,
- tableName: "test_boundaries",
}),
).toThrow(/Stage 5D/);
});
diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts
index 86dcad606..1cff4e73f 100644
--- a/packages/vitnode/src/content/localization.ts
+++ b/packages/vitnode/src/content/localization.ts
@@ -4,7 +4,6 @@ import type {
ContentLocalizationConfig,
ContentLocalizationFallback,
ResolvedContentLocalizationConfig,
- ResolvedContentPublicApiConfig,
} from "./types";
import {
@@ -186,29 +185,22 @@ const assertLocalizedFields = (
};
/**
- * Stage 5B boundaries.
+ * Stage 5C boundaries.
*
- * Stage 5A landed the infrastructure and Stage 5B the editorial layer on top of
- * it: per-locale publication, per-locale revisions, restore and the locale
- * editor. What is still missing is everything that reads *outwards* - the public
- * API and the search index - and the honest failure for that is a refused
- * definition rather than a content type that quietly runs Stage 1-4 logic
- * against the base table while pretending its localized fields do not exist.
+ * Stage 5A landed the infrastructure, Stage 5B the editorial layer and Stage 5C
+ * the public read: locale precedence, fallback, strict-locale slugs and
+ * locale-aware cache tags. One thing still reads outwards without knowing about
+ * languages - the search index - and the honest failure for that is a refused
+ * definition rather than a content type that quietly indexes one language and
+ * ranks every other one as a miss.
*
- * Every message names the stage that lifts the restriction, because "not yet" is
+ * The message names the stage that lifts the restriction, because "not yet" is
* only useful when it says how long.
*/
const assertStageBoundaries = (
id: string,
- { publicApi, search }: { publicApi: boolean; search: boolean },
+ { search }: { search: boolean },
): void => {
- if (publicApi) {
- throw new ContentEngineError(
- "localization cannot be combined with `publicApi` yet. A public read has to resolve a locale and decide what to do when a translation is missing, and locale-aware public routes land in Stage 5C.",
- { contentTypeId: id },
- );
- }
-
if (search) {
throw new ContentEngineError(
"localization cannot be combined with `search` yet. One document per record would index a single language and rank every other one as a miss; per-locale search documents land in Stage 5D.",
@@ -229,7 +221,6 @@ export const resolveContentLocalization = ({
fields,
id,
localization,
- publicApi,
publication,
search,
tableName,
@@ -237,7 +228,6 @@ export const resolveContentLocalization = ({
fields: ContentFieldMap;
id: string;
localization: ContentLocalizationConfig | undefined;
- publicApi: ResolvedContentPublicApiConfig;
publication: boolean;
search: boolean;
tableName: string;
@@ -256,7 +246,7 @@ export const resolveContentLocalization = ({
return contentLocalizationDisabled();
}
- assertStageBoundaries(id, { publicApi: publicApi.enabled, search });
+ assertStageBoundaries(id, { search });
const defaultLocale = assertDefaultLocale(id, localization.defaultLocale);
assertLocalizedFields(id, fields, localizedFields);
diff --git a/packages/vitnode/src/content/next/fetch.server.ts b/packages/vitnode/src/content/next/fetch.server.ts
index d17225d1f..e26ccfce4 100644
--- a/packages/vitnode/src/content/next/fetch.server.ts
+++ b/packages/vitnode/src/content/next/fetch.server.ts
@@ -38,15 +38,35 @@ export interface ContentPublicFetchResult {
*
* Only `200` responses are stored, so a 404 for a draft is never cached and
* publishing it is visible immediately.
+ *
+ * ## Localized content types
+ *
+ * Pass `locale`. It does two things that have to happen together, which is why it
+ * is one argument rather than a query parameter you add yourself:
+ *
+ * 1. It goes to the API as `?locale=`, so the response is the one for that
+ * language - explicitly, rather than through whatever `Accept-Language` the
+ * server-side `fetch` happens to send (which is none).
+ * 2. It goes into the cache tags, so publishing a Polish translation expires the
+ * Polish pages and leaves the English ones warm. Sharing one tag across
+ * languages would make every publish a site-wide invalidation *and* let one
+ * language's cached response be served under another's tag.
+ *
+ * Omitting it on a localized content type is not an error - the API falls back to
+ * the content type's default locale - but the response is then tagged as though it
+ * were locale-less, so a translation publish will not expire it. Pass it.
*/
export const contentPublicFetch = async ({
definition,
+ locale,
pluginId,
query,
schema,
slug,
}: {
definition: PublicContentTypeDefinition;
+ /** The language to read, for a localized content type. */
+ locale?: string;
pluginId: string;
query?: Record;
schema?: TSchema;
@@ -56,8 +76,8 @@ export const contentPublicFetch = async ({
const contentTypeId = definition.id;
const tags =
slug === undefined
- ? [contentPublicListTag(contentTypeId)]
- : [contentPublicSlugTag(contentTypeId, slug)];
+ ? [contentPublicListTag(contentTypeId, locale)]
+ : [contentPublicSlugTag(contentTypeId, slug, locale)];
const response = await rawApiFetch({
method: "get",
@@ -75,7 +95,9 @@ export const contentPublicFetch = async ({
// path would turn its separators into `%2F`.
path: slug === undefined ? "/" : `/${encodeURIComponent(slug)}`,
pluginId,
- query,
+ // Explicit, and last, so a caller cannot accidentally shadow it with a
+ // `locale` of its own in `query` and read one language under another's tag.
+ query: locale === undefined ? query : { ...query, locale },
});
if (!response.ok) return { status: response.status };
@@ -119,11 +141,22 @@ export const contentPublicFetch = async ({
*/
export const contentPreviewFetch = async ({
definition,
+ locale,
pluginId,
schema,
token,
}: {
definition: PreviewableContentTypeDefinition;
+ /**
+ * The language the link previews, for a localized content type.
+ *
+ * It has to **match the token**, and the route refuses a mismatch in either
+ * direction rather than falling back - a preview whose language could shift
+ * under it is not a preview of anything. The mint route puts the right value in
+ * the link as `?locale=`, so a page usually reads it straight off its own
+ * `searchParams` and passes it through.
+ */
+ locale?: string;
pluginId: string;
schema?: TSchema;
token: string;
@@ -134,6 +167,7 @@ export const contentPreviewFetch = async ({
options: { cache: "no-store" },
path: `/preview/${encodeURIComponent(token)}`,
pluginId,
+ query: locale === undefined ? undefined : { locale },
});
if (!response.ok) return { status: response.status };
@@ -150,8 +184,16 @@ export const contentPreviewFetch = async ({
: { status: response.status };
};
-/** The tag a detail response keyed by identifier should carry. */
+/**
+ * The tag a detail response keyed by identifier should carry.
+ *
+ * `locale` for a localized content type, for the same reason
+ * {@link contentPublicFetch} takes one: the record has a page per language, and a
+ * tag that named only the record would make one language's publish expire them
+ * all.
+ */
export const contentPublicItemTags = (
definition: AnyContentTypeDefinition,
id: number,
-): string[] => [contentPublicItemTag(definition.id, id)];
+ locale?: string,
+): string[] => [contentPublicItemTag(definition.id, id, locale)];
diff --git a/packages/vitnode/src/content/next/revalidate-route.server.ts b/packages/vitnode/src/content/next/revalidate-route.server.ts
index 2a22e6b28..c896939b6 100644
--- a/packages/vitnode/src/content/next/revalidate-route.server.ts
+++ b/packages/vitnode/src/content/next/revalidate-route.server.ts
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
import { z } from "zod";
import { CONFIG } from "../../lib/config";
+import { CONTENT_LOCALE_MAX_LENGTH } from "../const";
import {
CONTENT_REVALIDATE_MAX_SKEW_MS,
CONTENT_REVALIDATE_TIMESTAMP_HEADER,
@@ -13,6 +14,23 @@ const zodBody = z.object({
contentTypeId: z.string().min(1),
id: z.number().int().positive(),
isPublic: z.boolean(),
+ /**
+ * The per-locale share of a localized mutation.
+ *
+ * Optional, so a Stage 1-4 body is accepted byte for byte and a web app that
+ * has not been redeployed keeps working. When present it is what
+ * `contentInvalidationTags` reads, and the flat fields above are ignored.
+ */
+ locales: z
+ .array(
+ z.object({
+ isPublic: z.boolean(),
+ locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH),
+ slugs: z.array(z.string()),
+ wasPublic: z.boolean(),
+ }),
+ )
+ .optional(),
mode: z.enum(["immediate", "stale-while-revalidate"]),
slugs: z.array(z.string()),
wasPublic: z.boolean(),
diff --git a/packages/vitnode/src/content/public-localization.test-d.ts b/packages/vitnode/src/content/public-localization.test-d.ts
new file mode 100644
index 000000000..ff42790a7
--- /dev/null
+++ b/packages/vitnode/src/content/public-localization.test-d.ts
@@ -0,0 +1,80 @@
+import { describe, expectTypeOf, it } from "vitest";
+
+import type { testPostContentType } from "@/tests/content-fixtures";
+
+import { testLocalizedPageContentType } from "@/tests/content-fixtures";
+
+import type { ContentPublicSelect } from "./types";
+
+import { defineContentType } from "./define";
+import { field } from "./fields";
+
+type LocalizedRow = ContentPublicSelect;
+type PlainRow = ContentPublicSelect;
+
+describe("the public row of a localized content type", () => {
+ it("carries the language it was actually served in", () => {
+ // Not always the language that was asked for: with `fallback: "default"` a
+ // locale with no translation is served the default one, and `hreflang`, a
+ // language switcher and a "not translated yet" notice all need to know.
+ expectTypeOf().toEqualTypeOf();
+ });
+
+ it("still carries exactly the allowlisted fields", () => {
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
+ });
+
+ it("does not carry a field the allowlist leaves out", () => {
+ // @ts-expect-error - `status` is never exposable; every public row is
+ // published, so it would be a constant.
+ type _Missing = LocalizedRow["status"];
+ });
+});
+
+describe("the public row of a content type that is not localized", () => {
+ it("has no `locale` at all", () => {
+ // The key appears because of localization, so a Stage 1-4 content type's
+ // response shape is byte-identical to what it always was.
+ // @ts-expect-error - nothing resolved a language for this row.
+ type _Missing = PlainRow["locale"];
+ });
+
+ it("keeps its allowlisted fields unchanged", () => {
+ expectTypeOf().toEqualTypeOf();
+ });
+});
+
+describe("definition-time rules", () => {
+ it("refuses `locale` in the allowlist of a localized content type", () => {
+ defineContentType({
+ id: "test.locale-clash",
+ tableName: "test_locale_clash",
+ localization: { defaultLocale: "en", enabled: true },
+ publication: { enabled: true },
+ fields: {
+ title: field.text({ localized: true, required: true }),
+ slug: field.slug({ localized: true, source: "title" }),
+ locale: field.text({ nullable: true }),
+ },
+ publicApi: {
+ enabled: true,
+ // The runtime refuses this; the type cannot, because `locale` is a
+ // declared field like any other. The message is what makes it fixable.
+ fields: ["title", "slug", "locale"],
+ path: "clash",
+ },
+ admin: { label: { plural: "Clash", singular: "Clash" } },
+ });
+ });
+
+ it("keeps a localized definition assignable to the erased one", () => {
+ expectTypeOf(
+ testLocalizedPageContentType.publicApi.enabled,
+ ).toEqualTypeOf();
+ expectTypeOf(
+ testLocalizedPageContentType.localization.enabled,
+ ).toEqualTypeOf();
+ });
+});
diff --git a/packages/vitnode/src/content/public.test-d.ts b/packages/vitnode/src/content/public.test-d.ts
index 68d6c04a7..674eca6ad 100644
--- a/packages/vitnode/src/content/public.test-d.ts
+++ b/packages/vitnode/src/content/public.test-d.ts
@@ -7,7 +7,10 @@ import {
testPostContentType,
} from "@/tests/content-fixtures";
-import type { ContentPublicService } from "./server/public-service";
+import type {
+ ContentPublicReadOptions,
+ ContentPublicService,
+} from "./server/public-service";
import type {
AnyContentTypeDefinition,
ContentPublicFieldName,
@@ -174,10 +177,14 @@ describe("publicApi types", () => {
it("takes no predicate argument on either lookup", () => {
// The published condition is applied inside every method. There is no
- // parameter a caller could pass to widen it.
- expectTypeOf().parameters.toEqualTypeOf<[number]>();
+ // parameter a caller could pass to widen it - the optional second one
+ // names a *language*, which chooses which translation the predicate runs
+ // against and can never relax it.
+ expectTypeOf().parameters.toEqualTypeOf<
+ [number, ContentPublicReadOptions?]
+ >();
expectTypeOf().parameters.toEqualTypeOf<
- [string]
+ [string, ContentPublicReadOptions?]
>();
});
diff --git a/packages/vitnode/src/content/public.test.ts b/packages/vitnode/src/content/public.test.ts
index 3fd1ff72b..3f70aba5c 100644
--- a/packages/vitnode/src/content/public.test.ts
+++ b/packages/vitnode/src/content/public.test.ts
@@ -344,3 +344,115 @@ describe("publicApi", () => {
});
});
});
+
+describe("publicApi on a localized content type", () => {
+ const localized = (publicApi: Record) =>
+ defineContentType({
+ id: "test.public-localized",
+ tableName: "test_public_localized",
+ localization: { defaultLocale: "en", enabled: true },
+ publication: { enabled: true },
+ fields: {
+ title: field.text({ localized: true, required: true }),
+ slug: field.slug({ localized: true, source: "title" }),
+ featured: field.boolean({ defaultValue: false }),
+ },
+ admin: { label: { plural: "Localized", singular: "Localized" } },
+ publicApi,
+ } as never);
+
+ it("exposes a localized field alongside a shared one", () => {
+ const definition = localized({
+ enabled: true,
+ fields: ["title", "slug", "featured"],
+ path: "localized",
+ });
+
+ // A public localized response is a base row joined to a translation, so
+ // where a value is stored is a fact about the query, not the response.
+ expect(Object.keys(definition.schemas.publicSelectObject.shape)).toContain(
+ "title",
+ );
+ });
+
+ it("refuses a localized field in `orderableFields`", () => {
+ // A list ordered by a localized title reshuffles per language, and one
+ // cursor would mean two positions across a fallback set.
+ expect(() =>
+ localized({
+ enabled: true,
+ fields: ["title", "slug"],
+ orderableFields: ["title"],
+ path: "localized",
+ }),
+ ).toThrow(/localized field "title"/);
+ });
+
+ it("allows a localized field in `filterableFields`", () => {
+ const definition = localized({
+ enabled: true,
+ fields: ["title", "slug"],
+ filterableFields: ["slug"],
+ path: "localized",
+ });
+
+ expect(Object.keys(definition.schemas.publicFilters.shape)).toEqual([
+ "slug",
+ ]);
+ });
+
+ it("allows a localized field in `searchableFields`", () => {
+ expect(
+ localized({
+ enabled: true,
+ fields: ["title", "slug"],
+ path: "localized",
+ searchableFields: ["title"],
+ }).publicApi.searchableFields,
+ ).toEqual(["title"]);
+ });
+
+ it("reserves `locale`, which the response already carries", () => {
+ expect(() =>
+ defineContentType({
+ id: "test.public-locale-clash",
+ tableName: "test_public_locale_clash",
+ localization: { defaultLocale: "en", enabled: true },
+ publication: { enabled: true },
+ fields: {
+ title: field.text({ localized: true, required: true }),
+ slug: field.slug({ localized: true, source: "title" }),
+ locale: field.text({ nullable: true }),
+ },
+ admin: { label: { plural: "Clash", singular: "Clash" } },
+ publicApi: {
+ enabled: true,
+ fields: ["title", "slug", "locale"],
+ path: "clash",
+ },
+ } as never),
+ ).toThrow(/reserves/);
+ });
+
+ it("leaves a field called `locale` alone when nothing is localized", () => {
+ // The reservation is a consequence of localization, not a global rename.
+ expect(
+ () =>
+ defineContentType({
+ id: "test.public-locale-plain",
+ tableName: "test_public_locale_plain",
+ publication: { enabled: true },
+ fields: {
+ slug: field.slug({}),
+ locale: field.text({ nullable: true }),
+ },
+ admin: { label: { plural: "Plain", singular: "Plain" } },
+ publicApi: {
+ enabled: true,
+ fields: ["slug", "locale"],
+ path: "plain",
+ },
+ } as never).publicApi.fields,
+ ).not.toThrow();
+ });
+});
diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts
index ce8ac0b73..97780419a 100644
--- a/packages/vitnode/src/content/schemas.ts
+++ b/packages/vitnode/src/content/schemas.ts
@@ -334,12 +334,21 @@ const publicRelationSchema = (): z.ZodObject =>
* This is also what the public service's `SELECT` map is derived from, so a
* field missing here is a field that never leaves Postgres - not one that is
* fetched and then deleted.
+ *
+ * Takes **every** declared field, shared and localized alike: a public localized
+ * response is one base row joined to one translation, so where a value is stored
+ * is a fact about the query rather than about the response.
*/
const publicSelectShape = (
fields: ContentFieldMap,
publicApi: ResolvedContentPublicApiConfig,
-): z.ZodRawShape =>
- Object.fromEntries(
+ localization: ResolvedContentLocalizationConfig,
+): z.ZodRawShape => ({
+ // The language actually served, which with a fallback is not always the one
+ // that was asked for. `defineContentType` reserves the name on a localized
+ // content type, so this cannot shadow a declared field.
+ ...(localization.enabled ? { locale: z.string() } : {}),
+ ...Object.fromEntries(
publicApi.fields.map(name => {
if (name === "id") return [name, z.number()];
if (name === "createdAt" || name === "updatedAt") return [name, z.date()];
@@ -354,7 +363,8 @@ const publicSelectShape = (
return [name, applyNullable(baseSelectSchema(fieldValue), fieldValue)];
}),
- );
+ ),
+});
/**
* Takes only the pieces it needs rather than a whole definition, so
@@ -530,15 +540,21 @@ export const buildContentSchemas = ({
const selectObject = z.object(selectShape);
const publicSelectObject = z.object(
- publicSelectShape(sharedFields, publicApi),
+ publicSelectShape(fields, publicApi, localization),
);
const publicFilterable = new Set(publicApi.filterableFields);
// Derived from the same `filterShape`, then narrowed to the configured
// allowlist - so a public filter can never reach a field the admin filter
// schema would not have accepted either.
+ //
+ // Over **every** field rather than the shared half: a localized field can be
+ // filtered on publicly, because the localized public service evaluates that
+ // filter against the one translation the reader is actually being served. The
+ // admin filter schema below stays shared-only, since an admin list is a query
+ // over the base table.
const publicFilters = z.object(
Object.fromEntries(
- Object.entries(filterShape(sharedFields)).filter(([name]) =>
+ Object.entries(filterShape(fields)).filter(([name]) =>
publicFilterable.has(name),
),
),
diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts
index e37f26101..ee5aa9653 100644
--- a/packages/vitnode/src/content/server/index.ts
+++ b/packages/vitnode/src/content/server/index.ts
@@ -51,6 +51,7 @@ export type {
ContentLanguage,
ContentLocalizationProblem,
} from "./language-resolver";
+export { createContentLocalizedPublicService } from "./localized-public-service";
export { createContentLocalizedService } from "./localized-service";
export type {
ContentLocalizedCreateInput,
@@ -65,6 +66,11 @@ export type {
RegisteredContentModel,
} from "./model";
export { buildContentAdminModule } from "./module";
+export {
+ assertContentPreviewIsServable,
+ contentPreviewSecret,
+ contentPreviewUrl,
+} from "./preview-link";
export {
createContentPreviewToken,
verifyContentPreviewToken,
@@ -74,6 +80,7 @@ export type {
ContentPreviewToken,
ContentPreviewTokenPayload,
} from "./preview-token";
+export { contentPublicLocaleStates } from "./public-locales";
export { buildContentPublicModule } from "./public-module";
export { buildContentPublicRoutes } from "./public-routes";
export {
@@ -83,9 +90,12 @@ export {
} from "./public-service";
export type {
ContentPublicFindManyArgs,
+ ContentPublicReadOptions,
ContentPublicService,
} from "./public-service";
export {
+ contentPublicCondition,
+ contentTranslationPublicationColumns,
publicationColumns,
publicationMethods,
publishedCondition,
diff --git a/packages/vitnode/src/content/server/localized-preview-routes.test.ts b/packages/vitnode/src/content/server/localized-preview-routes.test.ts
new file mode 100644
index 000000000..f8147a0c7
--- /dev/null
+++ b/packages/vitnode/src/content/server/localized-preview-routes.test.ts
@@ -0,0 +1,250 @@
+// @vitest-environment node
+import type { MiddlewareHandler } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ testLocalizedGuideContentType,
+ testLocalizedPageContentType,
+} from "@/tests/content-fixtures";
+
+import type * as LanguageResolverModule from "./language-resolver";
+
+import { createContentModel } from "./model";
+import { verifyContentPreviewToken } from "./preview-token";
+import { buildContentTranslationRoutes } from "./translation-routes";
+
+const SECRET = "a".repeat(48);
+const PLUGIN_ID = "@vitnode/example";
+
+const LANGUAGES = [
+ { id: 1, isDefault: true, isEnabled: true, locale: "en" },
+ { id: 2, isDefault: false, isEnabled: true, locale: "pl" },
+];
+
+vi.mock("../../api/lib/check-staff-permission", () => ({
+ assertStaffPermission: vi.fn(),
+}));
+
+vi.mock("./language-resolver", async importOriginal => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ findContentLanguage: vi.fn(async (_c: unknown, locale: string) =>
+ Promise.resolve(
+ LANGUAGES.find(
+ language => language.locale.toLowerCase() === locale.toLowerCase(),
+ ) ?? null,
+ ),
+ ),
+ listContentLanguages: vi.fn(async () => Promise.resolve(LANGUAGES)),
+ };
+});
+
+const pages = createContentModel(testLocalizedPageContentType);
+// Localized and editorial but with no `publicApi`, so `editorial.preview` cannot
+// be enabled on it at all - which is what makes the route conditional.
+const guides = createContentModel(testLocalizedGuideContentType);
+
+const translationRow = (overrides: Record = {}) => ({
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ itemId: 7,
+ languageId: 2,
+ locale: "pl",
+ publishedAt: null,
+ status: "draft" as const,
+ updatedAt: new Date("2026-01-01T00:00:00.000Z"),
+ values: { body: null, slug: "witaj", title: "Witaj" },
+ version: 3,
+ ...overrides,
+});
+
+const harness = ({ secret = SECRET }: { secret?: string } = {}) => {
+ const translations = {
+ create: vi.fn(),
+ delete: vi.fn(),
+ exists: vi.fn(),
+ findByLanguageId: vi.fn(),
+ findByLocale: vi.fn().mockResolvedValue(translationRow()),
+ findManyForItem: vi.fn(),
+ publish: vi.fn(),
+ resolveDefaultLanguage: vi.fn(),
+ resolveLanguage: vi.fn(),
+ unpublish: vi.fn(),
+ update: vi.fn(),
+ };
+
+ const editorial = {
+ create: vi.fn(),
+ delete: vi.fn(),
+ findRevision: vi.fn(),
+ listRevisions: vi.fn().mockResolvedValue({
+ edges: [{ id: 915, version: 3 }],
+ pageInfo: { endCursor: null, hasNextPage: false },
+ }),
+ publish: vi.fn(),
+ restore: vi.fn(),
+ unpublish: vi.fn(),
+ update: vi.fn(),
+ };
+
+ const shared = {
+ revisions: { latest: vi.fn().mockResolvedValue({ id: 812 }) },
+ };
+
+ vi.spyOn(pages, "translationService", "get").mockReturnValue(
+ () => translations,
+ );
+ vi.spyOn(pages, "translationEditorialService", "get").mockReturnValue(
+ () => editorial,
+ );
+ vi.spyOn(pages, "editorialService", "get").mockReturnValue(
+ () => shared as never,
+ );
+
+ const app = new OpenAPIHono();
+ const context: MiddlewareHandler = async (c, next) => {
+ c.set("admin", { user: { id: 1 } } as never);
+ c.set("core", { contentPreviewSecret: secret } as never);
+ await next();
+ };
+ app.use("*", context);
+
+ for (const { handler, route } of buildContentTranslationRoutes(pages, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ return { app, editorial, shared, translations };
+};
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+ process.env.CONTENT_PREVIEW_SECRET = SECRET;
+});
+
+describe("route registration", () => {
+ it("mints a locale preview only where there is a public API to preview against", () => {
+ const paths = buildContentTranslationRoutes(pages, {
+ pluginId: PLUGIN_ID,
+ }).map(entry => `${entry.route.method.toUpperCase()} ${entry.route.path}`);
+
+ expect(paths).toContain("POST /{id}/translations/{locale}/preview");
+ expect(paths).toContain("GET /{id}/public-locales");
+ });
+
+ it("builds neither for a localized content type with no public API", () => {
+ const paths = buildContentTranslationRoutes(guides, {
+ pluginId: PLUGIN_ID,
+ }).map(entry => entry.route.path);
+
+ expect(paths).not.toContain("/{id}/translations/{locale}/preview");
+ expect(paths).not.toContain("/{id}/public-locales");
+ });
+});
+
+describe("minting a locale preview link", () => {
+ it("freezes both halves of the page", async () => {
+ const { app } = harness();
+
+ const body = (await (
+ await app.request("/7/translations/pl/preview", { method: "post" })
+ ).json()) as { revisionId: number; translationRevisionId: number };
+
+ // A localized page is a record plus a translation. Freezing one would let
+ // the other drift under the reviewer.
+ expect(body.revisionId).toBe(812);
+ expect(body.translationRevisionId).toBe(915);
+ });
+
+ it("binds the token to the locale it was minted for", async () => {
+ const { app } = harness();
+
+ const body = (await (
+ await app.request("/7/translations/pl/preview", { method: "post" })
+ ).json()) as { token: string };
+
+ expect(
+ verifyContentPreviewToken({
+ definition: pages.definition,
+ locale: "pl",
+ pluginId: PLUGIN_ID,
+ secret: SECRET,
+ token: body.token,
+ }),
+ ).toMatchObject({ i: 7, l: "pl", lid: 2, r: 812, tr: 915 });
+ });
+
+ it("refuses the same token on another language", async () => {
+ const { app } = harness();
+
+ const body = (await (
+ await app.request("/7/translations/pl/preview", { method: "post" })
+ ).json()) as { token: string };
+
+ // Never falls back: a reviewer sent a Polish link must not be shown English.
+ expect(
+ verifyContentPreviewToken({
+ definition: pages.definition,
+ locale: "en",
+ pluginId: PLUGIN_ID,
+ secret: SECRET,
+ token: body.token,
+ }),
+ ).toBeNull();
+ });
+
+ it("carries the locale in the link, so the reader stays bound", async () => {
+ const { app } = harness();
+
+ const body = (await (
+ await app.request("/7/translations/pl/preview", { method: "post" })
+ ).json()) as { url: string };
+
+ expect(new URL(body.url).searchParams.get("locale")).toBe("pl");
+ });
+
+ it("404s a locale with no translation rather than linking to the fallback", async () => {
+ const { app, translations } = harness();
+ translations.findByLocale.mockResolvedValue(null);
+
+ // The button is on a language tab. A link that quietly previewed a
+ // different language would be worse than no link.
+ const response = await app.request("/7/translations/pl/preview", {
+ method: "post",
+ });
+
+ expect(response.status).toBe(404);
+ });
+
+ it("503s rather than signing with an unusable secret", async () => {
+ process.env.CONTENT_PREVIEW_SECRET = "short";
+ const { app } = harness({ secret: "short" });
+
+ const response = await app.request("/7/translations/pl/preview", {
+ method: "post",
+ });
+
+ expect(response.status).toBe(503);
+ });
+
+ it("freezes nothing when there is no revision to freeze", async () => {
+ const { app, editorial, shared } = harness();
+ shared.revisions.latest.mockResolvedValue(null);
+ editorial.listRevisions.mockResolvedValue({
+ edges: [],
+ pageInfo: { endCursor: null, hasNextPage: false },
+ });
+
+ const body = (await (
+ await app.request("/7/translations/pl/preview", { method: "post" })
+ ).json()) as { revisionId: number; translationRevisionId: number };
+
+ // `0` in either slot means "the live row is read for that half", which is
+ // the only honest answer when there is nothing recorded to show.
+ expect(body).toMatchObject({ revisionId: 0, translationRevisionId: 0 });
+ });
+});
diff --git a/packages/vitnode/src/content/server/localized-public-routes.test.ts b/packages/vitnode/src/content/server/localized-public-routes.test.ts
new file mode 100644
index 000000000..521b61761
--- /dev/null
+++ b/packages/vitnode/src/content/server/localized-public-routes.test.ts
@@ -0,0 +1,234 @@
+// @vitest-environment node
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { testLocalizedPageContentType } from "@/tests/content-fixtures";
+
+import type * as LanguageResolverModule from "./language-resolver";
+
+import { createContentModel } from "./model";
+import { buildContentPublicRoutes } from "./public-routes";
+
+const LANGUAGES = [
+ { id: 1, isDefault: true, isEnabled: true, locale: "en" },
+ { id: 2, isDefault: false, isEnabled: true, locale: "pl" },
+ // Present in `core_languages` but switched off in this app's config. Readable
+ // in the AdminCP, unreachable in public - the read-side half of the rule that
+ // already stops content being written into one.
+ { id: 3, isDefault: false, isEnabled: false, locale: "de" },
+];
+
+vi.mock("./language-resolver", async importOriginal => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ findContentLanguage: vi.fn(async (_c: unknown, locale: string) =>
+ Promise.resolve(
+ LANGUAGES.find(
+ language => language.locale.toLowerCase() === locale.toLowerCase(),
+ ) ?? null,
+ ),
+ ),
+ listContentLanguages: vi.fn(async () => Promise.resolve(LANGUAGES)),
+ };
+});
+
+const pages = createContentModel(testLocalizedPageContentType);
+const PLUGIN_ID = "@vitnode/example";
+
+const emptyPage = {
+ edges: [],
+ pageInfo: {
+ count: 0,
+ endCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ totalCount: 0,
+ },
+};
+
+const row = {
+ body: "Cześć",
+ featured: false,
+ locale: "pl",
+ publishedAt: new Date("2026-01-01T00:00:00.000Z"),
+ slug: "witaj",
+ title: "Witaj",
+};
+
+const harness = () => {
+ const service = {
+ findById: vi.fn(),
+ findBySlug: vi.fn(),
+ findMany: vi.fn().mockResolvedValue(emptyPage),
+ };
+
+ vi.spyOn(pages, "publicService", "get").mockReturnValue(() => service);
+
+ const app = new OpenAPIHono();
+ for (const { handler, route } of buildContentPublicRoutes(pages, {
+ pluginId: PLUGIN_ID,
+ })) {
+ app.openapi(route, handler);
+ }
+
+ return { app, service };
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("locale precedence on a public route", () => {
+ it("uses the content type's default locale with no signal at all", async () => {
+ const { app, service } = harness();
+
+ await app.request("/");
+
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ locale: "en" }),
+ );
+ });
+
+ it("prefers an explicit `?locale=` over `Accept-Language`", async () => {
+ const { app, service } = harness();
+
+ await app.request("/?locale=pl", {
+ headers: { "accept-language": "en" },
+ });
+
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ locale: "pl" }),
+ );
+ });
+
+ it("negotiates from `Accept-Language` when no locale is given", async () => {
+ const { app, service } = harness();
+
+ await app.request("/", {
+ headers: { "accept-language": "pl;q=0.9, en;q=0.2" },
+ });
+
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ locale: "pl" }),
+ );
+ });
+
+ it("returns the canonical spelling, not the caller's", async () => {
+ const { app, service } = harness();
+
+ await app.request("/?locale=PL");
+
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ locale: "pl" }),
+ );
+ });
+
+ it("404s an explicit locale this install does not serve", async () => {
+ const { app, service } = harness();
+
+ // Not an empty list, and not a silent substitution: an empty list would say
+ // "this language has no pages", which is a different and untrue thing.
+ expect((await app.request("/?locale=fr")).status).toBe(404);
+ expect(service.findMany).not.toHaveBeenCalled();
+ });
+
+ it("404s an explicit locale the app has switched off", async () => {
+ const { app } = harness();
+
+ expect((await app.request("/?locale=de")).status).toBe(404);
+ });
+
+ it("404s a locale-shaped attack rather than passing it down", async () => {
+ const { app, service } = harness();
+
+ expect((await app.request("/?locale=..%2F..%2Fetc")).status).toBe(404);
+ expect(service.findMany).not.toHaveBeenCalled();
+ });
+
+ it("ignores a disabled language while negotiating", async () => {
+ const { app, service } = harness();
+
+ await app.request("/", { headers: { "accept-language": "de" } });
+
+ expect(service.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ locale: "en" }),
+ );
+ });
+});
+
+describe("locale headers", () => {
+ it("states the language it answered in", async () => {
+ const { app, service } = harness();
+ service.findBySlug.mockResolvedValue(row);
+
+ const response = await app.request("/witaj?locale=pl");
+
+ expect(response.headers.get("Content-Language")).toBe("pl");
+ });
+
+ it("varies on `Accept-Language` only when the header decided", async () => {
+ const { app } = harness();
+
+ const negotiated = await app.request("/", {
+ headers: { "accept-language": "pl" },
+ });
+ const explicit = await app.request("/?locale=pl");
+
+ expect(negotiated.headers.get("Vary")).toBe("Accept-Language");
+ // Keyed by its URL, so varying on a header that decided nothing would
+ // fragment every shared cache for free.
+ expect(explicit.headers.get("Vary")).toBeNull();
+ });
+});
+
+describe("locale-aware detail route", () => {
+ it("passes the resolved locale to the strict-locale lookup", async () => {
+ const { app, service } = harness();
+ service.findBySlug.mockResolvedValue(row);
+
+ await app.request("/witaj?locale=pl");
+
+ expect(service.findBySlug).toHaveBeenCalledWith("witaj", { locale: "pl" });
+ });
+
+ it("404s a slug that has no translation in this language", async () => {
+ const { app, service } = harness();
+ service.findBySlug.mockResolvedValue(null);
+
+ // The service is strict-locale, so this is what "never falls back" looks
+ // like from the outside: the same 404 as a typo.
+ expect((await app.request("/witaj?locale=en")).status).toBe(404);
+ });
+
+ it("returns the language the row is actually in", async () => {
+ const { app, service } = harness();
+ // A fallback: asked for Polish, served the English translation.
+ service.findBySlug.mockResolvedValue({ ...row, locale: "en" });
+
+ const body = (await (await app.request("/hello?locale=pl")).json()) as {
+ locale: string;
+ };
+
+ expect(body.locale).toBe("en");
+ });
+});
+
+describe("localized public schema", () => {
+ it("declares `locale` on the response", () => {
+ expect(pages.schemas.publicSelectObject.shape).toHaveProperty("locale");
+ });
+
+ it("still declares only the allowlisted fields besides it", () => {
+ expect(Object.keys(pages.schemas.publicSelectObject.shape).sort()).toEqual([
+ "body",
+ "featured",
+ "locale",
+ "publishedAt",
+ "slug",
+ "title",
+ ]);
+ });
+});
diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts
new file mode 100644
index 000000000..92bd77da0
--- /dev/null
+++ b/packages/vitnode/src/content/server/localized-public-service.ts
@@ -0,0 +1,510 @@
+import type { ColumnBaseConfig, SQL } from "drizzle-orm";
+import type {
+ PgColumn,
+ PgTable,
+ PgTableWithColumns,
+ TableConfig,
+} from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import { and, eq, exists, not, or, sql } from "drizzle-orm";
+import { alias } from "drizzle-orm/pg-core";
+
+import type { AnyContentTypeDefinition, ContentPublicSelect } from "../types";
+import type { ContentLanguage } from "./language-resolver";
+import type { ContentPublicService } from "./public-service";
+
+import { withPagination } from "../../api/lib/with-pagination";
+import {
+ CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,
+ CONTENT_PUBLIC_MAX_PAGE_SIZE,
+} from "../const";
+import { ContentEngineError } from "../errors";
+import { partitionContentFields } from "../localization";
+import { publicOrderableColumns } from "../registry";
+import { findContentLanguage } from "./language-resolver";
+import {
+ clampContentPublicPageSize,
+ createContentPublicProjector,
+} from "./public-service";
+import {
+ contentTranslationPublicationColumns,
+ publicationColumns,
+ publishedCondition,
+} from "./publication";
+import {
+ buildFilterCondition,
+ buildOrderColumn,
+ buildSearchCondition,
+} from "./query";
+
+/**
+ * The key the resolved language id travels back on.
+ *
+ * A leading underscore, which `CONTENT_FIELD_NAME_PATTERN` forbids, so it can
+ * never collide with a declared field however the content type is written.
+ */
+const LANGUAGE_KEY = "_languageId";
+
+const conditions = (...parts: (SQL | undefined)[]): SQL | undefined => {
+ const present = parts.filter((part): part is SQL => part !== undefined);
+ if (present.length === 0) return undefined;
+
+ return present.length === 1 ? present[0] : and(...present);
+};
+
+const anyOf = (...parts: (SQL | undefined)[]): SQL | undefined => {
+ const present = parts.filter((part): part is SQL => part !== undefined);
+ if (present.length === 0) return undefined;
+
+ return present.length === 1 ? present[0] : or(...present);
+};
+
+/** The one or two languages one public read touches. */
+interface ResolvedLocale {
+ /** The default language, when this read is allowed to fall back to it. */
+ fallbackTo: ContentLanguage | null;
+ requested: ContentLanguage;
+}
+
+const EMPTY_PAGE = {
+ count: 0,
+ endCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ totalCount: 0,
+};
+
+/**
+ * The read-only public repository of a **localized** content type.
+ *
+ * Everything here follows from one sentence: *a public localized response is one
+ * base row joined to one translation, and both halves have to be published.*
+ *
+ * - **Subordination is not a parameter.** Every read `and`s
+ * `publishedCondition` on the base row and the same predicate on the
+ * translation it serves, so there is no argument a caller could forget and no
+ * path that reaches an unpublished translation.
+ * - **Fallback picks *which* translation the predicate runs against.** It never
+ * relaxes the predicate. `fallback: "default"` can serve the default language
+ * to a locale that has no translation of its own; it can never serve a *draft*
+ * translation, in any language.
+ * - **A slug never falls back.** `findBySlug` is strict-locale, because a URL
+ * belongs to a language: answering `/pl/witaj` from the English row would be
+ * the wrong article, in the wrong language, cached under the Polish tag.
+ * - **Only allowlisted columns are read.** Shared ones off the base table,
+ * localized ones off the translation - the `SELECT` is built from
+ * `publicApi.fields` either way, so a private column is never fetched.
+ *
+ * The visibility test appears twice in the same statement on purpose: once as an
+ * `EXISTS` in the `WHERE` (which is what lets the paginator count matching rows
+ * without a join of its own), and once as the `ON` of the join that fetches the
+ * values. Both are generated from {@link publishedCondition} and the same language
+ * ids, so they cannot disagree about which translation is being served.
+ */
+export const createContentLocalizedPublicService = <
+ TDefinition extends AnyContentTypeDefinition,
+>({
+ c,
+ columns,
+ definition,
+ table,
+ translationColumns,
+ translationTable,
+}: {
+ c: Context;
+ columns: Record;
+ definition: TDefinition;
+ table: PgTableWithColumns;
+ translationColumns: Record;
+ translationTable: PgTable;
+}): ContentPublicService => {
+ const contentTypeId = definition.id;
+ const publicApi = definition.publicApi;
+ const localization = definition.localization;
+
+ if (!publicApi.enabled || !localization.enabled) {
+ throw new ContentEngineError(
+ "The localized public service needs both `publicApi: { enabled: true, path, fields }` and `localization: { enabled: true, defaultLocale }`.",
+ { contentTypeId },
+ );
+ }
+
+ const { localizedFields, sharedFields } = partitionContentFields(
+ definition.fields,
+ );
+ const isLocalized = (name: string): boolean =>
+ localizedFields[name] !== undefined;
+
+ const basePublication = publicationColumns(definition, columns);
+ const translationPublication = contentTranslationPublicationColumns(
+ definition,
+ translationColumns,
+ );
+
+ const primaryCursor = columns.id as PgColumn<
+ ColumnBaseConfig<"number", string>
+ >;
+ const orderable = publicOrderableColumns(definition);
+ const project = createContentPublicProjector(definition);
+
+ const exposedShared = publicApi.fields.filter(name => !isLocalized(name));
+ const exposedLocalized = publicApi.fields.filter(isLocalized);
+ const sharedSearchable = publicApi.searchableFields.filter(
+ name => !isLocalized(name),
+ );
+ const localizedSearchable = publicApi.searchableFields.filter(isLocalized);
+
+ // Two aliases of the same table, because one statement reads it twice: the
+ // language the reader asked for, and the one it may fall back to. Named rather
+ // than positional so the generated SQL stays legible in a slow-query log.
+ const requestedTable = alias(translationTable, "vn_locale");
+ const fallbackTable = alias(translationTable, "vn_fallback");
+ const requestedRows = requestedTable as unknown as Record;
+ const fallbackRows = fallbackTable as unknown as Record;
+
+ /**
+ * `EXISTS (a published translation of this row, in this language)`.
+ *
+ * Correlated to the base table by `itemId`, so it can sit in a `WHERE` that the
+ * paginator also uses for its `COUNT` - which is the whole reason the visibility
+ * test is written as a subquery rather than only as a join condition.
+ *
+ * `extra` narrows it with a predicate over the *translation's* columns: that is
+ * how a filter or a search on a localized field stays bound to the language
+ * actually being served, instead of matching any translation at all.
+ */
+ const publishedTranslation = (languageId: number, extra?: SQL): SQL =>
+ exists(
+ c
+ .get("db")
+ .select({ one: sql`1` })
+ .from(translationTable)
+ .where(
+ conditions(
+ eq(translationColumns.itemId, columns.id),
+ eq(translationColumns.languageId, languageId),
+ publishedCondition(translationPublication),
+ extra,
+ ),
+ ),
+ );
+
+ /**
+ * The row is readable in this locale - and, with `extra`, the translation being
+ * read also matches it.
+ *
+ * The fallback arm is deliberately mutually exclusive with the first: a locale
+ * that *has* a published translation is never also matched through the default
+ * one, so a filter or a search can never match a language the reader will not
+ * be shown.
+ */
+ const visibleIn = (
+ { fallbackTo, requested }: ResolvedLocale,
+ extra?: SQL,
+ ): SQL | undefined => {
+ const direct = publishedTranslation(requested.id, extra);
+ if (!fallbackTo) return direct;
+
+ return anyOf(
+ direct,
+ conditions(
+ not(publishedTranslation(requested.id)),
+ publishedTranslation(fallbackTo.id, extra),
+ ),
+ );
+ };
+
+ const joinOn = (
+ rows: Record,
+ languageId: number,
+ ): SQL | undefined =>
+ conditions(
+ eq(rows.itemId, columns.id),
+ eq(rows.languageId, languageId),
+ publishedCondition({
+ publishedAt: rows.publishedAt,
+ status: rows.status,
+ }),
+ );
+
+ /**
+ * One localized column, read off whichever translation this row resolved to.
+ *
+ * The `CASE` is gated on the *join* having matched rather than on the column
+ * being null, which is what stops a nullable localized field being taken from
+ * one language while its neighbours come from another. Either the requested
+ * translation matched and every localized value comes from it, or none did and
+ * every value comes from the fallback.
+ */
+ const localizedValue = (
+ name: string,
+ withFallback: boolean,
+ ): PgColumn | SQL =>
+ withFallback
+ ? sql`case when ${requestedRows.itemId} is not null then ${requestedRows[name]} else ${fallbackRows[name]} end`
+ : requestedRows[name];
+
+ const selection = (
+ withFallback: boolean,
+ ): Record => ({
+ id: columns.id,
+ // Shared values off the base row - including `createdAt`, `updatedAt` and
+ // `publishedAt`, which on a public response mean "this published thing", not
+ // "this translation row". A reader has no notion of a translation to
+ // attribute a timestamp to.
+ ...Object.fromEntries(exposedShared.map(name => [name, columns[name]])),
+ ...Object.fromEntries(
+ exposedLocalized.map(name => [name, localizedValue(name, withFallback)]),
+ ),
+ [LANGUAGE_KEY]: withFallback
+ ? sql`case when ${requestedRows.itemId} is not null then ${requestedRows.languageId} else ${fallbackRows.languageId} end`
+ : requestedRows.languageId,
+ });
+
+ /**
+ * Turns a locale into the one or two languages this read touches.
+ *
+ * `null` - not a throw - for a locale that names no language or one the install
+ * has switched off. A public reader gets the same 404 as for a record that does
+ * not exist: which locales an install serves is not something an anonymous
+ * request needs to learn, and a `ContentLanguageError` escaping here would put a
+ * raw engine message on a public route.
+ *
+ * The fallback language is resolved from the database too, never assumed from
+ * `defaultLocale`: whether that string names a usable row is a fact about the
+ * installation, and the boot guard is what reports it properly.
+ */
+ const resolveLocale = async (
+ locale: string | undefined,
+ ): Promise => {
+ const requested = await findContentLanguage(
+ c,
+ locale ?? localization.defaultLocale,
+ );
+ if (!requested?.isEnabled) return null;
+
+ if (localization.fallback !== "default") {
+ return { fallbackTo: null, requested };
+ }
+
+ const defaultLanguage = await findContentLanguage(
+ c,
+ localization.defaultLocale,
+ );
+ if (!defaultLanguage || defaultLanguage.id === requested.id) {
+ return { fallbackTo: null, requested };
+ }
+
+ return { fallbackTo: defaultLanguage, requested };
+ };
+
+ const projectRow = (
+ row: Record,
+ { fallbackTo, requested }: ResolvedLocale,
+ ): ContentPublicSelect => ({
+ ...project(row),
+ // The language this row is actually in, which is not always the one that
+ // was asked for. Without it a reader cannot tell a Polish article from an
+ // English one served through the fallback - and `hreflang`, a language
+ // switcher and a "not translated yet" notice all need that distinction.
+ locale:
+ fallbackTo !== null && row[LANGUAGE_KEY] === fallbackTo.id
+ ? fallbackTo.locale
+ : requested.locale,
+ });
+
+ /**
+ * Runs one read with the translation join attached.
+ *
+ * The two branches are kept separate rather than folded into a conditional
+ * join, because the shapes are genuinely different: without a fallback the join
+ * is `INNER` and a row with no published translation in the requested language
+ * is not in the result at all, and with one it is two `LEFT` joins so the `CASE`
+ * has something to choose between. The `WHERE` has already excluded the rows
+ * where neither matched.
+ */
+ const read = async (
+ scope: ResolvedLocale,
+ where: SQL | undefined,
+ { limit, order }: { limit: number; order?: SQL },
+ ): Promise[]> => {
+ const query = c
+ .get("db")
+ .select(selection(scope.fallbackTo !== null))
+ .from(table);
+
+ if (!scope.fallbackTo) {
+ const scoped = query
+ .innerJoin(requestedTable, joinOn(requestedRows, scope.requested.id))
+ .where(where);
+
+ return order
+ ? await scoped.orderBy(order).limit(limit)
+ : await scoped.limit(limit);
+ }
+
+ const scoped = query
+ .leftJoin(requestedTable, joinOn(requestedRows, scope.requested.id))
+ .leftJoin(fallbackTable, joinOn(fallbackRows, scope.fallbackTo.id))
+ .where(where);
+
+ return order
+ ? await scoped.orderBy(order).limit(limit)
+ : await scoped.limit(limit);
+ };
+
+ const readOne = async (
+ resolved: ResolvedLocale,
+ condition: SQL | undefined,
+ { strict = false }: { strict?: boolean } = {},
+ ): Promise | null> => {
+ // A strict read must not fall back - `findBySlug`. Dropping the fallback
+ // language makes the join inner and the visibility test single-armed, so
+ // there is no arm left that could resolve another language.
+ const scope: ResolvedLocale = strict
+ ? { fallbackTo: null, requested: resolved.requested }
+ : resolved;
+
+ const [row] = await read(
+ scope,
+ conditions(
+ publishedCondition(basePublication),
+ visibleIn(scope),
+ condition,
+ ),
+ { limit: 1 },
+ );
+
+ return row ? projectRow(row, scope) : null;
+ };
+
+ return {
+ findById: async (id, options) => {
+ const resolved = await resolveLocale(options?.locale);
+ if (!resolved) return null;
+
+ return await readOne(resolved, eq(columns.id, id));
+ },
+
+ findBySlug: async (slug, options) => {
+ const resolved = await resolveLocale(options?.locale);
+ if (!resolved) return null;
+
+ const slugField = publicApi.slugField;
+
+ // A localized slug is matched inside the translation, a shared one on the
+ // base row. Both are strict: the fallback language is not consulted either
+ // way, so a URL always resolves in the language it was published under.
+ const condition = isLocalized(slugField)
+ ? publishedTranslation(
+ resolved.requested.id,
+ eq(translationColumns[slugField], slug),
+ )
+ : eq(columns[slugField], slug);
+
+ return await readOne(resolved, condition, { strict: true });
+ },
+
+ findMany: async ({ filters = {}, locale, orderBy, query = {} } = {}) => {
+ const resolved = await resolveLocale(locale);
+ if (!resolved) return { edges: [], pageInfo: EMPTY_PAGE };
+
+ const raw = filters as Record;
+ const sharedFilters = Object.fromEntries(
+ Object.entries(raw).filter(([name]) => !isLocalized(name)),
+ );
+ const localizedFilters = Object.fromEntries(
+ Object.entries(raw).filter(([name]) => isLocalized(name)),
+ );
+
+ const term = query.search;
+ const sharedSearch = buildSearchCondition(
+ sharedSearchable.map(name => columns[name]),
+ term,
+ );
+ const localizedSearch = buildSearchCondition(
+ localizedSearchable.map(name => translationColumns[name]),
+ term,
+ );
+
+ const where = conditions(
+ // Not optional, not a parameter, and first: whatever else a caller
+ // passes, an unpublished record cannot come back.
+ publishedCondition(basePublication),
+ // The localized filter goes through the same visibility test as the read
+ // itself, so a filter can only ever match the translation the reader
+ // would actually be shown.
+ visibleIn(
+ resolved,
+ buildFilterCondition({
+ allowed: publicApi.filterableFields,
+ columns: translationColumns,
+ contentTypeId,
+ fields: localizedFields,
+ filters: localizedFilters,
+ }),
+ ),
+ buildFilterCondition({
+ allowed: publicApi.filterableFields,
+ columns,
+ contentTypeId,
+ fields: sharedFields,
+ filters: sharedFilters,
+ }),
+ anyOf(
+ sharedSearch,
+ localizedSearch === undefined
+ ? undefined
+ : visibleIn(resolved, localizedSearch),
+ ),
+ );
+
+ const data = await withPagination({
+ c,
+ params: {
+ query: {
+ ...query,
+ first: clampContentPublicPageSize(query.first),
+ last: clampContentPublicPageSize(query.last),
+ // Folded into `where` above so the term is escaped and so it can
+ // reach the translation table at all; handing it to `withPagination`
+ // would build an unescaped `ilike` over base columns only.
+ search: undefined,
+ },
+ },
+ primaryCursor,
+ // Ordering is a base-table column by construction:
+ // `publicApi.orderableFields` refuses a localized field, because a page
+ // ordered by a localized title would reshuffle itself for every language
+ // and paginate inconsistently across a fallback set.
+ orderBy: {
+ column: buildOrderColumn({
+ columns,
+ contentTypeId,
+ fallback: publicApi.defaultOrderBy,
+ orderBy: orderBy?.column,
+ orderable,
+ }),
+ order: orderBy?.order ?? publicApi.defaultOrder,
+ },
+ table,
+ where,
+ query: async ({ limit, orderBy: order, where: paged }) =>
+ await read(resolved, paged, {
+ limit:
+ typeof limit === "number"
+ ? Math.min(limit, CONTENT_PUBLIC_MAX_PAGE_SIZE + 1)
+ : CONTENT_PUBLIC_DEFAULT_PAGE_SIZE,
+ order,
+ }),
+ });
+
+ return {
+ edges: data.edges.map(row => projectRow(row, resolved)),
+ pageInfo: data.pageInfo,
+ };
+ },
+ };
+};
diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts
index 9e3f49a50..b92440dcf 100644
--- a/packages/vitnode/src/content/server/model.ts
+++ b/packages/vitnode/src/content/server/model.ts
@@ -22,6 +22,7 @@ import type {
import { ContentEngineError } from "../errors";
import { createContentEditorialService } from "./editorial-service";
+import { createContentLocalizedPublicService } from "./localized-public-service";
import { createContentLocalizedService } from "./localized-service";
import { createContentPublicService } from "./public-service";
import { createContentService } from "./service";
@@ -288,9 +289,22 @@ export const createContentModel = <
});
}
: undefined,
+ // Two implementations behind one name, chosen by the definition rather than
+ // by the caller: a route builder is written against `AnyContentTypeDefinition`
+ // and has no way to know which it was handed, so the choice has to be made
+ // where the answer is a literal.
publicService: definition.publicApi.enabled
? (c: Context) =>
- createContentPublicService({ c, columns, definition, table })
+ localized && translationTable && translationColumns
+ ? createContentLocalizedPublicService({
+ c,
+ columns,
+ definition,
+ table,
+ translationColumns,
+ translationTable,
+ })
+ : createContentPublicService({ c, columns, definition, table })
: undefined,
schemas,
service: (c: Context) =>
diff --git a/packages/vitnode/src/content/server/preview-link.ts b/packages/vitnode/src/content/server/preview-link.ts
new file mode 100644
index 000000000..8fa75264f
--- /dev/null
+++ b/packages/vitnode/src/content/server/preview-link.ts
@@ -0,0 +1,90 @@
+import type { Context } from "hono";
+
+import { HTTPException } from "hono/http-exception";
+
+import type { AnyContentTypeDefinition } from "../types";
+
+import { CONFIG } from "../../lib/config";
+import { CONTENT_PREVIEW_TOKEN_PLACEHOLDER } from "../const";
+import { contentPreviewConfigProblems } from "./preview-config";
+
+/**
+ * The secret from the boot config, falling back to the env getter.
+ *
+ * The fallback matters for a direct `app.request()` in a test, which does not go
+ * through the global middleware that populates `core`.
+ */
+export const contentPreviewSecret = (c: Context): string =>
+ c.get("core")?.contentPreviewSecret ?? CONFIG.contentPreviewSecret;
+
+/**
+ * Refuses to mint a link the install cannot protect.
+ *
+ * 503 rather than 500: the request was fine and the code is fine, the deployment
+ * is missing a secret - and a service that is temporarily not offering a feature
+ * is what 503 means. The message names the environment variable, because the
+ * person clicking the button is usually the person who can set it.
+ */
+export const assertContentPreviewIsServable = (c: Context): void => {
+ const problems = contentPreviewConfigProblems(
+ c.get("core")?.contentPreviewSecret ?? process.env.CONTENT_PREVIEW_SECRET,
+ );
+ if (problems.length === 0) return;
+
+ throw new HTTPException(503, {
+ message: `Preview is unavailable: ${problems.join(" ")}`,
+ });
+};
+
+/**
+ * Where a preview link points, as something a person can paste into a browser.
+ *
+ * Absolute in both branches, and against **different origins**, because they are
+ * served by different processes: a `pathTemplate` names a page in the web app,
+ * and the generated JSON endpoint lives on the API. Assuming those share a host
+ * is exactly the assumption a split deployment breaks, and a relative path would
+ * resolve against whichever one the AdminCP happened to be on.
+ *
+ * `split`/`join` rather than `String.replace`, so a `$` in the encoded token
+ * cannot be read as a replacement pattern. `defineContentType` has already proven
+ * the template holds exactly one `{token}`.
+ *
+ * `locale` is appended as a query parameter rather than baked into the template,
+ * and both halves of the system read it from there: the generated public preview
+ * route resolves its locale exactly the way every other public read does, and a
+ * web page passes the same value to `contentPreviewFetch`. A second placeholder
+ * would have made every existing `pathTemplate` wrong the day localization
+ * landed - and a locale that is only in the path could not be honoured by the API
+ * form of the link at all.
+ */
+export const contentPreviewUrl = ({
+ definition,
+ locale,
+ pluginId,
+ token,
+}: {
+ definition: AnyContentTypeDefinition;
+ /** The language the link previews, for a localized content type. */
+ locale?: string;
+ pluginId: string;
+ token: string;
+}): string => {
+ const encoded = encodeURIComponent(token);
+ const template = definition.editorial.preview.pathTemplate;
+
+ const url = template
+ ? new URL(
+ template.split(CONTENT_PREVIEW_TOKEN_PLACEHOLDER).join(encoded),
+ CONFIG.web,
+ )
+ : new URL(
+ `/api/${pluginId}/content/${definition.publicApi.path}/preview/${encoded}`,
+ CONFIG.api,
+ );
+
+ if (locale !== undefined && locale !== "") {
+ url.searchParams.set("locale", locale);
+ }
+
+ return url.toString();
+};
diff --git a/packages/vitnode/src/content/server/preview-route.test.ts b/packages/vitnode/src/content/server/preview-route.test.ts
index 01fee88a3..7a8a2a423 100644
--- a/packages/vitnode/src/content/server/preview-route.test.ts
+++ b/packages/vitnode/src/content/server/preview-route.test.ts
@@ -305,6 +305,8 @@ describe("route registration", () => {
const res = await app.request("/preview");
expect(res.status).toBe(200);
- expect(service.findBySlug).toHaveBeenCalledWith("preview");
+ expect(service.findBySlug).toHaveBeenCalledWith("preview", {
+ locale: undefined,
+ });
});
});
diff --git a/packages/vitnode/src/content/server/public-locales.test.ts b/packages/vitnode/src/content/server/public-locales.test.ts
new file mode 100644
index 000000000..a14af0033
--- /dev/null
+++ b/packages/vitnode/src/content/server/public-locales.test.ts
@@ -0,0 +1,212 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ testLocalizedPageContentType,
+ testStrictLocalizedPageContentType,
+} from "@/tests/content-fixtures";
+
+import type * as LanguageResolverModule from "./language-resolver";
+
+import { createContentModel } from "./model";
+import { contentPublicLocaleStates } from "./public-locales";
+
+const LANGUAGES = [
+ { id: 1, isDefault: true, isEnabled: true, locale: "en" },
+ { id: 2, isDefault: false, isEnabled: true, locale: "pl" },
+ { id: 3, isDefault: false, isEnabled: true, locale: "de" },
+ // Switched off, so it has no public page in any state and is not reported.
+ { id: 4, isDefault: false, isEnabled: false, locale: "cs" },
+];
+
+vi.mock("./language-resolver", async importOriginal => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ listContentLanguages: vi.fn(async () => Promise.resolve(LANGUAGES)),
+ };
+});
+
+const fallbackPages = createContentModel(testLocalizedPageContentType);
+const strictPages = createContentModel(testStrictLocalizedPageContentType);
+
+const PAST = new Date("2020-01-01T00:00:00.000Z");
+
+const published = { publishedAt: PAST, status: "published" };
+const draft = { publishedAt: null, status: "draft" };
+
+/**
+ * A Drizzle stand-in that hands out one result set per `select`, in order.
+ *
+ * In order rather than by table, because the number of reads is part of what is
+ * being asserted: passing a base row skips the first query entirely.
+ */
+const context = (...resultSets: Record[][]): Context => {
+ let call = 0;
+
+ const db = {
+ select: () => {
+ const rows = resultSets[call] ?? [];
+ call += 1;
+
+ // Both a promise and a builder, which is exactly what the real query
+ // builder is: the base read ends in `.limit(1)` and the translation read
+ // is awaited directly. Typed loosely so the linter does not read it as an
+ // ordinary promise-returning function and rewrite it.
+ const result = Object.assign(Promise.resolve(rows), {
+ limit: async (value: number) =>
+ await Promise.resolve(rows.slice(0, value)),
+ }) as unknown as Record;
+
+ const builder: Record = {
+ from: () => builder,
+ where: () => result,
+ };
+
+ return builder;
+ },
+ };
+
+ return { get: (key: string) => (key === "db" ? db : undefined) } as never;
+};
+
+/** The common case: one base row plus its translations. */
+const contextWithRow = (
+ base: Record,
+ translations: Record[],
+): Context => context([base], translations);
+
+describe("contentPublicLocaleStates", () => {
+ it("reports only the enabled languages", async () => {
+ const states = await contentPublicLocaleStates(
+ contextWithRow({ ...published }, []),
+ fallbackPages,
+ 7,
+ );
+
+ expect(states.map(state => state.locale)).toEqual(["en", "pl", "de"]);
+ });
+
+ it("marks a locale served by its own published translation", async () => {
+ const states = await contentPublicLocaleStates(
+ contextWithRow({ ...published }, [
+ { ...published, languageId: 2, slug: "witaj" },
+ ]),
+ fallbackPages,
+ 7,
+ );
+
+ expect(states.find(state => state.locale === "pl")).toEqual({
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: "pl",
+ slug: "witaj",
+ });
+ });
+
+ it("marks a locale served by the fallback", async () => {
+ const states = await contentPublicLocaleStates(
+ contextWithRow({ ...published }, [
+ { ...published, languageId: 1, slug: "hello" },
+ ]),
+ fallbackPages,
+ 7,
+ );
+
+ // Public, and explicitly not by a translation of its own - which is what
+ // makes it a downstream consumer of the default locale's cache.
+ expect(states.find(state => state.locale === "de")).toEqual({
+ hasOwnTranslation: false,
+ isPublic: true,
+ locale: "de",
+ slug: "hello",
+ });
+ });
+
+ it("does not fall back when the content type says `none`", async () => {
+ const states = await contentPublicLocaleStates(
+ contextWithRow({ ...published }, [
+ { ...published, languageId: 1, slug: "hello" },
+ ]),
+ strictPages,
+ 7,
+ );
+
+ expect(states.find(state => state.locale === "de")).toMatchObject({
+ isPublic: false,
+ });
+ });
+
+ it("treats a draft translation as not its own, so the fallback applies", async () => {
+ const states = await contentPublicLocaleStates(
+ contextWithRow({ ...published }, [
+ { ...published, languageId: 1, slug: "hello" },
+ { ...draft, languageId: 2, slug: "witaj" },
+ ]),
+ fallbackPages,
+ 7,
+ );
+
+ expect(states.find(state => state.locale === "pl")).toEqual({
+ hasOwnTranslation: false,
+ isPublic: true,
+ locale: "pl",
+ slug: "hello",
+ });
+ });
+
+ it("makes every locale private when the record itself is a draft", async () => {
+ // Subordination: publishing a translation of a draft record puts nothing on
+ // the internet, in any language.
+ const states = await contentPublicLocaleStates(
+ contextWithRow({ ...draft }, [
+ { ...published, languageId: 1, slug: "hello" },
+ { ...published, languageId: 2, slug: "witaj" },
+ ]),
+ fallbackPages,
+ 7,
+ );
+
+ expect(states.every(state => !state.isPublic)).toBe(true);
+ });
+
+ it("keeps a withdrawn locale's slug, so its page can still be expired", async () => {
+ const states = await contentPublicLocaleStates(
+ contextWithRow({ ...draft }, [
+ { ...published, languageId: 2, slug: "witaj" },
+ ]),
+ fallbackPages,
+ 7,
+ );
+
+ expect(states.find(state => state.locale === "pl")).toMatchObject({
+ isPublic: false,
+ slug: "witaj",
+ });
+ });
+
+ it("returns nothing for a record that does not exist", async () => {
+ expect(
+ await contentPublicLocaleStates(context([]), fallbackPages, 7),
+ ).toEqual([]);
+ });
+
+ it("reads the base row from the caller when it already has one", async () => {
+ // No base query at all - so the *first* result set the fake hands out is
+ // the translations. The schedule handler holds the row its transition
+ // returned, and re-reading it could see a later edit.
+ const states = await contentPublicLocaleStates(
+ context([{ ...published, languageId: 2, slug: "witaj" }]),
+ fallbackPages,
+ 7,
+ { row: { ...published } },
+ );
+
+ expect(states.find(state => state.locale === "pl")).toMatchObject({
+ isPublic: true,
+ });
+ });
+});
diff --git a/packages/vitnode/src/content/server/public-locales.ts b/packages/vitnode/src/content/server/public-locales.ts
new file mode 100644
index 000000000..f98252bad
--- /dev/null
+++ b/packages/vitnode/src/content/server/public-locales.ts
@@ -0,0 +1,163 @@
+import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
+import type { Context } from "hono";
+
+import { eq } from "drizzle-orm";
+
+import type { ContentPublicLocaleState } from "../cache";
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentModel } from "./model";
+
+import { isContentPubliclyVisible } from "../cache";
+import { contentLocalesMatch } from "../locale";
+import { partitionContentFields } from "../localization";
+import { listContentLanguages } from "./language-resolver";
+
+export type { ContentPublicLocaleState };
+
+type PublicationRow = Record | undefined;
+
+const isVisible = (row: PublicationRow): boolean =>
+ row !== undefined &&
+ isContentPubliclyVisible({
+ publishedAt: row.publishedAt as Date | null | string | undefined,
+ status: typeof row.status === "string" ? row.status : undefined,
+ });
+
+/** A column value as the slug it is, or `""`. A slug column is always text. */
+const asSlug = (value: unknown): string =>
+ typeof value === "string" ? value : "";
+
+/**
+ * Which languages one record is publicly reachable in, and under which URL.
+ *
+ * This is the one place the fallback rule is *evaluated* rather than described,
+ * and it lives on the server for a reason: answering it needs the language
+ * registry, the base row's publication state and every translation's, and a
+ * caller that tried to assemble those itself would be reimplementing
+ * `createContentLocalizedPublicService`'s visibility test in a second place - the
+ * classic pair that drifts, with a stale cache as the symptom.
+ *
+ * `hasOwnTranslation` means **served by a translation of its own**, not "a
+ * translation row exists". A locale whose translation is still a draft is served
+ * the default one, so it is a downstream consumer of the default locale's cache
+ * and has to be expired when that changes.
+ *
+ * Every *enabled* language is reported, including the ones with no translation at
+ * all: with `fallback: "default"` those have public pages too, and pages that
+ * nothing ever expires are worse than pages that are not cached.
+ */
+export const contentPublicLocaleStates = async <
+ TDefinition extends AnyContentTypeDefinition,
+>(
+ c: Context,
+ model: ContentModel,
+ itemId: number,
+ { row }: { row?: Record } = {},
+): Promise => {
+ const { definition } = model;
+ const { localization, publicApi } = definition;
+
+ if (!localization.enabled || !publicApi.enabled) return [];
+
+ const translationColumns: null | Record =
+ model.translationColumns;
+ const translationTable: null | PgTable = model.translationTable;
+ if (!translationColumns || !translationTable) return [];
+
+ const columns = model.columns as Record;
+ // Widened, not asserted: the generated table type carries every column as a
+ // literal, which Drizzle's `.from()` overloads cannot resolve through a generic.
+ const table: PgTable = model.table;
+ const slugField = publicApi.slugField;
+ const { localizedFields } = partitionContentFields(definition.fields);
+ const slugIsLocalized = localizedFields[slugField] !== undefined;
+
+ const base =
+ row ??
+ (
+ await c
+ .get("db")
+ .select({
+ publishedAt: columns.publishedAt,
+ status: columns.status,
+ ...(slugIsLocalized ? {} : { [slugField]: columns[slugField] }),
+ })
+ .from(table)
+ .where(eq(columns.id, itemId))
+ .limit(1)
+ )[0];
+
+ if (!base) return [];
+
+ const baseRow: Record = base;
+ const basePublic = isVisible(baseRow);
+ const sharedSlug = slugIsLocalized ? "" : asSlug(baseRow[slugField]);
+
+ const rows = await c
+ .get("db")
+ .select({
+ languageId: translationColumns.languageId,
+ publishedAt: translationColumns.publishedAt,
+ status: translationColumns.status,
+ ...(slugIsLocalized
+ ? { [slugField]: translationColumns[slugField] }
+ : {}),
+ })
+ .from(translationTable)
+ .where(eq(translationColumns.itemId, itemId));
+
+ const byLanguage = new Map(
+ rows.map(entry => [entry.languageId as number, entry]),
+ );
+
+ const languages = (await listContentLanguages(c)).filter(
+ language => language.isEnabled,
+ );
+ const defaultLanguage = languages.find(language =>
+ contentLocalesMatch(language.locale, localization.defaultLocale),
+ );
+ const defaultTranslation =
+ defaultLanguage === undefined
+ ? undefined
+ : byLanguage.get(defaultLanguage.id);
+ const defaultIsPublic = basePublic && isVisible(defaultTranslation);
+
+ const slugOf = (translation: PublicationRow): string =>
+ slugIsLocalized ? asSlug(translation?.[slugField]) : sharedSlug;
+
+ return languages.map(language => {
+ const own = byLanguage.get(language.id);
+ const ownIsPublic = basePublic && isVisible(own);
+
+ if (ownIsPublic) {
+ return {
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: language.locale,
+ slug: slugOf(own),
+ };
+ }
+
+ // Not its own, so this locale is whatever the fallback says it is. The
+ // fallback's slug travels with it: a fallback page answers to the default
+ // translation's URL, under this locale's tag.
+ if (localization.fallback === "default" && defaultIsPublic) {
+ return {
+ hasOwnTranslation: false,
+ isPublic: true,
+ locale: language.locale,
+ slug: slugOf(defaultTranslation),
+ };
+ }
+
+ return {
+ hasOwnTranslation: false,
+ isPublic: false,
+ locale: language.locale,
+ // The URL it *used* to answer to, when there is a row to read one off.
+ // Reported even though it is not public, because the caller compares two
+ // snapshots and this is the side that has to expire a withdrawn page.
+ slug: slugOf(own),
+ };
+ });
+};
diff --git a/packages/vitnode/src/content/server/public-routes.test.ts b/packages/vitnode/src/content/server/public-routes.test.ts
index 9372f20de..75ec86712 100644
--- a/packages/vitnode/src/content/server/public-routes.test.ts
+++ b/packages/vitnode/src/content/server/public-routes.test.ts
@@ -168,7 +168,9 @@ describe("public detail route", () => {
const response = await app.request("/hello-world");
expect(response.status).toBe(200);
- expect(service.findBySlug).toHaveBeenCalledWith("hello-world");
+ expect(service.findBySlug).toHaveBeenCalledWith("hello-world", {
+ locale: undefined,
+ });
});
it("returns exactly the allowlisted keys", async () => {
diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts
index 66ef5507d..d29614e2b 100644
--- a/packages/vitnode/src/content/server/public-routes.ts
+++ b/packages/vitnode/src/content/server/public-routes.ts
@@ -1,8 +1,13 @@
-import type { PgTableWithColumns, TableConfig } from "drizzle-orm/pg-core";
+import type {
+ PgColumn,
+ PgTable,
+ PgTableWithColumns,
+ TableConfig,
+} from "drizzle-orm/pg-core";
import type { Context } from "hono";
import { z } from "@hono/zod-openapi";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import type {
@@ -20,15 +25,24 @@ import {
zodPaginationQuery,
} from "../../api/lib/with-pagination";
import { CONFIG, isSecureContentPreviewSecret } from "../../lib/config";
-import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const";
+import {
+ CONTENT_LOCALE_MAX_LENGTH,
+ CONTENT_PUBLIC_MAX_PAGE_SIZE,
+} from "../const";
import { ContentEngineError } from "../errors";
+import { resolveContentPublicLocale } from "../locale";
+import { partitionContentFields } from "../localization";
import { publicOrderableColumns } from "../registry";
+import { findContentLanguage, listContentLanguages } from "./language-resolver";
import { verifyContentPreviewToken } from "./preview-token";
import {
contentPublicSelection,
createContentPublicProjector,
} from "./public-service";
-import { contentSnapshotRow } from "./revision-snapshot";
+import {
+ contentSnapshotRow,
+ projectTranslationRevisionSnapshot,
+} from "./revision-snapshot";
/**
* The read-only routes one public content type gets.
@@ -69,16 +83,28 @@ export const buildContentPublicRoutes = <
return build(c);
};
+ const localized = definition.localization.enabled;
+
// `orderBy` is a literal enum, so a column outside the public allowlist is a
// 400 at validation time and shows up in the OpenAPI document. The service
// keeps its own allowlist check for callers that did not come through here.
const orderable = publicOrderableColumns(definition) as [string, ...string[]];
+ const localeQuery = localized
+ ? {
+ // Loose on purpose, like `publicParams.slug`: an unknown locale and a
+ // malformed one are both the same 404, so a stricter pattern here would
+ // only turn one of them into a differently-shaped 400 that says more.
+ locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).optional(),
+ }
+ : {};
const paginationQuery = zodPaginationQuery.extend({
+ ...localeQuery,
order: z.enum(["asc", "desc"]).optional(),
orderBy: z.enum(orderable).optional(),
search: z.string().optional(),
});
const listQuery = paginationQuery.extend(schemas.publicFilters.shape);
+ const localeOnlyQuery = z.object(localeQuery);
const notFound = () =>
new HTTPException(404, {
@@ -87,11 +113,65 @@ export const buildContentPublicRoutes = <
const project = createContentPublicProjector(definition);
+ /**
+ * Which language this request is for, or `null`.
+ *
+ * `null` is a 404, always. An explicit `?locale=xx` that names no language this
+ * install serves is a request for something that does not exist - substituting
+ * the default would answer a Polish URL with English content and then cache it
+ * under the Polish tag, which is the one failure locale-aware caching is here to
+ * prevent.
+ *
+ * A *negotiated* locale is a preference rather than an instruction, so an
+ * unmatched `Accept-Language` falls through to the default inside
+ * {@link resolveContentPublicLocale} and never reaches this as a `null`.
+ */
+ const localeFor = async (c: Context) => {
+ if (!localized) return { locale: undefined, source: "default" as const };
+
+ const languages = await listContentLanguages(c);
+
+ return resolveContentPublicLocale({
+ acceptLanguage: c.req.header("accept-language"),
+ // Only the locales this install actually serves. A disabled language is
+ // readable in the AdminCP and unreachable in public, which is the read-side
+ // half of the rule that already stops content being written into one.
+ available: languages
+ .filter(language => language.isEnabled)
+ .map(language => language.locale),
+ defaultLocale: definition.localization.defaultLocale,
+ explicit: c.req.query("locale"),
+ });
+ };
+
+ /**
+ * The response headers a localized read carries.
+ *
+ * `Content-Language` is the resolved locale, which with a fallback is not always
+ * the one that was asked for. `Vary: Accept-Language` is added only when the
+ * locale actually came from the header - a response chosen by an explicit
+ * `?locale=` is keyed by its URL, and varying on a header that did not decide
+ * anything would fragment every shared cache for nothing.
+ */
+ const localeHeaders = (
+ locale: string | undefined,
+ source: "default" | "explicit" | "negotiated",
+ ): Record => {
+ if (locale === undefined) return {};
+
+ return {
+ "Content-Language": locale,
+ ...(source === "negotiated" ? { Vary: "Accept-Language" } : {}),
+ };
+ };
+
// Widened, not cast: the generated table type carries every column as a
// literal, which Drizzle's `.from()` overloads cannot resolve through a
// generic. This is the same parameter type `createContentPublicService`
// declares, so the assignment is checked rather than asserted.
const table: PgTableWithColumns = model.table;
+ /** The same widening, for the translation half of a localized preview. */
+ const translationTable: null | PgTable = model.translationTable;
/**
* The row a preview link points at.
@@ -138,6 +218,75 @@ export const buildContentPublicRoutes = <
return row ?? null;
};
+ /**
+ * The localized half of a preview: one language's field values.
+ *
+ * The language is resolved from the token's `l` - the locale **string** - and
+ * never from its `lid`. The id is carried so a reader that already trusts it can
+ * skip a lookup, but a signed token outlives the row it names: a language can be
+ * deleted and its id reused, and resolving the code through the registry is one
+ * query that cannot go stale in that direction.
+ *
+ * `tr === 0` reads live for the same reason `r === 0` does, and loses exactly
+ * the same guarantee: there is no translation revision to freeze.
+ */
+ const readPreviewTranslation = async (
+ c: Context,
+ payload: ContentPreviewTokenPayload,
+ ): Promise> => {
+ const locale = payload.l;
+ if (locale === undefined) return null;
+
+ const language = await findContentLanguage(c, locale);
+ if (!language) return null;
+
+ const translationColumns: null | Record =
+ model.translationColumns;
+ if (!translationColumns || !translationTable) return null;
+
+ if (payload.tr && payload.tr > 0) {
+ const build = model.translationEditorialService;
+ if (!build) return null;
+
+ const revision = await build(c, { pluginId }).findRevision(
+ payload.i,
+ language.locale,
+ payload.tr,
+ );
+
+ // `projectTranslationRevisionSnapshot`, not the whole snapshot row: the
+ // localized *values* and nothing else. A translation snapshot also carries
+ // its own `createdAt`, `version` and publication state, and letting those
+ // through would overwrite the record's with the translation's.
+ return revision
+ ? projectTranslationRevisionSnapshot(definition, revision.snapshot)
+ : null;
+ }
+
+ const { localizedFields } = partitionContentFields(definition.fields);
+ const exposed = definition.publicApi.fields.filter(
+ name => localizedFields[name] !== undefined,
+ );
+
+ const [row] = await c
+ .get("db")
+ .select(
+ Object.fromEntries(
+ exposed.map(name => [name, translationColumns[name]]),
+ ),
+ )
+ .from(translationTable)
+ .where(
+ and(
+ eq(translationColumns.itemId, payload.i),
+ eq(translationColumns.languageId, language.id),
+ ),
+ )
+ .limit(1);
+
+ return row ?? null;
+ };
+
const list = buildRoute({
pluginId,
route: {
@@ -172,8 +321,15 @@ export const buildContentPublicRoutes = <
raw,
) as ContentPublicFilterInput;
+ // An explicit locale naming no language this install serves is the same
+ // 404 the detail route answers, not an empty list: an empty list would say
+ // "this language has no articles", which is a different and untrue thing.
+ const resolved = await localeFor(c);
+ if (!resolved) throw notFound();
+
const data = await service(c).findMany({
filters,
+ locale: resolved.locale,
// Both narrowings restate what the schemas just proved: `orderBy` came
// out of a literal enum built from `publicApi.orderableFields`, and
// `filters` out of a shape built from `publicApi.filterableFields`. The
@@ -185,7 +341,7 @@ export const buildContentPublicRoutes = <
query: { cursor, first, last, search },
});
- return c.json(data, 200);
+ return c.json(data, 200, localeHeaders(resolved.locale, resolved.source));
},
});
@@ -208,6 +364,11 @@ export const buildContentPublicRoutes = <
* - **Nothing caches it.** `private, no-store` keeps it out of shared caches
* and `noindex, nofollow` keeps it out of search results, in case a link is
* pasted somewhere public.
+ * - **A localized preview is bound to its language.** The locale this request
+ * resolved to is handed to `verifyContentPreviewToken`, which compares it with
+ * the token's own and refuses a mismatch in either direction. A `pl` link
+ * opened on the English URL is the same 404 as a forged one - falling back
+ * would hand a reviewer a different language from the one they were sent.
*/
const preview = buildRoute({
pluginId,
@@ -217,7 +378,10 @@ export const buildContentPublicRoutes = <
// literally "preview" still resolves the ordinary way.
path: "/preview/{token}",
description: `Read one ${label.singular} from a signed preview link`,
- request: { params: z.object({ token: z.string() }) },
+ request: {
+ params: z.object({ token: z.string() }),
+ ...(localized ? { query: localeOnlyQuery } : {}),
+ },
responses: {
200: {
content: {
@@ -239,8 +403,12 @@ export const buildContentPublicRoutes = <
// not something an anonymous request needs to learn.
if (!isSecureContentPreviewSecret(secret)) throw notFound();
+ const resolved = await localeFor(c);
+ if (!resolved) throw notFound();
+
const payload = verifyContentPreviewToken({
definition,
+ locale: resolved.locale,
pluginId,
secret,
token: c.req.param("token"),
@@ -250,10 +418,26 @@ export const buildContentPublicRoutes = <
const row = await readPreviewRow(c, payload);
if (!row) throw notFound();
- return c.json(project(row), 200, {
- "Cache-Control": "private, no-store",
- "X-Robots-Tag": "noindex, nofollow",
- });
+ // Both halves, or nothing. A localized preview promises the page as it
+ // stood, and a page is a record plus a translation - serving the shared
+ // half with the localized fields missing would be a different page.
+ const translated = localized
+ ? await readPreviewTranslation(c, payload)
+ : null;
+ if (localized && !translated) throw notFound();
+
+ return c.json(
+ {
+ ...project({ ...row, ...translated }),
+ ...(localized ? { locale: resolved.locale } : {}),
+ },
+ 200,
+ {
+ "Cache-Control": "private, no-store",
+ "X-Robots-Tag": "noindex, nofollow",
+ ...localeHeaders(resolved.locale, resolved.source),
+ },
+ );
},
});
@@ -263,7 +447,10 @@ export const buildContentPublicRoutes = <
method: "get",
path: "/{slug}",
description: `Get one published ${label.singular} by slug`,
- request: { params: schemas.publicParams },
+ request: {
+ params: schemas.publicParams,
+ ...(localized ? { query: localeOnlyQuery } : {}),
+ },
responses: {
200: {
content: {
@@ -275,13 +462,22 @@ export const buildContentPublicRoutes = <
},
},
handler: async c => {
- // A draft, an unpublished row, a cleared publication date and a typo are
- // all the same 404. A 403 would confirm the record exists, which is the
- // one thing a draft URL must not do.
- const row = await service(c).findBySlug(c.req.param("slug"));
+ const resolved = await localeFor(c);
+ if (!resolved) throw notFound();
+
+ // A draft, an unpublished row, a cleared publication date, a translation
+ // that is not published in this language and a typo are all the same 404.
+ // A 403 would confirm the record exists, which is the one thing a draft
+ // URL must not do - and so would a 404 that only some of them produced.
+ //
+ // Strict-locale by construction: `findBySlug` does not fall back, so this
+ // never answers a Polish URL with the English article.
+ const row = await service(c).findBySlug(c.req.param("slug"), {
+ locale: resolved.locale,
+ });
if (!row) throw notFound();
- return c.json(row, 200);
+ return c.json(row, 200, localeHeaders(resolved.locale, resolved.source));
},
});
diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts
index ab9b4cf15..9faec83ba 100644
--- a/packages/vitnode/src/content/server/public-service.ts
+++ b/packages/vitnode/src/content/server/public-service.ts
@@ -31,7 +31,32 @@ import {
buildSearchCondition,
} from "./query";
-export interface ContentPublicFindManyArgs {
+/**
+ * Which language a public read is for.
+ *
+ * Ignored by a content type that is not localized - there is one version of the
+ * row and it is the answer to every locale. Present on the shared interface
+ * rather than only on the localized one so a route handler, which is written
+ * against `AnyContentTypeDefinition` and cannot know which it was handed, passes
+ * the locale unconditionally and lets the service decide whether it means
+ * anything.
+ */
+export interface ContentPublicReadOptions {
+ /**
+ * The **canonical** locale this read is for, already resolved through
+ * `resolveContentPublicLocale`.
+ *
+ * A locale that names no language, or one the install has switched off, is a
+ * `null` result rather than a throw or a silent substitution: the caller answers
+ * the same 404 it answers for a slug that does not exist, and no reader is ever
+ * handed a language they did not ask for.
+ */
+ locale?: string;
+}
+
+export interface ContentPublicFindManyArgs<
+ TDefinition,
+> extends ContentPublicReadOptions {
/** Equality filters, restricted to `publicApi.filterableFields`. */
filters?: ContentPublicFilterInput;
orderBy?: {
@@ -51,10 +76,21 @@ export interface ContentPublicFindManyArgs {
*/
export interface ContentPublicService {
/** `null` unless the row exists *and* is published. */
- findById: (id: number) => Promise | null>;
- /** The public detail lookup. `null` for a draft, an unpublished row or a typo. */
+ findById: (
+ id: number,
+ options?: ContentPublicReadOptions,
+ ) => Promise | null>;
+ /**
+ * The public detail lookup. `null` for a draft, an unpublished row or a typo.
+ *
+ * **Never falls back.** A slug belongs to one language, so resolving a Polish
+ * URL against an English translation would answer a request for `/pl/witaj`
+ * with the English article - and then cache it under the Polish tag. See
+ * `createContentLocalizedPublicService`.
+ */
findBySlug: (
slug: string,
+ options?: ContentPublicReadOptions,
) => Promise | null>;
findMany: (args?: ContentPublicFindManyArgs) => Promise<{
edges: ContentPublicListRow[];
@@ -134,7 +170,9 @@ export const contentPublicSelection = (
});
/** Public pages are smaller than admin ones, and the cap is lower too. */
-const clampPageSize = (value: string | undefined): string | undefined => {
+export const clampContentPublicPageSize = (
+ value: string | undefined,
+): string | undefined => {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
@@ -239,8 +277,8 @@ export const createContentPublicService = <
params: {
query: {
...query,
- first: clampPageSize(query.first),
- last: clampPageSize(query.last),
+ first: clampContentPublicPageSize(query.first),
+ last: clampContentPublicPageSize(query.last),
// Folded into `where` above so the term is escaped; handing it to
// `withPagination` would build an unescaped `ilike`.
search: undefined,
diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts
index 1c8471d45..e70809cf5 100644
--- a/packages/vitnode/src/content/server/publication.ts
+++ b/packages/vitnode/src/content/server/publication.ts
@@ -95,6 +95,66 @@ export const publishedCondition = (
lte(columns.publishedAt, sql`now()`),
);
+/**
+ * The publication pair on a generated **translation** table.
+ *
+ * Its own function rather than a second argument to {@link publicationColumns}
+ * because the two check different things: a translation carries `status` and
+ * `publishedAt` only when the *base* content type has publication, and a
+ * localized content type without it has translations that are simply always
+ * visible once the record is.
+ */
+export const contentTranslationPublicationColumns = (
+ definition: AnyContentTypeDefinition,
+ translationColumns: Record,
+): PublicationColumns => {
+ const { publishedAt, status } = translationColumns;
+
+ if (
+ !definition.localization.enabled ||
+ !definition.publication.enabled ||
+ !publishedAt ||
+ !status
+ ) {
+ throw new ContentEngineError(
+ "The translation published predicate needs both `localization: { enabled: true }` and `publication: { enabled: true }` on the content type.",
+ { contentTypeId: definition.id },
+ );
+ }
+
+ return { publishedAt, status };
+};
+
+/**
+ * The one definition of **publicly visible**.
+ *
+ * For a Stage 1-4 content type it is {@link publishedCondition} on the base row
+ * and nothing else. For a localized one it is that *and* the same predicate on the
+ * translation being served - subordination, stated once, in SQL:
+ *
+ * ```sql
+ * base.status = 'published' AND base.published_at IS NOT NULL AND base.published_at <= NOW()
+ * AND t.status = 'published' AND t.published_at IS NOT NULL AND t.published_at <= NOW()
+ * ```
+ *
+ * Two clauses of the same predicate rather than a second predicate, which is what
+ * keeps "published" from meaning one thing for a record and a slightly different
+ * thing for its Polish translation. `isContentTranslationPubliclyVisible` is the
+ * JavaScript half, written the same way for the same reason.
+ *
+ * A published record with an unpublished translation is **not** public in that
+ * language. It may still be public in another one - that is what fallback decides,
+ * and the fallback is applied by choosing *which* translation this predicate is
+ * evaluated against, never by relaxing it.
+ */
+export const contentPublicCondition = (
+ base: PublicationColumns,
+ translation?: PublicationColumns,
+): SQL | undefined =>
+ translation === undefined
+ ? publishedCondition(base)
+ : and(publishedCondition(base), publishedCondition(translation));
+
/**
* Narrows a service to its publication methods.
*
diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts
index 627a52f83..93b3ffaaf 100644
--- a/packages/vitnode/src/content/server/routes.ts
+++ b/packages/vitnode/src/content/server/routes.ts
@@ -16,7 +16,6 @@ import {
zodPaginationPageInfo,
zodPaginationQuery,
} from "../../api/lib/with-pagination";
-import { CONFIG } from "../../lib/config";
import {
zodContentConflict,
zodContentScheduleRejection,
@@ -26,7 +25,6 @@ import {
CONTENT_ACTOR_TYPES,
CONTENT_OPTIONS_LIMIT,
CONTENT_PERMISSIONS,
- CONTENT_PREVIEW_TOKEN_PLACEHOLDER,
CONTENT_REVISION_OPERATIONS,
CONTENT_SCHEDULE_ACTIONS,
CONTENT_SCHEDULE_STATUSES,
@@ -36,7 +34,11 @@ import { resolveContentActor } from "./actor";
import { contentEditorialEffects } from "./editorial-effects";
import { emitContentEvent } from "./emit";
import { withHttpErrors } from "./http-errors";
-import { contentPreviewConfigProblems } from "./preview-config";
+import {
+ assertContentPreviewIsServable,
+ contentPreviewSecret,
+ contentPreviewUrl,
+} from "./preview-link";
import { createContentPreviewToken } from "./preview-token";
import { publicationMethods } from "./publication";
import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model";
@@ -161,62 +163,10 @@ export const buildContentRoutes = <
const previewEnabled = definition.editorial.preview.enabled;
- /**
- * The secret from the boot config, falling back to the env getter.
- *
- * The fallback matters for a direct `app.request()` in a test, which does not
- * go through the global middleware that populates `core`.
- */
- const previewSecret = (c: Context): string =>
- c.get("core")?.contentPreviewSecret ?? CONFIG.contentPreviewSecret;
-
- /**
- * Where the link points, as something a person can paste into a browser.
- *
- * Absolute in both branches, and against **different origins**, because they
- * are served by different processes: a `pathTemplate` names a page in the web
- * app, and the generated JSON endpoint lives on the API. Assuming those share
- * a host is exactly the assumption a split deployment breaks, and a relative
- * path would resolve against whichever one the AdminCP happened to be on.
- *
- * `split`/`join` rather than `String.replace`, so a `$` in the encoded token
- * cannot be read as a replacement pattern. `defineContentType` has already
- * proven the template holds exactly one `{token}`.
- */
- const previewUrl = (token: string): string => {
- const encoded = encodeURIComponent(token);
- const template = definition.editorial.preview.pathTemplate;
-
- return template
- ? new URL(
- template.split(CONTENT_PREVIEW_TOKEN_PLACEHOLDER).join(encoded),
- CONFIG.web,
- ).toString()
- : new URL(
- `/api/${pluginId}/content/${definition.publicApi.path}/preview/${encoded}`,
- CONFIG.api,
- ).toString();
- };
-
- /**
- * Refuses to mint a link the install cannot protect.
- *
- * 503 rather than 500: the request was fine and the code is fine, the
- * deployment is missing a secret - and a service that is temporarily not
- * offering a feature is what 503 means. The message names the environment
- * variable, because the person clicking the button is usually the person who
- * can set it.
- */
- const assertPreviewIsServable = (c: Context): void => {
- const problems = contentPreviewConfigProblems(
- c.get("core")?.contentPreviewSecret ?? process.env.CONTENT_PREVIEW_SECRET,
- );
- if (problems.length === 0) return;
-
- throw new HTTPException(503, {
- message: `Preview is unavailable: ${problems.join(" ")}`,
- });
- };
+ const previewSecret = contentPreviewSecret;
+ const previewUrl = (token: string): string =>
+ contentPreviewUrl({ definition, pluginId, token });
+ const assertPreviewIsServable = assertContentPreviewIsServable;
const list = buildRoute({
pluginId,
diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts
index 3a16bdf93..277a5ac51 100644
--- a/packages/vitnode/src/content/server/schedule-effects.ts
+++ b/packages/vitnode/src/content/server/schedule-effects.ts
@@ -2,16 +2,60 @@ import type { Context } from "hono";
import { z } from "zod";
+import type { ContentLocaleInvalidation } from "../cache";
import type { AnyContentTypeDefinition } from "../types";
import type { ContentEditorialOutcome } from "./editorial-service";
+import type { AnyContentModel } from "./model";
+import {
+ contentLocaleInvalidations,
+ diffContentPublicLocaleStates,
+} from "../cache";
import { CONTENT_SCHEDULE_ACTIONS } from "../const";
import { contentEditorialEffects } from "./editorial-effects";
import { findContentModel } from "./model";
+import { contentPublicLocaleStates } from "./public-locales";
import { dispatchContentRevalidation } from "./revalidate-bridge";
import { recordContentScheduleEffectsError } from "./schedules-model";
import { isContentRowPublic } from "./search-document";
+/**
+ * The per-locale cache work one scheduled transition owes.
+ *
+ * Taken as a before-and-after pair rather than reasoned about, because the two
+ * differ only in the *base* row's publication state and every locale's answer
+ * follows from that plus its own translation - which is exactly what
+ * `contentPublicLocaleStates` already computes. Synthesising the previous base
+ * state is safe here in a way it would not be generally: a publish or unpublish
+ * writes no field values, so nothing else about the row moved.
+ */
+const scheduledLocales = async (
+ c: Context,
+ model: AnyContentModel,
+ payload: ContentScheduleEffectsPayload,
+ row: Record,
+): Promise => {
+ const after = await contentPublicLocaleStates(c, model, payload.itemId, {
+ row,
+ });
+ const before = await contentPublicLocaleStates(c, model, payload.itemId, {
+ row: {
+ ...row,
+ // `publishedAt` only has to be a past instant for the predicate; the real
+ // one is on the row when it was public, and irrelevant when it was not.
+ publishedAt: payload.wasPublic ? (row.publishedAt ?? new Date(0)) : null,
+ status: payload.wasPublic ? "published" : "draft",
+ },
+ });
+
+ return contentLocaleInvalidations({
+ changed: "shared",
+ defaultLocale: model.definition.localization.defaultLocale,
+ fallback: model.definition.localization.fallback,
+ states: diffContentPublicLocaleStates(before, after),
+ });
+};
+
/**
* Everything the announcements need, and nothing they have to re-read.
*
@@ -166,6 +210,13 @@ export const runContentScheduleEffects = async (
contentTypeId: definition.id,
id: payload.itemId,
isPublic: isContentRowPublic(row),
+ // A scheduled transition moves the *record*, and the record's publication
+ // state gates every language - so every locale that had a page, or has one
+ // now, is expired. Absent for a content type that is not localized, which
+ // leaves the flat fields below as the whole input, exactly as before.
+ ...(definition.localization.enabled && definition.publicApi.enabled
+ ? { locales: await scheduledLocales(c, entry.model, payload, row) }
+ : {}),
mode: "immediate",
// Both, because a transition that moved the URL has to expire the one it
// used to answer to as well.
diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts
index d3233c110..4516c2d01 100644
--- a/packages/vitnode/src/content/server/translation-routes.ts
+++ b/packages/vitnode/src/content/server/translation-routes.ts
@@ -27,6 +27,13 @@ import {
CONTENT_TRANSLATION_REVISION_OPERATIONS,
} from "../const";
import { resolveContentActor } from "./actor";
+import {
+ assertContentPreviewIsServable,
+ contentPreviewSecret,
+ contentPreviewUrl,
+} from "./preview-link";
+import { createContentPreviewToken } from "./preview-token";
+import { contentPublicLocaleStates } from "./public-locales";
import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model";
import { contentTranslationEffects } from "./translation-effects";
import { withTranslationHttpErrors } from "./translation-http-errors";
@@ -660,6 +667,162 @@ export const buildContentTranslationRoutes = <
},
});
+ /**
+ * Mints a preview link for one language.
+ *
+ * The localized counterpart of `POST /{id}/preview`, and it freezes **both**
+ * halves of the page: the record's newest shared revision and this locale's
+ * newest translation revision. A preview says "this is what the page looked
+ * like when I shared the link", and a localized page is built from two rows -
+ * freezing one of them would let the other drift underneath the reviewer.
+ *
+ * `can_view`, exactly like the base preview route: a preview shows what the
+ * public route would show, so anyone allowed to read the record in the AdminCP
+ * is already allowed to see it. The link is the credential from there on, and it
+ * is bound to this locale - opening it on another language's URL is a 404.
+ *
+ * A locale with no translation is a 404 rather than a link to the fallback: the
+ * button is on a language tab, and a link that quietly previewed a different
+ * language would be worse than no link.
+ */
+ const previewToken = buildRoute({
+ pluginId,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view },
+ route: {
+ method: "post",
+ path: "/{id}/translations/{locale}/preview",
+ description: `Create a preview link for one ${label.singular} in one language`,
+ request: { params: translationSchemas.params },
+ responses: {
+ 200: jsonResponse(
+ z.object({
+ expiresAt: z.date(),
+ locale: z.string(),
+ /** `0` when the record predates its content type opting in. */
+ revisionId: z.number(),
+ token: z.string(),
+ /** `0` when this locale has no translation revision to freeze. */
+ translationRevisionId: z.number(),
+ /** Absolute, and carrying `?locale=` so the reader stays bound. */
+ url: z.url(),
+ version: z.number(),
+ }),
+ "Preview link created",
+ ),
+ 400: invalidIdentifier,
+ 404: notFound,
+ 503: {
+ description:
+ "Preview is not configured securely on this deployment, so no link can be signed",
+ },
+ },
+ },
+ handler: async c => {
+ // Before the lookup, so a misconfigured install answers the same way for a
+ // record that exists and one that does not.
+ assertContentPreviewIsServable(c);
+
+ const id = identifier(c);
+ const target = locale(c);
+
+ const translation = await withTranslationHttpErrors(
+ "read",
+ async () => await translations(c).findByLocale(id, target),
+ { contentTypeId: definition.id, itemId: id, locale: target },
+ );
+ if (!translation) {
+ throw new HTTPException(404, { message: "Translation not found." });
+ }
+
+ const shared = model.editorialService?.(c, { pluginId });
+ const sharedRevision = shared ? await shared.revisions.latest(id) : null;
+
+ // Newest first, so one row is the whole answer. The newest is also the
+ // last one retention will prune, so a shared link stays resolvable for as
+ // long as any link would.
+ const history = buildEditorial
+ ? await editorial(c).listRevisions(id, translation.locale, { limit: 1 })
+ : { edges: [] };
+ const translationRevisionId = history.edges[0]?.id ?? 0;
+
+ const { expiresAt, token } = createContentPreviewToken({
+ definition,
+ itemId: id,
+ languageId: translation.languageId,
+ locale: translation.locale,
+ pluginId,
+ revisionId: sharedRevision?.id ?? 0,
+ secret: contentPreviewSecret(c),
+ translationRevisionId,
+ version: translation.version,
+ });
+
+ return c.json(
+ {
+ expiresAt,
+ locale: translation.locale,
+ revisionId: sharedRevision?.id ?? 0,
+ token,
+ translationRevisionId,
+ url: contentPreviewUrl({
+ definition,
+ locale: translation.locale,
+ pluginId,
+ token,
+ }),
+ version: translation.version,
+ },
+ 200,
+ );
+ },
+ });
+
+ /**
+ * Which languages this record is publicly reachable in, and under which URL.
+ *
+ * Exists for the cache, and only incidentally for the screen. A Server Action
+ * runs in the web app and talks to the API over HTTP, so it cannot evaluate the
+ * fallback rule itself - and a second implementation of "is this locale public"
+ * living in the AdminCP is exactly the copy that drifts, with a stale page in
+ * one language as the symptom. It takes this snapshot on each side of a
+ * mutation and expires the difference.
+ *
+ * `can_view`, because it says no more than the public API already does - which
+ * languages have a page, and what its slug is.
+ */
+ const publicLocales = buildRoute({
+ pluginId,
+ adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view },
+ route: {
+ method: "get",
+ path: "/{id}/public-locales",
+ description: `Which languages one ${label.singular} is publicly reachable in`,
+ request: { params: model.schemas.params },
+ responses: {
+ 200: jsonResponse(
+ z.object({
+ edges: z.array(
+ z.object({
+ /** `false` when the fallback is what makes this locale public. */
+ hasOwnTranslation: z.boolean(),
+ isPublic: z.boolean(),
+ locale: z.string(),
+ slug: z.string(),
+ }),
+ ),
+ }),
+ "One entry per enabled language",
+ ),
+ 400: invalidIdentifier,
+ },
+ },
+ handler: async c =>
+ c.json(
+ { edges: await contentPublicLocaleStates(c, model, identifier(c)) },
+ 200,
+ ),
+ });
+
const publication = definition.publication.enabled;
const editorialEnabled = definition.editorial.enabled;
@@ -673,5 +836,9 @@ export const buildContentTranslationRoutes = <
? [transitionRoute("publish"), transitionRoute("unpublish")]
: []),
...(editorialEnabled ? [revisionList, revisionDetail, restore] : []),
+ // `editorial.preview` already requires `publicApi`, so this exists only for a
+ // localized content type that has a public API to preview against.
+ ...(definition.editorial.preview.enabled ? [previewToken] : []),
+ ...(definition.publicApi.enabled ? [publicLocales] : []),
];
};
diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts
index 3a11debe3..3936f6908 100644
--- a/packages/vitnode/src/content/types.ts
+++ b/packages/vitnode/src/content/types.ts
@@ -1282,12 +1282,32 @@ type ContentPublicValue = TName extends "id"
* absent from this type *and* absent from the generated `SELECT`, so it never
* leaves Postgres. Adding a field to the content type does not add it here.
*/
-export type ContentPublicSelect = Prettify<{
- [K in ContentPublicFieldName]: ContentPublicValue<
- ContentFieldsOf,
- K
- >;
-}>;
+export type ContentPublicSelect = Prettify<
+ ContentPublicLocaleColumn & {
+ [K in ContentPublicFieldName]: ContentPublicValue<
+ ContentFieldsOf,
+ K
+ >;
+ }
+>;
+
+/**
+ * The language a public row is actually in, on a localized content type.
+ *
+ * Not always the language that was asked for: with `fallback: "default"` a locale
+ * with no translation of its own is served the default one, and a reader that
+ * cannot tell the difference cannot render `hreflang`, a language switcher or a
+ * "not translated yet" notice. So the served locale is part of the response
+ * rather than something inferred from the URL.
+ *
+ * `defineContentType` reserves the name: a localized content type with a public
+ * API may not expose a field called `locale`.
+ */
+type ContentPublicLocaleColumn = TDefinition extends {
+ localization: { enabled: true };
+}
+ ? { locale: string }
+ : Record;
/**
* A row in a public list.
diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts
index b4a500068..ab701b68e 100644
--- a/packages/vitnode/src/tests/content-fixtures.ts
+++ b/packages/vitnode/src/tests/content-fixtures.ts
@@ -262,8 +262,9 @@ export const testLocalizedNoteContentType = defineContentType({
* each locale gets its own version and its own history, and the base row keeps the
* global lifecycle every translation's visibility is subordinate to.
*
- * `publicApi` and `search` are still absent - both remain refused alongside
- * localization until Stage 5C and 5D respectively.
+ * `publicApi` and `search` are still absent - the first because
+ * `testLocalizedPageContentType` covers the public read layer, and the second
+ * because it remains refused alongside localization until Stage 5D.
*/
export const testLocalizedGuideContentType = defineContentType({
id: "test.localized-guide",
@@ -286,3 +287,79 @@ export const testLocalizedGuideContentType = defineContentType({
list: { columns: ["featured", "status"] },
},
});
+
+/**
+ * The Stage 5C fixture: localized **and** public.
+ *
+ * Everything the localized guide has, plus `publicApi` - so it exercises the
+ * things only a public localized content type can have: a locale-aware read, a
+ * strict-locale slug, a fallback, a per-locale cache tag and a preview link bound
+ * to one language.
+ *
+ * The allowlist deliberately mixes the two halves of the partition. `title`,
+ * `slug` and `body` come off the translation and `featured` off the base row, so
+ * a public response is a join rather than a projection - and `searchableFields`
+ * and `filterableFields` each name one of each, which is what proves both are
+ * evaluated against the translation actually being served.
+ */
+export const testLocalizedPageContentType = defineContentType({
+ id: "test.localized-page",
+ tableName: "test_localized_pages",
+ editorial: {
+ enabled: true,
+ preview: { enabled: true, expiresInMinutes: 30 },
+ revisions: { retention: 5 },
+ },
+ localization: { enabled: true, defaultLocale: "en", fallback: "default" },
+ publication: { enabled: true },
+ fields: {
+ title: field.text({ localized: true, required: true, maxLength: 200 }),
+ slug: field.slug({ localized: true, source: "title" }),
+ body: field.textarea({ localized: true, nullable: true }),
+ featured: field.boolean({ defaultValue: false }),
+ },
+ publicApi: {
+ enabled: true,
+ path: "localized-pages",
+ fields: ["title", "slug", "body", "featured", "publishedAt"],
+ searchableFields: ["title", "body"],
+ // Shared only, and that is the rule: a localized column is not on the base
+ // table, and a list ordered by one would reshuffle per language.
+ orderableFields: ["publishedAt"],
+ filterableFields: ["featured", "slug"],
+ defaultOrderBy: "publishedAt",
+ defaultOrder: "desc",
+ },
+ admin: {
+ label: { plural: "Test Localized Pages", singular: "Test Localized Page" },
+ list: { columns: ["featured", "status"] },
+ },
+});
+
+/** The same shape with `fallback: "none"`, for the refusal half of the rules. */
+export const testStrictLocalizedPageContentType = defineContentType({
+ id: "test.strict-localized-page",
+ tableName: "test_strict_localized_pages",
+ localization: { enabled: true, defaultLocale: "en", fallback: "none" },
+ publication: { enabled: true },
+ fields: {
+ title: field.text({ localized: true, required: true, maxLength: 200 }),
+ slug: field.slug({ localized: true, source: "title" }),
+ featured: field.boolean({ defaultValue: false }),
+ },
+ publicApi: {
+ enabled: true,
+ path: "strict-localized-pages",
+ fields: ["title", "slug", "featured", "publishedAt"],
+ searchableFields: ["title"],
+ orderableFields: ["publishedAt"],
+ filterableFields: ["featured"],
+ },
+ admin: {
+ label: {
+ plural: "Test Strict Localized Pages",
+ singular: "Test Strict Localized Page",
+ },
+ list: { columns: ["featured", "status"] },
+ },
+});
diff --git a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts
index 2e378eedb..4cd467ce8 100644
--- a/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts
+++ b/packages/vitnode/src/views/admin/views/content/actions/mutation-api.server.ts
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
+import type { ContentPublicLocaleState } from "@/content/cache";
import type {
ContentConflict,
ContentScheduleRejection,
@@ -30,6 +31,11 @@ import {
import { CONTENT_OPTIONS_LIMIT } from "@/content/const";
import { revalidateContent } from "@/content/next/revalidate.server";
+import {
+ invalidateContentLocales,
+ readContentPublicLocales,
+} from "./public-locale-cache";
+
/**
* The generic content screen ships from core, so its cached page path is the
* catch-all route copied into every web app.
@@ -161,9 +167,23 @@ const invalidate = (
id: number,
before: ContentRow | undefined,
after: ContentRow | undefined,
+ locales?: {
+ after: readonly ContentPublicLocaleState[];
+ before: readonly ContentPublicLocaleState[];
+ },
): void => {
if (!definition.publicApi.enabled) return;
+ if (definition.localization.enabled) {
+ // Without a snapshot pair there is nothing honest to expire, and expiring
+ // tags that name pages which do not exist is worse than leaving them.
+ if (locales) {
+ invalidateContentLocales(definition, id, locales.before, locales.after);
+ }
+
+ return;
+ }
+
const previous = publicStateOf(definition, before);
const current = publicStateOf(definition, after);
@@ -197,9 +217,14 @@ export const createContentAction = async (
if (result.status !== 201) return failure(result);
revalidatePath(CONTENT_PAGE_PATH, "page");
+ const created = result.data?.id ?? 0;
// A new row starts as a draft, so this normally invalidates nothing at all -
- // it is computed rather than assumed, so the rule holds if that changes.
- invalidate(definition, result.data?.id ?? 0, undefined, result.data);
+ // it is computed rather than assumed, so the rule holds if that changes. The
+ // "before" side is empty because the record did not exist.
+ invalidate(definition, created, undefined, result.data, {
+ after: await readContentPublicLocales(definition, pluginId, created),
+ before: [],
+ });
return {};
};
@@ -218,6 +243,11 @@ export const editContentAction = async (
// Before the write, so a slug change can invalidate the URL it replaced.
const before = await readRow(definition, pluginId, id);
+ const localesBefore = await readContentPublicLocales(
+ definition,
+ pluginId,
+ id,
+ );
const result = await contentApiFetch({
body: definition.editorial.enabled ? { expectedVersion, values } : values,
@@ -231,7 +261,10 @@ export const editContentAction = async (
if (result.status !== 200) return failure(result);
revalidatePath(CONTENT_PAGE_PATH, "page");
- invalidate(definition, id, before, result.data);
+ invalidate(definition, id, before, result.data, {
+ after: await readContentPublicLocales(definition, pluginId, id),
+ before: localesBefore,
+ });
return {};
};
@@ -348,6 +381,11 @@ export const restoreContentRevisionAction = async (
// Same as an edit: the old slug has to be known before the write, or a
// restore that moves the URL leaves the previous one resolving.
const before = await readRow(definition, pluginId, id);
+ const localesBefore = await readContentPublicLocales(
+ definition,
+ pluginId,
+ id,
+ );
const result = await contentApiFetch({
body: { expectedVersion },
@@ -363,7 +401,10 @@ export const restoreContentRevisionAction = async (
revalidatePath(CONTENT_PAGE_PATH, "page");
// A restore never moves `status`, so visibility is unchanged - but the slug
// may have, and `invalidate` compares both rows to work out which.
- invalidate(definition, id, before, result.data?.row);
+ invalidate(definition, id, before, result.data?.row, {
+ after: await readContentPublicLocales(definition, pluginId, id),
+ before: localesBefore,
+ });
const version = result.data?.row.version;
@@ -521,6 +562,14 @@ export const deleteContentAction = async (
): Promise => {
const { definition, pluginId } = resolve(contentTypeId);
+ // Before the write, because afterwards there is no record left to ask which
+ // languages it had pages in.
+ const localesBefore = await readContentPublicLocales(
+ definition,
+ pluginId,
+ id,
+ );
+
const result = await contentApiFetch({
// A body on a `DELETE`, matching the route: the precondition travels with
// the request that acts on it rather than in a query string that ends up in
@@ -537,7 +586,11 @@ export const deleteContentAction = async (
revalidatePath(CONTENT_PAGE_PATH, "page");
- if (definition.publicApi.enabled) {
+ // Every language loses its page at once, so the "after" side is empty rather
+ // than re-read - there is nothing left to read.
+ if (definition.localization.enabled) {
+ invalidateContentLocales(definition, id, localesBefore, []);
+ } else if (definition.publicApi.enabled) {
// A delete is final, so the question is "was it ever published?" rather
// than "was it live a second ago". `publishedAt` survives an unpublish, and
// expiring a URL that is now gone forever costs nothing.
@@ -572,6 +625,14 @@ const publicationAction = async (
): Promise => {
const { definition, pluginId } = resolve(contentTypeId);
+ // Before the write, because a transition of the record moves every language
+ // it has a page in, and afterwards only the new side is readable.
+ const localesBefore = await readContentPublicLocales(
+ definition,
+ pluginId,
+ id,
+ );
+
const result = await contentApiFetch({
definition,
method: "post",
@@ -586,7 +647,14 @@ const publicationAction = async (
// A no-op transitioned nothing, so nothing public went stale. Expiring a tag
// on every button press would throw away a warm cache for free.
- if (result.data?.changed && definition.publicApi.enabled) {
+ if (result.data?.changed && definition.localization.enabled) {
+ invalidateContentLocales(
+ definition,
+ id,
+ localesBefore,
+ await readContentPublicLocales(definition, pluginId, id),
+ );
+ } else if (result.data?.changed && definition.publicApi.enabled) {
const { isPublic, slug } = publicStateOf(definition, result.data.row);
revalidateContent(
diff --git a/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts
new file mode 100644
index 000000000..f824b4ce3
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts
@@ -0,0 +1,111 @@
+/**
+ * The cache half of a localized mutation, for the AdminCP Server Actions.
+ *
+ * A plain module rather than another `"use server"` file: everything here is a
+ * helper the actions call, not an action a browser may invoke, and a file with
+ * that directive exports only React Server Functions - which would force the
+ * synchronous invalidator below to become an `async` one for no reason.
+ */
+import { z } from "zod";
+
+import type { ContentPublicLocaleState } from "@/content/cache";
+import type { AnyContentTypeDefinition } from "@/content/types";
+
+import { contentApiFetch } from "@/content/admin/fetch.server";
+import {
+ contentLocaleInvalidationMode,
+ contentLocaleInvalidations,
+ diffContentPublicLocaleStates,
+} from "@/content/cache";
+import { revalidateContent } from "@/content/next/revalidate.server";
+
+const zodPublicLocales = z.object({
+ edges: z.array(
+ z.object({
+ hasOwnTranslation: z.boolean(),
+ isPublic: z.boolean(),
+ locale: z.string(),
+ slug: z.string(),
+ }),
+ ),
+});
+
+/**
+ * Which languages the record is publicly reachable in, straight from the API.
+ *
+ * Read rather than worked out here, and that is the point: whether a locale has a
+ * page depends on the base row, that locale's translation, the fallback setting
+ * and the language registry. A Server Action can see none of those - it talks to
+ * the API over HTTP - so the answer comes from the one place that evaluates the
+ * rule, and there is no second copy to drift.
+ *
+ * `[]` for anything that is not a localized public content type, which is what
+ * keeps every Stage 1-4 mutation on exactly the path it was on before.
+ */
+export const readContentPublicLocales = async (
+ definition: AnyContentTypeDefinition,
+ pluginId: string,
+ id: number,
+): Promise => {
+ if (!definition.publicApi.enabled || !definition.localization.enabled) {
+ return [];
+ }
+
+ const result = await contentApiFetch({
+ definition,
+ method: "get",
+ path: `/${id}/public-locales`,
+ pluginId,
+ schema: zodPublicLocales,
+ });
+
+ return result.data?.edges ?? [];
+};
+
+/**
+ * Expires the per-locale cache entries one mutation actually affected.
+ *
+ * A localized content type has no locale-less public URL, so its tags are the
+ * per-locale ones and nothing else - and which locales a mutation reaches is the
+ * fan-out rule, which lives in `contentLocaleInvalidations` so a shared-field
+ * edit and a Polish publish cannot disagree about it.
+ */
+export const invalidateContentLocales = (
+ definition: AnyContentTypeDefinition,
+ id: number,
+ before: readonly ContentPublicLocaleState[],
+ after: readonly ContentPublicLocaleState[],
+ {
+ changed = "shared",
+ locale,
+ }: {
+ /** `"shared"` for a base-row mutation, `"translation"` for a locale's own. */
+ changed?: "shared" | "translation";
+ /** The locale that moved, for a translation mutation. */
+ locale?: string;
+ } = {},
+): void => {
+ if (!definition.publicApi.enabled || !definition.localization.enabled) return;
+
+ const reached = contentLocaleInvalidations({
+ changed,
+ defaultLocale: definition.localization.defaultLocale,
+ fallback: definition.localization.fallback,
+ locale,
+ states: diffContentPublicLocaleStates(before, after),
+ });
+
+ revalidateContent(
+ {
+ contentTypeId: definition.id,
+ id,
+ // Not consulted when `locales` is present, and supplied truthfully anyway:
+ // a record is publicly reachable when any of its languages is.
+ isPublic: after.some(state => state.isPublic),
+ locales: reached,
+ slugs: [],
+ wasPublic: before.some(state => state.isPublic),
+ },
+ { mode: contentLocaleInvalidationMode(reached) },
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts
index 6d2f4a32d..fb2b46f56 100644
--- a/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts
+++ b/packages/vitnode/src/views/admin/views/content/actions/translation-api.server.ts
@@ -20,6 +20,11 @@ import {
parseContentUnprocessable,
} from "@/content/conflicts";
+import {
+ invalidateContentLocales,
+ readContentPublicLocales,
+} from "./public-locale-cache";
+
/**
* The generic content screen ships from core, so its cached page path is the
* catch-all route copied into every web app. Same constant the shared mutation
@@ -112,6 +117,44 @@ export interface TranslationRow {
version: number;
}
+/**
+ * The cache work one translation mutation owes, taken as a before-and-after pair.
+ *
+ * `changed: "translation"` narrows the fan-out to this locale - and, when the
+ * content type falls back to the default *and this is the default locale*, to
+ * every locale that has no translation of its own, because those are the pages
+ * built from the row that just moved. A Polish edit expires Polish pages and
+ * leaves the English cache warm.
+ *
+ * The snapshot is read on both sides rather than reasoned about, because whether
+ * a locale has a page depends on the base row, its own translation and the
+ * fallback - and that rule lives on the API, evaluated once.
+ */
+const withLocaleCache = async (
+ contentTypeId: string,
+ id: number,
+ locale: string,
+ mutate: () => Promise,
+): Promise => {
+ const { definition, pluginId } = resolve(contentTypeId);
+ const before = await readContentPublicLocales(definition, pluginId, id);
+
+ const result = await mutate();
+ if (result.error !== undefined || result.conflict || result.unprocessable) {
+ return result;
+ }
+
+ invalidateContentLocales(
+ definition,
+ id,
+ before,
+ await readContentPublicLocales(definition, pluginId, id),
+ { changed: "translation", locale },
+ );
+
+ return result;
+};
+
/** One locale's presence and lifecycle, without its values. */
export interface TranslationMeta {
locale: string;
@@ -180,24 +223,25 @@ export const createContentTranslationAction = async (
id: number,
locale: string,
values: Record,
-): Promise => {
- const { definition, pluginId } = resolve(contentTypeId);
+): Promise =>
+ await withLocaleCache(contentTypeId, id, locale, async () => {
+ const { definition, pluginId } = resolve(contentTypeId);
- const result = await contentApiFetch({
- body: { values },
- definition,
- method: "post",
- path: `/${id}/translations/${segment(locale)}`,
- pluginId,
- schema: zodTranslation,
- });
+ const result = await contentApiFetch({
+ body: { values },
+ definition,
+ method: "post",
+ path: `/${id}/translations/${segment(locale)}`,
+ pluginId,
+ schema: zodTranslation,
+ });
- if (result.status !== 201) return failure(result);
+ if (result.status !== 201) return failure(result);
- revalidatePath(CONTENT_PAGE_PATH, "page");
+ revalidatePath(CONTENT_PAGE_PATH, "page");
- return {};
-};
+ return {};
+ });
export const editContentTranslationAction = async (
contentTypeId: string,
@@ -205,48 +249,50 @@ export const editContentTranslationAction = async (
locale: string,
values: Record,
expectedVersion: number,
-): Promise => {
- const { definition, pluginId } = resolve(contentTypeId);
+): Promise =>
+ await withLocaleCache(contentTypeId, id, locale, async () => {
+ const { definition, pluginId } = resolve(contentTypeId);
- const result = await contentApiFetch({
- body: { expectedVersion, values },
- definition,
- method: "put",
- path: `/${id}/translations/${segment(locale)}`,
- pluginId,
- schema: zodTranslationResult,
- });
+ const result = await contentApiFetch({
+ body: { expectedVersion, values },
+ definition,
+ method: "put",
+ path: `/${id}/translations/${segment(locale)}`,
+ pluginId,
+ schema: zodTranslationResult,
+ });
- if (result.status !== 200) return failure(result);
+ if (result.status !== 200) return failure(result);
- revalidatePath(CONTENT_PAGE_PATH, "page");
+ revalidatePath(CONTENT_PAGE_PATH, "page");
- return {};
-};
+ return {};
+ });
export const deleteContentTranslationAction = async (
contentTypeId: string,
id: number,
locale: string,
expectedVersion: number,
-): Promise => {
- const { definition, pluginId } = resolve(contentTypeId);
+): Promise =>
+ await withLocaleCache(contentTypeId, id, locale, async () => {
+ const { definition, pluginId } = resolve(contentTypeId);
- const result = await contentApiFetch({
- body: { expectedVersion },
- definition,
- method: "delete",
- path: `/${id}/translations/${segment(locale)}`,
- pluginId,
- schema: zodTranslation,
- });
+ const result = await contentApiFetch({
+ body: { expectedVersion },
+ definition,
+ method: "delete",
+ path: `/${id}/translations/${segment(locale)}`,
+ pluginId,
+ schema: zodTranslation,
+ });
- if (result.status !== 200) return failure(result);
+ if (result.status !== 200) return failure(result);
- revalidatePath(CONTENT_PAGE_PATH, "page");
+ revalidatePath(CONTENT_PAGE_PATH, "page");
- return {};
-};
+ return {};
+ });
const transition = async (
action: "publish" | "unpublish",
@@ -254,24 +300,25 @@ const transition = async (
id: number,
locale: string,
expectedVersion: number,
-): Promise => {
- const { definition, pluginId } = resolve(contentTypeId);
+): Promise =>
+ await withLocaleCache(contentTypeId, id, locale, async () => {
+ const { definition, pluginId } = resolve(contentTypeId);
- const result = await contentApiFetch({
- body: { expectedVersion },
- definition,
- method: "post",
- path: `/${id}/translations/${segment(locale)}/${action}`,
- pluginId,
- schema: zodTranslationResult,
- });
+ const result = await contentApiFetch({
+ body: { expectedVersion },
+ definition,
+ method: "post",
+ path: `/${id}/translations/${segment(locale)}/${action}`,
+ pluginId,
+ schema: zodTranslationResult,
+ });
- if (result.status !== 200) return failure(result);
+ if (result.status !== 200) return failure(result);
- revalidatePath(CONTENT_PAGE_PATH, "page");
+ revalidatePath(CONTENT_PAGE_PATH, "page");
- return {};
-};
+ return {};
+ });
export const publishContentTranslationAction = async (
contentTypeId: string,
@@ -360,21 +407,22 @@ export const restoreContentTranslationRevisionAction = async (
locale: string,
revisionId: number,
expectedVersion: number,
-): Promise => {
- const { definition, pluginId } = resolve(contentTypeId);
+): Promise =>
+ await withLocaleCache(contentTypeId, id, locale, async () => {
+ const { definition, pluginId } = resolve(contentTypeId);
- const result = await contentApiFetch({
- body: { expectedVersion },
- definition,
- method: "post",
- path: `/${id}/translations/${segment(locale)}/revisions/${revisionId}/restore`,
- pluginId,
- schema: zodTranslationResult,
- });
+ const result = await contentApiFetch({
+ body: { expectedVersion },
+ definition,
+ method: "post",
+ path: `/${id}/translations/${segment(locale)}/revisions/${revisionId}/restore`,
+ pluginId,
+ schema: zodTranslationResult,
+ });
- if (result.status !== 200) return failure(result);
+ if (result.status !== 200) return failure(result);
- revalidatePath(CONTENT_PAGE_PATH, "page");
+ revalidatePath(CONTENT_PAGE_PATH, "page");
- return {};
-};
+ return {};
+ });
diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts
index 13a798b67..20d9f122a 100644
--- a/plugins/example/src/config.api.ts
+++ b/plugins/example/src/config.api.ts
@@ -5,6 +5,7 @@ import { adminModule } from "@/api/modules/admin/admin.module";
import { CONFIG_PLUGIN } from "@/const";
import { articleContent } from "@/database/articles";
import { categoryContent } from "@/database/categories";
+import { localizedArticleContent } from "@/database/localized-articles";
import "@/api/lib/events";
/**
@@ -18,7 +19,9 @@ import "@/api/lib/events";
* nothing, and it registers no content types of its own (that would be a
* duplicate registration).
*
- * Public routes land at `/api/@vitnode/example/content/articles/`.
+ * Public routes land at `/api/@vitnode/example/content/articles/` and, for the
+ * localized fixture, `/api/@vitnode/example/content/localized-articles/` - the
+ * same two shapes, with `?locale=` deciding which language they answer in.
*/
export const exampleApiPlugin = () =>
buildApiPlugin({
@@ -27,7 +30,11 @@ export const exampleApiPlugin = () =>
adminModule,
buildContentPublicModule({
pluginId: CONFIG_PLUGIN.pluginId,
- contentTypes: [articleContent, categoryContent],
+ contentTypes: [
+ articleContent,
+ categoryContent,
+ localizedArticleContent,
+ ],
}),
],
});
diff --git a/plugins/example/src/content/localized-article.ts b/plugins/example/src/content/localized-article.ts
index 637ed74f2..042778b25 100644
--- a/plugins/example/src/content/localized-article.ts
+++ b/plugins/example/src/content/localized-article.ts
@@ -11,11 +11,12 @@ import { defineContentType, field } from "@vitnode/core/content";
* history, and a unique `(languageId, slug)` index so `/en/hello` and `/pl/hello`
* can both exist while a second English `hello` is a 409.
*
- * `publicApi` and `search` are still absent: both remain refused alongside
- * `localization` until Stage 5C and Stage 5D respectively - see the boundaries in
- * `resolveContentLocalization`. So this fixture exercises the tables, the schemas,
- * the services, the per-locale lifecycle, the per-locale history and the generated
- * routes, and nothing that reads outwards.
+ From Stage 5C it is public as well: `publicApi` exposes the localized `title`,
+ * `slug` and `body` alongside the shared `featured`, and a public read resolves one
+ * language - explicitly, negotiated or the default - with `fallback: "default"`
+ * serving English to a locale that has no translation of its own. `search` is the
+ * one thing still refused alongside `localization`; per-locale search documents
+ * land in Stage 5D.
*/
export const localizedArticleContentType = defineContentType({
id: "example.localized-article",
@@ -27,8 +28,10 @@ export const localizedArticleContentType = defineContentType({
// process. The Postgres suite inserts it (and `pl`) itself - nothing seeds
// languages, they are created by the installer.
defaultLocale: "en",
- // Resolved here, acted on in Stage 5C. `"default"` so the fixture carries the
- // interesting value rather than the inert one.
+ // Acted on by the public read layer from Stage 5C. `"default"` so the
+ // fixture carries the interesting value rather than the inert one: a locale
+ // with no published translation is served the English copy, and says so in
+ // the `locale` field of the response.
fallback: "default",
},
@@ -36,9 +39,36 @@ export const localizedArticleContentType = defineContentType({
// publishing the English copy of a draft article puts nothing on the internet.
publication: { enabled: true },
+ /**
+ * The public read layer, over both halves of the partition.
+ *
+ * `orderableFields` names shared columns only, and that is a rule rather than
+ * an oversight: a list ordered by a localized title would reshuffle itself for
+ * every language, and a cursor would mean two different positions across a
+ * fallback set. `searchableFields` and `filterableFields` *may* name localized
+ * fields - both are evaluated against the one translation the reader is being
+ * served, so they can never match a language nobody will see.
+ */
+ publicApi: {
+ enabled: true,
+ path: "localized-articles",
+ fields: ["title", "slug", "body", "featured", "publishedAt"],
+ searchableFields: ["title", "body"],
+ orderableFields: ["publishedAt"],
+ filterableFields: ["featured", "slug"],
+ defaultOrderBy: "publishedAt",
+ defaultOrder: "desc",
+ },
+
// Per-locale versions, per-locale revisions, per-locale restore. `retention` is
// per language, so five Polish revisions do not evict the English ones.
- editorial: { enabled: true, revisions: { retention: 20 } },
+ // `preview` mints a link per language, freezing the shared revision and that
+ // locale's translation revision together.
+ editorial: {
+ enabled: true,
+ preview: { enabled: true, expiresInMinutes: 30 },
+ revisions: { retention: 20 },
+ },
fields: {
title: field.text({
diff --git a/plugins/example/src/database/postgres.test.ts b/plugins/example/src/database/postgres.test.ts
index f8d78b4ae..f2da6d68f 100644
--- a/plugins/example/src/database/postgres.test.ts
+++ b/plugins/example/src/database/postgres.test.ts
@@ -11,6 +11,7 @@ import {
} from "@vitnode/core/content";
import {
claimContentSchedule,
+ contentPublicLocaleStates,
createContentSearchIndexer,
settleContentSchedule,
syncContentSearch,
@@ -3291,5 +3292,339 @@ describe.skipIf(!url)("Content Engine against Postgres", () => {
).rejects.toThrow(/version 2, not 1/);
});
});
+
+ /**
+ * The Stage 5C public read, against a real database.
+ *
+ * The interesting behaviour is entirely in the SQL: subordination is two
+ * `published` predicates on two tables, the fallback is which translation the
+ * join resolves to, and a strict-locale slug is the absence of a second arm.
+ * A mock asked whether the right translation was joined can only agree with
+ * itself.
+ */
+ describe("public reads", () => {
+ const publicService = (handle = context) => {
+ const build = localizedArticleContent.publicService;
+ if (!build) throw new Error("Expected a public service.");
+
+ return build(handle);
+ };
+
+ const editorial = (handle = context) => {
+ const build = localizedArticleContent.translationEditorialService;
+ if (!build)
+ throw new Error("Expected a translation editorial service.");
+
+ return build(handle, { pluginId: CONFIG_PLUGIN.pluginId });
+ };
+
+ /**
+ * A record published globally, with an English translation and - when asked
+ * - a Polish one, each published or left as a draft.
+ */
+ const seed = async ({
+ featured = false,
+ pl,
+ title,
+ }: {
+ featured?: boolean;
+ pl?: { published: boolean; title: string };
+ title: string;
+ }) => {
+ const { row } = await localizedService().create({
+ shared: { featured },
+ translation: { body: `Body of ${title}`, title },
+ });
+
+ await articlePublish(row.id);
+ await editorial().publish(row.id, "en", { actor: ACTOR });
+
+ if (pl) {
+ await translations().create(row.id, "pl", {
+ body: `Tresc ${pl.title}`,
+ title: pl.title,
+ });
+ if (pl.published) {
+ await editorial().publish(row.id, "pl", { actor: ACTOR });
+ }
+ }
+
+ return row.id;
+ };
+
+ /** Publishes the *record*, which every translation is subordinate to. */
+ const articlePublish = async (itemId: number) => {
+ await sql`
+ UPDATE "example_localized_articles"
+ SET "status" = 'published', "publishedAt" = now()
+ WHERE "id" = ${itemId}
+ `;
+ };
+
+ it("serves the requested language", async () => {
+ const itemId = await seed({
+ pl: { published: true, title: "Witaj" },
+ title: "Hello",
+ });
+
+ const row = await publicService().findById(itemId, { locale: "pl" });
+
+ expect(row).toMatchObject({ locale: "pl", title: "Witaj" });
+ });
+
+ it("mixes shared and localized columns in one row", async () => {
+ const itemId = await seed({ featured: true, title: "Mixed" });
+
+ // A public localized response is a base row joined to a translation.
+ expect(
+ await publicService().findById(itemId, { locale: "en" }),
+ ).toMatchObject({ featured: true, title: "Mixed" });
+ });
+
+ it("falls back to the default language and says so", async () => {
+ const itemId = await seed({ title: "Only English" });
+
+ const row = await publicService().findById(itemId, { locale: "pl" });
+
+ // Served, and honest about which language it is - which is what a
+ // language switcher and `hreflang` need.
+ expect(row).toMatchObject({ locale: "en", title: "Only English" });
+ });
+
+ it("never falls back to a draft translation", async () => {
+ const itemId = await seed({
+ pl: { published: false, title: "Szkic" },
+ title: "Published English",
+ });
+
+ // The fallback picks *which* translation the predicate runs against; it
+ // never relaxes the predicate.
+ expect(
+ await publicService().findById(itemId, { locale: "pl" }),
+ ).toMatchObject({ locale: "en" });
+ });
+
+ it("hides every language while the record itself is a draft", async () => {
+ const { row } = await localizedService().create({
+ shared: {},
+ translation: { body: "Body", title: "Unpublished Record" },
+ });
+ await editorial().publish(row.id, "en", { actor: ACTOR });
+
+ // Subordination: publishing the English copy of a draft article puts
+ // nothing on the internet.
+ expect(
+ await publicService().findById(row.id, { locale: "en" }),
+ ).toBeNull();
+ });
+
+ it("resolves a slug strictly in its own language", async () => {
+ await seed({
+ pl: { published: true, title: "Witaj Swiecie" },
+ title: "Hello World",
+ });
+ const polish = await publicService().findBySlug("witaj-swiecie", {
+ locale: "pl",
+ });
+
+ expect(polish).toMatchObject({ locale: "pl", title: "Witaj Swiecie" });
+ // The same slug on the English URL is a 404 rather than the Polish
+ // article: a URL belongs to a language.
+ expect(
+ await publicService().findBySlug("witaj-swiecie", { locale: "en" }),
+ ).toBeNull();
+ });
+
+ it("does not fall back on a slug lookup", async () => {
+ await seed({ title: "Fallback Slug" });
+
+ // The English article is reachable in Polish through the fallback, but
+ // its English URL is not a Polish URL.
+ expect(
+ await publicService().findBySlug("fallback-slug", { locale: "pl" }),
+ ).toBeNull();
+ });
+
+ it("lists one row per record, in the requested language", async () => {
+ await seed({ pl: { published: true, title: "Jeden" }, title: "One" });
+ await seed({ title: "Two" });
+
+ const { edges } = await publicService().findMany({ locale: "pl" });
+
+ // Two records, not three rows: the join resolves one translation each.
+ expect(edges.map(edge => edge.title).sort()).toEqual(["Jeden", "Two"]);
+ });
+
+ it("counts the same rows it returns", async () => {
+ await seed({ title: "Counted One" });
+ await seed({ title: "Counted Two" });
+
+ const { pageInfo } = await publicService().findMany({ locale: "en" });
+
+ // The `EXISTS` in the `WHERE` is what makes the paginator's `COUNT`
+ // agree with the joined read.
+ expect(pageInfo.totalCount).toBe(2);
+ });
+
+ it("omits a record with no published translation at all", async () => {
+ const { row } = await localizedService().create({
+ shared: {},
+ translation: { body: "Body", title: "Draft Everywhere" },
+ });
+ await articlePublish(row.id);
+
+ expect(
+ (await publicService().findMany({ locale: "en" })).edges,
+ ).toEqual([]);
+ });
+
+ it("filters on a localized field against the served translation", async () => {
+ await seed({
+ pl: { published: true, title: "Filtr" },
+ title: "Filter",
+ });
+
+ const matched = await publicService().findMany({
+ filters: { slug: "filtr" },
+ locale: "pl",
+ });
+ const crossed = await publicService().findMany({
+ filters: { slug: "filtr" },
+ locale: "en",
+ });
+
+ expect(matched.edges.map(edge => edge.title)).toEqual(["Filtr"]);
+ // The English read is served the English translation, whose slug is
+ // `filter` - so a filter can never match a language nobody will see.
+ expect(crossed.edges).toEqual([]);
+ });
+
+ it("searches a localized field against the served translation", async () => {
+ await seed({
+ pl: { published: true, title: "Wyszukiwanie" },
+ title: "Searching",
+ });
+
+ const polish = await publicService().findMany({
+ locale: "pl",
+ query: { search: "Wyszuk" },
+ });
+ const english = await publicService().findMany({
+ locale: "en",
+ query: { search: "Wyszuk" },
+ });
+
+ expect(polish.edges).toHaveLength(1);
+ expect(english.edges).toEqual([]);
+ });
+
+ it("filters on a shared field alongside a localized one", async () => {
+ await seed({ featured: true, title: "Featured One" });
+ await seed({ featured: false, title: "Plain One" });
+
+ const { edges } = await publicService().findMany({
+ filters: { featured: true },
+ locale: "en",
+ });
+
+ expect(edges.map(edge => edge.title)).toEqual(["Featured One"]);
+ });
+
+ it("returns nothing at all for a locale the install does not serve", async () => {
+ await seed({ title: "Unknown Locale" });
+
+ // Not a throw, and not a substitution: the route turns this into the
+ // same 404 a missing record gets.
+ expect(await publicService().findMany({ locale: "fr" })).toMatchObject({
+ edges: [],
+ });
+ });
+
+ it("returns nothing for a locale the app has switched off", async () => {
+ const itemId = await seed({ title: "Disabled Locale" });
+
+ // `de` exists in `core_languages` and is `enabled: false` in the app
+ // config: readable in the AdminCP, unreachable in public.
+ expect(
+ await publicService().findById(itemId, { locale: "de" }),
+ ).toBeNull();
+ });
+
+ it("exposes only the allowlisted columns", async () => {
+ const itemId = await seed({ title: "Allowlist" });
+
+ const row = await publicService().findById(itemId, { locale: "en" });
+
+ expect(Object.keys(row ?? {}).sort()).toEqual([
+ "body",
+ "featured",
+ "locale",
+ "publishedAt",
+ "slug",
+ "title",
+ ]);
+ });
+
+ it("hides a language again when its translation is unpublished", async () => {
+ const itemId = await seed({
+ pl: { published: true, title: "Znika" },
+ title: "Disappears",
+ });
+
+ await editorial().unpublish(itemId, "pl", { actor: ACTOR });
+
+ // Back to the fallback, not to a 404: the record is still public in
+ // English, and Polish has no translation of its own any more.
+ expect(
+ await publicService().findById(itemId, { locale: "pl" }),
+ ).toMatchObject({ locale: "en" });
+ });
+ });
+
+ /** Which languages a record is publicly reachable in, evaluated in SQL. */
+ describe("public locale states", () => {
+ const editorial = (handle = context) => {
+ const build = localizedArticleContent.translationEditorialService;
+ if (!build)
+ throw new Error("Expected a translation editorial service.");
+
+ return build(handle, { pluginId: CONFIG_PLUGIN.pluginId });
+ };
+
+ it("reports the fallback consumers as public but not their own", async () => {
+ const { row } = await localizedService().create({
+ shared: {},
+ translation: { body: "Body", title: "Locale States" },
+ });
+ await sql`
+ UPDATE "example_localized_articles"
+ SET "status" = 'published', "publishedAt" = now()
+ WHERE "id" = ${row.id}
+ `;
+ await editorial().publish(row.id, "en", { actor: ACTOR });
+
+ const states = await contentPublicLocaleStates(
+ context,
+ localizedArticleContent,
+ row.id,
+ );
+
+ // `de` is disabled in this app's config, so it is not reported at all.
+ expect(states).toEqual([
+ {
+ hasOwnTranslation: true,
+ isPublic: true,
+ locale: "en",
+ slug: "locale-states",
+ },
+ {
+ hasOwnTranslation: false,
+ isPublic: true,
+ locale: "pl",
+ slug: "locale-states",
+ },
+ ]);
+ });
+ });
});
});