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
12 changes: 7 additions & 5 deletions apps/docs/content/docs/dev/content-engine/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ Four more declarations, each opt-in:
optimistic locking so two editors cannot silently overwrite each other, and a
[revision history](/docs/dev/content-engine/revisions) you can restore from
- [`localization`](/docs/dev/content-engine/localization) moves the text fields
you mark into a generated per-language table, with its own version per locale
and a URL per locale
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

Publication alone exposes nothing. Public exposure requires both of the first
two; `editorial` works with or without either. `localization` is currently
[exclusive of the other three](/docs/dev/content-engine/localization#stage-5a-boundaries) -
each combination arrives in a later stage.
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.

## What it is not

Expand Down
26 changes: 16 additions & 10 deletions apps/docs/content/docs/dev/content-engine/limitations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,31 @@ 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 is infrastructure only, for now
## Localization does not read outwards yet

[`localization`](/docs/dev/content-engine/localization) generates the tables, the
types, the schemas, the services and the translation routes. What it deliberately
refuses is every combination whose *reading* half is not built yet:
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` + `publication` | Stage 5B |
| `localization` + `editorial` | Stage 5B |
| `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.

Also not in Stage 5A: AdminCP locale tabs, completeness badges, `can_translate`,
per-locale publication or revisions, fallback resolution, locale-aware cache tags
and per-locale search documents. The translation routes reuse `can_view`,
`can_edit` and `can_delete` until the UI they gate exists.
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.

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.

## Localized field names cannot appear on base-table surfaces

Expand All @@ -73,7 +77,9 @@ A localized field has no column on the base table, so it cannot be an
six are compile errors and runtime errors.

`admin.titleField` therefore falls back to `null` on a content type whose only
text fields are localized. Stage 5B gives the AdminCP a locale-aware title.
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.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,55 @@ the timestamps, and the language-scoped unique slug index.
`en` language exists. Test fixtures have to insert their own.
</Callout>

## Adding the editorial layer to an existing localized type

Opting a Stage 5A content type into `publication` and `editorial` is purely
additive, and `drizzle-kit` generates all of it. The committed
`0030_add_translation_editorial.sql`, in full:

```sql
DROP INDEX "example_localized_articles_translations_language_id_idx";--> statement-breakpoint
DROP INDEX "core_content_revisions_item_version_unique";--> statement-breakpoint
ALTER TABLE "core_content_revisions" ADD COLUMN "languageId" integer;--> statement-breakpoint
ALTER TABLE "example_localized_articles" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint
ALTER TABLE "example_localized_articles" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint
ALTER TABLE "example_localized_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "example_localized_articles_translations" ADD COLUMN "publishedAt" timestamp;--> statement-breakpoint
ALTER TABLE "example_localized_articles_translations" ADD COLUMN "status" varchar(32) DEFAULT 'draft' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "core_content_revisions_translation_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","languageId","version") WHERE "languageId" IS NOT NULL;--> statement-breakpoint
CREATE INDEX "core_content_revisions_language_idx" ON "core_content_revisions" USING btree ("contentTypeId","itemId","languageId","version");--> statement-breakpoint
CREATE INDEX "example_localized_articles_status_published_at_idx" ON "example_localized_articles" USING btree ("status","publishedAt");--> statement-breakpoint
CREATE INDEX "example_localized_articles_translations_language_id_status_idx" ON "example_localized_articles_translations" USING btree ("languageId","status");--> statement-breakpoint
CREATE UNIQUE INDEX "core_content_revisions_item_version_unique" ON "core_content_revisions" USING btree ("contentTypeId","itemId","version") WHERE "languageId" IS NULL;
```

No `DROP COLUMN`, no `DELETE`, no data step - which is why this one is safe to
apply as generated. Four things are worth reading closely:

1. **`status DEFAULT 'draft' NOT NULL`** backfills every existing translation to
a draft in one statement. That is the only correct answer: silently publishing
translations somebody wrote while the feature did not exist would put them on
the internet.
2. **`publishedAt` arrives `NULL`** on every existing row, because none of them
has been published in this sense yet.
3. **`languageId` arrives nullable with no default**, so every revision written
before this migration becomes a shared one - which is exactly what it was.
4. **The unique index becomes two partial ones.** The old one is dropped and
recreated with `WHERE "languageId" IS NULL`, so it covers precisely the rows
it covered before; the new one covers the translation rows. One index over a
nullable `languageId` would enforce nothing at all, because Postgres treats
every `NULL` as distinct.

The dropped `..._language_id_idx` is superseded rather than lost:
`(languageId, status)` leads with the same column, so it serves "every row in
Polish" as well as "every published row in Polish".

<Callout title="Quote a camelCase column in a partial index predicate">
The predicate is raw SQL, so `WHERE language_id IS NULL` would look for a
column Postgres folded to lower case and fail at apply time with
`column "language_id" does not exist`. The engine writes `"languageId"`.
</Callout>

## Localizing a Content Type that already has rows

This is the interesting case, and the engine deliberately does **not** generate it
Expand Down
35 changes: 21 additions & 14 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,11 @@ 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="Stage 5A is the foundation, not the finished feature">
Localization currently cannot be combined with `publication`, `editorial`,
`publicApi` or `search` - each combination is refused at definition time with a
message naming the stage that lifts it. See [Stage 5A
boundaries](#stage-5a-boundaries).
<Callout type="warn" title="Two combinations are still refused">
Localization works with `publication` and `editorial` from Stage 5B on, 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).
</Callout>

## This is not UI translation
Expand Down Expand Up @@ -274,28 +274,32 @@ 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 5A boundaries
## Stage 5B boundaries

Localization lands as infrastructure. The stages that read *through* it are not
here yet, 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 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.

| Combination | Refused until | Why |
| --- | --- | --- |
| `localization` + `publication` | Stage 5B | A localized record has one status per *language*. Publishing the English draft must not put an empty Polish page on the internet |
| `localization` + `editorial` | Stage 5B | A revision would snapshot the base row only, so restoring it would silently drop every translation |
| `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.

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).

## The roadmap

| Stage | What it adds |
| --- | --- |
| **5A** (this one) | Tables, types, schemas, language resolution, translation service, per-locale locking, atomic create, routes, migrations |
| **5B** | AdminCP locale tabs, completeness badges, per-locale publication status, per-locale revisions, `can_translate` |
| **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 |

Expand All @@ -308,4 +312,7 @@ plugin onto the Content Engine.
- [Localized fields](/docs/dev/content-engine/localized-fields) - which kinds, and why the others are refused
- [Translation tables](/docs/dev/content-engine/translation-tables) - the generated schema, keys and indexes
- [Translation service](/docs/dev/content-engine/translation-service) - every method, and every conflict it can raise
- [Translation lifecycle](/docs/dev/content-engine/translation-editorial) - per-locale publish, the subordination rule, permissions and the locale tabs
- [Translation revisions](/docs/dev/content-engine/translation-revisions) - one history per language, and what a restore may not cross
- [Locale preview](/docs/dev/content-engine/translation-preview) - freezing one language, both halves of it
- [Localization migrations](/docs/dev/content-engine/localization-migrations) - the generated migration, and how to localize an existing content type safely
3 changes: 3 additions & 0 deletions apps/docs/content/docs/dev/content-engine/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"localized-fields",
"translation-tables",
"translation-service",
"translation-editorial",
"translation-revisions",
"translation-preview",
"localization-migrations",
"admincp",
"permissions",
Expand Down
Loading
Loading