367 lines
10 KiB
JavaScript
367 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const [metaRootArgument] = process.argv.slice(2);
|
|
if (!metaRootArgument) {
|
|
throw new Error("Usage: extract-webui-structure.mjs META_ROOT");
|
|
}
|
|
|
|
const metaRoot = path.resolve(metaRootArgument);
|
|
const repositoryCatalog = JSON.parse(
|
|
fs.readFileSync(path.join(metaRoot, "repositories.json"), "utf8")
|
|
);
|
|
const workspaceRoot = path.resolve(repositoryCatalog.default_parent);
|
|
const typescriptPath = path.join(
|
|
workspaceRoot,
|
|
"govoplan-core",
|
|
"webui",
|
|
"node_modules",
|
|
"typescript",
|
|
"lib",
|
|
"typescript.js"
|
|
);
|
|
const ts = await import(pathToFileURL(typescriptPath).href);
|
|
|
|
const fieldComponents = new Set([
|
|
"input",
|
|
"select",
|
|
"textarea",
|
|
"Checkbox",
|
|
"DateTimeField",
|
|
"EmailAddressInput",
|
|
"ReferenceSelect",
|
|
"SearchableSelect",
|
|
"ToggleSwitch"
|
|
]);
|
|
const fieldComponentPattern =
|
|
/(?:Input|Field|Select|Picker|Toggle|Checkbox|Radio|Editor)$/;
|
|
const labelAttributes = new Set([
|
|
"aria-label",
|
|
"description",
|
|
"help",
|
|
"helperText",
|
|
"helpText",
|
|
"label",
|
|
"placeholder",
|
|
"title"
|
|
]);
|
|
const helpAttributes = new Set([
|
|
"description",
|
|
"help",
|
|
"helperText",
|
|
"helpText"
|
|
]);
|
|
|
|
const result = {
|
|
fields: [],
|
|
labels: [],
|
|
visibleText: [],
|
|
translationCatalog: {},
|
|
translationUsages: [],
|
|
dynamicTranslationUsages: [],
|
|
routes: [],
|
|
navigation: [],
|
|
frontendApiReferences: [],
|
|
uiCapabilities: []
|
|
};
|
|
|
|
for (const repository of repositoryCatalog.repositories) {
|
|
const sourceRoot = path.join(workspaceRoot, repository.path, "webui", "src");
|
|
if (!fs.existsSync(sourceRoot)) continue;
|
|
for (const sourcePath of sourceFiles(sourceRoot)) {
|
|
inspectSource(repository.name, sourceRoot, sourcePath);
|
|
}
|
|
}
|
|
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
|
|
function sourceFiles(root) {
|
|
const files = [];
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
if (
|
|
entry.name === "node_modules" ||
|
|
entry.name.startsWith(".") ||
|
|
entry.name === "dist"
|
|
) {
|
|
continue;
|
|
}
|
|
const candidate = path.join(current, entry.name);
|
|
if (entry.isDirectory()) pending.push(candidate);
|
|
else if (/\.(?:ts|tsx)$/.test(entry.name)) files.push(candidate);
|
|
}
|
|
}
|
|
return files.sort();
|
|
}
|
|
|
|
function inspectSource(repository, sourceRoot, sourcePath) {
|
|
const sourceText = fs.readFileSync(sourcePath, "utf8");
|
|
const sourceFile = ts.createSourceFile(
|
|
sourcePath,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
sourcePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
);
|
|
const relativeFile = path.relative(path.join(workspaceRoot, repository), sourcePath);
|
|
|
|
function location(node) {
|
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
return {
|
|
repository,
|
|
file: relativeFile,
|
|
line: position.line + 1,
|
|
column: position.character + 1
|
|
};
|
|
}
|
|
|
|
function visit(node) {
|
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
inspectJsxOpening(node, location);
|
|
}
|
|
if (ts.isJsxText(node)) {
|
|
const value = node.getText(sourceFile).replace(/\s+/g, " ").trim();
|
|
if (value) {
|
|
result.visibleText.push({
|
|
...location(node),
|
|
value,
|
|
translationKey: value.startsWith("i18n:") ? value : null
|
|
});
|
|
}
|
|
}
|
|
if (ts.isStringLiteralLike(node)) {
|
|
inspectString(node.text, node, location);
|
|
} else if (ts.isTemplateExpression(node)) {
|
|
inspectString(templateText(node), node, location);
|
|
}
|
|
if (ts.isPropertyAssignment(node)) {
|
|
inspectProperty(node, location);
|
|
inspectTranslationProperty(node, location);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(sourceFile);
|
|
|
|
function inspectJsxOpening(node, locate) {
|
|
const component = node.tagName.getText(sourceFile);
|
|
const attributes = new Map();
|
|
for (const attribute of node.attributes.properties) {
|
|
if (!ts.isJsxAttribute(attribute)) continue;
|
|
attributes.set(
|
|
attribute.name.getText(sourceFile),
|
|
jsxAttributeValue(attribute)
|
|
);
|
|
}
|
|
for (const [attribute, value] of attributes) {
|
|
if (!labelAttributes.has(attribute) || value === null) continue;
|
|
result.labels.push({
|
|
...locate(node),
|
|
component,
|
|
attribute,
|
|
value,
|
|
translationKey: value.startsWith("i18n:") ? value : null
|
|
});
|
|
}
|
|
|
|
const isField =
|
|
fieldComponents.has(component) ||
|
|
(fieldComponentPattern.test(component) && component !== "FormField");
|
|
if (!isField) return;
|
|
|
|
const parentFormField = nearestFormField(node);
|
|
const parentAttributes = parentFormField
|
|
? jsxAttributes(parentFormField)
|
|
: new Map();
|
|
const label =
|
|
attributes.get("label") ??
|
|
attributes.get("aria-label") ??
|
|
parentAttributes.get("label") ??
|
|
null;
|
|
const help = firstAttribute(attributes, helpAttributes) ??
|
|
firstAttribute(parentAttributes, helpAttributes);
|
|
result.fields.push({
|
|
...locate(node),
|
|
component,
|
|
name:
|
|
attributes.get("name") ??
|
|
attributes.get("id") ??
|
|
attributes.get("field") ??
|
|
null,
|
|
label,
|
|
placeholder: attributes.get("placeholder") ?? null,
|
|
help: help ?? null,
|
|
helpCandidate: help === null
|
|
});
|
|
}
|
|
|
|
function nearestFormField(node) {
|
|
let current = node.parent;
|
|
while (current) {
|
|
if (
|
|
ts.isJsxElement(current) &&
|
|
current.openingElement.tagName.getText(sourceFile) === "FormField"
|
|
) {
|
|
return current.openingElement;
|
|
}
|
|
if (
|
|
ts.isJsxOpeningElement(current) ||
|
|
ts.isJsxSelfClosingElement(current)
|
|
) {
|
|
const name = current.tagName.getText(sourceFile);
|
|
if (name !== "FormField") return null;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function jsxAttributes(node) {
|
|
const mapped = new Map();
|
|
for (const attribute of node.attributes.properties) {
|
|
if (!ts.isJsxAttribute(attribute)) continue;
|
|
mapped.set(
|
|
attribute.name.getText(sourceFile),
|
|
jsxAttributeValue(attribute)
|
|
);
|
|
}
|
|
return mapped;
|
|
}
|
|
|
|
function jsxAttributeValue(attribute) {
|
|
if (!attribute.initializer) return "true";
|
|
if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text;
|
|
if (!ts.isJsxExpression(attribute.initializer)) return null;
|
|
const expression = attribute.initializer.expression;
|
|
if (!expression) return null;
|
|
if (ts.isStringLiteralLike(expression)) return expression.text;
|
|
if (ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
|
|
if (ts.isTemplateExpression(expression)) return templateText(expression);
|
|
return null;
|
|
}
|
|
|
|
function inspectString(value, node, locate) {
|
|
if (value.startsWith("i18n:") && value.length > "i18n:".length) {
|
|
const target = value.includes("${}") || isStringPrefixCheck(node)
|
|
? result.dynamicTranslationUsages
|
|
: result.translationUsages;
|
|
target.push({ ...locate(node), key: value });
|
|
}
|
|
if (value.includes("/api/")) {
|
|
result.frontendApiReferences.push({ ...locate(node), path: value });
|
|
}
|
|
}
|
|
|
|
function isStringPrefixCheck(node) {
|
|
const call = node.parent;
|
|
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
|
|
return false;
|
|
}
|
|
return ["endsWith", "includes", "startsWith"].includes(
|
|
call.expression.name.text
|
|
);
|
|
}
|
|
|
|
function inspectProperty(node, locate) {
|
|
const propertyName = propertyNameText(node.name);
|
|
const value = staticExpressionText(node.initializer);
|
|
if (value === null) return;
|
|
if (propertyName === "path" && value.startsWith("/") && !value.includes("/api/")) {
|
|
result.routes.push({ ...locate(node), path: value });
|
|
}
|
|
if (propertyName === "to" && value.startsWith("/")) {
|
|
result.navigation.push({ ...locate(node), path: value });
|
|
}
|
|
if (
|
|
ancestorPropertyName(node, "uiCapabilities") &&
|
|
typeof propertyName === "string"
|
|
) {
|
|
result.uiCapabilities.push({ ...locate(node), name: propertyName });
|
|
}
|
|
}
|
|
|
|
function inspectTranslationProperty(node, locate) {
|
|
const key = propertyNameText(node.name);
|
|
if (!key?.startsWith("i18n:")) return;
|
|
const value = staticExpressionText(node.initializer);
|
|
if (value === null) return;
|
|
const locale = nearestLocaleProperty(node);
|
|
if (!locale) return;
|
|
result.translationCatalog[locale] ??= {};
|
|
result.translationCatalog[locale][key] = {
|
|
value,
|
|
...locate(node)
|
|
};
|
|
}
|
|
|
|
function nearestLocaleProperty(node) {
|
|
let current = node.parent;
|
|
while (current) {
|
|
if (
|
|
ts.isPropertyAssignment(current) &&
|
|
(propertyNameText(current.name) === "en" ||
|
|
propertyNameText(current.name) === "de")
|
|
) {
|
|
return propertyNameText(current.name);
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ancestorPropertyName(node, expected) {
|
|
let current = node.parent;
|
|
while (current) {
|
|
if (
|
|
ts.isPropertyAssignment(current) &&
|
|
propertyNameText(current.name) === expected
|
|
) {
|
|
return true;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function firstAttribute(attributes, names) {
|
|
for (const name of names) {
|
|
const value = attributes.get(name);
|
|
if (value !== undefined && value !== null) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function propertyNameText(name) {
|
|
if (
|
|
ts.isIdentifier(name) ||
|
|
ts.isStringLiteral(name) ||
|
|
ts.isNumericLiteral(name)
|
|
) {
|
|
return name.text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function staticExpressionText(node) {
|
|
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
return node.text;
|
|
}
|
|
if (ts.isTemplateExpression(node)) return templateText(node);
|
|
return null;
|
|
}
|
|
|
|
function templateText(node) {
|
|
return (
|
|
node.head.text +
|
|
node.templateSpans
|
|
.map((span) => "${}" + span.literal.text)
|
|
.join("")
|
|
);
|
|
}
|