Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions apps/docs/content/docs/dev/content-engine/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ Four more declarations, each opt-in:
- [`localization`](/docs/dev/content-engine/localization) moves the text fields
you mark into a generated per-language table, with its own version, its own
[publish button](/docs/dev/content-engine/translation-editorial), its own
[history](/docs/dev/content-engine/translation-revisions) and a URL per locale
[history](/docs/dev/content-engine/translation-revisions), a
[URL per locale](/docs/dev/content-engine/localized-public-api) and a
[search document per language](/docs/dev/content-engine/localized-search)

Publication alone exposes nothing. Public exposure requires both of the first
two; `editorial` works with or without either. `localization` combines with
`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.
two; `editorial` works with or without either. `localization` combines with all
four - a public read
[resolves one language](/docs/dev/content-engine/localized-public-api) and search
[indexes one document per translation](/docs/dev/content-engine/localized-search).

## What it is not

Expand Down
33 changes: 16 additions & 17 deletions apps/docs/content/docs/dev/content-engine/limitations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,29 +43,28 @@ None of these are blocked - they are simply not generated. The service, the
schemas and the table are all public, so a hand-written route sits next to a
generated one without friction.

## Localization does not read outwards yet
## Localization is complete, with one shape rule

[`localization`](/docs/dev/content-engine/localization) generates the tables, the
types, the schemas, the services, the per-locale lifecycle, the per-locale history
and the AdminCP locale tabs. What it deliberately refuses is every combination
whose *reading* half is not built yet:

| Combination | Refused until |
| --- | --- |
| `localization` + `search` | Stage 5D |

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.
Nothing is refused any more.
[Locale-aware public reads](/docs/dev/content-engine/localized-public-api) landed
in Stage 5C and [per-locale search](/docs/dev/content-engine/localized-search) in
Stage 5D, so `localization` combines with `publication`, `editorial`, `publicApi`
and `search`.

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.
One shape rule remains, and it is a rule rather than a boundary:
`search.pathTemplate` must contain `{locale}` on a localized content type. Two
languages routinely answer to the same slug, so a template without it would give
every translation of a record the same link.

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.
Still outside Stage 5: an `Outdated` translation badge (its honest definition
needs a comparison two timestamps cannot make), locale-specific scheduling,
`hreflang` and sitemap generation, locale-specific relations and localized
media.

## A public list cannot be ordered by a localized field

Expand All @@ -85,8 +84,8 @@ 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 5D.
tab, and the list's language selector adds a column showing each record's title
in the language being viewed.

## Foreign key names on a long translation table are truncated by Postgres

Expand Down
74 changes: 45 additions & 29 deletions apps/docs/content/docs/dev/content-engine/localization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,10 @@ example_localized_articles_translations itemId, languageId, version,
A content type without the block generates the same single table, the same
routes and the same wire shapes it always did.

<Callout type="warn" title="Two combinations are still refused">
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 5C
boundaries](#stage-5c-boundaries).
<Callout title="It combines with everything">
Localization works with `publication`, `editorial`, `publicApi` and `search`.
There is one shape rule left - `search.pathTemplate` must carry `{locale}` -
and no capability is refused. See [the roadmap](#the-roadmap).
</Callout>

## This is not UI translation
Expand Down Expand Up @@ -154,9 +153,11 @@ worth knowing up front:
- it cannot appear in `indexes`,
- it is absent from `schemas.create`, `schemas.update` and `schemas.select`.

All five are compile errors *and* runtime errors. Localized values get their own
AdminCP surface in Stage 5B; until then there is nowhere on the base form for
them to go, and a silently-dropped title is worse than a refused definition.
All five are compile errors *and* runtime errors: there is nowhere on the base
form or in a base-table query for them to go, and a silently-dropped title is
worse than a refused definition. Localized values have their own AdminCP surface -
the [locale tabs](/docs/dev/content-engine/translation-editorial) in the edit
dialog, and the language selector on the list.

## Optimistic locking per locale

Expand Down Expand Up @@ -250,6 +251,25 @@ A disabled language is **readable and not writable**. Its content is already in
the database, and hiding it would make it unrecoverable; growing more of it in a
language nothing renders is the part that gets refused.

One rule, in both directions - everything that puts content *into* a disabled
locale is refused, and everything that takes content *out* of it still works:

| Operation | Disabled locale |
| --- | --- |
| create | refused |
| update | refused |
| restore | refused |
| publish | refused |
| unpublish | allowed |
| delete | allowed |
| read, history | allowed |

`publish` sits on the refused side because publishing into a language the app
does not serve puts a page on the internet that nothing routes to. `unpublish`
and `delete` sit on the allowed side because switching a language off is usually
the step *before* taking its pages down - refusing would strand published content
in a locale nobody can edit.

Deleting a language that content is written in is refused by Postgres itself -
the foreign key is `ON DELETE RESTRICT`. That is deliberately different from
`core_languages_words`, which cascades: losing a UI string is an inconvenience,
Expand All @@ -274,37 +294,33 @@ 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 5C boundaries

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` + `search` | Stage 5D | One document per record would index a single language and rank every other one as a miss |
## No capability is refused any more

