Merge branch 'type-i18n' into 'develop'

dev: Add type annotations to i18n

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

See merge request firefish/firefish!10773
This commit is contained in:
Linca 2024-05-06 19:03:29 +00:00
commit 0d27f038da
16 changed files with 356 additions and 38 deletions

View File

@ -8,4 +8,5 @@ This directory contains all of the packages Firefish uses.
- `client`: Web interface written in Vue3 and TypeScript
- `sw`: Web [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) written in TypeScript
- `firefish-js`: TypeScript SDK for both backend and client
- `i18n`: Typed i18n for client
- `megalodon`: TypeScript library used for partial Mastodon API compatibility

View File

@ -57,6 +57,7 @@
"gsap": "^3.12.5",
"idb-keyval": "6.2.1",
"insert-text-at-cursor": "0.3.0",
"i18n": "workspace:*",
"json5": "2.2.3",
"katex": "0.16.10",
"long": "^5.2.3",

View File

@ -1,36 +1,6 @@
import { markRaw } from "vue";
import { locale } from "@/config";
class I18n<T extends Record<string, any>> {
public ts: T;
constructor(locale: T) {
this.ts = locale;
// #region BIND
this.t = this.t.bind(this);
// #endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, string | number>): string {
try {
let str = key
.split(".")
.reduce((o, i) => o[i], this.ts) as unknown as string;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replaceAll(`{${k}}`, v.toString());
}
}
return str;
} catch (err) {
console.warn(`missing localization '${key}'`);
return key;
}
}
}
import { I18n } from "i18n";
export const i18n = markRaw(new I18n(locale));

24
packages/i18n/.swcrc Normal file
View File

@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "typescript",
"dynamicImport": true,
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"experimental": {
"emitAssertForImportAttributes": true
},
"target": "es2022"
},
"minify": false,
"module": {
"type": "es6",
"strict": true,
"resolveFully": true
}
}

21
packages/i18n/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Firefish contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
packages/i18n/README.md Normal file
View File

@ -0,0 +1,3 @@
# i18n
## Typed i18n for firefish

View File

@ -0,0 +1,41 @@
{
"name": "i18n",
"version": "0.0.1",
"description": "Firefish i18n for TypeScript",
"homepage": "https://firefish.dev/firefish/firefish/-/tree/develop/packages/i18n",
"main": "./built/index.js",
"types": "./src/index.ts",
"type": "module",
"license": "MIT",
"scripts": {
"build:types": "node scripts/build.js",
"build": "pnpm run build:types && pnpm swc src --out-dir built --source-maps false --copy-files --strip-leading-paths",
"build:debug": "pnpm run build:types && pnpm swc src --out-dir built --source-maps true --copy-files --strip-leading-paths",
"watch": "pnpm swc src --out-dir built --source-maps true --copy-files --strip-leading-paths --watch",
"lint": "pnpm biome check --apply src",
"format": "pnpm biome format --write src",
"jest": "echo NOT_IMPLEMENTED",
"test": "pnpm jest && pnpm tsd"
},
"repository": {
"type": "git",
"url": "https://firefish.dev/firefish/firefish.git"
},
"devDependencies": {
"@swc/cli": "0.3.12",
"@swc/core": "1.5.0",
"@swc/types": "^0.1.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "20.12.7",
"typescript": "5.4.5"
},
"files": [
"built", "src"
],
"dependencies": {
"js-yaml": "4.1.0"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11"
}
}

View File

