fimo 0.2.4 → 0.2.5-experimental.1782327181771
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/assets/agent-templates/content-translator/GOAL.md +8 -11
- package/assets/agent-templates/content-translator/capabilities.yaml +1 -3
- package/assets/agent-templates/content-translator/scripts/translate-entries.ts +10 -7
- package/assets/content-templates/hooks-template.eta +73 -23
- package/assets/skills/fimo/SKILL.md +3 -3
- package/assets/skills/fimo/references/forms.md +1 -1
- package/assets/skills/fimo/references/setup-plain-vite.md +1 -1
- package/assets/skills/fimo/references/setup-react-router.md +1 -1
- package/assets/skills/fimo/references/translations.md +42 -14
- package/assets/skills/fimo/references/ui.md +4 -4
- package/assets/skills/fimo-cli/SKILL.md +3 -3
- package/assets/skills/fimo-cli/references/content.md +1 -1
- package/assets/skills/fimo-cli/references/forms.md +1 -1
- package/assets/skills/fimo-cli/references/translations.md +45 -19
- package/dist/build/vite/plugins/fimo-config.d.ts.map +1 -1
- package/dist/build/vite/plugins/fimo-config.js +16 -0
- package/dist/build/vite/plugins/fimo-config.test.d.ts +2 -0
- package/dist/build/vite/plugins/fimo-config.test.d.ts.map +1 -0
- package/dist/build/vite/plugins/fimo-config.test.js +46 -0
- package/dist/build/vite/plugins/translations.d.ts +7 -6
- package/dist/build/vite/plugins/translations.d.ts.map +1 -1
- package/dist/build/vite/plugins/translations.js +366 -33
- package/dist/build/vite/plugins/translations.test.d.ts +2 -0
- package/dist/build/vite/plugins/translations.test.d.ts.map +1 -0
- package/dist/build/vite/plugins/translations.test.js +177 -0
- package/dist/cli/bundle.json +2 -2
- package/dist/cli/index.js +1305 -1078
- package/dist/runtime/app/FimoScripts.d.ts.map +1 -1
- package/dist/runtime/app/FimoScripts.js +35 -1
- package/dist/runtime/app/prefetch.d.ts.map +1 -1
- package/dist/runtime/app/prefetch.js +6 -1
- package/dist/runtime/paths/get-fimo-paths.d.ts.map +1 -1
- package/dist/runtime/paths/get-fimo-paths.js +9 -4
- package/dist/runtime/primitives/components/Text.d.ts +1 -1
- package/dist/runtime/primitives/components/Text.js +1 -1
- package/dist/runtime/primitives/lib/query.d.ts +5 -0
- package/dist/runtime/primitives/lib/query.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/template.d.ts +1 -1
- package/dist/runtime/primitives/lib/template.js +1 -1
- package/dist/runtime/primitives/translations.d.ts +9 -3
- package/dist/runtime/primitives/translations.d.ts.map +1 -1
- package/dist/runtime/primitives/translations.js +32 -5
- package/dist/runtime/seo/htmlProps.d.ts +1 -1
- package/dist/runtime/seo/htmlProps.js +2 -2
- package/dist/runtime/shared/fimo-config.server.d.ts.map +1 -1
- package/dist/runtime/shared/fimo-config.server.js +1 -0
- package/dist/runtime/shared/fimo-config.types.d.ts +7 -0
- package/dist/runtime/shared/fimo-config.types.d.ts.map +1 -1
- package/dist/scripts/extract-translations.d.ts +8 -8
- package/dist/scripts/extract-translations.js +22 -57
- package/dist/scripts/lint-translation-keys.js +24 -5
- package/dist/scripts/lint-translation-keys.test.d.ts +1 -0
- package/dist/scripts/lint-translation-keys.test.js +16 -0
- package/package.json +1 -1
- package/release.json +2 -2
- package/templates/react-router/fimo-config.json +4 -0
- package/templates/react-router/package.json +1 -1
- package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +0 -66
- package/dist/scripts/inject-translations.d.ts +0 -6
- package/dist/scripts/inject-translations.js +0 -168
- package/templates/react-router/translations/en.json +0 -1
|
@@ -1,43 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Labels workflow
|
|
2
2
|
|
|
3
|
-
> For the `t()` helper signature, `useTranslations()` hook, key naming rules, and the "wrap every user-facing string" rule
|
|
3
|
+
> For the `t()` helper signature, `useTranslations()` hook, key naming rules, and the "wrap every user-facing string" rule, see **`fimo/references/translations.md`**.
|
|
4
4
|
|
|
5
|
-
This file covers the **CLI workflow** for
|
|
5
|
+
This file covers the **CLI workflow** for labels: static UI copy such as nav items, buttons, headings, placeholders, alt text, and SEO strings.
|
|
6
6
|
|
|
7
|
-
## What
|
|
7
|
+
## What validate does
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`fimo validate` scans `src/**/*.{ts,tsx,js,jsx}` for `t('literal-key')` calls (skipping `src/ui` and `node_modules`) and verifies every key has a value in the tenant DB for the default locale.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The database is the source of truth for visible label values. Code declares keys; it does not create visible copy.
|
|
12
12
|
|
|
13
13
|
## CLI commands
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
fimo validate
|
|
17
|
-
fimo
|
|
18
|
-
fimo
|
|
16
|
+
fimo validate # run all checks; fails if label keys are missing in DB
|
|
17
|
+
fimo labels list # inspect labels currently in the DB
|
|
18
|
+
fimo labels list --locale en # filter by locale
|
|
19
|
+
fimo labels set nav.home --value Home # create/update one label
|
|
20
|
+
fimo labels set-many --locale es --data '{"nav.home":"Inicio"}'
|
|
21
|
+
fimo labels set-many --locale es --file /tmp/es-labels.json
|
|
22
|
+
fimo labels delete nav.home --locale en
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
There is no `fimo translations` command. Use `fimo labels`.
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
## Locale config
|
|
28
|
+
|
|
29
|
+
`fimo-config.json` controls label/content locales:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"i18n": {
|
|
34
|
+
"defaultLocale": "en",
|
|
35
|
+
"locales": ["en", "es"],
|
|
36
|
+
"autoTranslateLabels": true,
|
|
37
|
+
"autoTranslateContent": true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `defaultLocale` is what `fimo validate` and label commands use when `--locale` is omitted.
|
|
43
|
+
- `locales` is the enabled locale list and must include `defaultLocale`.
|
|
44
|
+
- `autoTranslateLabels` defaults to enabled when omitted. Set it to `false` to stop default-locale label writes from enqueueing machine translations.
|
|
45
|
+
- `autoTranslateContent` does the same for CMS/schema entries.
|
|
46
|
+
- For a non-default locale, pass `--locale <locale>` explicitly.
|
|
47
|
+
|
|
48
|
+
Advanced/internal primitive (hidden from `fimo --help`):
|
|
24
49
|
|
|
25
50
|
```bash
|
|
26
|
-
fimo scripts extract-translations #
|
|
27
|
-
fimo scripts inject-translations # rewrite t() defaults in src/ to match translations/en.json
|
|
51
|
+
fimo scripts extract-translations # legacy helper: scan src/ for literal t() keys
|
|
28
52
|
```
|
|
29
53
|
|
|
30
|
-
Prefer `fimo validate` —
|
|
54
|
+
Prefer `fimo validate` — this primitive is for narrow debugging/test use.
|
|
31
55
|
|
|
32
56
|
## When to run validate
|
|
33
57
|
|
|
34
|
-
- After adding or changing any `t()`
|
|
35
|
-
-
|
|
58
|
+
- After adding or changing any `t()` keys in code, run `fimo validate`.
|
|
59
|
+
- If validate reports `labels/missing`, add values with `fimo labels set` or `fimo labels set-many`, then run validate again.
|
|
60
|
+
- After renaming/removing keys, clean old DB labels intentionally with `fimo labels delete` when they are no longer used.
|
|
36
61
|
|
|
37
62
|
## Workflow
|
|
38
63
|
|
|
39
|
-
1. Write JSX with `t('key'
|
|
40
|
-
2. Run `fimo validate
|
|
41
|
-
3.
|
|
64
|
+
1. Write JSX with `t('key')` calls. Load `fimo/references/translations.md` for the rules.
|
|
65
|
+
2. Run `fimo validate`.
|
|
66
|
+
3. Add missing values with `fimo labels set` or `fimo labels set-many`.
|
|
67
|
+
4. Editors can now change copy in the Fimo admin without code changes.
|
|
42
68
|
|
|
43
69
|
For the helper signatures, hooks, and code patterns, **always** load `fimo/references/translations.md` before writing code that uses `t()`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fimo-config.d.ts","sourceRoot":"","sources":["../../../../src/build/vite/plugins/fimo-config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAKnC;;;;;;;;GAQG;AACH,MAAM,CAAC,OAAO,UAAU,gBAAgB,IAAI,MAAM,
|
|
1
|
+
{"version":3,"file":"fimo-config.d.ts","sourceRoot":"","sources":["../../../../src/build/vite/plugins/fimo-config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAKnC;;;;;;;;GAQG;AACH,MAAM,CAAC,OAAO,UAAU,gBAAgB,IAAI,MAAM,CA6CjD"}
|
|
@@ -38,6 +38,13 @@ export default function fimoConfigPlugin() {
|
|
|
38
38
|
return 'export default {};';
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
|
+
transformIndexHtml(html) {
|
|
42
|
+
const lang = readHtmlLang(configPath);
|
|
43
|
+
if (/<html\b[^>]*\blang=/.test(html)) {
|
|
44
|
+
return html.replace(/(<html\b[^>]*\blang=["'])[^"']*(["'])/i, `$1${lang}$2`);
|
|
45
|
+
}
|
|
46
|
+
return html.replace(/<html\b([^>]*)>/i, `<html$1 lang="${lang}">`);
|
|
47
|
+
},
|
|
41
48
|
handleHotUpdate(ctx) {
|
|
42
49
|
if (ctx.file !== configPath) {
|
|
43
50
|
return;
|
|
@@ -50,3 +57,12 @@ export default function fimoConfigPlugin() {
|
|
|
50
57
|
},
|
|
51
58
|
};
|
|
52
59
|
}
|
|
60
|
+
function readHtmlLang(configPath) {
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
63
|
+
return (raw.seo?.locale ?? raw.i18n?.defaultLocale ?? 'en').replace('_', '-');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'en';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fimo-config.test.d.ts","sourceRoot":"","sources":["../../../../src/build/vite/plugins/fimo-config.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import fimoConfigPlugin from './fimo-config';
|
|
6
|
+
let scratch = null;
|
|
7
|
+
const pluginContext = {
|
|
8
|
+
meta: {},
|
|
9
|
+
error: (error) => {
|
|
10
|
+
throw error;
|
|
11
|
+
},
|
|
12
|
+
info: () => undefined,
|
|
13
|
+
warn: () => undefined,
|
|
14
|
+
debug: () => undefined,
|
|
15
|
+
};
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (scratch) {
|
|
18
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
19
|
+
scratch = null;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
function createPluginWithConfig(config) {
|
|
23
|
+
scratch = mkdtempSync(join(tmpdir(), 'fimo-config-plugin-'));
|
|
24
|
+
writeFileSync(join(scratch, 'fimo-config.json'), JSON.stringify(config, null, 2));
|
|
25
|
+
const plugin = fimoConfigPlugin();
|
|
26
|
+
if (typeof plugin.config === 'function') {
|
|
27
|
+
plugin.config.call(pluginContext, { root: scratch }, { command: 'build', mode: 'production' });
|
|
28
|
+
}
|
|
29
|
+
return plugin;
|
|
30
|
+
}
|
|
31
|
+
function transformHtml(plugin, html) {
|
|
32
|
+
if (typeof plugin.transformIndexHtml !== 'function') {
|
|
33
|
+
throw new Error('Expected transformIndexHtml function');
|
|
34
|
+
}
|
|
35
|
+
return plugin.transformIndexHtml.call(pluginContext, html, {});
|
|
36
|
+
}
|
|
37
|
+
describe('fimoConfigPlugin', () => {
|
|
38
|
+
it('sets html lang from seo.locale first', () => {
|
|
39
|
+
const plugin = createPluginWithConfig({ seo: { locale: 'es_ES' }, i18n: { defaultLocale: 'fr' } });
|
|
40
|
+
expect(transformHtml(plugin, '<html><head></head><body></body></html>')).toContain('<html lang="es-ES">');
|
|
41
|
+
});
|
|
42
|
+
it('falls back to i18n.defaultLocale when seo.locale is absent', () => {
|
|
43
|
+
const plugin = createPluginWithConfig({ i18n: { defaultLocale: 'fr' } });
|
|
44
|
+
expect(transformHtml(plugin, '<html lang="en"><head></head><body></body></html>')).toContain('<html lang="fr">');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
2
|
+
interface LabelBundle {
|
|
3
|
+
labels: Record<string, string>;
|
|
4
|
+
locale: string;
|
|
5
|
+
updatedAt: string | null;
|
|
6
|
+
}
|
|
8
7
|
export default function translationsPlugin(): Plugin;
|
|
8
|
+
export declare function loadLabelBundle(rootDir: string): Promise<LabelBundle>;
|
|
9
|
+
export {};
|
|
9
10
|
//# sourceMappingURL=translations.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"translations.d.ts","sourceRoot":"","sources":["../../../../src/build/vite/plugins/translations.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"translations.d.ts","sourceRoot":"","sources":["../../../../src/build/vite/plugins/translations.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAOlD,UAAU,WAAW;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAuBD,MAAM,CAAC,OAAO,UAAU,kBAAkB,IAAI,MAAM,CAmMnD;AA8BD,wBAAsB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAkC3E"}
|
|
@@ -1,22 +1,76 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
1
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
2
5
|
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
* Edits to that JSON trigger HMR on consumers via `handleHotUpdate`.
|
|
8
|
-
*/
|
|
6
|
+
import { getConfigServer } from '../../../runtime/shared/fimo-config.server.js';
|
|
7
|
+
const VIRTUAL_ID = 'virtual:translations';
|
|
8
|
+
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
|
9
9
|
export default function translationsPlugin() {
|
|
10
|
-
const VIRTUAL_ID = 'virtual:translations';
|
|
11
|
-
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
|
12
10
|
let rootDir = process.cwd();
|
|
13
|
-
let
|
|
11
|
+
let bundle = { labels: {}, locale: 'en', updatedAt: null };
|
|
12
|
+
let command = 'build';
|
|
13
|
+
let devServer = null;
|
|
14
|
+
let eventSockets = [];
|
|
15
|
+
let reconnectTimer;
|
|
16
|
+
let refreshTimer;
|
|
17
|
+
let pollTimer;
|
|
18
|
+
const refreshLabels = async () => {
|
|
19
|
+
bundle = await loadLabelBundle(rootDir);
|
|
20
|
+
return bundle;
|
|
21
|
+
};
|
|
22
|
+
const refreshLabelsAndInvalidateIfChanged = async () => {
|
|
23
|
+
const before = serializeBundle(bundle);
|
|
24
|
+
const next = await refreshLabels();
|
|
25
|
+
if (serializeBundle(next) !== before) {
|
|
26
|
+
invalidateLabels();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const invalidateLabels = () => {
|
|
30
|
+
if (!devServer) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const mod of findVirtualTranslationModules(devServer)) {
|
|
34
|
+
devServer.moduleGraph.invalidateModule(mod);
|
|
35
|
+
}
|
|
36
|
+
devServer.ws.send({ type: 'full-reload', path: '*' });
|
|
37
|
+
};
|
|
38
|
+
const scheduleRefresh = () => {
|
|
39
|
+
if (refreshTimer) {
|
|
40
|
+
clearTimeout(refreshTimer);
|
|
41
|
+
}
|
|
42
|
+
refreshTimer = setTimeout(() => {
|
|
43
|
+
void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
|
|
44
|
+
}, 150);
|
|
45
|
+
};
|
|
14
46
|
return {
|
|
15
47
|
name: 'fimo-virtual-translations',
|
|
16
48
|
enforce: 'pre',
|
|
17
49
|
configResolved(config) {
|
|
18
50
|
rootDir = config.root;
|
|
19
|
-
|
|
51
|
+
command = config.command;
|
|
52
|
+
},
|
|
53
|
+
async buildStart() {
|
|
54
|
+
bundle = emptyLabelBundle(rootDir);
|
|
55
|
+
this.addWatchFile(join(rootDir, 'fimo-config.json'));
|
|
56
|
+
this.addWatchFile(join(rootDir, '.env'));
|
|
57
|
+
this.addWatchFile(join(rootDir, '.env.local'));
|
|
58
|
+
if (command === 'build') {
|
|
59
|
+
await refreshLabels();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
configureServer(server) {
|
|
63
|
+
devServer = server;
|
|
64
|
+
server.watcher.add([join(rootDir, 'fimo-config.json'), join(rootDir, '.env'), join(rootDir, '.env.local')]);
|
|
65
|
+
void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
|
|
66
|
+
connectLabelEvents(rootDir, scheduleRefresh)
|
|
67
|
+
.then((sockets) => {
|
|
68
|
+
eventSockets = sockets;
|
|
69
|
+
})
|
|
70
|
+
.catch(() => undefined);
|
|
71
|
+
pollTimer = setInterval(() => {
|
|
72
|
+
void refreshLabelsAndInvalidateIfChanged().catch(() => undefined);
|
|
73
|
+
}, 2000);
|
|
20
74
|
},
|
|
21
75
|
resolveId(id) {
|
|
22
76
|
if (id === VIRTUAL_ID) {
|
|
@@ -28,34 +82,313 @@ export default function translationsPlugin() {
|
|
|
28
82
|
if (id !== RESOLVED_ID) {
|
|
29
83
|
return null;
|
|
30
84
|
}
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
85
|
+
const current = command === 'serve' ? bundle : await refreshLabels();
|
|
86
|
+
const labels = JSON.stringify(current.labels);
|
|
87
|
+
return [
|
|
88
|
+
`export const translations = ${labels};`,
|
|
89
|
+
`export const labels = translations;`,
|
|
90
|
+
`export const locale = ${JSON.stringify(current.locale)};`,
|
|
91
|
+
`export const updatedAt = ${JSON.stringify(current.updatedAt)};`,
|
|
92
|
+
`export const signature = ${JSON.stringify(labelSignature(current.labels))};`,
|
|
93
|
+
].join('\n');
|
|
94
|
+
},
|
|
95
|
+
async handleHotUpdate(ctx) {
|
|
96
|
+
const watched = new Set([join(rootDir, 'fimo-config.json'), join(rootDir, '.env'), join(rootDir, '.env.local')]);
|
|
97
|
+
if (!watched.has(ctx.file)) {
|
|
98
|
+
return;
|
|
41
99
|
}
|
|
42
|
-
|
|
43
|
-
|
|
100
|
+
await refreshLabels();
|
|
101
|
+
const modules = findVirtualTranslationModules(ctx.server);
|
|
102
|
+
if (modules.length > 0) {
|
|
103
|
+
for (const mod of modules) {
|
|
104
|
+
ctx.server.moduleGraph.invalidateModule(mod);
|
|
105
|
+
}
|
|
106
|
+
return modules;
|
|
44
107
|
}
|
|
45
|
-
|
|
46
|
-
return `export const translations = ${json};`;
|
|
108
|
+
return [];
|
|
47
109
|
},
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
110
|
+
closeBundle() {
|
|
111
|
+
for (const socket of eventSockets) {
|
|
112
|
+
socket.close();
|
|
113
|
+
}
|
|
114
|
+
eventSockets = [];
|
|
115
|
+
if (reconnectTimer) {
|
|
116
|
+
clearTimeout(reconnectTimer);
|
|
117
|
+
}
|
|
118
|
+
if (refreshTimer) {
|
|
119
|
+
clearTimeout(refreshTimer);
|
|
53
120
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
ctx.server.moduleGraph.invalidateModule(mod);
|
|
57
|
-
return [mod];
|
|
121
|
+
if (pollTimer) {
|
|
122
|
+
clearInterval(pollTimer);
|
|
58
123
|
}
|
|
59
124
|
},
|
|
60
125
|
};
|
|
126
|
+
async function connectLabelEvents(root, onLabelsChanged) {
|
|
127
|
+
const env = await readProjectEnv(root);
|
|
128
|
+
const apiUrl = env.VITE_API_URL;
|
|
129
|
+
if (!apiUrl) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const runtimeConfig = await loadRuntimeConfig(apiUrl);
|
|
133
|
+
const eventsUrl = runtimeConfig.eventsServerUrl ?? undefined;
|
|
134
|
+
if (!eventsUrl) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const settings = await readProjectSettings(root);
|
|
138
|
+
if (!settings.projectId) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const WebSocketCtor = globalThis.WebSocket;
|
|
142
|
+
if (!WebSocketCtor) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const sockets = [];
|
|
146
|
+
for (const room of deriveEventsRooms(settings.projectId, apiUrl)) {
|
|
147
|
+
const token = await generateOneTimeToken(settings.apiUrl ?? process.env.FIMO_API_URL ?? 'http://localhost:3000');
|
|
148
|
+
const wsUrl = new URL(`/parties/events/${room}`, eventsUrl.replace(/^http/, 'ws'));
|
|
149
|
+
wsUrl.searchParams.set('authToken', token);
|
|
150
|
+
const socket = new WebSocketCtor(wsUrl.toString());
|
|
151
|
+
socket.onmessage = (event) => {
|
|
152
|
+
const raw = typeof event.data === 'string' ? event.data : '';
|
|
153
|
+
if (!raw) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const message = JSON.parse(raw);
|
|
158
|
+
if (message.type !== 'labels:changed') {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const changedLocales = message.payload?.changes?.map((change) => change.locale).filter(Boolean) ?? [];
|
|
162
|
+
if (changedLocales.length === 0 || changedLocales.includes(bundle.locale)) {
|
|
163
|
+
onLabelsChanged();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
socket.onclose = () => {
|
|
171
|
+
reconnectTimer = setTimeout(() => {
|
|
172
|
+
void connectLabelEvents(root, onLabelsChanged)
|
|
173
|
+
.then((nextSockets) => {
|
|
174
|
+
eventSockets = nextSockets;
|
|
175
|
+
})
|
|
176
|
+
.catch(() => undefined);
|
|
177
|
+
}, 2000);
|
|
178
|
+
};
|
|
179
|
+
socket.onerror = () => undefined;
|
|
180
|
+
sockets.push(socket);
|
|
181
|
+
}
|
|
182
|
+
return sockets;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function serializeBundle(bundle) {
|
|
186
|
+
return JSON.stringify({ labels: bundle.labels, locale: bundle.locale });
|
|
187
|
+
}
|
|
188
|
+
function emptyLabelBundle(rootDir) {
|
|
189
|
+
const config = getConfigServer(rootDir);
|
|
190
|
+
return {
|
|
191
|
+
labels: {},
|
|
192
|
+
locale: config.i18n?.defaultLocale ?? 'en',
|
|
193
|
+
updatedAt: null,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function findVirtualTranslationModules(server) {
|
|
197
|
+
const direct = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
198
|
+
const matches = new Set(direct ? [direct] : []);
|
|
199
|
+
for (const mod of server.moduleGraph.idToModuleMap.values()) {
|
|
200
|
+
if (mod.id?.includes('virtual:translations')) {
|
|
201
|
+
matches.add(mod);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return [...matches];
|
|
205
|
+
}
|
|
206
|
+
function labelSignature(labels) {
|
|
207
|
+
return Buffer.from(JSON.stringify(labels)).toString('base64');
|
|
208
|
+
}
|
|
209
|
+
export async function loadLabelBundle(rootDir) {
|
|
210
|
+
const config = getConfigServer(rootDir);
|
|
211
|
+
const locale = config.i18n?.defaultLocale ?? 'en';
|
|
212
|
+
const env = await readProjectEnv(rootDir);
|
|
213
|
+
const apiUrl = env.VITE_API_URL;
|
|
214
|
+
if (!apiUrl) {
|
|
215
|
+
return { labels: {}, locale, updatedAt: null };
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const url = new URL('/labels', apiUrl);
|
|
219
|
+
url.searchParams.set('locale', locale);
|
|
220
|
+
const response = await fetchWithTimeout(url, 4000);
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
return { labels: {}, locale, updatedAt: null };
|
|
223
|
+
}
|
|
224
|
+
const body = (await response.json());
|
|
225
|
+
if (Array.isArray(body.data)) {
|
|
226
|
+
return {
|
|
227
|
+
labels: Object.fromEntries(body.data.map((row) => [row.key, row.value ?? row.defaultValue ?? ''])),
|
|
228
|
+
locale,
|
|
229
|
+
updatedAt: null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
labels: body.data?.labels ?? {},
|
|
234
|
+
locale,
|
|
235
|
+
updatedAt: body.data?.updatedAt ?? null,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { labels: {}, locale, updatedAt: null };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function loadRuntimeConfig(apiUrl) {
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetchWithTimeout(new URL('/runtime-config', apiUrl), 2000);
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
const body = await response.json();
|
|
249
|
+
return parseRuntimeConfig(body);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function parseRuntimeConfig(body) {
|
|
256
|
+
if (!body || typeof body !== 'object') {
|
|
257
|
+
return {};
|
|
258
|
+
}
|
|
259
|
+
const envelopeData = body.data;
|
|
260
|
+
const source = envelopeData && typeof envelopeData === 'object' ? envelopeData : body;
|
|
261
|
+
const eventsServerUrl = source.eventsServerUrl;
|
|
262
|
+
return {
|
|
263
|
+
eventsServerUrl: typeof eventsServerUrl === 'string' ? eventsServerUrl : undefined,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
async function fetchWithTimeout(url, timeoutMs) {
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
269
|
+
try {
|
|
270
|
+
return await fetch(url, { signal: controller.signal });
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
clearTimeout(timeout);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function readProjectEnv(rootDir) {
|
|
277
|
+
const values = {
|
|
278
|
+
...(await readEnvFile(join(rootDir, '.env'))),
|
|
279
|
+
...(await readEnvFile(join(rootDir, '.env.local'))),
|
|
280
|
+
};
|
|
281
|
+
for (const key of ['VITE_API_URL']) {
|
|
282
|
+
if (process.env[key]) {
|
|
283
|
+
values[key] = process.env[key];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return values;
|
|
287
|
+
}
|
|
288
|
+
async function readEnvFile(filePath) {
|
|
289
|
+
try {
|
|
290
|
+
const content = await readFile(filePath, 'utf8');
|
|
291
|
+
const values = {};
|
|
292
|
+
for (const line of content.split(/\r?\n/)) {
|
|
293
|
+
const trimmed = line.trim();
|
|
294
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const index = trimmed.indexOf('=');
|
|
298
|
+
if (index === -1) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const key = trimmed.slice(0, index).trim();
|
|
302
|
+
const value = trimmed
|
|
303
|
+
.slice(index + 1)
|
|
304
|
+
.trim()
|
|
305
|
+
.replace(/^['"]|['"]$/g, '');
|
|
306
|
+
values[key] = value;
|
|
307
|
+
}
|
|
308
|
+
return values;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return {};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async function readProjectSettings(rootDir) {
|
|
315
|
+
try {
|
|
316
|
+
const raw = await readFile(join(rootDir, '.fimo.settings.json'), 'utf8');
|
|
317
|
+
return JSON.parse(raw);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return {};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function deriveEventsRooms(projectId, tenantApiUrl) {
|
|
324
|
+
const rooms = new Set([projectId]);
|
|
325
|
+
try {
|
|
326
|
+
const firstLabel = new URL(tenantApiUrl).hostname.split('.')[0];
|
|
327
|
+
if (firstLabel.startsWith(`${projectId}-`)) {
|
|
328
|
+
rooms.add(`${projectId}:${firstLabel.slice(projectId.length + 1)}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return [...rooms];
|
|
333
|
+
}
|
|
334
|
+
return [...rooms];
|
|
335
|
+
}
|
|
336
|
+
async function generateOneTimeToken(apiUrl) {
|
|
337
|
+
const bearer = process.env.FIMO_API_TOKEN?.trim() || (await readStoredToken(apiUrl));
|
|
338
|
+
if (!bearer) {
|
|
339
|
+
throw new Error('Not signed in to Fimo.');
|
|
340
|
+
}
|
|
341
|
+
const response = await fetch(new URL('/api/auth/one-time-token/generate', apiUrl), {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: {
|
|
344
|
+
Authorization: `Bearer ${bearer}`,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
throw new Error(`Failed to generate events auth token: ${response.status}`);
|
|
349
|
+
}
|
|
350
|
+
const body = (await response.json());
|
|
351
|
+
const token = body.token ?? body.data?.token;
|
|
352
|
+
if (!token) {
|
|
353
|
+
throw new Error('Events auth token response did not include a token.');
|
|
354
|
+
}
|
|
355
|
+
return token;
|
|
356
|
+
}
|
|
357
|
+
async function readStoredToken(apiUrl) {
|
|
358
|
+
const configDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'fimo');
|
|
359
|
+
const credentialsFile = join(configDir, 'credentials', credentialsFilename(apiUrl));
|
|
360
|
+
try {
|
|
361
|
+
const raw = await readFile(credentialsFile, 'utf8');
|
|
362
|
+
const parsed = JSON.parse(raw);
|
|
363
|
+
return parsed.access_token ?? null;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function credentialsFilename(apiUrl) {
|
|
370
|
+
const normalized = normalizeApiUrl(apiUrl);
|
|
371
|
+
let hostLabel = 'unknown';
|
|
372
|
+
try {
|
|
373
|
+
hostLabel = new URL(normalized).host
|
|
374
|
+
.replace(/:/g, '-')
|
|
375
|
+
.replace(/[^a-zA-Z0-9.-]+/g, '-')
|
|
376
|
+
.replace(/^[.-]+|[.-]+$/g, '')
|
|
377
|
+
.toLowerCase();
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
hostLabel = 'unknown';
|
|
381
|
+
}
|
|
382
|
+
return `${hostLabel || 'unknown'}-${createHash('sha256').update(normalized).digest('hex').slice(0, 6)}.json`;
|
|
383
|
+
}
|
|
384
|
+
function normalizeApiUrl(input) {
|
|
385
|
+
const trimmed = input.trim();
|
|
386
|
+
try {
|
|
387
|
+
const url = new URL(trimmed);
|
|
388
|
+
const pathname = url.pathname.replace(/\/+$/, '');
|
|
389
|
+
return `${url.protocol}//${url.host}${pathname}`;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return trimmed.replace(/\/+$/, '');
|
|
393
|
+
}
|
|
61
394
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"translations.test.d.ts","sourceRoot":"","sources":["../../../../src/build/vite/plugins/translations.test.ts"],"names":[],"mappings":""}
|