feat: Add content engine Stage 5C — Public Localization - #739
feat: Add content engine Stage 5C — Public Localization#739aXenDeveloper wants to merge 1 commit into
Conversation
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21560059ba
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| return { | ||
| "Content-Language": locale, | ||
| ...(source === "negotiated" ? { Vary: "Accept-Language" } : {}), |
There was a problem hiding this comment.
Vary all negotiable responses on Accept-Language
When a localized request has no explicit ?locale=, the representation can depend on Accept-Language even if the current header is absent or does not match. Such a request is labeled source: "default" here and receives no Vary, so a browser or shared cache can reuse an English default response for a later Polish request to the same URL. Omit Vary only for explicit-locale URLs; default and negotiated responses must both vary on the header.
Useful? React with 👍 / 👎.
| for (const tag of wanted) { | ||
| const exact = byNormalized.get(tag); | ||
| if (exact !== undefined) return exact; |
There was a problem hiding this comment.
Preserve quality order while applying locale fallback
Scanning every requested tag for an exact match before considering any primary-tag fallback defeats the quality ranking produced by parseAcceptLanguage. For example, fr;q=1, en;q=0.1 with available locales fr-FR and en resolves to English, even though French is the user's strongest preference and the function explicitly supports resolving fr to fr-FR. Process each ranked tag by trying its exact and primary-language matches before moving to the next tag.
Useful? React with 👍 / 👎.
| const translated = localized | ||
| ? await readPreviewTranslation(c, payload) |
There was a problem hiding this comment.
Select only shared fields for live localized previews
When a localized preview token has r: 0—the supported case for records that predate editorial revisions—readPreviewRow calls contentPublicSelection(definition, model.columns). The base model's column map contains only shared fields, while this change permits localized fields in publicApi.fields, so that selection contains undefined columns and Drizzle fails before this translation merge runs. Build the live base selection from exposed shared fields plus the cursor id so legacy localized preview links do not return a server error.
Useful? React with 👍 / 👎.
Lifts the
localization+publicApiboundary. 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-Languageparsing with quality values, two-pass negotiation (exact before prefix), and the precedence explicit -> negotiated -> default. An explicit locale that names no served language isnull, 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
contentPublicConditionstates subordination once, in SQL, as anandof the existingpublishedConditionover both pairs of columns.createContentLocalizedPublicServicejoins the translation being served and evaluates visibility twice in the same statement - as anEXISTSin theWHERE, so the paginator'sCOUNTagrees with the joined read, and as the join'sON. Filters and searches on localized fields go through the same test, so they can never match a language the reader will not be shown.findBySlugis strict-locale on both fallback settings: a URL belongs to a language, and two languages routinely share a slug.Content-Languageand - only when the header decided -Vary: Accept-Language.Caching
contentLocaleInvalidationsis the fan-out rule: a shared change reaches every locale, a translation reaches its own - plus, for the default locale underfallback: "default", every locale served by it.GET /{id}/public-localesevaluates "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.Preview
POST /{id}/translations/{locale}/previewfreezes 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.orderableFieldsrefuses a localized field: a list ordered by one reshuffles per language, and a cursor would mean two positions across a fallback set.locale.No migration:
publicApiadds no column.Improving Documentation
pnpm lint:fixto fix formatting issues before opening the PR.Description
What?
Why?