@ -0,0 +1,79 @@
/**
* Build I18n types from en-US.yml
*/
import yaml from "js-yaml";
import fs from "node:fs";
const locale = yaml.load(fs.readFileSync("../../locales/en-US.yml"));
function safeStr(str) {
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(str) ? str : `"${str}"`;
}
/**
*
* @param {*} obj
* @param {string} prefix
* @returns {string[]}
*/
function generateKeys(obj, prefix = "") {
const keys = [];
for (const [key, o] of Object.entries(obj)) {
const keyStr = safeStr(`${prefix}${key}`);
if (typeof o === 'string') {
const vars = o.match(/\{[A-Za-z0-9_]+\}/g);
if (vars == null || vars.length === 0) {
keys.push(`
/** ${o.replaceAll("\n", " \n * ") } */
${keyStr}: {
param: Record<string, never>;
result: ${JSON.stringify(o)};
}`);
} else {
keys.push(`
/** ${o.replaceAll("\n", " \n * ") } */
${keyStr}: {
param: {
${
[...new Set(vars)].map(v => `${v.slice(1, -1)}: any;`).join("\n\t\t")
}
};
result: ${JSON.stringify(o)};
}`);
}
} else if (typeof o === 'object') {
keys.push(generateKeys(o, `${prefix}${key}.`));
}
}
return keys;
}
function generateValues(obj) {
const keys = [];
for (const [key, o] of Object.entries(obj)) {
const keyStr = safeStr(key);
if (typeof o === 'string') {
// const vars = o.match(/\{[A-Za-z0-9_]+\}/g);
keys.push(`\n/** ${o.replaceAll("\n", " \n * ") } */\n${keyStr}: ${JSON.stringify(o)}`);
} else if (typeof o === 'object') {
keys.push(`\n${keyStr}: {\n\t${
generateValues(o).replaceAll("\n", "\n\t")
}\n}`);
}
}
return keys.join("\n");
}
const typestr = `
export interface I18nKeys {
${generateKeys(locale).flat(100).join("\n").replaceAll("\n", "\n\t")}
}
export interface I18nValues {
${generateValues(locale).replaceAll("\n", "\n\t")}
}`;
fs.mkdirSync("built", { recursive: true });
fs.writeFileSync("built/types.d.ts", typestr);

View File

@ -0,0 +1,63 @@
import type { I18nKeys, I18nValues } from "../built/types.d.ts";
const undefinedProxy: object = new Proxy(
{},
{
get() {
return undefinedProxy;
},
},
);
// biome-ignore lint/suspicious/noExplicitAny: used intentially
type ExplicitAny = any;
const makeProxy: <T extends object>(_any: T) => T = (target: ExplicitAny) =>
new Proxy(target, {
get(target, prop) {
if (target[prop] == null) {
return undefinedProxy;
}
if (typeof target[prop] === "object") {
return makeProxy(target[prop]);
}
return target[prop];
},
});
export class I18n {
public ts: I18nValues;
private _ts: I18nValues;
constructor(locale: I18nValues) {
this.ts = makeProxy(locale);
this._ts = locale;
// #region BIND
this.t = this.t.bind(this);
// #endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t<K extends keyof I18nKeys>(
key: K,
args?: I18nKeys[K]["param"],
): I18nKeys[K]["result"] {
try {
let str = key
.split(".")
.reduce((o, i) => o[i as never], this._ts) as unknown as string;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replaceAll(`{${k}}`, v.toString());
}
}
return str as never;
} catch (err) {
console.warn(`missing localization '${key}'`);
return key as never;
}
}
}

View File

@ -0,0 +1,20 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "es2021",
"module": "es2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./built/",
"removeComments": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "test/**/*"]
}

View File

