docs/plans/5.5-6.0-resource-translations-api.md
Status: Draft
Target: Spree 5.5 (Phase 1 API) → 6.0 (staleness + bulk + metafields fold-in)
Depends on: Mobility / Spree::TranslatableResource (shipped), Markets (locale config, shipped), Admin API v3 (shipped), 5.4-centralized-translations-admin.md, 5.4-metafield-translations.md
Author: Damian + Claude
Last updated: 2026-06-24
Spree stores translations via Mobility (table backend, one *_translations row per (record_id, locale)), and reads are already locale-aware — the Store/Admin serializers return the value for Mobility.locale, which Spree::Api::V3::LocaleAndCurrency resolves from the x-spree-locale header. What is missing is a write path in the API v3 and a way for the React dashboard to manage all locales at once. Today the only translation-editing UI is the legacy Rails admin TranslationsController (Turbo drawer, Mobility locale accessors like name_fr=).
This plan adds translation management to the Admin API v3 and the @spree/dashboard SPA, covering every model in the Spree.translatable_resources registry (Product, Taxon/category, Taxonomy, OptionType, OptionValue, Store, Policy — and later MetafieldDefinition/Metafield). After comparing Shopify, Vendure, Medusa, and Saleor, writes go through a single atomic batch endpoint (POST /admin/translations/batch, a flat list of per-record { locale → { field → value } } writes — so one request saves an option type and all its values together), while reads are a registry-driven, read-only per-resource matrix (GET …/:id/translations, with nested translatable children) plus an opt-in ?expand=translations. Backed by self-describing field discovery and server-side, advisory staleness (Shopify's idea minus its mandatory-digest footgun).
| Platform | Write shape | Read shape | Field discovery | Staleness | Locale model |
|---|---|---|---|---|---|
| Shopify | Dedicated translationsRegister (per field+locale, batched per resource) | translatableResource (all locales + source + digest); storefront via @inContext | translatableContent enumerates keys + type | Hard digest (SHA-256 of sanitized source; required on every write) | shopLocales (primary + published) |
| Vendure | Embedded translations: [{ languageCode, … }] on entity create/update; upsert by languageCode, omit-to-leave-alone, never implicit-delete | Dual: active-locale value at top level and sibling translations array | Schema-declared (LocaleString fields + XTranslationInput) | None | availableLanguages (global) + per-channel defaultLanguageCode/availableLanguageCodes |
| Medusa v2 | Dedicated Translation Module; { reference, reference_id, locale_code, translations: {field→value} } (one locale per row, JSON bag of fields) | Read-time overlay onto native fields; locale via ?locale= / x-medusa-locale; GET /store/locales discovery | Model .translatable() + admin Settings toggles | None (only translated_field_count) | Locale rows + store-level active set |
| Saleor | Per-resource productTranslate(id, languageCode, input) — one resource, one language, many fields | Inline translation(languageCode:) returns a *Translation object beside default fields | *TranslatableContent types + top-level translations(kind:) enumeration | None | Global LanguageCodeEnum; no per-store allowlist |
Decisions distilled from the matrix:
{ locale → { field → value } } object shape Vendure pioneered (but as an object, not an array — Mobility stores per-field, so a single field can be written without resending the row). It rides on the dedicated endpoint, not embedded in the resource update.TranslationsController proving the pattern). Cross-record bulk stays on CSV (async import/export), not a JSON endpoint — no competitor ships a generic JSON bulk route and Spree already has the CSV machinery.stale flag, but never require a digest on write (client-side digest computation is a footgun; sanitizer differences make it unreproducible).supported_locales_list (enabled) + default_locale (primary); no new Locale entity, no published/staged distinction in v1.POST /api/v3/admin/translations/batch — a flat array of { resource_type, resource_id, values: { locale: { field } } }, upserted atomically in one transaction via TranslatableResource#upsert_translations (a model method beside the existing readers — no service/DI). One entry = the degenerate single-resource case (a product); N entries = a multi-record save (an option type plus all its option values, in one atomic request). Reads are a separate, read-only per-resource matrix (GET …/:id/translations, with nested translatable children) plus an opt-in ?expand=translations. No per-resource write endpoint — the dashboard's only write path is the batch endpoint, so a separate per-resource POST/DELETE would be pure redundancy (decided after the Medusa research: Medusa proves one atomic {create/update/delete}-style batch beats N per-resource requests; the multi-entity dual-save genuinely needs atomicity a per-resource endpoint can't give). No embedded write (3 of 4 competitors keep writes off the resource update). Reject per-model translate controllers (Saleor's verbosity). Reject a JSON cross-record bulk endpoint distinct from this — cross-record bulk is CSV import/export (§C); the batch endpoint is bounded (one editor's worth of entities), not a mass-import tool.Spree::Api::V3::Admin::TranslationsController, mounted via a :translatable route concern (mirroring the existing :custom_fieldable concern), with the parent model resolved by set_parent and validated against Spree.translatable_resources. No per-model translation controllers.{ locale → { field → value } }. This matches the 5.4-metafield-translations.md plan's already-committed shape ("translations": { "fr": { "value": … } }), so metafields fold in as just-another-registered-resource later."" → write empty string (explicit clear; read falls back to source via column_fallback). Field = null → delete that field's translation cell. All of this flows through the single batch endpoint; there is no separate per-locale DELETE route.name, description) via Mobility + x-spree-locale — no parallel name_translations field on the storefront. Only the admin gains an all-locales read and the write surfaces. Read and write use the same field keys everywhere (where a column is legacy-named, the model alias already bridges it — e.g. OptionType#label/label=).fields[] array (key + type + source) so the SPA renders generically; plus a GET /api/v3/admin/translatable_resources registry endpoint for the no-record case (centralized page columns, tooling).current_store.supported_locales_list; primary = current_store.default_locale; read-time selection = existing x-spree-locale resolution; missing-translation fallback = existing column_fallback + store_based_fallbacks. Writes to an unsupported locale return a typed 422 (à la Vendure's LanguageNotAvailableError), never a silent accept.source_digest per translation cell; compute stale at read time; never require a digest on write. Purely additive — does not break the Phase 1 contract.TRANSLATABLE_FIELDS and is in the registry, so it's writable via the batch endpoint and discoverable. For reads it's returned inline as a child of an option type's matrix (one editor fetches the type plus all its values in one request), so it needs no standalone read route. (The legacy admin special-cases OptionValue inside OptionType — we keep the one-request read but drop the special-casing in the write path.)Spree.translatable_resources + declare TRANSLATABLE_FIELDS. No new controller, route, or serializer per model.translations on GET is opt-in via ?expand=translations, never an always-on serializer attribute (the existing CountrySerializer states/market precedent). The matrix is costly (non-default locales × fields × joins) and unwanted on list/most-detail reads. params[:expand] is already in the HTTP cache key, so this is free and conventional. Expand returns values only; discovery metadata (fields[]/source/type) stays on the dedicated …/:id/translations endpoint.Spree::Api::V3::LocaleAndCurrency resolves the request locale: x-spree-locale header → params[:locale] → Spree::Current.locale (market → store fallback), validated against supported_locale?. It sets Mobility.locale / I18n.locale and configures store fallbacks. The base ResourceController#scope already calls .i18n for translatable models. Storefront reads need zero new work.
TranslatableResource (not a service)The write mechanics belong on the model that already owns translation behavior — every translatable model includes Spree::TranslatableResource, which already declares translatable_fields and the get_field_with_locale reader. Two instance methods sit beside them, so no ServiceModule ceremony, no DI registration, and no record.store indirection (the model knows its own store). The earlier draft routed this through a Spree::Translations::Upsert/DestroyLocale service pair — collapsed into the model because each was a thin wrapper over a single operation called from one or two places.
module Spree
module TranslatableResource
# ...existing reader + class methods...
# Omit-to-leave-alone; "" clears, nil deletes a cell. Field/locale filtering
# and validation happen here.
# @raise [ActiveRecord::RecordInvalid] on an unsupported locale or invalid save
def upsert_translations(values)
return self if values.blank?
validate_translation_locales!(values.keys)
allowed = self.class.translatable_fields.map(&:to_s)
transaction do
values.each do |locale, fields|
Mobility.with_locale(locale) do
fields.to_h.slice(*allowed).each { |field, value| public_send("#{field}=", value) }
end
end
save!
end
self
end
# Deletes the locale's translation row directly (a presence-validated field
# like Product#name would reject a nil-and-resave).
def destroy_locale_translation(locale)
rows = translations
rows = rows.with_deleted if rows.respond_to?(:with_deleted)
rows.where(locale: locale.to_s).delete_all
reload
self
end
private
def validate_translation_locales!(locales)
supported = translatable_store&.supported_locales_list || []
unsupported = locales.map(&:to_s) - supported.map(&:to_s)
return if unsupported.empty?
errors.add(:base, :unsupported_locale, message: "Unsupported locale(s): #{unsupported.join(', ')}")
raise ActiveRecord::RecordInvalid, self
end
end
end
Both write paths call record.upsert_translations(values) and rescue ActiveRecord::RecordInvalid → render_validation_error(e.record.errors) (422). Value normalization (HTML sanitization, slug parameterization) stays on the model's own field writers — upsert_translations only routes values to them. No Spree::Dependencies entry: the behavior is on the model, overridable like any model method via a decorator.
Note on rich text:
Taxon#descriptionandPolicy#bodyuse the ActionText Mobility backend today. Translated HTML must be sanitized on write per the6.0-rich-text-descriptions.mdplan. Thefields[].type == "html"hint (below) tells both the serializer and the sanitizer how to treat the field.
Route concern mirrors :custom_fieldable:
# spree/api/config/routes.rb (Admin namespace)
# Read-only — writes go through POST /translations/batch (decision #1).
concern :translatable do
resources :translations, only: [:index], controller: 'translations'
end
resources :products, concerns: [:custom_fieldable, :translatable] do … end
resources :categories, only: [:index, :show], concerns: [:custom_fieldable, :translatable]
resources :option_types, concerns: [:custom_fieldable, :translatable]
# Phase 1 mounts the read route on products, categories, and option_types only.
# Other registered resources (option_value, taxonomy, store, policy) are still
# writable via the batch endpoint; the discovery registry flags each entry with
# `readable` so a client never GETs a matrix that isn't routed. Mounting their
# read routes is a follow-up.
One generic controller:
module Spree::Api::V3::Admin
# Generic translations controller. The parent model is resolved from the
# nested route and validated against Spree.translatable_resources. One
# controller serves every translatable model — no per-model subclasses.
class TranslationsController < ResourceController
before_action :set_parent
before_action :ensure_translatable!
# GET .../:parent/:parent_id/translations (read-only)
def index
render json: serialize_translations(parent_resource)
end
# Reads only. Per decision #1, the single write surface is
# POST /api/v3/admin/translations/batch — there is no per-resource
# create/update/destroy here.
private
def ensure_translatable!
raise ActiveRecord::RecordNotFound unless Spree.translatable_resources.include?(@parent.class)
end
end
end
GET …/products/prod_86Rf07xd4z/translations — management read (all locales + source + discovery):
{
"data": {
"resource_type": "product",
"resource_id": "prod_86Rf07xd4z",
"default_locale": "en",
"supported_locales": ["en", "de", "fr", "es"],
"fields": [
{ "key": "name", "type": "string", "source": "Espresso Machine" },
{ "key": "description", "type": "html", "source": "<p>Pulls a great shot.</p>" },
{ "key": "slug", "type": "slug", "source": "espresso-machine" },
{ "key": "meta_title", "type": "string", "source": "Espresso Machine | Acme" },
{ "key": "meta_description", "type": "string", "source": "Buy the Acme espresso machine." }
],
"translations": {
"de": { "name": "Espressomaschine", "description": "<p>Zieht…</p>", "slug": "espressomaschine", "meta_title": "Espressomaschine | Acme", "meta_description": "Kaufen…", "translated_field_count": 5 },
"fr": { "name": "Machine à espresso", "description": null, "slug": "machine-a-espresso", "meta_title": null, "meta_description": null, "translated_field_count": 2 },
"es": { "name": null, "description": null, "slug": null, "meta_title": null, "meta_description": null, "translated_field_count": 0 }
}
}
}
fields[].type (string / html / slug) drives generic SPA rendering (plain input vs rich-text editor vs slug field). translated_field_count (Medusa's cheap completeness counter) drives progress bars on the centralized page.
POST …/products/prod_86Rf07xd4z/translations — upsert:
{
"translations": {
"fr": { "description": "<p>Tire un excellent shot.</p>", "meta_title": "Machine à espresso | Acme" },
"es": { "name": "Máquina de espresso", "slug": "maquina-de-espresso" }
}
}
Response 200 echoes the GET shape. Validation errors (unsupported locale, invalid slug, sanitizer rejection) return standard v3 422 keyed translations.fr.slug so mapSpreeErrorsToForm routes them inline.
DELETE …/products/prod_86Rf07xd4z/translations/fr → 204.
?expand=translations read on the resource (no embedded write)There is no embedded write — translations are written only through the dedicated …/:id/translations endpoint (§A). The research is decisive: 3 of 4 competitors (Shopify, Medusa, Saleor) keep translation writes on a separate surface; only Vendure embeds them in the resource update. Adding an embedded write meant a controller concern overriding create/update plus transaction-wrapping that interfered with other inline-nested writes (custom fields), for a convenience nothing in the SPA uses (the translations UI saves through the dedicated endpoint). Cut.
The read side stays as a convenience: the admin ProductSerializer exposes a translations matrix attribute, opt-in via the existing ?expand= convention (CountrySerializer's states/market precedent), so a client can fetch the matrix alongside a product in one read without a second call.
module Spree::Api::V3::Admin
class ProductSerializer < V3::ProductSerializer
attribute :translations, if: proc { expand?('translations') } do |product|
Spree::Translations.matrix_for(product) # { "de" => { "name" => …, "translated_field_count" => 5 }, … }
end
end
end
Why opt-in, not always-on: the matrix is non-default-locales × translatable fields × translation-table joins — wasteful on list views and detail views that don't edit translations. params[:expand] is already part of the HTTP cache key, so expanded and non-expanded responses cache separately for free. Store serializers never get translations — customers see only the resolved active-locale value.
Expand returns values only; the dedicated endpoint returns values + discovery. ?expand=translations returns just { locale → { field → value } }. The discovery metadata (fields[] with key/type/source, supported_locales) lives only on GET …/:id/translations (§A) — that's what the dashboard editor consumes, since it needs source + type to render fields generically.
Decision: no dedicated JSON bulk-write endpoint. It would be redundant and the weaker tool. The bulk story is two things, neither of which is a new synchronous JSON route:
Spree::Import/Spree::Export job), is already the right shape for "translate 500 products across 6 locales" — and it's the channel the 5.4-centralized-translations-admin.md plan already commits to.✓/— cells — cheap, and not redundant with import.Why no JSON bulk endpoint:
POST …/bulk over hundreds of records × locales is a timeout/partial-failure hazard; the existing Spree::Import runs in the background and reports per-row errors — strictly better for the actual bulk job.translationsRegister), Vendure (embedded array), and Medusa all write one resource per call. Saleor reluctantly added productBulkTranslate only to amortize GraphQL round-trips — still product-only, one language per entry, never generic/cross-resource. REST + the registry-driven per-resource endpoint (§A) doesn't have the round-trip problem that forced Saleor's hand.The real gap to close (Phase 2): Spree's CSV import/export exists for products only (Spree::Imports/Exports::ProductTranslations, slug-keyed, fields name/description/meta_title/meta_description). There is no taxon/option-type/policy/store translation importer. Generalize the existing pattern across Spree.translatable_resources:
Spree::Imports::ResourceTranslations / Spree::Exports::ResourceTranslations parameterized by resource_type, keyed by the resource's natural identifier (slug/permalink/name), columns derived from TRANSLATABLE_FIELDS (one column per field × non-default locale, or a long format with a locale column as products do today).Spree::Import/Spree::Export machinery, the admin ExportsController, and the import/export drawer plumbing (5.5-admin-spa-csv-export.md).ProductTranslations types working (back-compat) or fold them into the generic one.Coverage read (keep — drives the grid):
GET /api/v3/admin/translations?resource_type=product&q[…]&page=1 — paginated coverage matrix (single array_agg(locale) query, no per-row N+1 — replaces the legacy ProductTranslationsController#build_translated_locales_map):
{
"data": {
"resource_type": "product",
"default_locale": "en",
"locales": ["de", "fr", "es"],
"coverage": [
{ "locale": "de", "translated": 36, "total": 36, "coverage": 1.0 },
{ "locale": "fr", "translated": 34, "total": 36, "coverage": 0.94 },
{ "locale": "es", "translated": 0, "total": 36, "coverage": 0.0 }
],
"records": [
{ "id": "prod_86Rf07xd4z", "name": "Espresso Machine", "locales": { "de": true, "fr": true, "es": false } },
{ "id": "prod_9aKd21mn7p", "name": "Drip Coffee Maker", "locales": { "de": true, "fr": false, "es": false } }
]
},
"meta": { "total_count": 36, "total_pages": 2, "page": 1 }
}
Inline grid edits (e.g. "fill the missing es name on this row") write through the per-resource endpoint (§A), one record at a time — the user is already looking at that row. Mass fills go through CSV.
GET /api/v3/admin/translatable_resources — the registry made public (drives centralized-page columns + tooling, no record needed):
{
"data": [
{ "resource_type": "product", "fields": [ {"key":"name","type":"string"}, {"key":"description","type":"html"}, {"key":"slug","type":"slug"}, {"key":"meta_title","type":"string"}, {"key":"meta_description","type":"string"} ] },
{ "resource_type": "category", "fields": [ {"key":"name","type":"string"}, {"key":"description","type":"html"}, {"key":"permalink","type":"slug"} ] },
{ "resource_type": "option_type", "fields": [ {"key":"presentation","type":"string"} ] },
{ "resource_type": "option_value", "fields": [ {"key":"presentation","type":"string"} ] },
{ "resource_type": "taxonomy", "fields": [ {"key":"name","type":"string"} ] },
{ "resource_type": "store", "fields": [ {"key":"name","type":"string"}, {"key":"seo_title","type":"string"} ] },
{ "resource_type": "policy", "fields": [ {"key":"name","type":"string"}, {"key":"body","type":"html"} ] }
]
}
Derived from Spree.translatable_resources.map { |k| [k, k.translatable_fields] } plus a small type inference (html for description/body, slug for slug/permalink, else string).
GET /api/v3/locales (Store) and GET /api/v3/admin/locales (Admin) — so clients enumerate instead of hardcoding (Medusa's GET /store/locales):
{
"data": [
{ "code": "en", "name": "English", "default": true, "rtl": false },
{ "code": "de", "name": "Deutsch", "default": false, "rtl": false },
{ "code": "fr", "name": "Français", "default": false, "rtl": false },
{ "code": "ar", "name": "العربية", "default": false, "rtl": true }
]
}
Built from supported_locales_list + default_locale; names via the existing Spree::LocaleHelper.
source_digest column to each *_translations table (or a shared mechanism in TranslatableResource). On each cell write, store Digest::SHA256.hexdigest(current_source_value) for that field.stale = stored_source_digest != current_source_digest at serialization time. Surface it in a verbose mode of the management read (?include=staleness), which enriches each value to an object — keeping the default flat { field → value } shape simple for the common form path:"fr": {
"name": { "value": "Machine à espresso", "stale": true, "updated_at": "2026-01-10T…" },
"slug": { "value": "machine-a-espresso", "stale": false, "updated_at": "2026-06-01T…" }
}
If-Source-Digest per-field guard (opt-in 409 on mismatch) for concurrency-sensitive tooling; default is accept-and-record. This gives Spree Shopify's "outdated" UX without the mandatory-round-trip footgun, and beats Vendure/Saleor/Medusa's zero tracking.One API contract powers both surfaces (the 5.4-centralized-translations-admin.md plan + the per-resource drawer):
Surface 1 — Per-resource translation editor: a full-page spreadsheet dialog (opened from a "Translations" launcher card on the product edit page; not a side sheet like the legacy Rails admin). Modeled on Medusa's translations editor and our bulk-price editor dialog (bulk-price-editor-dialog.tsx):
Select in the bulk price editor) picks one target locale at a time, showing its localized name (from GET /admin/locales).Field | Original (read-only source) | <selected locale>. Editable cells are borderless Textareas that fill the cell (true spreadsheet feel); type: html fields use the rich-text editor.POST …/translations with the full { locale → { field → value } } matrix. Dirty count + discard-confirm in the header. 422s toast via mapSpreeErrorsToForm.GET …/:id/translations (its own dirty-state/submit, independent of the product form). SDK calls go through a useProductTranslations(id) hook; query key includes useStore().storeId (per repo convention).Surface 2 — Centralized Translations page (under Products in the nav, per the existing plan):
GET /api/v3/admin/translations?resource_type=product (one query). Phase 2 stale flags add an amber "outdated" cell state.ExportsController + exports drawer (5.5-admin-spa-csv-export.md), generalized across the registry (§C). Inline single-row cell edits write through the per-resource endpoint (§A); mass fills go through CSV. No JSON bulk endpoint.resource_type (Products → Categories → Options …) come free — the grid read is parameterized by resource_type and the column set comes from GET /admin/translatable_resources. Adding a tab is config, not code.All labels go through i18next across every language file in packages/dashboard/src/locales/ and packages/dashboard-core/src/locales/ (en/de/fr/zh-CN/ar/pl), per repo convention. Locale-name display reuses GET /admin/locales.
Phase 1 (5.5) — read-all + write, no staleness:
TranslatableResource#upsert_translations + #destroy_locale_translation (write API on the concern every translatable model already includes; no service/DI).:translatable route concern + generic Spree::Api::V3::Admin::TranslationsController (index/create/destroy).OptionValue to its own nested translatable route (…/option_types/:id/option_values/:id/translations).translations ?expand attribute on the admin serializers for registry members (extends store serializer, admin-only — never on store serializers).GET /api/v3/admin/translatable_resources + GET /api/v3/{,admin/}locales discovery endpoints + serializers.:update on parent). Run the full type-generation pipeline (typelizer → zod → rswag) per CLAUDE.md.Phase 2 (6.0) — bulk + staleness + SPA centralized page:
8. GET /api/v3/admin/translations coverage matrix; generalize CSV import/export across the registry (Spree::Imports/Exports::ResourceTranslations, parameterized by resource_type) — no JSON bulk endpoint.
9. source_digest columns (data backfill via rake task, never in migration) + stale computation + ?include=staleness verbose read.
10. Dashboard: per-resource translation tab (Surface 1) + centralized Translations page (Surface 2) consuming the above.
Phase 3 (6.0) — fold in metafields:
11. Per 5.4-metafield-translations.md, register MetafieldDefinition (translatable name) and Metafield (translatable value, text types only) in Spree.translatable_resources. They become just-another-registered-resource — the {locale:{field}} write shape that plan already committed to is a subset of this contract, so no new endpoint is needed.
*_translations field to a Store (customer-facing) serializer. Storefront reads resolve to the active locale via Mobility — translations matrices are admin-only.translations matrix under key X must be writable under key X. Legacy-named columns get a model-level alias (the OptionType label/label= precedent), never a controller/serializer/client-side mapper.Spree.translatable_resources and declare TRANSLATABLE_FIELDS. Do not write a per-model translation controller, route, or serializer — the generic controller + registry handle it.description/body (and future RichText metafields) must be sanitized on write per 6.0-rich-text-descriptions.md. Use the fields[].type == "html" hint.supported_locales_list return 422, never a silent accept.…/settings/general/translations) rather than a nested resource route? Likely yes; confirm the route shape.supported_locales_list = enabled = visible. If staging is later wanted, add a published_locales subset on Market — out of scope for v1; flagged, not built.marketId on the translation). Spree's Mobility model is per-locale, not per-market. Defer unless a concrete need emerges; would require a schema change (market_id on translation rows).slug/permalink must stay unique within the store per locale; confirm the existing TranslatableResourceSlug + uniqueness handling covers API writes, and that a 422 surfaces cleanly.name / option_value generated presentations — do generated/derived fields need translation, or only authored ones? Restrict to authored TRANSLATABLE_FIELDS for v1.locale column, as products do today) vs wide (one column per field×locale). Long is simpler and matches the existing product importer; wide is friendlier for a translator editing one spreadsheet. And the join key per resource type (slug/permalink/name) — confirm each registry member has a stable, unique-per-store natural identifier for the CSV to key on.docs/plans/5.4-centralized-translations-admin.md — centralized overview grid + CSV bulk (the dashboard Surface 2 this API powers); keep consistent.docs/plans/5.4-metafield-translations.md — already commits to the {locale:{field}} write shape; folds in as Phase 3.docs/plans/6.0-rich-text-descriptions.md — HTML sanitization for translated rich-text fields.docs/plans/6.0-replace-taxons-with-categories.md — Taxon→Category naming for the category translatable resource.spree/core/app/models/concerns/spree/translatable_resource.rb, spree/admin/app/controllers/spree/admin/translations_controller.rb (generic-controller + OptionValue-nesting template), spree/core/lib/spree/core/engine.rb:245 (registry), spree/api/app/controllers/concerns/spree/api/v3/locale_and_currency.rb (locale resolution), spree/api/config/routes.rb (:custom_fieldable concern to mirror), spree/core/app/models/concerns/spree/stores/markets.rb (supported_locales_list/default_locale).translationsRegister/translatableResource; Vendure translatable guide + translation-differ.ts; Medusa v2 Translation Module (docs.medusajs.com/resources/commerce-modules/translation); Saleor translations API conventions.