Skip to content

Commit 07673c5

Browse files
authored
Merge pull request #11 from script-development/fs-translation
feat: add @script-development/fs-translation package
2 parents f52df0c + dfba2c2 commit 07673c5

7 files changed

Lines changed: 478 additions & 0 deletions

File tree

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/translation/package.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "@script-development/fs-translation",
3+
"version": "0.1.0",
4+
"description": "Type-safe reactive i18n service for Vue 3 — multi-locale, dot-notation keys, parameter interpolation, memoized computed refs",
5+
"license": "UNLICENSED",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/script-development/fs-packages.git",
9+
"directory": "packages/translation"
10+
},
11+
"files": [
12+
"dist"
13+
],
14+
"type": "module",
15+
"main": "./dist/index.cjs",
16+
"module": "./dist/index.mjs",
17+
"types": "./dist/index.d.mts",
18+
"exports": {
19+
".": {
20+
"import": {
21+
"types": "./dist/index.d.mts",
22+
"default": "./dist/index.mjs"
23+
},
24+
"require": {
25+
"types": "./dist/index.d.cts",
26+
"default": "./dist/index.cjs"
27+
}
28+
}
29+
},
30+
"publishConfig": {
31+
"access": "public",
32+
"registry": "https://registry.npmjs.org"
33+
},
34+
"scripts": {
35+
"build": "tsdown",
36+
"typecheck": "tsc --noEmit",
37+
"lint:pkg": "publint && attw --pack",
38+
"test": "vitest run",
39+
"test:coverage": "vitest run --coverage"
40+
},
41+
"dependencies": {
42+
"string-ts": "^2.3.1"
43+
},
44+
"devDependencies": {
45+
"vue": "^3.5.0"
46+
},
47+
"peerDependencies": {
48+
"vue": "^3.5.0"
49+
}
50+
}

packages/translation/src/index.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { ComputedRef, Ref } from "vue";
2+
3+
import { replaceAll } from "string-ts";
4+
import { computed, ref } from "vue";
5+
6+
/** Schema constraint: two-level nested object of translation strings. */
7+
export type TranslationSchema = Record<string, Record<string, string>>;
8+
9+
// Rejects strings that contain a dot (e.g., "a.b" -> never, "ab" -> "ab")
10+
type NoDot<S extends string> = S extends `${string}.${string}` ? never : S;
11+
12+
/**
13+
* Generates valid dot-notation keys from a TranslationSchema.
14+
* Only produces keys where both section and key names don't contain dots,
15+
* ensuring keys are always exactly "section.name" format.
16+
*/
17+
export type NestedKeys<T extends TranslationSchema, K extends keyof T = keyof T> = K extends string
18+
? K extends NoDot<K>
19+
? T[K] extends Record<string, string>
20+
? keyof T[K] extends infer Name extends string
21+
? Name extends NoDot<Name>
22+
? `${K}.${Name}`
23+
: never
24+
: never
25+
: never
26+
: never
27+
: never;
28+
29+
/** Public API of a translation service instance. */
30+
export interface TranslationService<TSchema extends TranslationSchema, TLocale extends string> {
31+
/**
32+
* Get a reactive translation by dot-notation key.
33+
* Returns a ComputedRef that updates when the locale changes.
34+
* Supports parameter interpolation via `{param}` placeholders.
35+
* Returns the key itself if the translation is not found.
36+
*/
37+
t: (key: NestedKeys<TSchema>, params?: Record<string, string>) => ComputedRef<string>;
38+
/** Reactive locale ref. Assign to switch languages — all computed translations update automatically. */
39+
locale: Ref<TLocale>;
40+
}
41+
42+
const getCacheKey = (key: string, params?: Record<string, string>): string => {
43+
if (!params) {
44+
return key;
45+
}
46+
return `${key}:${JSON.stringify(params)}`;
47+
};
48+
49+
/**
50+
* Create a type-safe, reactive translation service.
51+
*
52+
* @param translations - Translation dictionaries keyed by locale. All locales must share the same schema.
53+
* @param defaultLocale - The initial locale to use.
54+
*/
55+
export const createTranslationService = <
56+
const TSchema extends TranslationSchema,
57+
const TLocale extends string,
58+
>(
59+
translations: Record<TLocale, TSchema>,
60+
defaultLocale: NoInfer<TLocale>,
61+
): TranslationService<TSchema, TLocale> => {
62+
const locale = ref(defaultLocale) as Ref<TLocale>;
63+
const cache = new Map<string, ComputedRef<string>>();
64+
65+
const createTranslationComputed = (
66+
key: string,
67+
params?: Record<string, string>,
68+
): ComputedRef<string> => {
69+
return computed(() => {
70+
const parts = key.split(".");
71+
72+
if (parts.length !== 2) {
73+
return key;
74+
}
75+
76+
const [section, name] = parts as [string, string];
77+
const localeData = translations[locale.value] as
78+
| Record<string, Record<string, string>>
79+
| undefined;
80+
const sectionData = localeData?.[section];
81+
let text = sectionData?.[name];
82+
83+
if (text === undefined) {
84+
return key;
85+
}
86+
87+
if (params) {
88+
for (const [param, value] of Object.entries(params)) {
89+
text = replaceAll(text, `{${param}}`, value);
90+
}
91+
}
92+
93+
return text;
94+
});
95+
};
96+
97+
const t = (key: NestedKeys<TSchema>, params?: Record<string, string>): ComputedRef<string> => {
98+
const keyString = key as string;
99+
const cacheKey = getCacheKey(keyString, params);
100+
101+
const cached = cache.get(cacheKey);
102+
if (cached) {
103+
return cached;
104+
}
105+
106+
const translationComputed = createTranslationComputed(keyString, params);
107+
cache.set(cacheKey, translationComputed);
108+
return translationComputed;
109+
};
110+
111+
return { t, locale };
112+
};

0 commit comments

Comments
 (0)