mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
6087b63878
commit
eb120baf1b
9 changed files with 365 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
packages/plugin-dev/boilerplate-solid-js/i18n/de.json
Normal file
30
packages/plugin-dev/boilerplate-solid-js/i18n/de.json
Normal 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..."
|
||||
}
|
||||
30
packages/plugin-dev/boilerplate-solid-js/i18n/en.json
Normal file
30
packages/plugin-dev/boilerplate-solid-js/i18n/en.json
Normal 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..."
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -16,5 +16,8 @@
|
|||
"iFrame": true,
|
||||
"sidePanel": false,
|
||||
"isSkipMenuEntry": false,
|
||||
"icon": "icon.svg"
|
||||
"icon": "icon.svg",
|
||||
"i18n": {
|
||||
"languages": ["en", "de"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
'*',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue