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)

Tier 2 (Medium Priority)

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;
}