Files
govoplan/tools/inventory/extract-webui-structure.mjs
T
zemion 9bb2c808a6
Dependency Audit / dependency-audit (push) Failing after 1m36s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 10m2s
Enforce declared platform interface inventory
2026-08-04 05:20:47 +02:00

596 lines
18 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
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 siblingWorkspaceRoot = path.dirname(metaRoot);
const configuredWorkspaceRoot = path.resolve(repositoryCatalog.default_parent);
const workspaceRoot = fs.existsSync(
path.join(siblingWorkspaceRoot, "govoplan-core", "webui")
)
? siblingWorkspaceRoot
: configuredWorkspaceRoot;
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 actionComponentPattern = /(?:Action|Button|Link)$/;
const contributionTypes = new Map([
["AdminSectionsUiCapability", "admin_section"],
["DashboardWidgetsUiCapability", "widget"],
["OrganizationFunctionActionsUiCapability", "action"],
["SearchContextsUiCapability", "search_object"],
["SettingsSectionsUiCapability", "setting"],
["WizardDirectoriesUiCapability", "workflow_directory"]
]);
const result = {
fields: [],
actions: [],
labels: [],
visibleText: [],
translationCatalog: {},
translationUsages: [],
dynamicTranslationUsages: [],
routes: [],
navigation: [],
frontendApiReferences: [],
uiCapabilities: [],
contributions: []
};
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);
const identityCounters = new Map();
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");
inspectAction(node, component, attributes, locate);
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);
const hasHelp = hasAnyAttribute(attributes, helpAttributes) ||
hasAnyAttribute(parentAttributes, helpAttributes);
const explicitId = firstAttribute(
attributes,
new Set(["interfaceId", "data-interface-id", "id", "name", "field"])
);
const context = nearestNamedContext(node);
const stableId = sourceIdentity(
"field",
node,
component,
explicitId ?? label ?? attributes.get("placeholder") ?? "field"
);
result.fields.push({
...locate(node),
id: stableId,
explicitId,
idSource: explicitId === null ? "source_anchor" : "explicit",
context,
component,
name:
attributes.get("name") ??
attributes.get("id") ??
attributes.get("field") ??
null,
label,
placeholder: attributes.get("placeholder") ?? null,
help: help ?? null,
helpId: hasHelp ? `${stableId}.help` : null,
helpDynamic: hasHelp && help === null,
helpCandidate: !hasHelp
});
}
function inspectAction(node, component, attributes, locate) {
const lowerComponent = component.toLowerCase();
const inputType = attributes.get("type")?.toLowerCase();
const isAction = lowerComponent === "button" ||
lowerComponent === "a" ||
actionComponentPattern.test(component) ||
(lowerComponent === "input" && ["button", "reset", "submit"].includes(inputType));
if (!isAction) return;
const label = attributes.get("aria-label") ??
attributes.get("title") ??
attributes.get("label") ??
staticJsxChildText(node) ??
null;
const explicitId = firstAttribute(
attributes,
new Set(["interfaceId", "data-interface-id", "id", "name"])
);
const context = nearestNamedContext(node);
result.actions.push({
...locate(node),
id: sourceIdentity(
"action",
node,
component,
explicitId ?? label ?? "action"
),
explicitId,
idSource: explicitId === null ? "source_anchor" : "explicit",
context,
component,
label
});
}
function nearestNamedContext(node) {
let current = node.parent;
while (current) {
if (ts.isFunctionDeclaration(current) && current.name) {
return current.name.text;
}
if (ts.isMethodDeclaration(current) && current.name) {
return current.name.getText(sourceFile);
}
if (
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
ts.isVariableDeclaration(current.parent) &&
ts.isIdentifier(current.parent.name)
) {
return current.parent.name.text;
}
if (
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
ts.isPropertyAssignment(current.parent)
) {
return propertyNameText(current.parent.name) ?? "anonymous";
}
current = current.parent;
}
return path.basename(relativeFile).replace(/\.[^.]+$/, "");
}
function sourceIdentity(kind, node, component, semantic) {
const context = nearestNamedContext(node);
const normalizedSemantic = slug(String(semantic));
const counterKey = `${kind}:${context}:${component}:${normalizedSemantic}`;
const occurrence = (identityCounters.get(counterKey) ?? 0) + 1;
identityCounters.set(counterKey, occurrence);
const anchor = [relativeFile, context, component, normalizedSemantic, occurrence].join(":");
const digest = createHash("sha256").update(anchor).digest("hex").slice(0, 12);
return `${repository}.${kind}.${slug(context)}.${normalizedSemantic}.${digest}`;
}
function staticJsxChildText(node) {
if (!ts.isJsxOpeningElement(node) || !ts.isJsxElement(node.parent)) return null;
const values = [];
for (const child of node.parent.children) {
if (ts.isJsxText(child)) {
const value = child.getText(sourceFile).replace(/\s+/g, " ").trim();
if (value) values.push(value);
} else if (
ts.isJsxExpression(child) &&
child.expression &&
ts.isStringLiteralLike(child.expression)
) {
values.push(child.expression.text);
}
}
return values.length > 0 ? values.join(" ") : 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);
if (isDirectUiCapabilityProperty(node) && typeof propertyName === "string") {
result.uiCapabilities.push({ ...locate(node), name: propertyName });
result.contributions.push({
...locate(node),
kind: "ui_capability",
id: propertyName
});
}
const value = staticExpressionText(node.initializer);
if (value === null) return;
if (propertyName === "path" && value.startsWith("/") && !value.includes("/api/")) {
result.routes.push({ ...locate(node), path: value });
const routeCollection = nearestCollectionProperty(node);
if (routeCollection === "routes" || routeCollection === "publicRoutes") {
result.contributions.push({
...locate(node),
kind: routeCollection === "publicRoutes" ? "public_route" : "frontend_route",
id: value,
path: value
});
}
}
if (propertyName === "to" && value.startsWith("/")) {
result.navigation.push({ ...locate(node), path: value });
if (nearestCollectionProperty(node) === "navItems") {
result.contributions.push({
...locate(node),
kind: "navigation",
id: value,
path: value
});
}
}
if (propertyName === "id") {
const contributionKind = contributionKindFor(node);
if (contributionKind !== null) {
result.contributions.push({
...locate(node),
kind: contributionKind,
id: value
});
}
}
}
function contributionKindFor(node) {
const collection = nearestCollectionProperty(node);
if (collection === "viewSurfaces") return "view_surface";
if (collection === "widgets") return "widget";
if (collection === "contexts") return "search_object";
if (collection === "actions") return "action";
if (collection === "directories") return "workflow_directory";
if (collection !== "sections") return null;
const variableType = nearestVariableType(node);
for (const [typeName, kind] of contributionTypes) {
if (variableType.includes(typeName)) return kind;
}
return ancestorPropertyName(node, "admin.sections")
? "admin_section"
: ancestorPropertyName(node, "settings.sections")
? "setting"
: "section";
}
function nearestCollectionProperty(node) {
let current = node.parent;
while (current) {
if (
ts.isArrayLiteralExpression(current) &&
ts.isPropertyAssignment(current.parent)
) {
return propertyNameText(current.parent.name);
}
current = current.parent;
}
return null;
}
function nearestVariableType(node) {
let current = node.parent;
while (current) {
if (ts.isVariableDeclaration(current)) {
return current.type?.getText(sourceFile) ?? "";
}
current = current.parent;
}
return "";
}
function isDirectUiCapabilityProperty(node) {
const parent = node.parent;
const uiCapabilities = parent?.parent;
return ts.isObjectLiteralExpression(parent) &&
ts.isPropertyAssignment(uiCapabilities) &&
propertyNameText(uiCapabilities.name) === "uiCapabilities";
}
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 hasAnyAttribute(attributes, names) {
for (const name of names) {
if (attributes.has(name)) return true;
}
return false;
}
function slug(value) {
const normalized = value
.toLowerCase()
.replace(/^i18n:/, "")
.replace(/\.[a-f0-9]{8}$/, "")
.replace(/[^a-z0-9]+/g, ".")
.replace(/^\.+|\.+$/g, "");
return (normalized || "unnamed").slice(0, 72);
}
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("")
);
}