@ -659,6 +659,9 @@ importers:
gsap:
specifier: ^3.12.5
version: 3.12.5
i18n:
specifier: workspace:*
version: link:../i18n
idb-keyval:
specifier: 6.2.1
version: 6.2.1
@ -824,6 +827,35 @@ importers:
specifier: 5.4.5
version: 5.4.5
packages/i18n:
dependencies:
js-yaml:
specifier: 4.1.0
version: 4.1.0
optionalDependencies:
'@swc/core-android-arm64':
specifier: 1.3.11
version: 1.3.11
devDependencies:
'@swc/cli':
specifier: 0.3.12
version: 0.3.12(@swc/core@1.5.0)
'@swc/core':
specifier: 1.5.0
version: 1.5.0
'@swc/types':
specifier: ^0.1.6
version: 0.1.6
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: 20.12.7
version: 20.12.7
typescript:
specifier: 5.4.5
version: 5.4.5
packages/megalodon:
dependencies:
'@types/oauth':
@ -2759,6 +2791,49 @@ packages:
slash: 3.0.0
dev: true
/@jest/core@29.7.0:
resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
peerDependencies:
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
peerDependenciesMeta:
node-notifier:
optional: true
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.12.7
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.5
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
dev: true
/@jest/core@29.7.0(ts-node@10.9.2):
resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2930,9 +3005,9 @@ packages:
resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@babel/core': 7.23.2
'@babel/core': 7.24.4
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.20
'@jridgewell/trace-mapping': 0.3.25
babel-plugin-istanbul: 6.1.1
chalk: 4.1.2
convert-source-map: 2.0.0
@ -3678,7 +3753,7 @@ packages:
'@swc/counter': 0.1.3
commander: 8.3.0
fast-glob: 3.3.2
minimatch: 9.0.3
minimatch: 9.0.4
piscina: 4.4.0
semver: 7.6.0
slash: 3.0.0
@ -4114,6 +4189,10 @@ packages:
pretty-format: 29.7.0
dev: true
/@types/js-yaml@4.0.9:
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
dev: true
/@types/json-schema@7.0.12:
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}
dev: true
@ -5158,7 +5237,6 @@ packages:
resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
/adm-zip@0.5.10:
resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==}
@ -9831,7 +9909,7 @@ packages:
node-notifier:
optional: true
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2)
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
@ -10276,7 +10354,7 @@ packages:
node-notifier:
optional: true
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2)
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.1.0
jest-cli: 29.7.0(@types/node@18.11.18)
@ -13921,7 +13999,7 @@ packages:
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 20.12.7
acorn: 8.10.0
acorn: 8.11.3
acorn-walk: 8.2.0
arg: 4.1.3
create-require: 1.1.1

View File

@ -5,3 +5,4 @@ packages:
- 'packages/sw'
- 'packages/firefish-js'
- 'packages/megalodon'
- 'packages/i18n'

View File

@ -12,6 +12,7 @@ import { execa } from "execa";
"--parallel",
"--filter=backend-rs",
"--filter=firefish-js",
"--filter=i18n",
"run",
"build",
], {
@ -26,6 +27,7 @@ import { execa } from "execa";
"--parallel",
"--filter=!backend-rs",
"--filter=!firefish-js",
"--filter=!i18n",
"run",
"build",
], {

View File

@ -25,6 +25,10 @@ import { fileURLToPath } from "node:url";
recursive: true,
force: true,
});
fs.rmSync(join(__dirname, "/../packages/i18n/built"), {
recursive: true,
force: true,
});
fs.rmSync(join(__dirname, "/../packages/megalodon/lib"), {
recursive: true,
force: true,

View File

@ -26,6 +26,14 @@ import { execa } from "execa";
recursive: true,
force: true,
});
fs.rmSync(join(__dirname, "/../packages/i18n/node_modules"), {
recursive: true,
force: true,
});
fs.rmSync(join(__dirname, "/../packages/firefish-js/node_modules"), {
recursive: true,
force: true,
});
fs.rmSync(join(__dirname, "/../packages/megalodon/node_modules"), {
recursive: true,
force: true,

View File

@ -12,6 +12,7 @@ import fs from "node:fs";
"--parallel",
"--filter=backend-rs",
"--filter=firefish-js",
"--filter=i18n",
"run",
"build:debug",
], {
@ -26,6 +27,7 @@ import fs from "node:fs";
"--parallel",
"--filter=!backend-rs",
"--filter=!firefish-js",
"--filter=!i18n",
"run",
"build:debug",
], {