#!/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("") ); }