/*
This file is part of GNU Taler
(C) 2019-2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
*/
/**
* Imports.
*/
import * as ts from "typescript";
import * as fs from "fs";
import * as path from "path";
const DEFAULT_PO_HEADER = `# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR , YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\\n"
"Report-Msgid-Bugs-To: \\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
"Last-Translator: FULL NAME \\n"
"Language-Team: LANGUAGE \\n"
"Language: \\n"
"MIME-Version: 1.0\\n"
"Content-Type: text/plain; charset=UTF-8\\n"
"Content-Transfer-Encoding: 8bit\\n"\n\n`;
function wordwrap(str: string, width: number = 80): string[] {
var regex = ".{1," + width + "}(\\s|$)|\\S+(\\s|$)";
return str.match(RegExp(regex, "g"));
}
function getTemplate(node: ts.Node): string {
switch (node.kind) {
case ts.SyntaxKind.FirstTemplateToken:
return (node).text;
case ts.SyntaxKind.TemplateExpression:
let te = node;
let textFragments = [te.head.text];
for (let tsp of te.templateSpans) {
textFragments.push(`%${(textFragments.length - 1) / 2 + 1}$s`);
textFragments.push(tsp.literal.text.replace(/%/g, "%%"));
}
return textFragments.join("");
default:
return "(pogen.ts: unable to parse)";
}
}
function getComment(
sourceFile: ts.SourceFile,
preLastTokLine: number,
lastTokLine: number,
node: ts.Node,
): string {
let lc = ts.getLineAndCharacterOfPosition(sourceFile, node.pos);
let lastComments: ts.CommentRange[];
for (let l = preLastTokLine; l < lastTokLine; l++) {
let pos = ts.getPositionOfLineAndCharacter(sourceFile, l, 0);
let comments = ts.getTrailingCommentRanges(sourceFile.text, pos);
if (comments) {
lastComments = comments;
}
}
if (!lastComments) {
return;
}
let candidate = lastComments[lastComments.length - 1];
let candidateEndLine = ts.getLineAndCharacterOfPosition(
sourceFile,
candidate.end,
).line;
if (candidateEndLine != lc.line - 1) {
return;
}
let text = sourceFile.text.slice(candidate.pos, candidate.end);
switch (candidate.kind) {
case ts.SyntaxKind.SingleLineCommentTrivia:
// Remove comment leader
text = text.replace(/^[/][/]\s*/, "");
break;
case ts.SyntaxKind.MultiLineCommentTrivia:
// Remove comment leader and trailer,
// handling white space just like xgettext.
text = text
.replace(/^[/][*](\s*?\n|\s*)?/, "")
.replace(/(\n[ \t]*?)?[*][/]$/, "");
break;
}
return text;
}
function getPath(node: ts.Node): { path: string[]; ctx: string } {
switch (node.kind) {
case ts.SyntaxKind.PropertyAccessExpression: {
let pae = node;
return {
path: Array.prototype.concat(getPath(pae.expression).path, [
pae.name.text,
]),
ctx: "",
};
}
case ts.SyntaxKind.Identifier: {
let id = node;
return {
path: [id.text],
ctx: "",
};
}
case ts.SyntaxKind.CallExpression: {
const call = node;
const firstArg = call.arguments[0];
if (
call.arguments.length === 1 &&
firstArg.kind === ts.SyntaxKind.StringLiteral
) {
const str = firstArg;
return {
path: getPath(call.expression).path,
ctx: str.text,
};
}
}
default: {
// console.log("ASDASD", ts.SyntaxKind[node.kind], node);
}
}
return {
path: ["(other)"],
ctx: "",
};
}
function arrayEq(a1: T[], a2: T[]) {
if (a1.length != a2.length) {
return false;
}
for (let i = 0; i < a1.length; i++) {
if (a1[i] != a2[i]) {
return false;
}
}
return true;
}
interface TemplateResult {
comment: string;
path: string[];
template: string;
line: number;
context: string;
}
function processTaggedTemplateExpression(
sourceFile: ts.SourceFile,
preLastTokLine: number,
lastTokLine: number,
tte: ts.TaggedTemplateExpression,
): TemplateResult {
let path = getPath(tte.tag);
let res: TemplateResult = {
path: path.path,
line: lastTokLine,
comment: getComment(sourceFile, preLastTokLine, lastTokLine, tte),
template: getTemplate(tte.template),
context: path.ctx,
};
return res;
}
function formatScreenId(
sourceFile: ts.SourceFile,
outChunks: string[],
screenId: string,
) {
if (!screenId) {
console.error("missing screen id for file" + sourceFile.fileName);
} else {
outChunks.push(`#. screenid: ${screenId}\n`);
}
}
function formatMsgComment(
projectPrefix: string,
sourceFile: ts.SourceFile,
outChunks: string[],
line: number,
comment?: string,
) {
if (comment) {
for (let cl of comment.split("\n")) {
outChunks.push(`#. ${cl}\n`);
}
}
const fn = path.relative(projectPrefix, sourceFile.fileName);
outChunks.push(`#: ${fn}:${line + 1}\n`);
outChunks.push(`#, c-format\n`);
}
function formatMsgLine(outChunks: string[], head: string, msg: string) {
const m = msg.match(/(.*\n|.+$)/g);
if (!m) return;
// Do escaping, wrap break at newlines
// console.log("head", JSON.stringify(head));
// console.log("msg", JSON.stringify(msg));
let parts = m
.map((x) => x.replace(/\n/g, "\\n").replace(/"/g, '\\"'))
.map((p) => wordwrap(p))
.reduce((a, b) => a.concat(b));
if (parts.length == 1) {
outChunks.push(`${head} "${parts[0]}"\n`);
} else {
outChunks.push(`${head} ""\n`);
for (let p of parts) {
outChunks.push(`"${p}"\n`);
}
}
}
function getJsxElementPath(node: ts.Node) {
let path;
let process = (childNode) => {
switch (childNode.kind) {
case ts.SyntaxKind.JsxOpeningElement: {
let e = childNode as ts.JsxOpeningElement;
return (path = getPath(e.tagName).path);
}
default:
break;
}
};
ts.forEachChild(node, process);
return path;
}
function translateJsxExpression(node: ts.Node, h) {
switch (node.kind) {
case ts.SyntaxKind.StringLiteral: {
let e = node as ts.StringLiteral;
return e.text;
}
default:
return `%${h[0]++}$s`;
}
}
function trim(s: string) {
return s.replace(/^[ \n\t]*/, "").replace(/[ \n\t]*$/, "");
}
function getJsxAttribute(sour: ts.SourceFile, node: ts.Node) {
const result = {};
ts.forEachChild(node, (childNode: ts.Node) => {
switch (childNode.kind) {
case ts.SyntaxKind.JsxOpeningElement: {
let e = childNode as ts.JsxOpeningElement;
e.attributes.properties.map((p) => {
const id = p.getChildAt(0, sour).getText(sour);
const v = p.getChildAt(2, sour);
if (v.kind !== ts.SyntaxKind.StringLiteral) {
return undefined;
}
result[id] = JSON.parse(v.getText(sour));
});
return;
}
}
});
return result;
}
function getJsxContent(node: ts.Node) {
let fragments = [];
let holeNum = [1];
let process = (childNode) => {
switch (childNode.kind) {
case ts.SyntaxKind.JsxText: {
let e = childNode as ts.JsxText;
let s = e.text;
let t = s.split("\n").map(trim).join(" ");
if (s[0] === " ") {
t = " " + t;
}
if (s[s.length - 1] === " ") {
t = t + " ";
}
fragments.push(t);
}
case ts.SyntaxKind.JsxOpeningElement:
break;
case ts.SyntaxKind.JsxSelfClosingElement:
case ts.SyntaxKind.JsxElement:
fragments.push(`%${holeNum[0]++}$s`);
break;
case ts.SyntaxKind.JsxExpression: {
let e = childNode as ts.JsxExpression;
fragments.push(translateJsxExpression(e.expression, holeNum));
break;
}
case ts.SyntaxKind.JsxClosingElement:
break;
default:
console.log("unhandled node type: ", childNode.kind);
let lc = ts.getLineAndCharacterOfPosition(
childNode.getSourceFile(),
childNode.getStart(),
);
console.error(
`unrecognized syntax in JSX Element ${
ts.SyntaxKind[childNode.kind]
} (${childNode.getSourceFile().fileName}:${lc.line + 1}:${
lc.character + 1
}`,
);
break;
}
};
ts.forEachChild(node, process);
return fragments.join("").trim().replace(/ +/g, " ");
}
function getJsxSingular(node: ts.Node) {
let res;
let process = (childNode) => {
switch (childNode.kind) {
case ts.SyntaxKind.JsxElement: {
let path = getJsxElementPath(childNode);
if (arrayEq(path, ["i18n", "TranslateSingular"])) {
res = getJsxContent(childNode);
}
}
default:
break;
}
};
ts.forEachChild(node, process);
return res;
}
function getJsxPlural(node: ts.Node) {
let res;
let process = (childNode) => {
switch (childNode.kind) {
case ts.SyntaxKind.JsxElement: {
let path = getJsxElementPath(childNode);
if (arrayEq(path, ["i18n", "TranslatePlural"])) {
res = getJsxContent(childNode);
}
}
default:
break;
}
};
ts.forEachChild(node, process);
return res;
}
function searchScreenId(parents: ts.Node[], sourceFile: ts.SourceFile) {
var result = undefined;
parents.forEach((parent) => {
// console.log("parent => ", ts.SyntaxKind[parent.kind]);
if (result) return;
parent.forEachChild((node) => {
// console.log(" childs => ", ts.SyntaxKind[node.kind]);
switch (node.kind) {
case ts.SyntaxKind.VariableStatement: {
const v = node as ts.VariableStatement;
const found = v.declarationList.declarations.find(
(d) => d.name.getText(sourceFile) === "TALER_SCREEN_ID",
);
if (found) {
if (found.initializer.kind === ts.SyntaxKind.NumericLiteral) {
const id = found.initializer.getText(sourceFile);
result = id;
} else {
console.error("TALER_SCREEN_ID but is not a NumericLiteral");
}
return;
}
}
// case ts.SyntaxKind.VariableDeclaration: {
// const v = node as ts.VariableDeclaration;
// console.log(v);
// return;
// }
}
});
});
// console.log("");
return result;
}
function processNode(
parents: ts.Node[],
node: ts.Node,
preLastTokLine: number,
lastTokLine: number,
sourceFile: ts.SourceFile,
outChunks: string[],
knownMessageIds: Set,
projectPrefix: string,
) {
switch (node.kind) {
case ts.SyntaxKind.JsxElement:
let path = getJsxElementPath(node);
if (arrayEq(path, ["i18n", "Translate"])) {
const content = getJsxContent(node);
const { line } = ts.getLineAndCharacterOfPosition(sourceFile, node.pos);
const comment = getComment(
sourceFile,
preLastTokLine,
lastTokLine,
node,
);
const context = getJsxAttribute(sourceFile, node)["context"] ?? "";
const msgid = context + content;
if (!knownMessageIds.has(msgid)) {
knownMessageIds.add(msgid);
const screenId = searchScreenId(parents, sourceFile);
formatScreenId(sourceFile, outChunks, screenId);
formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
formatMsgLine(outChunks, "msgctxt", context);
formatMsgLine(outChunks, "msgid", content);
outChunks.push(`msgstr ""\n`);
outChunks.push("\n");
}
return;
}
if (arrayEq(path, ["i18n", "TranslateSwitch"])) {
let { line } = ts.getLineAndCharacterOfPosition(sourceFile, node.pos);
let comment = getComment(sourceFile, preLastTokLine, lastTokLine, node);
formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
let singularForm = getJsxSingular(node);
if (!singularForm) {
console.error("singular form missing");
process.exit(1);
}
let pluralForm = getJsxPlural(node);
if (!pluralForm) {
console.error("plural form missing");
process.exit(1);
}
const context = getJsxAttribute(sourceFile, node)["context"] ?? "";
const msgid = context + singularForm;
if (!knownMessageIds.has(msgid)) {
knownMessageIds.add(msgid);
const screenId = searchScreenId(parents, sourceFile);
formatScreenId(sourceFile, outChunks, screenId);
formatMsgLine(outChunks, "msgctxt", context);
formatMsgLine(outChunks, "msgid", singularForm);
formatMsgLine(outChunks, "msgid_plural", pluralForm);
outChunks.push(`msgstr[0] ""\n`);
outChunks.push(`msgstr[1] ""\n`);
outChunks.push(`\n`);
}
return;
}
break;
case ts.SyntaxKind.CallExpression: {
// might be i18n.plural(i18n[.X]`...`, i18n[.X]`...`)
let ce = node;
let path = getPath(ce.expression);
if (!arrayEq(path.path, ["i18n", "plural"])) {
break;
}
if (ce.arguments[0].kind != ts.SyntaxKind.TaggedTemplateExpression) {
break;
}
if (ce.arguments[1].kind != ts.SyntaxKind.TaggedTemplateExpression) {
break;
}
let { line } = ts.getLineAndCharacterOfPosition(sourceFile, ce.pos);
const tte1 = ce.arguments[0];
let lc1 = ts.getLineAndCharacterOfPosition(sourceFile, tte1.pos);
if (lc1.line != lastTokLine) {
preLastTokLine = lastTokLine; // HERE
lastTokLine = lc1.line;
}
let t1 = processTaggedTemplateExpression(
sourceFile,
preLastTokLine,
lastTokLine,
tte1,
);
const tte2 = ce.arguments[1];
let lc2 = ts.getLineAndCharacterOfPosition(sourceFile, tte2.pos);
if (lc2.line != lastTokLine) {
preLastTokLine = lastTokLine; // HERE
lastTokLine = lc2.line;
}
let t2 = processTaggedTemplateExpression(
sourceFile,
preLastTokLine,
lastTokLine,
tte2,
);
let comment = getComment(sourceFile, preLastTokLine, lastTokLine, ce);
const msgid = path.ctx + t1.template;
if (!knownMessageIds.has(msgid)) {
knownMessageIds.add(msgid);
const screenId = searchScreenId(parents, sourceFile);
formatScreenId(sourceFile, outChunks, screenId);
formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
formatMsgLine(outChunks, "msgctxt", path.ctx);
formatMsgLine(outChunks, "msgid", t1.template);
formatMsgLine(outChunks, "msgid_plural", t2.template);
outChunks.push(`msgstr[0] ""\n`);
outChunks.push(`msgstr[1] ""\n`);
outChunks.push("\n");
}
// Important: no processing for child i18n expressions here
return;
}
case ts.SyntaxKind.TaggedTemplateExpression: {
let tte = node;
let lc2 = ts.getLineAndCharacterOfPosition(sourceFile, tte.pos);
if (lc2.line != lastTokLine) {
preLastTokLine = lastTokLine;
lastTokLine = lc2.line;
}
let { comment, template, line, path, context } =
processTaggedTemplateExpression(
sourceFile,
preLastTokLine,
lastTokLine,
tte,
);
if (path[0] != "i18n") {
break;
}
const msgid = context + template;
if (!knownMessageIds.has(msgid)) {
knownMessageIds.add(msgid);
const screenId = searchScreenId(parents, sourceFile);
formatScreenId(sourceFile, outChunks, screenId);
formatMsgComment(projectPrefix, sourceFile, outChunks, line, comment);
formatMsgLine(outChunks, "msgctxt", context);
formatMsgLine(outChunks, "msgid", template);
outChunks.push(`msgstr ""\n`);
outChunks.push("\n");
}
break;
}
}
ts.forEachChild(node, (child) => {
processNode(
[node, ...parents],
child,
lastTokLine,
preLastTokLine,
sourceFile,
outChunks,
knownMessageIds,
projectPrefix
);
});
}
export function processFileForTesting(sourceFile: ts.SourceFile): string {
const result: string[] = new Array();
processNode([], sourceFile, 0, 0, sourceFile, result, new Set(), "");
return result.join("");
}
export function processFile(
sourceFile: ts.SourceFile,
outChunks: string[],
knownMessageIds: Set,
projectPrefix: string,
) {
// let lastTokLine = 0;
// let preLastTokLine = 0;
processNode([], sourceFile, 0, 0, sourceFile, outChunks, knownMessageIds, projectPrefix);
}
function searchIntoParents(directory: string, fileFlag: string) {
if (!path.isAbsolute(directory)) {
return searchIntoParents(path.join(process.cwd(), directory), fileFlag);
}
const parent = path.dirname(directory);
if (fs.existsSync(path.join(directory, fileFlag))) {
return directory;
}
if (parent === directory) {
return directory;
}
return searchIntoParents(parent, fileFlag);
}
export function potextract() {
const configPath = ts.findConfigFile(
/*searchPath*/ "./",
ts.sys.fileExists,
"tsconfig.json",
);
if (!configPath) {
throw new Error("Could not find a valid 'tsconfig.json'.");
}
const cmdline = ts.getParsedCommandLineOfConfigFile(
configPath,
{},
{
fileExists: ts.sys.fileExists,
getCurrentDirectory: ts.sys.getCurrentDirectory,
onUnRecoverableConfigFileDiagnostic: (e) => console.log(e),
readDirectory: ts.sys.readDirectory,
readFile: ts.sys.readFile,
useCaseSensitiveFileNames: true,
},
);
const prog = ts.createProgram({
options: cmdline.options,
rootNames: cmdline.fileNames,
});
const allFiles = prog.getSourceFiles();
const ownFiles = allFiles.filter(
(x) =>
!x.isDeclarationFile &&
!prog.isSourceFileFromExternalLibrary(x) &&
!prog.isSourceFileDefaultLibrary(x),
);
let header: string;
try {
header = fs.readFileSync("src/i18n/poheader", "utf-8");
} catch (e) {
header = DEFAULT_PO_HEADER;
}
const gitRoot = searchIntoParents(process.cwd(), ".git");
const chunks = [header];
const knownMessageIds = new Set();
for (const f of ownFiles) {
processFile(f, chunks, knownMessageIds, gitRoot);
}
const pot = chunks.join("");
//console.log(pot);
const packageJson = JSON.parse(
fs.readFileSync("./package.json", { encoding: "utf-8" }),
);
const poDomain = packageJson.pogen?.domain;
if (!poDomain) {
console.error("missing 'pogen.domain' field in package.json");
process.exit(1);
}
fs.writeFileSync(`./src/i18n/${poDomain}.pot`, pot);
}