feat(plugins): add i18n support to boilerplate-solid-js

- Add useTranslate() hook for reactive translations in SolidJS
- Include example translation files (en.json, de.json)
- Update Vite plugin to copy i18n folder during build
- Add i18n message handlers to plugin.ts
- Demonstrate i18n usage in App.tsx with ~10 translation keys
- Update documentation with comprehensive i18n guide
- Add i18n to features list in main plugin-dev README

The boilerplate now provides a complete working example of multi-language
plugin support, making it easy for developers to create internationalized
plugins.
This commit is contained in:
Johannes Millan 2026-01-16 20:30:14 +01:00
parent 6087b63878
commit eb120baf1b
9 changed files with 365 additions and 41 deletions

View file

@ -375,10 +375,20 @@ PluginAPI.registerHook('taskUpdate', (data: unknown) => {
1. **minimal-plugin** - The simplest possible plugin (10 lines)
2. **simple-typescript-plugin** - TypeScript with minimal tooling
3. **example-plugin** - Full featured example with webpack
4. **procrastination-buster** - SolidJS plugin with modern UI
4. **boilerplate-solid-js** - Modern Solid.js boilerplate with i18n support
5. **procrastination-buster** - SolidJS plugin with modern UI
### Example Features
**boilerplate-solid-js** demonstrates:
- SolidJS for reactive UI
- Vite for fast builds
- Internationalization (i18n) support with example translations
- Modern component architecture
- Plugin-to-iframe communication
- Best practices for plugin development
**example-plugin** demonstrates:
- TypeScript setup with webpack

View file

@ -98,22 +98,118 @@ src/
├── app/ # Solid.js application
│ ├── App.tsx # Main app component
│ └── App.css # App styles
├── utils/ # Helper utilities
│ └── useTranslate.ts # i18n hook for translations
├── index.html # Plugin UI entry point
├── index.ts # UI initialization
├── plugin.ts # Plugin logic and API integration
└── manifest.json # Plugin metadata
i18n/ # Translation files (optional)
├── en.json # English translations (required)
└── de.json # German translations (example)
scripts/ # Build and utility scripts
└── build-plugin.js # Plugin packaging script
dist/ # Build output (gitignored)
├── assets/
├── i18n/ # Copied translation files
├── index.html
├── index.js
├── plugin.js
└── manifest.json
```
## Internationalization (i18n)
This boilerplate includes built-in support for multi-language plugins.
### Translation Files
Translation files are located in the `i18n/` directory and use JSON format with nested keys:
```json
{
"APP": {
"TITLE": "My Plugin",
"SUBTITLE": "Description"
},
"BUTTONS": {
"SAVE": "Save",
"CANCEL": "Cancel"
},
"MESSAGES": {
"SUCCESS": "Task \"{{title}}\" created!"
}
}
```
**Note**: English (`en.json`) is required and used as a fallback when translations are missing.
### Using Translations in Components
Use the `useTranslate()` hook in your Solid.js components:
```typescript
import { useTranslate } from '../utils/useTranslate';
function MyComponent() {
const t = useTranslate();
const [title, setTitle] = createSignal('');
// Load translation
createEffect(async () => {
setTitle(await t('APP.TITLE'));
});
return <h1>{title()}</h1>;
}
```
**With parameters** (for interpolation):
```typescript
createEffect(async () => {
const message = await t('MESSAGES.SUCCESS', { title: 'My Task' });
// Returns: 'Task "My Task" created!'
setMessage(message);
});
```
### Adding New Languages
1. Add the language code to `manifest.json`:
```json
{
"i18n": {
"languages": ["en", "de", "fr"]
}
}
```
2. Create the translation file (e.g., `i18n/fr.json`):
```json
{
"APP": {
"TITLE": "Mon Plugin"
}
}
```
3. Rebuild the plugin: `npm run build`
### Translation Key Format
- Use hierarchical keys: `APP.TITLE`, `SETTINGS.THEME`
- Use parameter interpolation: `"message": "Hello {{name}}"`
- Keep keys descriptive and consistent
- English is the fallback language
For complete i18n documentation, see [Plugin i18n Guide](../PLUGIN_I18N.md).
## Plugin API Usage
### Basic Setup

View file

@ -0,0 +1,30 @@
{
"APP": {
"TITLE": "Solid.js Boilerplate Plugin",
"SUBTITLE": "Erstellt mit der Super Productivity Plugin API"
},
"STATS": {
"TOTAL_TASKS": "Aufgaben Gesamt",
"COMPLETED_TODAY": "Heute Erledigt",
"PENDING": "Ausstehend"
},
"TASK": {
"CREATE_NEW": "Neue Aufgabe Erstellen",
"ENTER_TITLE": "Aufgabentitel eingeben...",
"NO_PROJECT": "Kein Projekt",
"CREATE_BUTTON": "Aufgabe Erstellen",
"RECENT_TASKS": "Letzte Aufgaben",
"CREATED_SUCCESS": "Aufgabe \"{{title}}\" erstellt!"
},
"SETTINGS": {
"TITLE": "Einstellungen",
"THEME": "Theme",
"THEME_LIGHT": "Hell",
"THEME_DARK": "Dunkel",
"SHOW_COMPLETED": "Erledigte Aufgaben anzeigen"
},
"BUTTONS": {
"REFRESH": "Daten Aktualisieren"
},
"LOADING": "Lädt..."
}

View file

@ -0,0 +1,30 @@
{
"APP": {
"TITLE": "Solid.js Boilerplate Plugin",
"SUBTITLE": "Built with Super Productivity Plugin API"
},
"STATS": {
"TOTAL_TASKS": "Total Tasks",
"COMPLETED_TODAY": "Completed Today",
"PENDING": "Pending"
},
"TASK": {
"CREATE_NEW": "Create New Task",
"ENTER_TITLE": "Enter task title...",
"NO_PROJECT": "No Project",
"CREATE_BUTTON": "Create Task",
"RECENT_TASKS": "Recent Tasks",
"CREATED_SUCCESS": "Task \"{{title}}\" created!"
},
"SETTINGS": {
"TITLE": "Settings",
"THEME": "Theme",
"THEME_LIGHT": "Light",
"THEME_DARK": "Dark",
"SHOW_COMPLETED": "Show completed tasks"
},
"BUTTONS": {
"REFRESH": "Refresh Data"
},
"LOADING": "Loading..."
}

View file

@ -1,5 +1,6 @@
import { createSignal, createEffect, For, Show, onMount } from 'solid-js';
import { Task, Project } from '@super-productivity/plugin-api';
import { useTranslate } from '../utils/useTranslate';
import './App.css';
// Communication with plugin.js
@ -20,6 +21,7 @@ const sendMessage = async (type: string, payload?: any) => {
};
function App() {
const t = useTranslate();
const [tasks, setTasks] = createSignal<Task[]>([]);
const [projects, setProjects] = createSignal<Project[]>([]);
const [stats, setStats] = createSignal({
@ -32,6 +34,32 @@ function App() {
const [settings, setSettings] = createSignal({ theme: 'light', showCompleted: true });
const [isLoading, setIsLoading] = createSignal(true);
// Translation signals for reactive i18n
const [appTitle, setAppTitle] = createSignal('');
const [refreshButton, setRefreshButton] = createSignal('');
const [totalTasksLabel, setTotalTasksLabel] = createSignal('');
const [completedTodayLabel, setCompletedTodayLabel] = createSignal('');
const [pendingLabel, setPendingLabel] = createSignal('');
const [createNewLabel, setCreateNewLabel] = createSignal('');
const [taskPlaceholder, setTaskPlaceholder] = createSignal('');
const [noProjectLabel, setNoProjectLabel] = createSignal('');
const [createButtonLabel, setCreateButtonLabel] = createSignal('');
const [loadingLabel, setLoadingLabel] = createSignal('');
// Load translations
createEffect(async () => {
setAppTitle(await t('APP.TITLE'));
setRefreshButton(await t('BUTTONS.REFRESH'));
setTotalTasksLabel(await t('STATS.TOTAL_TASKS'));
setCompletedTodayLabel(await t('STATS.COMPLETED_TODAY'));
setPendingLabel(await t('STATS.PENDING'));
setCreateNewLabel(await t('TASK.CREATE_NEW'));
setTaskPlaceholder(await t('TASK.ENTER_TITLE'));
setNoProjectLabel(await t('TASK.NO_PROJECT'));
setCreateButtonLabel(await t('TASK.CREATE_BUTTON'));
setLoadingLabel(await t('LOADING'));
});
// Load initial data
onMount(async () => {
try {
@ -107,12 +135,9 @@ function App() {
return (
<div class="app">
<header class="app-header">
<h1>🚀 Solid.js Boilerplate Plugin</h1>
<button
onClick={refreshData}
class="refresh-btn"
>
Refresh Data
<h1>🚀 {appTitle()}</h1>
<button onClick={refreshData} class="refresh-btn">
{refreshButton()}
</button>
</header>
@ -124,25 +149,25 @@ function App() {
<section class="stats-section">
<div class="stat-card">
<div class="stat-value">{stats().totalTasks}</div>
<div class="stat-label">Total Tasks</div>
<div class="stat-label">{totalTasksLabel()}</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats().completedToday}</div>
<div class="stat-label">Completed Today</div>
<div class="stat-label">{completedTodayLabel()}</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats().pendingTasks}</div>
<div class="stat-label">Pending</div>
<div class="stat-label">{pendingLabel()}</div>
</div>
</section>
{/* Create Task Section */}
<section class="create-task-section">
<h2>Create New Task</h2>
<h2>{createNewLabel()}</h2>
<div class="create-task-form">
<input
type="text"
placeholder="Enter task title..."
placeholder={taskPlaceholder()}
value={newTaskTitle()}
onInput={(e) => setNewTaskTitle(e.currentTarget.value)}
onKeyPress={(e) => e.key === 'Enter' && createTask()}
@ -153,16 +178,13 @@ function App() {
onChange={(e) => setSelectedProjectId(e.currentTarget.value)}
class="project-select"
>
<option value="">No Project</option>
<option value="">{noProjectLabel()}</option>
<For each={projects()}>
{(project) => <option value={project.id}>{project.title}</option>}
</For>
</select>
<button
onClick={createTask}
class="create-btn"
>
Create Task
<button onClick={createTask} class="create-btn">
{createButtonLabel()}
</button>
</div>
</section>
@ -173,10 +195,7 @@ function App() {
<div class="tasks-list">
<For each={tasks().slice(0, 10)}>
{(task) => (
<div
class="task-item"
classList={{ completed: task.isDone }}
>
<div class="task-item" classList={{ completed: task.isDone }}>
<span class="task-title">{task.title}</span>
<Show when={task.projectId}>
<span class="task-project">
@ -197,9 +216,7 @@ function App() {
<span>Theme:</span>
<select
value={settings().theme}
onChange={(e) =>
setSettings({ ...settings(), theme: e.currentTarget.value })
}
onChange={(e) => setSettings({ ...settings(), theme: e.currentTarget.value })}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
@ -223,7 +240,7 @@ function App() {
</main>
}
>
<div class="loading">Loading...</div>
<div class="loading">{loadingLabel()}</div>
</Show>
<footer class="app-footer">

View file

@ -16,5 +16,8 @@
"iFrame": true,
"sidePanel": false,
"isSkipMenuEntry": false,
"icon": "icon.svg"
"icon": "icon.svg",
"i18n": {
"languages": ["en", "de"]
}
}

View file

@ -55,19 +55,16 @@ plugin.registerHook(PluginHooks.TASK_UPDATE, (taskData: TaskUpdatePayload) => {
});
// Example: Hook into context changes
plugin.registerHook(
PluginHooks.ANY_TASK_UPDATE,
async (payload: AnyTaskUpdatePayload) => {
const changes = payload.changes;
if (changes && 'projectId' in changes && changes.projectId) {
const projects = await plugin.getAllProjects();
const currentProject = projects.find((p) => p.id === changes.projectId);
if (currentProject) {
plugin.log.info('Switched to project:', currentProject.title);
}
plugin.registerHook(PluginHooks.ANY_TASK_UPDATE, async (payload: AnyTaskUpdatePayload) => {
const changes = payload.changes;
if (changes && 'projectId' in changes && changes.projectId) {
const projects = await plugin.getAllProjects();
const currentProject = projects.find((p) => p.id === changes.projectId);
if (currentProject) {
plugin.log.info('Switched to project:', currentProject.title);
}
},
);
}
});
// Example: Custom command handler
if (plugin.onMessage) {
@ -76,8 +73,7 @@ if (plugin.onMessage) {
case 'getStats':
const tasks = await plugin.getTasks();
const completedToday = tasks.filter(
(t) =>
t.isDone && new Date(t.doneOn!).toDateString() === new Date().toDateString(),
(t) => t.isDone && new Date(t.doneOn!).toDateString() === new Date().toDateString(),
);
return {
@ -111,8 +107,25 @@ if (plugin.onMessage) {
const settings = await plugin.loadSyncedData();
return settings ? JSON.parse(settings) : {};
}
// i18n support
case 'translate':
return plugin.translate(message.data.key, message.data.params);
case 'getCurrentLanguage':
return plugin.getCurrentLanguage();
default:
return { error: 'Unknown message type' };
}
});
}
// Listen for language changes and notify iframe
plugin.registerHook(PluginHooks.LANGUAGE_CHANGE, (language: string) => {
// Notify the iframe about language change
const iframe = document.querySelector('iframe[data-plugin-iframe]');
if (iframe && (iframe as HTMLIFrameElement).contentWindow) {
(iframe as HTMLIFrameElement).contentWindow!.postMessage(
{ type: 'languageChanged', language },
'*',
);
}
});

View file

@ -0,0 +1,96 @@
import { createSignal, createEffect, onCleanup } from 'solid-js';
// Communication with plugin.js
const sendMessage = async (type: string, payload?: any): Promise<any> => {
return new Promise((resolve) => {
const messageId = Math.random().toString(36).substring(2, 9);
const handler = (event: MessageEvent) => {
if (event.data.messageId === messageId) {
window.removeEventListener('message', handler);
resolve(event.data.response);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type, payload, messageId }, '*');
});
};
/**
* SolidJS hook for reactive translations
*
* This hook provides a simple way to translate strings in your plugin components.
* It automatically handles language changes and caches translations for performance.
*
* @example
* ```tsx
* function MyComponent() {
* const t = useTranslate();
* const [greeting, setGreeting] = createSignal('');
*
* createEffect(async () => {
* setGreeting(await t('GREETING'));
* });
*
* return <h1>{greeting()}</h1>;
* }
* ```
*
* @example With parameters
* ```tsx
* function TaskCount() {
* const t = useTranslate();
* const [message, setMessage] = createSignal('');
*
* createEffect(async () => {
* setMessage(await t('TASK.CREATED_SUCCESS', { title: 'My Task' }));
* });
*
* return <p>{message()}</p>;
* }
* ```
*/
export function useTranslate() {
const [currentLanguage, setCurrentLanguage] = createSignal<string>('en');
// Listen for language change events
createEffect(() => {
const handleLanguageChange = (event: MessageEvent) => {
if (event.data.type === 'languageChanged') {
setCurrentLanguage(event.data.language);
}
};
window.addEventListener('message', handleLanguageChange);
onCleanup(() => {
window.removeEventListener('message', handleLanguageChange);
});
});
/**
* Translate a key with optional parameter interpolation
*
* @param key - Translation key (supports dot notation for nested keys)
* @param params - Optional parameters for interpolation (e.g., { count: 5 })
* @returns Promise that resolves to the translated string
*
* @example
* ```tsx
* const greeting = await t('APP.TITLE');
* const message = await t('TASK.COUNT', { count: 5 });
* ```
*/
const t = async (key: string, params?: Record<string, string | number>): Promise<string> => {
try {
return await sendMessage('translate', { key, params });
} catch (error) {
console.error('Translation error:', error);
return key;
}
};
// Also expose the current language as a signal for reactive language-dependent logic
return Object.assign(t, { currentLanguage });
}

View file

@ -69,6 +69,35 @@ export const superProductivityPlugin = (
fs.copyFileSync(iconSrc, iconDest);
}
// 2.5. Copy i18n folder if it exists
const i18nSrcDir = path.resolve(process.cwd(), 'i18n');
if (fs.existsSync(i18nSrcDir) && fs.statSync(i18nSrcDir).isDirectory()) {
try {
const i18nDestDir = path.join(distDir, 'i18n');
if (!fs.existsSync(i18nDestDir)) {
fs.mkdirSync(i18nDestDir, { recursive: true });
}
const i18nFiles = fs.readdirSync(i18nSrcDir);
let copiedCount = 0;
for (const file of i18nFiles) {
if (file.endsWith('.json')) {
fs.copyFileSync(
path.join(i18nSrcDir, file),
path.join(i18nDestDir, file),
);
copiedCount++;
}
}
if (copiedCount > 0) {
console.log(`📋 Copied ${copiedCount} i18n file(s)`);
}
} catch (error) {
console.warn('⚠️ Failed to copy i18n files:', error);
}
}
// 3. Handle index.html and inlining
const htmlPath = path.join(distDir, 'index.html');