From 21560059ba38d579cbadaaeeef57657a4f62047f Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 7 Aug 2026 20:29:58 +0200 Subject: [PATCH] feat(content): add the locale-aware public read layer Lifts the `localization` + `publicApi` boundary. A public localized response is one base row joined to one translation, and both halves have to be published; fallback chooses *which* translation that predicate runs against and never relaxes it. Locale resolution - `content/locale.ts`: `Accept-Language` parsing with quality values, two-pass negotiation (exact before prefix), and the precedence explicit -> negotiated -> default. An explicit locale that names no served language is `null`, which the routes answer as the same 404 a missing record gets; a negotiated one falls through, because a preference is not an instruction. The read layer - `contentPublicCondition` states subordination once, in SQL, as an `and` of the existing `publishedCondition` over both pairs of columns. - `createContentLocalizedPublicService` joins the translation being served and evaluates visibility twice in the same statement - as an `EXISTS` in the `WHERE`, so the paginator's `COUNT` agrees with the joined read, and as the join's `ON`. Filters and searches on localized fields go through the same test, so they can never match a language the reader will not be shown. - `findBySlug` is strict-locale on both fallback settings: a URL belongs to a language, and two languages routinely share a slug. - The response carries the locale it resolved to, plus `Content-Language` and - only when the header decided - `Vary: Accept-Language`. Caching - Every tag gains a locale segment after the scope, so a non-localized content type's tags are byte-identical to what they were. - `contentLocaleInvalidations` is the fan-out rule: a shared change reaches every locale, a translation reaches its own - plus, for the default locale under `fallback: "default"`, every locale served by it. - `GET /{id}/public-locales` evaluates "which languages have a page" on the server, where the language registry lives; the AdminCP diffs two snapshots rather than reimplementing the rule in the browser. - Scheduled transitions fan out the same way, over the revalidation bridge. Preview - `POST /{id}/translations/{locale}/preview` freezes the shared revision and that locale's translation revision together, and puts `?locale=` in the link. The public preview route resolves its locale the same way every other public read does and refuses a mismatch in either direction. Definition-time rules - `publicApi.orderableFields` refuses a localized field: a list ordered by one reshuffles per language, and a cursor would mean two positions across a fallback set. - A localized content type may not expose a field called `locale`. No migration: `publicApi` adds no column. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/dev/content-engine/caching.mdx | 49 +- .../content/docs/dev/content-engine/index.mdx | 8 +- .../docs/dev/content-engine/limitations.mdx | 33 +- .../docs/dev/content-engine/localization.mdx | 33 +- .../content-engine/localized-public-api.mdx | 248 +++++++++ .../content/docs/dev/content-engine/meta.json | 1 + .../docs/dev/content-engine/public-api.mdx | 11 + .../content-engine/translation-editorial.mdx | 5 +- .../content-engine/translation-preview.mdx | 40 +- .../vitnode/src/content/cache.locale.test.ts | 379 +++++++++++++ packages/vitnode/src/content/cache.ts | 287 +++++++++- packages/vitnode/src/content/define.ts | 30 +- packages/vitnode/src/content/index.ts | 17 + packages/vitnode/src/content/locale.test.ts | 165 ++++++ packages/vitnode/src/content/locale.ts | 183 +++++++ .../vitnode/src/content/localization.test.ts | 75 ++- packages/vitnode/src/content/localization.ts | 30 +- .../vitnode/src/content/next/fetch.server.ts | 52 +- .../content/next/revalidate-route.server.ts | 18 + .../src/content/public-localization.test-d.ts | 80 +++ packages/vitnode/src/content/public.test-d.ts | 15 +- packages/vitnode/src/content/public.test.ts | 112 ++++ packages/vitnode/src/content/schemas.ts | 26 +- packages/vitnode/src/content/server/index.ts | 10 + .../server/localized-preview-routes.test.ts | 250 +++++++++ .../server/localized-public-routes.test.ts | 234 ++++++++ .../server/localized-public-service.ts | 510 ++++++++++++++++++ packages/vitnode/src/content/server/model.ts | 16 +- .../src/content/server/preview-link.ts | 90 ++++ .../src/content/server/preview-route.test.ts | 4 +- .../src/content/server/public-locales.test.ts | 212 ++++++++ .../src/content/server/public-locales.ts | 163 ++++++ .../src/content/server/public-routes.test.ts | 4 +- .../src/content/server/public-routes.ts | 228 +++++++- .../src/content/server/public-service.ts | 50 +- .../vitnode/src/content/server/publication.ts | 60 +++ packages/vitnode/src/content/server/routes.ts | 68 +-- .../src/content/server/schedule-effects.ts | 51 ++ .../src/content/server/translation-routes.ts | 167 ++++++ packages/vitnode/src/content/types.ts | 32 +- .../vitnode/src/tests/content-fixtures.ts | 81 ++- .../content/actions/mutation-api.server.ts | 80 ++- .../content/actions/public-locale-cache.ts | 111 ++++ .../content/actions/translation-api.server.ts | 188 ++++--- plugins/example/src/config.api.ts | 11 +- .../example/src/content/localized-article.ts | 46 +- plugins/example/src/database/postgres.test.ts | 335 ++++++++++++ 47 files changed, 4585 insertions(+), 313 deletions(-) create mode 100644 apps/docs/content/docs/dev/content-engine/localized-public-api.mdx create mode 100644 packages/vitnode/src/content/cache.locale.test.ts create mode 100644 packages/vitnode/src/content/locale.test.ts create mode 100644 packages/vitnode/src/content/locale.ts create mode 100644 packages/vitnode/src/content/public-localization.test-d.ts create mode 100644 packages/vitnode/src/content/server/localized-preview-routes.test.ts create mode 100644 packages/vitnode/src/content/server/localized-public-routes.test.ts create mode 100644 packages/vitnode/src/content/server/localized-public-service.ts create mode 100644 packages/vitnode/src/content/server/preview-link.ts create mode 100644 packages/vitnode/src/content/server/public-locales.test.ts create mode 100644 packages/vitnode/src/content/server/public-locales.ts create mode 100644 packages/vitnode/src/views/admin/views/content/actions/public-locale-cache.ts 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", + }, + ]); + }); + }); }); });