It is a `ContentEngineError` at definition time, with the stage in the message.
Stage 5A landed the infrastructure, Stage 5B the editorial layer, Stage 5C the
[public read](/docs/dev/content-engine/localized-public-api) and Stage 5D
[per-locale search](/docs/dev/content-engine/localized-search). `localization`
combines with `publication`, `editorial`, `publicApi` and `search`, and every one
of them reads the language it was asked for rather than pretending there is only
one.

`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.
There is one *shape* rule left, and it is a rule rather than a boundary:
`search.pathTemplate` must contain `{locale}` on a localized content type. One
document per language means one URL per language, and two languages routinely
answer to the same slug.

## The roadmap

| Stage | What it adds |
| --- | --- |
| **5A** | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations |
| **5B** (this one) | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, AdminCP locale tabs |
| **5C** | Locale-aware public API, fallback resolution, locale-aware cache tags |
| **5D** | Per-locale search documents, `hreflang`, localized sitemap |
| **5B** | Per-locale publication, per-locale revisions and restore, locale-bound preview tokens, translation events, `can_translate`, AdminCP locale tabs |
| **5C** | Locale-aware public API, locale precedence, fallback resolution, strict-locale slugs, locale-aware cache tags, locale preview links |
| **5D** (this one) | Per-locale search documents, the localized rebuild, per-language diagnostics, the AdminCP list language selector |

Explicitly outside all four: locale-specific relations, localized media, AI
translation, translation memory, external TMS integration, and migrating the blog
plugin onto the Content Engine.
translation, translation memory, external TMS integration, `hreflang` and sitemap
generation, locale-specific scheduling, and migrating the blog plugin onto the
Content Engine.

## Where to next

Expand Down
23 changes: 20 additions & 3 deletions apps/docs/content/docs/dev/content-engine/localized-public-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,26 @@ 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.
`?locale=`, **and** it goes into the cache tags.

Omitting it is not an error and not a trap either. The content type's
`defaultLocale` is filled in before either one is built, so the request and the
tags always name the same language:

```text
locale: "pl" → ?locale=pl content:example.article:list:pl
locale omitted → ?locale=en content:example.article:list:en
```

<Callout type="warn" title="A localized response never has a locale-less tag">
It cannot: every invalidation path names the locale-aware tag, so an entry
tagged `content:example.article:list` would hold default-locale content that
nothing could ever expire - not a translation publish, not an edit, not a
rebuild. Filling the default in is what makes that shape unreachable.
</Callout>

`contentPublicItemTags` follows the same rule, so a page that tags its own
`fetch` with it cannot disagree with one that used `contentPublicFetch`.

## Caching

Expand Down
Loading
Loading