padloc/packages/locale/src/extract.ts

247 lines
7.3 KiB
TypeScript

import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
import { resolve } from "path";
import { execSync } from "child_process";
import * as ts from "typescript";
import * as YAML from "yaml";
function parseExpression(expr: ts.Expression): string {
switch (expr.kind) {
case ts.SyntaxKind.StringLiteral:
return (expr as ts.StringLiteral).text;
case ts.SyntaxKind.BinaryExpression:
const bin = expr as ts.BinaryExpression;
return parseExpression(bin.left) + parseExpression(bin.right);
default:
return "";
}
}
export interface ItemSource {
file: string;
line: number;
character: number;
comment?: string;
}
export interface TranslationItem {
original: string;
translation: string;
sources: ItemSource[];
}
export interface Translation {
language: string;
commit: string;
date: Date;
items: TranslationItem[];
}
function extract(files: ts.SourceFile[]) {
const items = new Map<string, TranslationItem>();
function traverseFile(file: ts.SourceFile) {
function traverseNode(node: ts.Node) {
if (node.kind === ts.SyntaxKind.CallExpression) {
const call = node as ts.CallExpression;
if ((call.expression as ts.Identifier).escapedText === "$l") {
const fileText = file.getText();
const { line, character } = file.getLineAndCharacterOfPosition(call.getFullStart());
const keyExpr = call.arguments[0];
const original = parseExpression(keyExpr);
if (original) {
if (!items.has(original)) {
items.set(original, {
original,
translation: "",
sources: [],
});
}
const item = items.get(original)!;
const source: ItemSource = {
file: file.fileName,
line,
character,
};
const comments =
ts.getLeadingCommentRanges(fileText, keyExpr.getFullStart()) ||
ts.getTrailingCommentRanges(fileText, keyExpr.getFullStart());
if (comments && comments.length) {
const rawComment = fileText.substring(comments[0].pos, comments[0].end);
const matchComment = rawComment.match(/@tcomment: ([^\*\n]+)/);
if (matchComment && matchComment[1]) {
source.comment = matchComment[1].trim();
}
}
item.sources.push(source);
return;
}
}
}
ts.forEachChild(node, traverseNode);
}
traverseNode(file);
}
files.forEach(traverseFile);
return [...items.values()];
}
export function toYAML({ language, date, commit, items }: Translation) {
const doc = new YAML.Document();
doc.commentBefore = `
Padloc Translation File
language: ${language}
date: ${date.toISOString()}
commit: ${commit}
`;
doc.contents = YAML.createNode(items.flatMap((item) => [item.original, item.translation])) as any;
for (const [i, item] of items.entries()) {
const node = (doc.contents as any).items[i * 2];
node.commentBefore = item.sources
.map(
({ file, line, character, comment }) => ` ${file}:${line},${character}${comment ? ` (${comment})` : ""}`
)
.join("\n");
(node as any).spaceBefore = true;
}
return doc.toString();
}
export function fromYAML(str: string, language: string): Translation {
const raw = YAML.parse(str) as string[];
const items: TranslationItem[] = [];
for (let i = 0; i < raw.length; i += 2) {
items.push({ original: raw[i], translation: raw[i + 1], sources: [] });
}
return {
language,
date: new Date(),
commit: "",
items,
};
}
export function toModule(translation: Translation) {
return `\
import { parse } from "yaml";
export default parse(\`
${toYAML(translation)}
\`);
`;
}
export function toJSON(translation: Translation) {
return JSON.stringify(
translation.items.map(({ original, translation }) => [original, translation]),
null,
2
);
}
export function fromJSON(str: string, language: string) {
const items = JSON.parse(str).map(([original, translation]: [string, string]) => ({
original,
translation,
sources: [],
}));
return {
language,
date: new Date(),
commit: "",
items,
};
}
export function fromSource(fileNames: string[], language: string): Translation {
const files = fileNames.map((path) =>
ts.createSourceFile(path, readFileSync(resolve(path), "utf8"), ts.ScriptTarget.ES2017, false)
);
const commit = execSync("git rev-parse HEAD").toString().trim();
const items = extract(files);
return {
language,
commit,
date: new Date(),
items,
};
}
export function merge(curr: Translation, prev: Translation) {
for (const item of curr.items) {
const prevItemIndex = prev.items.findIndex((i) => i.original === item.original);
if (prevItemIndex !== -1) {
item.translation = prev.items[prevItemIndex].translation;
prev.items.splice(prevItemIndex, 1);
}
}
}
export function updateTranslation(sources: string[], language: string, dest: string) {
const destPath = resolve(dest, language + ".json");
const backupPath = resolve(dest, language + ".backup.json");
const translation = fromSource(sources, language);
if (existsSync(destPath)) {
const previous = fromJSON(readFileSync(destPath, "utf-8"), language);
merge(translation, previous);
writeFileSync(backupPath, toJSON(previous));
}
writeFileSync(destPath, toJSON(translation));
}
function main() {
const translationsDir = resolve(__dirname, "../res/translations/");
const [, , ...args] = process.argv;
const languages: string[] = [];
const files: string[] = [];
while (args.length) {
// Languages can be specified explictly using the "-l" flag
if (args[0] === "-l") {
if (!args[1]) {
throw "No language provided after -l flag";
}
languages.push(args[1]);
args.splice(0, 2);
} else {
files.push(args.shift() as string);
}
}
// If not languagese were specified explicitly, update all existing language files
if (!languages.length) {
for (const file of readdirSync(translationsDir)) {
const match = file.match(/^([^\.]+)\.json$/);
if (match) {
languages.push(match[1]);
}
}
}
for (const lang of languages) {
console.log("updating translation keys for language: " + lang);
updateTranslation(files, lang, translationsDir);
}
}
main();