Localization
Hoikka supports multiple languages. The default language (English) is stored directly on entity tables for simplicity, while non-default languages use separate translation tables.
Language Configuration
Languages are configured in src/lib/config/languages.ts:
import {
DEFAULT_LANGUAGE, // "en"
LANGUAGES, // [{ code: "en", name: "English" }, { code: "fi", name: "Suomi" }]
TRANSLATION_LANGUAGES, // [{ code: "fi", name: "Suomi" }] — excludes default
translationsToMap // Utility for components
} from "$lib/config/languages";DEFAULT_LANGUAGE— The language stored on entity tables (e.g.products.name)LANGUAGES— All supported languagesTRANSLATION_LANGUAGES— Languages that use translation tables (everything except default)translationsToMap()— Converts translation rows intoRecord<langCode, Record<field, value>>for component consumption
How It Works
Default Language (English)
Default language fields live directly on entity tables:
products.name = "Blue Shirt"
products.slug = "blue-shirt"
products.description = "<p>A nice shirt</p>"Services return these fields directly — no translation resolution needed:
const product = await productService.getById(123);
product.name; // "Blue Shirt" — directly from the products tableNon-Default Languages (Finnish, etc.)
Non-default languages are stored in translation tables and managed through the TranslationService:
import { translationService } from "$lib/server/services/translations";
// Save a Finnish translation
await translationService.upsertProductTranslation(productId, "fi", {
name: "Sininen paita",
slug: "sininen-paita",
description: "<p>Hieno paita</p>"
});
// Read translations
const rows = await translationService.getProductTranslations(productId);
// [{ languageCode: "fi", name: "Sininen paita", slug: "sininen-paita", ... }]Translation Tables
| Parent Table | Translation Table | Fields |
|---|---|---|
products | product_translations | name, slug, description |
product_variants | product_variant_translations | name |
facets | facet_translations | name |
facet_values | facet_value_translations | name |
collections | collection_translations | name, slug, description |
categories | category_translations | name |
content_pages | content_page_translations | title, slug, body |
Each translation table has a unique index on (entity_id, language_code). The TranslationService uses Drizzle’s onConflictDoUpdate for upserts.
TranslationService
A single service (src/lib/server/services/translations.ts) handles all 7 entities with a consistent API:
import { translationService } from "$lib/server/services/translations";
// Pattern: get + upsert per entity
await translationService.getProductTranslations(productId);
await translationService.upsertProductTranslation(productId, "fi", { name, slug, description });
await translationService.getVariantTranslations(variantId);
await translationService.upsertVariantTranslation(variantId, "fi", { name });
await translationService.getFacetTranslations(facetId);
await translationService.upsertFacetTranslation(facetId, "fi", { name });
await translationService.getAllFacetValueTranslations(facetId); // Bulk: all values for a facet
await translationService.upsertFacetValueTranslation(facetValueId, "fi", { name });
await translationService.getCollectionTranslations(collectionId);
await translationService.upsertCollectionTranslation(collectionId, "fi", { name, slug, description });
await translationService.getAllCategoryTranslations(); // Bulk: all categories
await translationService.upsertCategoryTranslation(categoryId, "fi", { name });
await translationService.getContentPageTranslations(pageId);
await translationService.upsertContentPageTranslation(pageId, "fi", { title, slug, body });Admin UI
Translations are saved as part of the main entity form — there is no separate save action for translations. The update action in each +page.server.ts reads translation fields from the form data and calls the appropriate translationService.upsert*() method.
Translation field inputs use the naming convention {fieldName}_{langCode} (e.g. name_fi, slug_fi, description_fi).
Inline Language Tabs
Products, collections, and content pages render language tabs directly in the main form card. Each tab shows the translatable fields for that language, using a hidden class to keep all fields mounted (important for rich text editors).
TranslationEditor Component
For simpler entities (variants, facets), a shared TranslationEditor component (src/lib/components/admin/TranslationEditor.svelte) renders a separate “Translations” card. It uses the HTML form attribute to associate its inputs with the main form.
Inline Translation Editing
For entities with inline editing (categories, facet values), translation fields are added directly to the create/update forms:
<input name="name_en" value={node.name} />
<input name="name_fi" value={categoryTranslations[node.id]?.fi?.name ?? ""} />Storefront Resolution
The storefront resolves translations at the service layer. Services accept an optional language parameter. When multi-language storefronts are needed, pass the detected language to service calls:
// Future: detect language from URL prefix, cookie, or Accept-Language header
const product = await productService.getById(123, language);Date Formatting
Date display locale is configured in src/lib/config/locale.ts:
export const DATE_LOCALE = "fi-FI"; // BCP 47 language tagAll dates in the admin and storefront use formatDate() and formatDateTime() from $lib/utils, which read this value.
Adding a New Language
- Add the language to
LANGUAGESinsrc/lib/config/languages.ts - No schema changes needed — translation tables already support any language code
- The admin UI automatically picks up the new language (language tabs render for all
TRANSLATION_LANGUAGES) - Add translations for existing records via the admin UI or programmatically via
TranslationService
Best Practices
- Never hardcode
"en"— always useDEFAULT_LANGUAGEfrom the config - Default language fields go on entity tables —
products.name, not inproduct_translations - Non-default translations go in translation tables — managed via
TranslationService - Use
translationsToMap()— converts DB rows to the map format used by translation UI - Leave translations empty to fall back — the storefront uses entity table fields when no translation exists