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