feat: add platform interface inventory and encryption registry
This commit is contained in:
366
tools/inventory/extract-webui-structure.mjs
Normal file
366
tools/inventory/extract-webui-structure.mjs
Normal file
@@ -0,0 +1,366 @@
|
||||
#!/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("")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user