mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 20:02:47 +01:00
211 lines
6.7 KiB
JavaScript
211 lines
6.7 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import ts from 'typescript';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const SOURCE_ROOT = path.resolve(__dirname, '../src/js');
|
|
const SKIPPED_DIRECTORIES = new Set(['editcore', 'types', 'i18n', 'config']);
|
|
const EXEMPT_FILES = new Set(['logic/email-to-pdf.ts', 'utils/comic-info.ts']);
|
|
const TRANSLATING_HELPERS = {
|
|
showAlert: 2,
|
|
showLoader: 1,
|
|
showAlertModal: 2,
|
|
showModal: 2,
|
|
};
|
|
const NATIVE_DIALOGS = { alert: 1, confirm: 1 };
|
|
const HELPER_ARGUMENTS = { ...TRANSLATING_HELPERS, ...NATIVE_DIALOGS };
|
|
const TEXT_PROPERTIES = new Set([
|
|
'textContent',
|
|
'title',
|
|
'placeholder',
|
|
'alt',
|
|
'ariaLabel',
|
|
]);
|
|
const TEXT_ATTRIBUTES = new Set(['title', 'placeholder', 'aria-label', 'alt']);
|
|
const LETTERS = /\p{L}{2}/u;
|
|
const CSS_RULE = /[#.\w-]+\s*\{[^}]*:[^}]*\}/;
|
|
const KEY_COMBINATION = /^(?:(?:Ctrl|Cmd|Alt|Shift|⌘|⌥|⇧)\s*\+\s*)+\S$/;
|
|
|
|
function sourceFiles(root) {
|
|
const files = [];
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(full);
|
|
} else if (/\.ts$/.test(entry.name)) {
|
|
files.push(full);
|
|
}
|
|
}
|
|
};
|
|
walk(root);
|
|
return files;
|
|
}
|
|
|
|
function isStringLike(node) {
|
|
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
|
|
}
|
|
|
|
function calleeName(call) {
|
|
if (ts.isIdentifier(call.expression)) return call.expression.text;
|
|
if (ts.isPropertyAccessExpression(call.expression)) {
|
|
return call.expression.name.text;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function rawLiterals(node, found = []) {
|
|
if (isStringLike(node)) {
|
|
if (LETTERS.test(node.text) && !KEY_COMBINATION.test(node.text)) {
|
|
found.push(node);
|
|
}
|
|
} else if (ts.isTemplateExpression(node)) {
|
|
const staticText = [
|
|
node.head.text,
|
|
...node.templateSpans.map((span) => span.literal.text),
|
|
].join(' ');
|
|
if (LETTERS.test(staticText)) found.push(node);
|
|
} else if (ts.isParenthesizedExpression(node)) {
|
|
rawLiterals(node.expression, found);
|
|
} else if (ts.isConditionalExpression(node)) {
|
|
rawLiterals(node.whenTrue, found);
|
|
rawLiterals(node.whenFalse, found);
|
|
} else if (ts.isBinaryExpression(node)) {
|
|
const kind = node.operatorToken.kind;
|
|
if (
|
|
kind === ts.SyntaxKind.BarBarToken ||
|
|
kind === ts.SyntaxKind.QuestionQuestionToken ||
|
|
kind === ts.SyntaxKind.PlusToken
|
|
) {
|
|
rawLiterals(node.left, found);
|
|
rawLiterals(node.right, found);
|
|
}
|
|
} else if (ts.isAsExpression(node) || ts.isNonNullExpression(node)) {
|
|
rawLiterals(node.expression, found);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function isTranslatingCall(node) {
|
|
const call = node.parent;
|
|
return (
|
|
call &&
|
|
ts.isCallExpression(call) &&
|
|
['trText', 'trHtml', 't', 'translate'].includes(calleeName(call))
|
|
);
|
|
}
|
|
|
|
function rawHtmlText(node) {
|
|
let pieces;
|
|
if (isStringLike(node)) {
|
|
pieces = [node.text];
|
|
} else if (ts.isTemplateExpression(node)) {
|
|
pieces = [node.head.text, ...node.templateSpans.map((s) => s.literal.text)];
|
|
} else {
|
|
return false;
|
|
}
|
|
const html = pieces.join('\u0000');
|
|
if (!/<[a-z][\w-]*[\s>/]/i.test(html)) return false;
|
|
for (const match of html.matchAll(/>([^<>]+)(?=<)/g)) {
|
|
const tagStart = html.lastIndexOf('<', match.index);
|
|
const tag = html.slice(tagStart, match.index + 1);
|
|
if (/data-i18n/.test(tag) || /^<\/?(script|style)/i.test(tag)) continue;
|
|
const visible = match[1]
|
|
.replace(/\u0000/g, ' ')
|
|
.replace(/&\w+;|&#\d+;/g, ' ');
|
|
if (LETTERS.test(visible)) return true;
|
|
}
|
|
for (const match of html.matchAll(
|
|
/<[^<>]*?\s(?:title|placeholder|aria-label|alt)="([^"]*)"[^<>]*>/g
|
|
)) {
|
|
if (/data-i18n/.test(match[0])) continue;
|
|
if (LETTERS.test(match[1].replace(/\u0000/g, ' '))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function scanRawUiText(root = SOURCE_ROOT) {
|
|
const findings = [];
|
|
for (const file of sourceFiles(root)) {
|
|
if (EXEMPT_FILES.has(path.relative(root, file))) continue;
|
|
const source = fs.readFileSync(file, 'utf8');
|
|
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
|
const report = (node, kind) => {
|
|
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
findings.push({
|
|
file: path.relative(root, file),
|
|
line: line + 1,
|
|
kind,
|
|
text: node.getText(sf).replace(/\s+/g, ' ').slice(0, 100),
|
|
});
|
|
};
|
|
const visit = (node) => {
|
|
if (ts.isCallExpression(node)) {
|
|
const name = calleeName(node);
|
|
if (name in HELPER_ARGUMENTS) {
|
|
node.arguments.slice(0, HELPER_ARGUMENTS[name]).forEach((argument) =>
|
|
rawLiterals(argument)
|
|
.filter(
|
|
(raw) => !(name in TRANSLATING_HELPERS && isStringLike(raw))
|
|
)
|
|
.forEach((raw) => report(raw, name))
|
|
);
|
|
} else if (
|
|
name === 'setAttribute' &&
|
|
node.arguments.length === 2 &&
|
|
isStringLike(node.arguments[0]) &&
|
|
TEXT_ATTRIBUTES.has(node.arguments[0].text)
|
|
) {
|
|
rawLiterals(node.arguments[1]).forEach((raw) =>
|
|
report(raw, 'setAttribute')
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
ts.isNewExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
node.expression.text === 'Option' &&
|
|
node.arguments &&
|
|
node.arguments.length > 0
|
|
) {
|
|
rawLiterals(node.arguments[0]).forEach((raw) => report(raw, 'Option'));
|
|
}
|
|
if (
|
|
ts.isBinaryExpression(node) &&
|
|
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
ts.isPropertyAccessExpression(node.left) &&
|
|
TEXT_PROPERTIES.has(node.left.name.text)
|
|
) {
|
|
rawLiterals(node.right)
|
|
.filter((raw) => !CSS_RULE.test(raw.getText()))
|
|
.forEach((raw) => report(raw, node.left.name.text));
|
|
}
|
|
if (
|
|
(isStringLike(node) || ts.isTemplateExpression(node)) &&
|
|
!isTranslatingCall(node) &&
|
|
rawHtmlText(node)
|
|
) {
|
|
report(node, 'html');
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sf);
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const findings = scanRawUiText();
|
|
const byKind = {};
|
|
for (const finding of findings)
|
|
byKind[finding.kind] = (byKind[finding.kind] || 0) + 1;
|
|
console.log(`${findings.length} raw UI text sites`, JSON.stringify(byKind));
|
|
for (const finding of findings.slice(0, Number(process.argv[2] || 25))) {
|
|
console.log(
|
|
`${finding.file}:${finding.line} [${finding.kind}] ${finding.text}`
|
|
);
|
|
}
|
|
process.exitCode = findings.length > 0 ? 1 : 0;
|
|
}
|