Static title
coreBaseline lookup without placeholders.
| Runtime | Result |
|---|---|
| basic | Welcome to typekit-i18n |
| icu-subset | Welcome to typekit-i18n |
| icu-formatjs | Welcome to typekit-i18n |
Appearance
Public docs can now run a Vue-based demo that mirrors selected runtime scenarios from apps/playground-ts.
Public Vue demo based on the same generated keys/table and translation resources used by apps/playground-ts.
It contains a subset of scenarios focused on ICU features and missing translation handling, with side-by-side result comparisons between the three runtimes. The "basic" runtime uses the core lookup and formatting logic without ICU support, while the "icu-subset" and "icu-formatjs" runtimes use different approaches to support ICU message syntax and features. Use the controls below to switch languages, toggle missing translation strategies, and filter scenarios by group.
Baseline lookup without placeholders.
greeting_title| Runtime | Result |
|---|---|
| basic | Welcome to typekit-i18n |
| icu-subset | Welcome to typekit-i18n |
| icu-formatjs | Welcome to typekit-i18n |
Backward-compatible {amount|currency} rendering.
invoice_totalplaceholder[{"key":"amount","value":99.99}]| Runtime | Result |
|---|---|
| basic | Invoice total: $99.99 |
| icu-subset | Invoice total: $99.99 |
| icu-formatjs | Invoice total: $99.99 |
Combined ICU select/plural expression.
inbox_summaryplaceholder[{"key":"gender","value":"female"},{"key":"count","value":1}]| Runtime | Result |
|---|---|
| basic | {gender, select, male {He has} female {She has} other {They have}} {count, plural, =0 {no messages} one {# message} other {# messages}}. |
| icu-subset | She has 1 message. |
| icu-formatjs | She has 1 message. |
Compact number and date/time skeleton forms.
icu_argument_skeleton_demoplaceholder[{"key":"amount","value":1234567},{"key":"when","value":"2025-01-15T12:34:56.000Z"}]| Runtime | Result |
|---|---|
| basic | Compact: {amount, number, ::compact-short}; Date skeleton: {when, date, ::yyyy-MM-dd}; Time skeleton: {when, time, ::HH:mm:ss} |
| icu-subset | Compact: 1.2M; Date skeleton: 01/15/2025; Time skeleton: 12:34:56 |
| icu-formatjs | Compact: 1.2M; Date skeleton: 01/15/2025; Time skeleton: 12:34:56 |
FormatJS extension token support comparison.
icu_formatjs_rounding_floor_demoplaceholder[{"key":"amount","value":1.239}]| Runtime | Result |
|---|---|
| basic | Rounding floor mode: {amount, number, ::.00 rounding-mode-floor} |
| icu-subset | ICU syntax error for key "icu_formatjs_rounding_floor_demo" in "en" at line 1, column 22: Invalid ICU number style for expression "amount, number, ::.00 rounding-mode-floor". |
| icu-formatjs | Rounding floor mode: 1.23 |
In strict mode this key throws because ES text is empty.
fallback_demo| Runtime | Result |
|---|---|
| basic | This text should fallback |
| icu-subset | This text should fallback |
| icu-formatjs | This text should fallback |
The docs demo consumes the same generated keys and table from apps/playground-ts/generated. apps/docs-site also runs playground-ts generation before docs dev/build, so translation changes stay centralized.
Shared config source:
import { defineTypekitI18nConfig } from '@number10/typekit-i18n/codegen'
const config = defineTypekitI18nConfig({
input: [
'./translations/ui.csv',
'./translations/features.yaml',
'./translations/diagnostics.csv',
'./translations/icu.yaml',
],
output: './generated/translationTable.ts',
outputKeys: './generated/translationKeys.ts',
languages: ['en', 'de', 'es', 'fr', 'ar', 'pl'] as const,
defaultLanguage: 'en',
})
export default configGenerated key unions:
/*
This file is generated.
Source files:
[1/4] "translations/diagnostics.csv"
[2/4] "translations/features.yaml"
[3/4] "translations/icu.yaml"
[4/4] "translations/ui.csv"
*/
// cspell:disable
export type TranslateKey = "diagnostics_title" | "no_issues" | "missing_count" | "greeting_title" | "greeting_body" | "item_count" | "price_formatted" | "date_formatted" | "fallback_demo" | "placeholder_basic" | "placeholder_number" | "inbox_summary" | "invoice_total" | "icu_argument_style_demo" | "icu_argument_skeleton_demo" | "ranking_place" | "group_invite" | "icu_escape_demo" | "plural_categories_demo" | "icu_formatjs_sign_demo" | "icu_formatjs_unit_demo" | "icu_formatjs_accounting_demo" | "icu_formatjs_precision_demo" | "icu_formatjs_rounding_floor_demo" | "icu_formatjs_scientific_demo" | "icu_formatjs_grouping_demo" | "icu_formatjs_unit_width_demo" | "settings_title" | "language_label" | "mode_label" | "mode_fallback" | "mode_strict" | "features_title" | "error_message"
export type TranslateKeys = TranslateKey
export const TranslationCategories = ["default", "playground"] as const
export type TranslateCategory = "default" | "playground"
export interface TranslateKeysByCategory {
"default": "diagnostics_title" | "no_issues" | "missing_count" | "greeting_body" | "item_count" | "price_formatted" | "date_formatted" | "fallback_demo" | "placeholder_basic" | "placeholder_number" | "inbox_summary" | "invoice_total" | "icu_argument_style_demo" | "icu_argument_skeleton_demo" | "ranking_place" | "group_invite" | "icu_escape_demo" | "plural_categories_demo" | "icu_formatjs_sign_demo" | "icu_formatjs_unit_demo" | "icu_formatjs_accounting_demo" | "icu_formatjs_precision_demo" | "icu_formatjs_rounding_floor_demo" | "icu_formatjs_scientific_demo" | "icu_formatjs_grouping_demo" | "icu_formatjs_unit_width_demo" | "settings_title" | "language_label" | "mode_label" | "mode_fallback" | "mode_strict" | "features_title" | "error_message"
"playground": "greeting_title"
}
export type TranslateKeyOf<C extends TranslateCategory> = TranslateKeysByCategory[C]
export const LanguageCodes = ["en", "de", "es", "fr", "ar", "pl"] as const
export type TranslateLanguage = (typeof LanguageCodes)[number]The Vue demo is a public adaptation of the original React sample:
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Alert,
Badge,
Code,
Container,
Divider,
Group,
Paper,
Select,
SegmentedControl,
Stack,
Text,
Title,
} from '@mantine/core'
import { createIcuTranslator, createTranslator } from '@number10/typekit-i18n'
import { createFormatjsIcuTranslator } from '@number10/typekit-i18n/runtime/icu-formatjs'
import type {
MissingTranslationEvent,
Placeholder,
PlaceholderFormatterMap,
PlaceholderValue,
} from '@number10/typekit-i18n'
import { LanguageCodes, type TranslateKey, type TranslateLanguage } from '@gen/translationKeys'
import { translationTable } from '@gen/translationTable'
type TranslationMode = 'fallback' | 'strict'
type ScenarioGroup = 'all' | 'core' | 'icu' | 'fallback'
type RuntimeId = 'basic' | 'icu-subset' | 'icu-formatjs'
interface ScenarioDefinition {
id: string
group: Exclude<ScenarioGroup, 'all'>
title: string
description: string
key: TranslateKey
placeholder?: Placeholder
}
interface RuntimeDescriptor {
id: RuntimeId
label: string
subtitle: string
color: string
}
interface RuntimeResult {
status: 'ok' | 'error'
value: string
}
interface LoggedMissingEvent extends MissingTranslationEvent<TranslateKey, TranslateLanguage> {
runtime: RuntimeId
}
const runtimeDescriptors: ReadonlyArray<RuntimeDescriptor> = [
{
id: 'basic',
label: 'basic',
subtitle: 'createTranslator',
color: 'gray',
},
{
id: 'icu-subset',
label: 'icu-subset',
subtitle: 'createIcuTranslator',
color: 'blue',
},
{
id: 'icu-formatjs',
label: 'icu-formatjs',
subtitle: 'createFormatjsIcuTranslator',
color: 'teal',
},
]
const scenarioGroups: { value: ScenarioGroup; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'core', label: 'Core' },
{ value: 'icu', label: 'ICU' },
{ value: 'fallback', label: 'Fallback' },
]
const numberLocaleByLanguage: Record<TranslateLanguage, string> = {
en: 'en-US',
de: 'de-DE',
es: 'es-ES',
fr: 'fr-FR',
ar: 'ar-SA',
pl: 'pl-PL',
}
const currencyByLanguage: Record<TranslateLanguage, 'USD' | 'EUR' | 'SAR' | 'PLN'> = {
en: 'USD',
de: 'EUR',
es: 'EUR',
fr: 'EUR',
ar: 'SAR',
pl: 'PLN',
}
const fixedDate = new Date('2025-01-15T12:34:56.000Z')
const scenarios: ReadonlyArray<ScenarioDefinition> = [
{
id: 'core-title',
group: 'core',
title: 'Static title',
description: 'Identical baseline lookup without placeholders.',
key: 'greeting_title',
},
{
id: 'core-placeholder',
group: 'core',
title: 'Simple placeholder',
description: 'Raw placeholder substitution for {name}.',
key: 'greeting_body',
placeholder: {
data: [{ key: 'name', value: 'Developer' }],
},
},
{
id: 'core-formatter',
group: 'core',
title: 'Legacy formatter placeholder',
description: 'Backward-compatible {amount|currency} rendering.',
key: 'invoice_total',
placeholder: {
data: [{ key: 'amount', value: 99.99 }],
},
},
{
id: 'core-date-formatter',
group: 'core',
title: 'Date formatter hook',
description: 'Custom formatter callback with Date values.',
key: 'date_formatted',
placeholder: {
data: [{ key: 'date', value: fixedDate }],
},
},
{
id: 'icu-select-plural',
group: 'icu',
title: 'Select + plural',
description: 'Combined ICU select/plural expression.',
key: 'inbox_summary',
placeholder: {
data: [
{ key: 'gender', value: 'female' },
{ key: 'count', value: 1 },
],
},
},
{
id: 'icu-plural-categories',
group: 'icu',
title: 'Plural categories',
description: 'Locale category handling (zero/one/few/many/other).',
key: 'plural_categories_demo',
placeholder: {
data: [{ key: 'count', value: 11 }],
},
},
{
id: 'icu-ordinal',
group: 'icu',
title: 'Selectordinal',
description: 'Ordinal suffix handling by language.',
key: 'ranking_place',
placeholder: {
data: [{ key: 'place', value: 3 }],
},
},
{
id: 'icu-offset',
group: 'icu',
title: 'Plural offset',
description: 'Offset subtraction for group invite style messages.',
key: 'group_invite',
placeholder: {
data: [{ key: 'count', value: 5 }],
},
},
{
id: 'icu-argument-style',
group: 'icu',
title: 'ICU style arguments',
description: 'Number/date/time styles in ICU argument expressions.',
key: 'icu_argument_style_demo',
placeholder: {
data: [
{ key: 'amount', value: 1234.56 },
{ key: 'ratio', value: 0.42 },
{ key: 'when', value: fixedDate },
],
},
},
{
id: 'icu-argument-skeleton',
group: 'icu',
title: 'ICU skeleton arguments',
description: 'Compact number and date/time skeleton forms.',
key: 'icu_argument_skeleton_demo',
placeholder: {
data: [
{ key: 'amount', value: 1234567 },
{ key: 'when', value: fixedDate },
],
},
},
{
id: 'icu-escape',
group: 'icu',
title: 'ICU escaping',
description: 'Apostrophe escaping and literal brace output.',
key: 'icu_escape_demo',
placeholder: {
data: [{ key: 'name', value: 'Alice' }],
},
},
{
id: 'divergence-sign',
group: 'icu',
title: 'ICU sign-always skeleton',
description: '::sign-always compact-short; runtime differences are visible side by side.',
key: 'icu_formatjs_sign_demo',
placeholder: {
data: [{ key: 'amount', value: 1234 }],
},
},
{
id: 'divergence-unit',
group: 'icu',
title: 'ICU unit skeleton',
description: '::unit/kilometer; runtime differences are visible side by side.',
key: 'icu_formatjs_unit_demo',
placeholder: {
data: [{ key: 'distance', value: 5 }],
},
},
{
id: 'icu-plus-accounting',
group: 'icu',
title: 'ICU accounting sign',
description: '::sign-accounting currency/USD for accounting-style negatives.',
key: 'icu_formatjs_accounting_demo',
placeholder: {
data: [{ key: 'amount', value: -1234.56 }],
},
},
{
id: 'icu-plus-precision',
group: 'icu',
title: 'ICU fixed fraction precision',
description: '::.00 enforces exactly two fractional digits.',
key: 'icu_formatjs_precision_demo',
placeholder: {
data: [{ key: 'amount', value: 1234.5 }],
},
},
{
id: 'icu-plus-rounding-floor',
group: 'icu',
title: 'ICU rounding mode floor',
description: '::.00 rounding-mode-floor rounds down instead of standard rounding.',
key: 'icu_formatjs_rounding_floor_demo',
placeholder: {
data: [{ key: 'amount', value: 1.239 }],
},
},
{
id: 'icu-plus-scientific',
group: 'icu',
title: 'ICU scientific notation',
description: '::scientific formats values in exponent notation.',
key: 'icu_formatjs_scientific_demo',
placeholder: {
data: [{ key: 'amount', value: 12345 }],
},
},
{
id: 'icu-plus-grouping-off',
group: 'icu',
title: 'ICU grouping off',
description: '::group-off disables thousands grouping.',
key: 'icu_formatjs_grouping_demo',
placeholder: {
data: [{ key: 'amount', value: 12345 }],
},
},
{
id: 'icu-plus-unit-width',
group: 'icu',
title: 'ICU unit width full name',
description: '::unit/kilometer unit-width-full-name spells out the unit.',
key: 'icu_formatjs_unit_width_demo',
placeholder: {
data: [{ key: 'distance', value: 5 }],
},
},
{
id: 'fallback-missing-language',
group: 'fallback',
title: 'Missing language fallback',
description: 'In "strict" mode this key throws because ES text is empty.',
key: 'fallback_demo',
},
]
/**
* Custom formatters used by all runtime variants.
*/
const formatters: PlaceholderFormatterMap<string, TranslateLanguage> = {
currency: (value, context) => {
const num = typeof value === 'number' ? value : parseFloat(String(value))
const locale = numberLocaleByLanguage[context.language]
const currency = currencyByLanguage[context.language]
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(num)
},
dateShort: (value, context) => {
const date = value instanceof Date ? value : new Date(String(value))
const locale = numberLocaleByLanguage[context.language]
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
},
}
const toSerializableValue = (value: PlaceholderValue): string | number | boolean => {
if (value instanceof Date) {
return value.toISOString()
}
if (typeof value === 'bigint') {
return `${value.toString()}n`
}
return value
}
const toPlaceholderPreview = (placeholder?: Placeholder): string | null => {
if (!placeholder) {
return null
}
const serialized = placeholder.data.map((entry) => ({
key: entry.key,
value: toSerializableValue(entry.value),
}))
return JSON.stringify(serialized)
}
const toErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error)
/**
* Playground app that compares runtime output across three translator variants.
*
* @returns Interactive comparison view.
*/
export const App = (): JSX.Element => {
const [language, setLanguage] = useState<TranslateLanguage>('en')
const [mode, setMode] = useState<TranslationMode>('fallback')
const [activeGroup, setActiveGroup] = useState<ScenarioGroup>('all')
const [missingEvents, setMissingEvents] = useState<LoggedMissingEvent[]>([])
const missingEventsRef = useRef<LoggedMissingEvent[]>([])
const seenMissingEventKeysRef = useRef<Set<string>>(new Set<string>())
const clearDiagnostics = useCallback((): void => {
missingEventsRef.current = []
seenMissingEventKeysRef.current.clear()
setMissingEvents([])
}, [])
const reportMissing = useCallback(
(runtime: RuntimeId, event: MissingTranslationEvent<TranslateKey, TranslateLanguage>): void => {
const dedupeKey = `${runtime}::${event.key}::${event.language}::${event.reason}`
if (seenMissingEventKeysRef.current.has(dedupeKey)) {
return
}
seenMissingEventKeysRef.current.add(dedupeKey)
missingEventsRef.current.push({
...event,
runtime,
})
},
[]
)
const basic = useMemo(
() =>
createTranslator(translationTable, {
language,
missingStrategy: mode,
formatters,
onMissingTranslation: (event) => reportMissing('basic', event),
}),
[language, mode, reportMissing]
)
const icuSubset = useMemo(
() =>
createIcuTranslator(translationTable, {
language,
missingStrategy: mode,
formatters,
onMissingTranslation: (event) => reportMissing('icu-subset', event),
}),
[language, mode, reportMissing]
)
const icuFormatjs = useMemo(
() =>
createFormatjsIcuTranslator(translationTable, {
language,
missingStrategy: mode,
formatters,
onMissingTranslation: (event) => reportMissing('icu-formatjs', event),
}),
[language, mode, reportMissing]
)
const filteredScenarios = useMemo(
() =>
activeGroup === 'all'
? scenarios
: scenarios.filter((scenario) => scenario.group === activeGroup),
[activeGroup]
)
useEffect(() => {
if (missingEventsRef.current.length !== missingEvents.length) {
setMissingEvents([...missingEventsRef.current])
}
}, [missingEvents.length, language, mode, activeGroup])
const runScenario = useCallback(
(runtime: RuntimeId, key: TranslateKey, placeholder?: Placeholder): RuntimeResult => {
try {
if (runtime === 'basic') {
return {
status: 'ok',
value: placeholder ? basic(key, placeholder) : basic(key),
}
}
if (runtime === 'icu-subset') {
return {
status: 'ok',
value: placeholder ? icuSubset(key, placeholder) : icuSubset(key),
}
}
return {
status: 'ok',
value: placeholder ? icuFormatjs(key, placeholder) : icuFormatjs(key),
}
} catch (error: unknown) {
return {
status: 'error',
value: toErrorMessage(error),
}
}
},
[basic, icuSubset, icuFormatjs]
)
const handleLanguageChange = (value: string | null): void => {
if (!value) {
return
}
clearDiagnostics()
setLanguage(value as TranslateLanguage)
}
const handleModeChange = (value: string | null): void => {
if (!value) {
return
}
clearDiagnostics()
setMode(value as TranslationMode)
}
return (
<Container size="xl" py="lg">
<Stack gap="md">
<Paper
p="lg"
radius="lg"
withBorder
style={{
background:
'linear-gradient(160deg, rgba(32, 40, 62, 0.95) 0%, rgba(19, 29, 52, 0.95) 45%, rgba(10, 46, 57, 0.95) 100%)',
}}
>
<Stack gap="xs">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="xs" c="cyan.2" tt="uppercase" fw={700}>
Runtime Comparison Playground
</Text>
<Title order={1} size="1.6rem" c="white">
{basic('greeting_title')}
</Title>
<Text c="gray.2">
Compare output of all three runtimes on the same translation key and payload.
</Text>
</Stack>
<Badge size="lg" variant="light" color="cyan">
{language.toUpperCase()} / {mode}
</Badge>
</Group>
</Stack>
</Paper>
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Group grow align="flex-end">
<Select
label={basic('language_label')}
value={language}
onChange={handleLanguageChange}
data={LanguageCodes.map((lang) => ({
value: lang,
label: lang.toUpperCase(),
}))}
/>
<Select
label={basic('mode_label')}
value={mode}
onChange={handleModeChange}
data={[
{ value: 'fallback', label: basic('mode_fallback') },
{ value: 'strict', label: basic('mode_strict') },
]}
/>
</Group>
<SegmentedControl
fullWidth
value={activeGroup}
onChange={(value) => setActiveGroup(value as ScenarioGroup)}
data={scenarioGroups}
/>
</Stack>
</Paper>
<Paper p="md" radius="md" withBorder>
<Stack gap="xs">
<Text fw={600}>Runtime Labels</Text>
<Group gap="xs">
{runtimeDescriptors.map((runtime) => (
<Group key={runtime.id} gap={6}>
<Badge color={runtime.color} variant="light">
{runtime.label}
</Badge>
<Code>{runtime.subtitle}</Code>
</Group>
))}
</Group>
</Stack>
</Paper>
<Stack gap="md">
{filteredScenarios.map((scenario) => {
const placeholderPreview = toPlaceholderPreview(scenario.placeholder)
return (
<Paper key={scenario.id} p="md" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Stack gap={2}>
<Text fw={600}>{scenario.title}</Text>
<Text size="sm" c="dimmed">
{scenario.description}
</Text>
</Stack>
<Badge variant="light" color="indigo">
{scenario.group}
</Badge>
</Group>
<Group gap="xs">
<Text size="xs" c="dimmed">
key
</Text>
<Code>{scenario.key}</Code>
{placeholderPreview && (
<>
<Text size="xs" c="dimmed">
placeholder
</Text>
<Code>{placeholderPreview}</Code>
</>
)}
</Group>
<Divider />
<Stack gap="xs">
{runtimeDescriptors.map((runtime) => {
const result = runScenario(runtime.id, scenario.key, scenario.placeholder)
return (
<Paper key={runtime.id} p="sm" radius="sm" withBorder>
<Stack gap={6}>
<Group justify="space-between" align="center">
<Badge color={runtime.color} variant="light">
{runtime.label}
</Badge>
<Code c="dimmed" fz="xs">
{runtime.subtitle}
</Code>
</Group>
{result.status === 'ok' ? (
<Text ff="monospace" size="sm">
{result.value}
</Text>
) : (
<Alert color="red" variant="light" title="Runtime Error">
<Text ff="monospace" size="xs">
{result.value}
</Text>
</Alert>
)}
</Stack>
</Paper>
)
})}
</Stack>
</Stack>
</Paper>
)
})}
</Stack>
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600}>{basic('diagnostics_title')}</Text>
<Badge color={missingEvents.length > 0 ? 'orange' : 'green'} variant="light">
{missingEvents.length}
</Badge>
</Group>
{missingEvents.length === 0 ? (
<Alert color="green" variant="light">
<Text size="sm">{basic('no_issues')}</Text>
</Alert>
) : (
<Stack gap="xs">
{missingEvents.map((event, index) => (
<Paper
key={`${event.runtime}-${event.key}-${index}`}
p="xs"
withBorder
radius="sm"
>
<Group gap="xs">
<Badge variant="light">{event.runtime}</Badge>
<Code>{event.key}</Code>
<Code>{event.language}</Code>
<Code>{event.reason}</Code>
</Group>
</Paper>
))}
</Stack>
)}
</Stack>
</Paper>
</Stack>
</Container>
)
}Vue implementation inside docs:
<script setup lang="ts">
import { computed, ref } from 'vue'
import { createIcuTranslator, createTranslator } from '@number10/typekit-i18n'
import { createFormatjsIcuTranslator } from '@number10/typekit-i18n/runtime/icu-formatjs'
import type {
Placeholder,
PlaceholderFormatterMap,
PlaceholderValue,
} from '@number10/typekit-i18n'
import { LanguageCodes, type TranslateKey, type TranslateLanguage } from '@playground-gen/translationKeys'
import { translationTable } from '@playground-gen/translationTable'
type TranslationMode = 'fallback' | 'strict'
type ScenarioGroup = 'all' | 'core' | 'icu' | 'fallback'
type RuntimeId = 'basic' | 'icu-subset' | 'icu-formatjs'
interface ScenarioDefinition {
id: string
group: Exclude<ScenarioGroup, 'all'>
title: string
description: string
key: TranslateKey
placeholder?: Placeholder
}
interface RuntimeDescriptor {
id: RuntimeId
label: string
}
interface RuntimeResult {
status: 'ok' | 'error'
value: string
}
interface ScenarioRow {
scenario: ScenarioDefinition
placeholderPreview: string | null
results: ReadonlyArray<{
runtime: RuntimeDescriptor
result: RuntimeResult
}>
}
const runtimeDescriptors: ReadonlyArray<RuntimeDescriptor> = [
{
id: 'basic',
label: 'basic',
},
{
id: 'icu-subset',
label: 'icu-subset',
},
{
id: 'icu-formatjs',
label: 'icu-formatjs',
},
]
const scenarioGroups: ReadonlyArray<{ value: ScenarioGroup; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'core', label: 'Core' },
{ value: 'icu', label: 'ICU' },
{ value: 'fallback', label: 'Fallback' },
]
const numberLocaleByLanguage: Record<TranslateLanguage, string> = {
en: 'en-US',
de: 'de-DE',
es: 'es-ES',
fr: 'fr-FR',
ar: 'ar-SA',
pl: 'pl-PL',
}
const currencyByLanguage: Record<TranslateLanguage, 'USD' | 'EUR' | 'SAR' | 'PLN'> = {
en: 'USD',
de: 'EUR',
es: 'EUR',
fr: 'EUR',
ar: 'SAR',
pl: 'PLN',
}
const fixedDate = new Date('2025-01-15T12:34:56.000Z')
const scenarios: ReadonlyArray<ScenarioDefinition> = [
{
id: 'core-title',
group: 'core',
title: 'Static title',
description: 'Baseline lookup without placeholders.',
key: 'greeting_title',
},
{
id: 'core-formatter',
group: 'core',
title: 'Legacy formatter placeholder',
description: 'Backward-compatible {amount|currency} rendering.',
key: 'invoice_total',
placeholder: {
data: [{ key: 'amount', value: 99.99 }],
},
},
{
id: 'icu-select-plural',
group: 'icu',
title: 'Select + plural',
description: 'Combined ICU select/plural expression.',
key: 'inbox_summary',
placeholder: {
data: [
{ key: 'gender', value: 'female' },
{ key: 'count', value: 1 },
],
},
},
{
id: 'icu-argument-skeleton',
group: 'icu',
title: 'ICU skeleton arguments',
description: 'Compact number and date/time skeleton forms.',
key: 'icu_argument_skeleton_demo',
placeholder: {
data: [
{ key: 'amount', value: 1234567 },
{ key: 'when', value: fixedDate },
],
},
},
{
id: 'icu-plus-rounding-floor',
group: 'icu',
title: 'ICU rounding mode floor',
description: 'FormatJS extension token support comparison.',
key: 'icu_formatjs_rounding_floor_demo',
placeholder: {
data: [{ key: 'amount', value: 1.239 }],
},
},
{
id: 'fallback-missing-language',
group: 'fallback',
title: 'Missing language fallback',
description: 'In strict mode this key throws because ES text is empty.',
key: 'fallback_demo',
},
]
const formatters: PlaceholderFormatterMap<string, TranslateLanguage> = {
currency: (value, context) => {
const num = typeof value === 'number' ? value : parseFloat(String(value))
const locale = numberLocaleByLanguage[context.language]
const currency = currencyByLanguage[context.language]
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(num)
},
dateShort: (value, context) => {
const date = value instanceof Date ? value : new Date(String(value))
const locale = numberLocaleByLanguage[context.language]
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
},
}
const language = ref<TranslateLanguage>('en')
const mode = ref<TranslationMode>('fallback')
const activeGroup = ref<ScenarioGroup>('all')
const basic = computed(() =>
createTranslator(translationTable, {
language: language.value,
missingStrategy: mode.value,
formatters,
})
)
const icuSubset = computed(() =>
createIcuTranslator(translationTable, {
language: language.value,
missingStrategy: mode.value,
formatters,
})
)
const icuFormatjs = computed(() =>
createFormatjsIcuTranslator(translationTable, {
language: language.value,
missingStrategy: mode.value,
formatters,
})
)
const filteredScenarios = computed(() =>
activeGroup.value === 'all'
? scenarios
: scenarios.filter((scenario) => scenario.group === activeGroup.value)
)
const toErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error)
const runScenario = (runtime: RuntimeId, key: TranslateKey, placeholder?: Placeholder): RuntimeResult => {
try {
if (runtime === 'basic') {
return {
status: 'ok',
value: placeholder ? basic.value(key, placeholder) : basic.value(key),
}
}
if (runtime === 'icu-subset') {
return {
status: 'ok',
value: placeholder ? icuSubset.value(key, placeholder) : icuSubset.value(key),
}
}
return {
status: 'ok',
value: placeholder ? icuFormatjs.value(key, placeholder) : icuFormatjs.value(key),
}
} catch (error: unknown) {
return {
status: 'error',
value: toErrorMessage(error),
}
}
}
const toSerializableValue = (value: PlaceholderValue): string | number | boolean => {
if (value instanceof Date) {
return value.toISOString()
}
if (typeof value === 'bigint') {
return `${value.toString()}n`
}
return value
}
const toPlaceholderPreview = (placeholder?: Placeholder): string | null => {
if (!placeholder) {
return null
}
const serialized = placeholder.data.map((entry) => ({
key: entry.key,
value: toSerializableValue(entry.value),
}))
return JSON.stringify(serialized)
}
const scenarioRows = computed<ReadonlyArray<ScenarioRow>>(() =>
filteredScenarios.value.map((scenario) => ({
scenario,
placeholderPreview: toPlaceholderPreview(scenario.placeholder),
results: runtimeDescriptors.map((runtime) => ({
runtime,
result: runScenario(runtime.id, scenario.key, scenario.placeholder),
})),
}))
)
</script>
<template>
<section class="runtime-playground">
<p>
Public Vue demo based on the same generated keys/table and translation resources used by
<code>apps/playground-ts</code>.
</p>
<p>
It contains a subset of scenarios focused on ICU features and missing translation handling, with side-by-side result comparisons between the three runtimes. The "basic" runtime uses the core lookup and formatting logic without ICU support, while the "icu-subset" and "icu-formatjs" runtimes use different approaches to support ICU message syntax and features. Use the controls below to switch languages, toggle missing translation strategies, and filter scenarios by group.
</p>
<div class="control-grid">
<label>
Language
<select v-model="language">
<option v-for="lang in LanguageCodes" :key="lang" :value="lang">
{{ lang.toUpperCase() }}
</option>
</select>
</label>
<label>
Missing strategy
<select v-model="mode">
<option value="fallback">fallback</option>
<option value="strict">strict</option>
</select>
</label>
</div>
<div class="group-switch">
<button
v-for="group in scenarioGroups"
:key="group.value"
type="button"
:class="{ active: activeGroup === group.value }"
@click="activeGroup = group.value"
>
{{ group.label }}
</button>
</div>
<article v-for="row in scenarioRows" :key="row.scenario.id" class="scenario-card">
<header>
<h3>{{ row.scenario.title }}</h3>
<span class="badge">{{ row.scenario.group }}</span>
</header>
<p>{{ row.scenario.description }}</p>
<div class="meta-row">
<span>key</span>
<code>{{ row.scenario.key }}</code>
<template v-if="row.placeholderPreview">
<span>placeholder</span>
<code>{{ row.placeholderPreview }}</code>
</template>
</div>
<table>
<thead>
<tr>
<th>Runtime</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in row.results" :key="entry.runtime.id">
<td>
<strong style="white-space: nowrap;">{{ entry.runtime.label }}</strong>
</td>
<td :class="entry.result.status">
<code>{{ entry.result.value }}</code>
</td>
</tr>
</tbody>
</table>
</article>
</section>
</template>
<style scoped>
.runtime-playground {
display: grid;
gap: 1rem;
}
.control-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
label {
display: grid;
gap: 0.4rem;
font-size: 0.9rem;
}
select {
border: 1px solid var(--vp-c-divider);
border-radius: 0.5rem;
padding: 0.45rem 0.6rem;
background: var(--vp-c-bg-soft);
}
.group-switch {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.group-switch button {
border: 1px solid var(--vp-c-divider);
border-radius: 999px;
padding: 0.35rem 0.8rem;
background: transparent;
cursor: pointer;
}
.group-switch button.active {
color: var(--vp-c-bg);
background: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
.scenario-card {
border: 1px solid var(--vp-c-divider);
border-radius: 0.75rem;
padding: 0.9rem;
background: var(--vp-c-bg-soft);
}
.scenario-card header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.scenario-card h3 {
margin: 0;
font-size: 1rem;
}
.badge {
border: 1px solid var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
border-radius: 999px;
padding: 0.1rem 0.5rem;
font-size: 0.75rem;
}
.meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.6rem;
margin-bottom: 0.8rem;
font-size: 0.82rem;
}
.meta-row span {
color: var(--vp-c-text-2);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
border-top: 1px solid var(--vp-c-divider);
vertical-align: top;
padding: 0.45rem 0;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.78rem;
}
td.ok code {
color: var(--vp-c-text-1);
}
td.error code {
color: var(--vp-c-danger-1);
}
code {
white-space: pre-wrap;
}
</style>