feat: extract i18n into a package

This commit is contained in:
JimmFly
2023-01-09 14:55:38 +08:00
parent 20e2984c04
commit 047537101c
22 changed files with 172 additions and 170 deletions

View File

@@ -0,0 +1,62 @@
import i18next, { Resource } from 'i18next';
import { initReactI18next, useTranslation } from 'react-i18next';
import { LOCALES } from './resources/index.js';
import type en_US from './resources/en.json';
// const localStorage = {
// getItem() {
// return undefined;
// },
// setItem() {},
// };
// See https://react.i18next.com/latest/typescript
declare module 'react-i18next' {
interface CustomTypeOptions {
// custom namespace type if you changed it
// defaultNS: 'ns1';
// custom resources type
resources: {
en: typeof en_US;
};
}
}
// const STORAGE_KEY = 'i18n_lng';
export { i18n, useTranslation, LOCALES };
const resources = LOCALES.reduce<Resource>(
(acc, { tag, res }) => ({ ...acc, [tag]: { translation: res } }),
{}
);
const fallbackLng = LOCALES[0].tag;
const standardizeLocale = (language: string) => {
if (LOCALES.find(locale => locale.tag === language)) return language;
if (LOCALES.find(locale => locale.tag === language.slice(0, 2).toLowerCase()))
return language;
return fallbackLng;
};
const language = standardizeLocale(
// localStorage.getItem(STORAGE_KEY) ??
// (typeof navigator !== 'undefined' ? navigator.language : 'en')
'en'
);
const i18n = i18next.createInstance();
i18n.use(initReactI18next).init({
lng: language,
fallbackLng,
debug: false,
resources,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
i18n.on('languageChanged', () => {
// localStorage.setItem(STORAGE_KEY, lng);
});
// const I18nProvider = I18nextProvider;

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,79 @@
{
"Quick search": "Quick search",
"All pages": "All pages",
"Favourites": "Favourites",
"No item": "No item",
"Import": "Import",
"Trash": "Trash",
"New Page": "New Page",
"New Keyword Page": "New '{{query}}' page",
"Find 0 result": "Find 0 result",
"Find results": "Find {{number}} results",
"Collapse sidebar": "Collapse sidebar",
"Expand sidebar": "Expand sidebar",
"Removed from Favourites": "Removed from Favourites",
"Remove from favourites": "Remove from favourites",
"Added to Favourites": "Added to Favourites",
"Add to favourites": "Add to favourites",
"Paper": "Paper",
"Edgeless": "Edgeless",
"Switch to": "Switch to",
"Convert to ": "Convert to ",
"Page": "Page",
"Export": "Export",
"Export to HTML": "Export to HTML",
"Export to Markdown": "Export to Markdown",
"Delete": "Delete",
"Title": "Title",
"Untitled": "Untitled",
"Created": "Created",
"Updated": "Updated",
"Open in new tab": "Open in new tab",
"Favourite": "Favourite",
"Favourited": "Favourited",
"Delete page?": "Delete page?",
"Delete permanently?": "Delete permanently?",
"will be moved to Trash": "{{title}} will be moved to Trash",
"Once deleted, you can't undo this action.": "Once deleted, you can't undo this action.",
"Moved to Trash": "Moved to Trash",
"Permanently deleted": "Permanently deleted",
"restored": "{{title}} restored",
"Cancel": "Cancel",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Contact Us": "Contact Us",
"Official Website": "Official Website",
"Get in touch!": "Get in touch!",
"AFFiNE Community": "AFFiNE Community",
"How is AFFiNE Alpha different?": "How is AFFiNE Alpha different?",
"Shortcuts": "Shortcuts",
"Undo": "Undo",
"Redo": "Redo",
"Bold": "Bold",
"Italic": "Italic",
"Underline": "Underline",
"Strikethrough": "Strikethrough",
"Inline code": "Inline code",
"Code block": "Code block",
"Link": "Link",
"Body text": "Body text",
"Heading": "Heading {{number}}",
"Increase indent": "Increase indent",
"Reduce indent": "Reduce indent",
"Markdown Syntax": "Markdown Syntax",
"Divider": "Divider",
"404 - Page Not Found": "404 - Page Not Found",
"New Workspace": "New Workspace",
"Workspace description": "Workspace is your virtual space to capture, create and plan as just one person or together as a team.",
"Create": "Create",
"Select": "Select",
"Text": "Text (coming soon)",
"Shape": "Shape",
"Sticky": "Sticky (coming soon)",
"Pen": "Pen (coming soon)",
"Connector": "Connector (coming soon)",
"Upload": "Upload",
"Restore it": "Restore it",
"TrashButtonGroupTitle": "Permanently delete",
"TrashButtonGroupDescription": "Once deleted, you can't undo this action. Do you confirm?",
"Delete permanently": "Delete permanently"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,72 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
// Run `pnpm run download-resources` to regenerate.
// To overwrite this, please overwrite download.ts
import en from './en.json';
import zh_Hans from './zh-Hans.json';
import zh_Hant from './zh-Hant.json';
import sr from './sr.json';
import fr from './fr.json';
import bn from './bn.json';
export const LOCALES = [
{
id: 1000016008,
name: 'English',
tag: 'en',
originalName: 'English',
flagEmoji: '🇬🇧',
base: true,
completeRate: 1,
res: en,
},
{
id: 1000016009,
name: 'Simplified Chinese',
tag: 'zh-Hans',
originalName: '简体中文',
flagEmoji: '🇨🇳',
base: false,
completeRate: 1,
res: zh_Hans,
},
{
id: 1000016012,
name: 'Traditional Chinese',
tag: 'zh-Hant',
originalName: '繁體中文',
flagEmoji: '🇭🇰',
base: false,
completeRate: 1,
res: zh_Hant,
},
{
id: 1000034005,
name: 'Serbian',
tag: 'sr',
originalName: 'српски',
flagEmoji: '🇷🇸',
base: false,
completeRate: 0.9166666666666666,
res: sr,
},
{
id: 1000034008,
name: 'French',
tag: 'fr',
originalName: 'français',
flagEmoji: '🇫🇷',
base: false,
completeRate: 1,
res: fr,
},
{
id: 1000034010,
name: 'Bangla',
tag: 'bn',
originalName: 'বাংলা',
flagEmoji: '🇧🇩',
base: false,
completeRate: 0.7083333333333334,
res: bn,
},
] as const;

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,65 @@
{
"Quick search": "快速搜索",
"All pages": "全部页面",
"Favourites": "收藏夹",
"No item": "没有项目",
"Import": "导入",
"Trash": "回收站",
"New Page": "新建文章",
"New Keyword Page": "新建 '{{query}}' 为标题的文章",
"Find 0 result": "找到 0 个结果",
"Find results": "找到 {{number}} 个结果",
"Collapse sidebar": "关闭侧边栏",
"Expand sidebar": "展开侧边栏",
"Removed from Favourites": "已从收藏中移除",
"Remove from favourites": "从收藏中移除",
"Added to Favourites": "已添加到收藏",
"Add to favourites": "添加到收藏",
"Paper": "文章",
"Edgeless": "无边模式",
"Switch to": "跳转到",
"Convert to ": "转换成 ",
"Page": "文章",
"Export": "导出",
"Export to HTML": "导出到 HTML",
"Export to Markdown": "导出到 Markdown",
"Delete": "删除",
"Title": "标题",
"Untitled": "无标题",
"Created": "创建时间",
"Updated": "更新时间",
"Open in new tab": "在新页面打开",
"Favourite": "收藏",
"Favourited": "已收藏",
"Delete page?": "删除文章?",
"Delete permanently?": "永久删除?",
"will be moved to Trash": "{{title}} 将被移动到回收站",
"Once deleted, you can't undo this action.": "一次性删除,无法恢复。",
"Moved to Trash": "已移动到回收站",
"Permanently deleted": "已永久删除",
"restored": "{{title}} 已恢复",
"Cancel": "取消",
"Keyboard Shortcuts": "快捷键",
"Contact Us": "联系我们",
"Official Website": "官网",
"Get in touch!": "Get in touch!",
"AFFiNE Community": "AFFiNE Community",
"How is AFFiNE Alpha different?": "How is AFFiNE Alpha different?",
"Shortcuts": "Shortcuts",
"Undo": "Undo",
"Redo": "Redo",
"Bold": "Bold",
"Italic": "Italic",
"Underline": "Underline",
"Strikethrough": "Strikethrough",
"Inline code": "Inline code",
"Code block": "Code block",
"Link": "Link",
"Body text": "Body text",
"Heading": "Heading {{number}}",
"Increase indent": "Increase indent",
"Reduce indent": "Reduce indent",
"Markdown Syntax": "Markdown Syntax",
"Divider": "Divider",
"404 - Page Not Found": "404 - Page Not Found"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,185 @@
// cSpell:ignore Tolgee
import { fetchTolgee } from './request';
/**
* Returns all project languages
*
* See https://tolgee.io/api#operation/getAll_6
* @example
* ```ts
* const languages = [
* {
* id: 1000016008,
* name: 'English',
* tag: 'en',
* originalName: 'English',
* flagEmoji: '🇬🇧',
* base: true
* },
* {
* id: 1000016013,
* name: 'Spanish',
* tag: 'es',
* originalName: 'español',
* flagEmoji: '🇪🇸',
* base: false
* },
* {
* id: 1000016009,
* name: 'Simplified Chinese',
* tag: 'zh-Hans',
* originalName: '简体中文',
* flagEmoji: '🇨🇳',
* base: false
* },
* {
* id: 1000016012,
* name: 'Traditional Chinese',
* tag: 'zh-Hant',
* originalName: '繁體中文',
* flagEmoji: '🇭🇰',
* base: false
* }
* ]
* ```
*/
export const getAllProjectLanguages = async (size = 1000) => {
const url = `/languages?size=${size}`;
const resp = await fetchTolgee(url);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
const json: {
_embedded: {
languages: {
id: number;
name: string;
tag: string;
originalName: string;
flagEmoji: string;
base: boolean;
}[];
};
page: unknown;
} = await resp.json();
return json._embedded.languages;
};
/**
* Returns translations in project
*
* See https://tolgee.io/api#operation/getTranslations_
*/
export const getTranslations = async () => {
const url = '/translations';
const resp = await fetchTolgee(url);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
const json = await resp.json();
return json;
};
/**
* Returns all translations for specified languages
*
* See https://tolgee.io/api#operation/getAllTranslations_1
*/
export const getLanguagesTranslations = async <T extends string>(
languages: T
) => {
const url = `/translations/${languages}`;
const resp = await fetchTolgee(url);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
const json: { [key in T]?: Record<string, string> } = await resp.json();
return json;
};
export const getRemoteTranslations = async (languages: string) => {
const translations = await getLanguagesTranslations(languages);
if (!(languages in translations)) {
return {};
}
// The assert is safe because we checked above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return translations[languages]!;
};
/**
* Creates new key
*
* See https://tolgee.io/api#operation/create_2
*/
export const createsNewKey = async (
key: string,
translations: Record<string, string>
) => {
const url = '/translations/keys/create';
const resp = await fetchTolgee(url, {
method: 'POST',
body: JSON.stringify({ name: key, translations }),
});
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
const json = await resp.json();
return json;
};
/**
* Tags a key with tag. If tag with provided name doesn't exist, it is created
*
* See https://tolgee.io/api#operation/tagKey_1
*/
export const addTag = async (keyId: string, tagName: string) => {
const url = `/keys/${keyId}/tags`;
const resp = await fetchTolgee(url, {
method: 'PUT',
body: JSON.stringify({ name: tagName }),
});
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
const json = await resp.json();
return json;
};
/**
* Tags a key with tag. If tag with provided name doesn't exist, it is created
*
* See https://tolgee.io/api#operation/tagKey_1
*/
export const removeTag = async (keyId: string, tagId: number) => {
const url = `/keys/${keyId}/tags/${tagId}`;
const resp = await fetchTolgee(url, {
method: 'DELETE',
});
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
const json = await resp.json();
return json;
};
// export const addTagByKey = async (key: string, tag: string) => {
// // TODO get key id by key name
// // const keyId =
// // addTag(keyId, tag);
// };
/**
* Exports data
*
* See https://tolgee.io/api#operation/export_1
*/
export const exportResources = async () => {
const url = `/export`;
const resp = await fetchTolgee(url);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
return resp;
};

View File

@@ -0,0 +1,132 @@
// cSpell:ignore Tolgee
import fs from 'node:fs/promises';
import path from 'node:path';
import { format } from 'prettier';
import { getAllProjectLanguages, getRemoteTranslations } from './api';
import type { TranslationRes } from './utils';
const RES_DIR = path.resolve(process.cwd(), 'src', 'resources');
const countKeys = (obj: TranslationRes) => {
let count = 0;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Object.entries(obj).forEach(([_, value]) => {
if (typeof value === 'string') {
count++;
} else {
count += countKeys(value);
}
});
return count;
};
const getBaseTranslations = async (baseLanguage: { tag: string }) => {
try {
const baseTranslationsStr = await fs.readFile(
path.resolve(RES_DIR, `${baseLanguage.tag}.json`),
{ encoding: 'utf8' }
);
const baseTranslations = JSON.parse(baseTranslationsStr);
return baseTranslations;
} catch (e) {
console.error('base language:', JSON.stringify(baseLanguage));
console.error('Failed to read base language', e);
const translations = await getRemoteTranslations(baseLanguage.tag);
await fs.writeFile(
path.resolve(RES_DIR, `${baseLanguage.tag}.json`),
JSON.stringify(translations, null, 4)
);
}
};
const main = async () => {
console.log('Loading project languages...');
const languages = await getAllProjectLanguages();
const baseLanguage = languages.find(language => language.base);
if (!baseLanguage) {
console.error(JSON.stringify(languages));
throw new Error('Could not find base language');
}
console.log(`Loading ${baseLanguage.tag} languages translations as base...`);
const baseTranslations = await getBaseTranslations(baseLanguage);
const baseKeyNum = countKeys(baseTranslations);
const languagesWithTranslations = await Promise.all(
languages.map(async language => {
console.log(`Loading ${language.tag} translations...`);
const translations = await getRemoteTranslations(language.tag);
const keyNum = countKeys(translations);
return {
...language,
translations,
completeRate: keyNum / baseKeyNum,
};
})
);
const availableLanguages = languagesWithTranslations.filter(
language => language.completeRate > 0
);
availableLanguages
// skip base language
.filter(i => !i.base)
.forEach(async language => {
await fs.writeFile(
path.resolve(RES_DIR, `${language.tag}.json`),
JSON.stringify(
{
'// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.':
'',
...language.translations,
},
null,
4
) + '\n'
);
});
console.log('Generating meta data...');
const code = `// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
// Run \`pnpm run download-resources\` to regenerate.
// To overwrite this, please overwrite ${path.basename(__filename)}
${availableLanguages
.map(
language =>
`import ${language.tag.replaceAll('-', '_')} from './${
language.tag
}.json'`
)
.join('\n')}
export const LOCALES = [
${availableLanguages
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ translations, ...language }) =>
JSON.stringify({
...language,
res: '__RES_PLACEHOLDER',
}).replace(
'"__RES_PLACEHOLDER"',
language.tag.replaceAll('-', '_')
)
)
.join(',\n')}
] as const;
`;
await fs.writeFile(
path.resolve(RES_DIR, 'index.ts'),
format(code, {
parser: 'typescript',
singleQuote: true,
trailingComma: 'es5',
tabWidth: 4,
arrowParens: 'avoid',
})
);
console.log('Done');
};
main();

View File

@@ -0,0 +1,55 @@
// cSpell:ignore Tolgee
const TOLGEE_API_KEY = process.env['TOLGEE_API_KEY'];
const TOLGEE_API_URL = 'https://i18n.affine.pro';
if (!TOLGEE_API_KEY) {
throw new Error(`Please set "TOLGEE_API_KEY" as environment variable!`);
}
const withTolgee = (
fetch: typeof globalThis.fetch
): typeof globalThis.fetch => {
const baseUrl = `${TOLGEE_API_URL}/v2/projects`;
const headers = new Headers({
'X-API-Key': TOLGEE_API_KEY,
'Content-Type': 'application/json',
});
const isRequest = (input: RequestInfo | URL): input is Request => {
return typeof input === 'object' && !('href' in input);
};
return new Proxy(fetch, {
apply(
target,
thisArg: unknown,
argArray: Parameters<typeof globalThis.fetch>
) {
if (isRequest(argArray[0])) {
// Request
if (!argArray[0].headers) {
argArray[0] = {
...argArray[0],
url: `${baseUrl}${argArray[0].url}`,
headers,
};
}
} else {
// URL or URLLike + ?RequestInit
if (typeof argArray[0] === 'string') {
argArray[0] = `${baseUrl}${argArray[0]}`;
}
if (!argArray[1]) {
argArray[1] = {};
}
if (!argArray[1].headers) {
argArray[1].headers = headers;
}
}
// console.log('fetch', argArray);
return target.apply(thisArg, argArray);
},
});
};
export const fetchTolgee = withTolgee(globalThis.fetch);

View File

@@ -0,0 +1,154 @@
// cSpell:ignore Tolgee
import { readFile } from 'fs/promises';
import path from 'path';
import { createsNewKey, getRemoteTranslations } from './api';
import type { TranslationRes } from './utils';
const BASE_JSON_PATH = path.resolve(
process.cwd(),
'src',
'resources',
'en.json'
);
const BASE_LANGUAGES = 'en' as const;
/**
*
* @example
* ```ts
* flatRes({ a: { b: 'c' } }); // { 'a.b': 'c' }
* ```
*/
const flatRes = (obj: TranslationRes) => {
const getEntries = (o: TranslationRes, prefix = ''): [string, string][] =>
Object.entries(o).flatMap<[string, string]>(([k, v]) =>
typeof v !== 'string'
? getEntries(v, `${prefix}${k}.`)
: [[`${prefix}${k}`, v]]
);
return Object.fromEntries(getEntries(obj));
};
const differenceObject = (
newObj: Record<string, string>,
oldObj: Record<string, string>
) => {
const add: string[] = [];
const remove: string[] = [];
const modify: string[] = [];
const both: string[] = [];
Object.keys(newObj).forEach(key => {
if (!(key in oldObj)) {
add.push(key);
} else {
both.push(key);
}
});
Object.keys(oldObj).forEach(key => {
if (!(key in newObj)) {
remove.push(key);
}
});
both.forEach(key => {
if (!(key in newObj) || !(key in oldObj)) {
throw new Error('Unreachable');
}
const newVal = newObj[key];
const oldVal = oldObj[key];
if (newVal !== oldVal) {
modify.push(key);
}
});
return { add, remove, modify };
};
function warnDiff(diff: { add: string[]; remove: string[]; modify: string[] }) {
if (diff.add.length) {
console.log('New keys found:', diff.add.join(', '));
//See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-notice-message
process.env['CI'] &&
console.log(
`::notice file=${BASE_JSON_PATH},line=1,title=New keys::${diff.add.join(
', '
)}`
);
}
if (diff.remove.length) {
console.warn('[WARN]', 'Unused keys found:', diff.remove.join(', '));
process.env['CI'] &&
console.warn(
`::notice file=${BASE_JSON_PATH},line=1,title=Unused keys::${diff.remove.join(
', '
)}`
);
}
if (diff.modify.length) {
console.warn('[WARN]', 'Inconsistent keys found:', diff.modify.join(', '));
process.env['CI'] &&
console.warn(
`::warning file=${BASE_JSON_PATH},line=1,title=Inconsistent keys::${diff.modify.join(
', '
)}`
);
}
}
const main = async () => {
console.log('Loading local base translations...');
const baseLocalTranslations = JSON.parse(
await readFile(BASE_JSON_PATH, {
encoding: 'utf8',
})
);
const flatLocalTranslations = flatRes(baseLocalTranslations);
console.log(
`Loading local base translations success! Total ${
Object.keys(flatLocalTranslations).length
} keys`
);
console.log('Fetch remote base translations...');
const baseRemoteTranslations = await getRemoteTranslations(BASE_LANGUAGES);
const flatRemoteTranslations = flatRes(baseRemoteTranslations);
console.log(
`Fetch remote base translations success! Total ${
Object.keys(flatRemoteTranslations).length
} keys`
);
const diff = differenceObject(flatLocalTranslations, flatRemoteTranslations);
console.log(''); // new line
warnDiff(diff);
console.log(''); // new line
if (process.argv.slice(2).includes('--check')) {
// check mode
return;
}
diff.add.forEach(async key => {
const val = flatLocalTranslations[key];
console.log(`Creating new key: ${key} -> ${val}`);
await createsNewKey(key, { [BASE_LANGUAGES]: val });
});
// TODO remove unused tags from used keys
// diff.remove.forEach(key => {
// // TODO set unused tag
// // console.log(`Add ${DEPRECATED_TAG_NAME} to ${key}`);
// addTagByKey(key, DEPRECATED_TAG_NAME);
// });
// diff.modify.forEach(key => {
// // TODO warn different between local and remote base translations
// });
// TODO send notification
};
main();

View File

@@ -0,0 +1,3 @@
export interface TranslationRes {
[x: string]: string | TranslationRes;
}