You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Plugins and themes need translation metadata for language switchers, SEO, sitemaps, analytics, and cache invalidation. I propose a hybrid API: cheap scalars (translationGroup, fallbackLocale) on PublicPageContext; queryable lists behind family helpers (content.getTranslations, taxonomies.getTranslations, menus.getTranslations).
The two options considered
(a) Everything on PublicPageContext. Eager list of translations on every render.
(b) Helpers only. Minimal context, every read goes through a function call.
Pure (a) costs a query per render even when nothing reads it, bloats the public type surface, and is unreachable from non-render callers. Pure (b) forces a function call to read a string we already have, encouraging duplicate fetches. Hybrid wins on every column:
Shared pattern: translation metadata is a first-class system concept, available to any caller, not baked into a render context.
The hybrid: precise definitions
Scalars on PublicPageContext
interfacePublicPageContext{// ... existing fields .../** * The ULID shared by every translation of the current content entry. * `null` when the page is not content-backed or i18n is disabled. * After migration 019, every content row has one — solo entries are their own group. */translationGroup: string|null;/** * The locale actually served, when it differs from the locale the request asked for. * `null` when the request locale matched exactly. Page-level only — not set for * field-level fallback. Sourced from `EntryResult.fallbackLocale`. */fallbackLocale: string|null;}
Both are scalars already computed during entry resolution. Adding them costs zero queries.
Family helpers (signatures)
// packages/core/src/query.ts (or per-module)namespacecontent{/** * @param type Collection slug as defined in `_emdash_collections.slug` * (e.g. "posts", "pages", "products"). Maps to `ec_{type}`. * @param id Entry ULID — primary key of the row in `ec_{type}`. * @param options publishedOnly defaults to `true` on the public path. * @returns Sibling rows in the same `translation_group`, excluding the * input id, filtered by `deleted_at IS NULL`. */functiongetTranslations(type: string,id: string,options?: {publishedOnly?: boolean},): Promise<ContentTranslationSummary[]>;}interfaceContentTranslationSummary{id: string;// entry ULIDlocale: string;// BCP-47 locale code, e.g. "en", "es-MX"slug: string;status: "draft"|"published"|"archived";publishedAt: string|null;// ISO 8601url: string|null;// resolved public URL when integration provides a builder}namespacetaxonomies{/** * @param taxonomy Taxonomy slug as defined in `_emdash_taxonomy_defs.slug` * (e.g. "categories", "tags"). * @param id Term ULID — primary key in `taxonomies`. */functiongetTranslations(taxonomy: string,id: string,): Promise<TaxonomyTranslationSummary[]>;}interfaceTaxonomyTranslationSummary{id: string;// term ULIDlocale: string;slug: string;name: string;}namespacemenus{/** @param id Menu ULID — primary key in `_emdash_menus`. */functiongetTranslations(id: string): Promise<MenuTranslationSummary[]>;}interfaceMenuTranslationSummary{id: string;// menu ULIDlocale: string;name: string;}
All three resolve via one private function:
// internal — not exportedasyncfunctionresolveTranslationGroup(table: string,// validated against allowlist; never user-suppliedid: string,filters: {publishedOnly?: boolean},): Promise<{translationGroup: string;rows: unknown[]}>;
The rule: scalars we already have go on the context; anything that costs a query goes through a typed family helper. All helpers use requestCached so multiple consumers in one render share a single fetch.
Why family helpers (not polymorphic, not low-level)
Three shapes considered:
(1) One polymorphic helpergetTranslations(kind, id) with kind: "content:posts" | "taxonomy:categories" | …. Forces a discriminated union return type, and kind strings leak storage layer naming into the public API.
(2) Family helpers as defined above.
(3) Public low-level resolver + thin wrappers. Same surface as (2) plus an exposed resolveTranslationGroup(table, id). The extra surface buys nothing.
I choose (2) because:
Matches existing module layout (content/, taxonomies/, menus/ each own their helpers).
Each return type is precise — no LCD shape, no narrowing on every call.
No leaky discriminators: content.getTranslations("posts", id) reads as intent, not as a table name.
Shared logic stays DRY internally via the private resolver.
Non-render callers (cron, sitemap, webhooks) use the same API — no PublicPageContext required.
Open questions
Existing getTranslations(type, id) in packages/core/src/query.ts is content-only, returns drafts, and is used by the admin switcher. Pick one:
(i) replace it with content.getTranslations and add { includeDrafts: true } for the admin path;
(ii) keep both, public-safe vs. admin;
(iii) single helper with { publishedOnly: boolean } defaulting to true. Preference: (iii), pending an audit of current callers.
Sandboxed plugins get the scalars by default (flat strings, no PII). The three family helpers gate on each family's existing read capability (content:read, taxonomies:read, menus:read). No new capability needed.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
TL;DR
Plugins and themes need translation metadata for language switchers, SEO, sitemaps, analytics, and cache invalidation. I propose a hybrid API: cheap scalars (
translationGroup,fallbackLocale) onPublicPageContext; queryable lists behind family helpers (content.getTranslations,taxonomies.getTranslations,menus.getTranslations).The two options considered
PublicPageContext. Eager list of translations on every render.Pure (a) costs a query per render even when nothing reads it, bloats the public type surface, and is unreachable from non-render callers. Pure (b) forces a function call to read a string we already have, encouraging duplicate fetches. Hybrid wins on every column:
How other CMSs do it
wpml_object_id,wpml_element_translations.pll_get_post_translations($id),pll_current_language().\Drupal::languageManager(),$entity->getTranslationLanguages().Shared pattern: translation metadata is a first-class system concept, available to any caller, not baked into a render context.
The hybrid: precise definitions
Scalars on
PublicPageContextBoth are scalars already computed during entry resolution. Adding them costs zero queries.
Family helpers (signatures)
All three resolve via one private function:
The rule: scalars we already have go on the context; anything that costs a query goes through a typed family helper. All helpers use
requestCachedso multiple consumers in one render share a single fetch.Why family helpers (not polymorphic, not low-level)
Three shapes considered:
getTranslations(kind, id)withkind: "content:posts" | "taxonomy:categories" | …. Forces a discriminated union return type, andkindstrings leak storage layer naming into the public API.resolveTranslationGroup(table, id). The extra surface buys nothing.I choose (2) because:
content/,taxonomies/,menus/each own their helpers).content.getTranslations("posts", id)reads as intent, not as a table name.PublicPageContextrequired.Open questions
getTranslations(type, id)inpackages/core/src/query.tsis content-only, returns drafts, and is used by the admin switcher. Pick one:(i) replace it with
content.getTranslationsand add{ includeDrafts: true }for the admin path;(ii) keep both, public-safe vs. admin;
(iii) single helper with
{ publishedOnly: boolean }defaulting totrue. Preference: (iii), pending an audit of current callers.content:read,taxonomies:read,menus:read). No new capability needed.Beta Was this translation helpful? Give feedback.
All reactions