/* 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); }