Storyteller Suite - Internationalization (i18n)
Overview
The plugin supports multiple languages through a JSON-based translation system. The t() function returns localized strings based on the user's selected language.
File Structure
src/i18n/
├── strings.ts # Translation loader and t() function
└── locales/
├── en.json # English (base language)
├── zh.json # Chinese (中文)
├── de.json.template # German (template)
├── es.json.template # Spanish (template)
├── fr.json.template # French (template)
├── ja.json.template # Japanese (template)
├── ko.json.template # Korean (template)
├── pt.json.template # Portuguese (template)
├── README.md
└── PRIORITY_LANGUAGES.md
strings.ts
Language Registry
import enJson from './locales/en.json';
import zhJson from './locales/zh.json';
// import esJson from './locales/es.json'; // Add when complete
// Language registry
const languageRegistry: Record<string, Record<string, string>> = {
en: enJson as Record<string, string>,
zh: zhJson as Record<string, string>,
// es: esJson as Record<string, string>, // Add when complete
};
// Language display names
const languageNames: Record<string, string> = {
en: 'English',
zh: 'Chinese (中文)',
es: 'Spanish (Español)',
fr: 'French (Français)',
de: 'German (Deutsch)',
pt: 'Portuguese (Português)',
ja: 'Japanese (日本語)',
ko: 'Korean (한국어)',
};
The t() Function
let currentLanguage: string = 'en';
let currentStrings: Record<string, string> = enJson;
// Set current language
export function setLanguage(lang: string): void {
if (languageRegistry[lang]) {
currentLanguage = lang;
currentStrings = languageRegistry[lang];
} else {
console.warn(`Language '${lang}' not found, falling back to English`);
currentLanguage = 'en';
currentStrings = enJson;
}
}
// Get translated string
export function t(key: string, ...args: (string | number)[]): string {
// Get string from current language
let text = currentStrings[key];
// Fallback to English if not found
if (!text && currentLanguage !== 'en') {
text = enJson[key];
}
// Return key if still not found
if (!text) {
console.warn(`Translation missing for key: ${key}`);
return key;
}
// Substitute placeholders {0}, {1}, etc.
args.forEach((arg, index) => {
text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), String(arg));
});
return text;
}
// Get available languages
export function getAvailableLanguages(): { code: string; name: string }[] {
return Object.keys(languageRegistry).map(code => ({
code,
name: languageNames[code] || code
}));
}
Locale File Format
en.json (Base)
{
"dashboardTitle": "Storyteller suite",
"openDashboard": "Open dashboard",
"createNew": "Create new",
"uploadImage": "Upload Image",
"character": "Character",
"characters": "Characters",
"location": "Location",
"locations": "Locations",
"event": "Event",
"events": "Events",
"create": "Create",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"createNewCharacter": "Create new character",
"editCharacter": "Edit character",
"characterNameRequired": "Character name cannot be empty.",
"storyCreated": "Story \"{0}\" created and activated.",
"created": "{0} \"{1}\" created.",
"updated": "{0} \"{1}\" updated.",
"movedToTrash": "{0} \"{1}\" moved to trash.",
"searchX": "Search {0}...",
"filterX": "Filter {0}",
"removeX": "Remove {0}"
}
zh.json (Chinese)
{
"dashboardTitle": "讲故事套件",
"openDashboard": "打开仪表板",
"createNew": "创建新",
"uploadImage": "上传图片",
"character": "角色",
"characters": "角色",
"location": "地点",
"locations": "地点",
"event": "事件",
"events": "事件",
"create": "创建",
"edit": "编辑",
"delete": "删除",
"save": "保存",
"cancel": "取消",
"createNewCharacter": "创建新角色",
"editCharacter": "编辑角色",
"characterNameRequired": "角色名称不能为空。",
"storyCreated": "故事 \"{0}\" 已创建并激活。",
"created": "{0} \"{1}\" 已创建。",
"updated": "{0} \"{1}\" 已更新。",
"movedToTrash": "{0} \"{1}\" 已移至垃圾箱。",
"searchX": "搜索 {0}...",
"filterX": "筛选 {0}",
"removeX": "移除 {0}"
}
Usage Examples
Basic Usage
import { t } from '../i18n/strings';
// Simple string
const title = t('dashboardTitle'); // "Storyteller suite"
// With placeholders
const message = t('storyCreated', 'My Story');
// "Story "My Story" created and activated."
const created = t('created', t('character'), 'Sir Aldric');
// "Character "Sir Aldric" created."
In UI Components
class CharacterModal extends ResponsiveModal {
onOpen(): void {
// Title
this.contentEl.createEl('h2', {
text: this.isEdit ? t('editCharacter') : t('createNewCharacter')
});
// Form field
new Setting(this.contentEl)
.setName(t('name'))
.addText(text => text
.setPlaceholder(t('enterCharacterName'))
.setValue(this.character.name)
);
// Button
new ButtonComponent(this.contentEl)
.setButtonText(t('save'))
.onClick(() => this.save());
}
private async save(): Promise<void> {
if (!this.character.name) {
new Notice(t('characterNameRequired'));
return;
}
await this.plugin.saveCharacter(this.character);
new Notice(t('created', t('character'), this.character.name));
}
}
In Settings
class StorytellerSuiteSettingTab extends PluginSettingTab {
display(): void {
new Setting(containerEl)
.setName(t('language'))
.setDesc(t('selectLanguage'))
.addDropdown(dropdown => {
const languages = getAvailableLanguages();
for (const lang of languages) {
dropdown.addOption(lang.code, lang.name);
}
dropdown.setValue(this.plugin.settings.language)
.onChange(async (value) => {
this.plugin.settings.language = value;
setLanguage(value);
await this.plugin.saveSettings();
new Notice(t('languageChanged'));
});
});
}
}
Adding a New Language
Step 1: Create Locale File
Copy en.json to your language code (e.g., es.json):
cp src/i18n/locales/en.json src/i18n/locales/es.json
Step 2: Translate Values
Translate all string values, keeping keys unchanged:
{
"dashboardTitle": "Suite de narrador",
"openDashboard": "Abrir panel",
"createNew": "Crear nuevo",
...
}
Step 3: Register Language
Update strings.ts:
import enJson from './locales/en.json';
import zhJson from './locales/zh.json';
import esJson from './locales/es.json'; // Add import
const languageRegistry: Record<string, Record<string, string>> = {
en: enJson as Record<string, string>,
zh: zhJson as Record<string, string>,
es: esJson as Record<string, string>, // Add to registry
};
const languageNames: Record<string, string> = {
en: 'English',
zh: 'Chinese (中文)',
es: 'Spanish (Español)', // Add display name
};
Translation Rules
1. Keep Keys Identical
Keys must match exactly across all locale files:
// ✅ Correct
"createCharacter": "Create character" // en.json
"createCharacter": "创建角色" // zh.json
// ❌ Wrong
"createChar": "Create character" // Different key
2. Preserve Placeholders
Keep {0}, {1}, etc. in translations:
// en.json
"storyCreated": "Story \"{0}\" created and activated."
// es.json - placeholder preserved
"storyCreated": "Historia \"{0}\" creada y activada."
3. Placeholder Reordering
Placeholders can be reordered for grammar:
// en.json
"savedAt": "Saved {0} at {1}"
// ja.json - different order for Japanese grammar
"savedAt": "{1}に{0}を保存しました"
4. Escape Special Characters
Use proper escaping in JSON:
{
"beatSheetExample": "- Meet the mentor\\n- The refusal",
"quoted": "He said \"Hello\""
}
Priority Languages
From PRIORITY_LANGUAGES.md:
Tier 1 (High Priority)
- Spanish (es) - 500M+ speakers
- French (fr) - 280M+ speakers
- German (de) - 130M+ speakers
- Portuguese (pt) - 260M+ speakers
- Japanese (ja) - 125M+ speakers
- Korean (ko) - 80M+ speakers
Tier 2 (Medium Priority)
- Italian (it)
- Russian (ru)
- Dutch (nl)
- Polish (pl)
- Turkish (tr)
- Arabic (ar)
String Categories
The locale files contain strings for:
| Category | Examples |
|---|---|
| Entity Names | character, location, event, scene |
| Actions | create, edit, delete, save, cancel |
| UI Labels | name, description, tags, status |
| Modal Titles | createNewCharacter, editLocation |
| Validation | characterNameRequired, eventNameRequired |
| Notices | created, updated, movedToTrash |
| Settings | language, galleryUploadFolder, timelineDefaultHeight |
| Templates | applyTemplate, useTemplate, duplicate |
| Timeline | timelineStart, ganttView, milestonesOnly |
| Maps | mapSettings, enableFrontmatterMarkers |
Testing Translations
// Verify all keys exist
function validateTranslations(): string[] {
const missing: string[] = [];
const enKeys = Object.keys(enJson);
for (const [lang, strings] of Object.entries(languageRegistry)) {
if (lang === 'en') continue;
for (const key of enKeys) {
if (!strings[key]) {
missing.push(`${lang}: Missing key "${key}"`);
}
}
}
return missing;
}