From 73959e3d48e61518582099ea4ca8c7c69c5ba759 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 18:45:39 +0000 Subject: [PATCH] Add registry export validation script --- package.json | 2 +- scripts/validate-json-registry.ts | 235 ++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 scripts/validate-json-registry.ts diff --git a/package.json b/package.json index 9d34649..9f3688b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "schemas:validate": "tsx scripts/validate-json-schemas.ts", "components:list": "node scripts/list-json-components.cjs", "components:scan": "node scripts/scan-and-update-registry.cjs", - "components:validate": "node scripts/validate-supported-components.cjs" + "components:validate": "node scripts/validate-supported-components.cjs && tsx scripts/validate-json-registry.ts" }, "dependencies": { "@heroicons/react": "^2.2.0", diff --git a/scripts/validate-json-registry.ts b/scripts/validate-json-registry.ts new file mode 100644 index 0000000..d7219cc --- /dev/null +++ b/scripts/validate-json-registry.ts @@ -0,0 +1,235 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import * as PhosphorIcons from '@phosphor-icons/react' +import { JSONUIShowcase } from '../src/components/JSONUIShowcase' + +type ComponentType = unknown + +interface JsonRegistryEntry { + name?: string + type?: string + export?: string + source?: string + status?: string + wrapperRequired?: boolean + wrapperComponent?: string + wrapperFor?: string + load?: { + export?: string + } + deprecated?: unknown +} + +interface JsonComponentRegistry { + components?: JsonRegistryEntry[] +} + +const sourceAliases: Record> = { + atoms: { + PageHeader: 'BasicPageHeader', + SearchInput: 'BasicSearchInput', + }, + molecules: {}, + organisms: {}, + ui: { + Chart: 'ChartContainer', + Resizable: 'ResizablePanelGroup', + }, + wrappers: {}, +} + +const explicitComponentAllowlist: Record = { + JSONUIShowcase, +} + +const getRegistryEntryKey = (entry: JsonRegistryEntry): string | undefined => + entry.name ?? entry.type + +const getRegistryEntryExportName = (entry: JsonRegistryEntry): string | undefined => + entry.load?.export ?? entry.export ?? getRegistryEntryKey(entry) + +const buildComponentMapFromExports = ( + exports: Record +): Record => { + return Object.entries(exports).reduce>((acc, [key, value]) => { + if (value && (typeof value === 'function' || typeof value === 'object')) { + acc[key] = value as ComponentType + } + return acc + }, {}) +} + +const buildComponentMapFromModules = ( + modules: Record +): Record => { + return Object.values(modules).reduce>((acc, moduleExports) => { + if (!moduleExports || typeof moduleExports !== 'object') { + return acc + } + Object.entries(buildComponentMapFromExports(moduleExports as Record)).forEach( + ([key, component]) => { + acc[key] = component + } + ) + return acc + }, {}) +} + +const listFiles = async (options: { + directory: string + extensions: string[] + recursive: boolean +}): Promise => { + const { directory, extensions, recursive } = options + const entries = await fs.readdir(directory, { withFileTypes: true }) + const files: string[] = [] + + await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name) + if (entry.isDirectory()) { + if (recursive) { + const nested = await listFiles({ directory: fullPath, extensions, recursive }) + files.push(...nested) + } + return + } + if (extensions.includes(path.extname(entry.name))) { + files.push(fullPath) + } + }) + ) + + return files +} + +const importModules = async (files: string[]): Promise> => { + const modules: Record = {} + await Promise.all( + files.map(async (file) => { + const moduleExports = await import(pathToFileURL(file).href) + modules[file] = moduleExports + }) + ) + return modules +} + +const validateRegistry = async () => { + const scriptDir = path.dirname(fileURLToPath(import.meta.url)) + const rootDir = path.resolve(scriptDir, '..') + const registryPath = path.join(rootDir, 'json-components-registry.json') + + const registryRaw = await fs.readFile(registryPath, 'utf8') + const registry = JSON.parse(registryRaw) as JsonComponentRegistry + const registryEntries = registry.components ?? [] + const registryEntryByType = new Map( + registryEntries + .map((entry) => { + const entryKey = getRegistryEntryKey(entry) + return entryKey ? [entryKey, entry] : null + }) + .filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry)) + ) + + const sourceConfigs = [ + { + source: 'atoms', + directory: path.join(rootDir, 'src/components/atoms'), + extensions: ['.tsx'], + recursive: false, + }, + { + source: 'molecules', + directory: path.join(rootDir, 'src/components/molecules'), + extensions: ['.tsx'], + recursive: false, + }, + { + source: 'organisms', + directory: path.join(rootDir, 'src/components/organisms'), + extensions: ['.tsx'], + recursive: false, + }, + { + source: 'ui', + directory: path.join(rootDir, 'src/components/ui'), + extensions: ['.ts', '.tsx'], + recursive: true, + }, + { + source: 'wrappers', + directory: path.join(rootDir, 'src/lib/json-ui/wrappers'), + extensions: ['.tsx'], + recursive: false, + }, + ] + + const componentMaps: Record> = {} + await Promise.all( + sourceConfigs.map(async (config) => { + const files = await listFiles({ + directory: config.directory, + extensions: config.extensions, + recursive: config.recursive, + }) + const modules = await importModules(files) + componentMaps[config.source] = buildComponentMapFromModules(modules) + }) + ) + + componentMaps.icons = buildComponentMapFromExports(PhosphorIcons) + + const errors: string[] = [] + + registryEntries.forEach((entry) => { + const entryKey = getRegistryEntryKey(entry) + const entryExportName = getRegistryEntryExportName(entry) + + if (!entryKey || !entryExportName) { + errors.push(`Entry missing name/type/export: ${JSON.stringify(entry)}`) + return + } + + const source = entry.source + if (!source || !componentMaps[source]) { + errors.push(`${entryKey}: unknown source "${source ?? 'missing'}"`) + return + } + + const aliasName = sourceAliases[source]?.[entryKey] + const component = + componentMaps[source][entryExportName] ?? + (aliasName ? componentMaps[source][aliasName] : undefined) ?? + explicitComponentAllowlist[entryKey] + + if (!component) { + const aliasNote = aliasName ? ` (alias: ${aliasName})` : '' + errors.push( + `${entryKey} (${source}) did not resolve export "${entryExportName}"${aliasNote}` + ) + } + + if (entry.wrapperRequired) { + if (!entry.wrapperComponent) { + errors.push(`${entryKey} (${source}) requires a wrapperComponent but none is defined`) + return + } + if (!registryEntryByType.has(entry.wrapperComponent)) { + errors.push( + `${entryKey} (${source}) references missing wrapperComponent ${entry.wrapperComponent}` + ) + } + } + }) + + if (errors.length > 0) { + console.error('❌ JSON component registry export validation failed:') + errors.forEach((error) => console.error(`- ${error}`)) + process.exit(1) + } + + console.log('✅ JSON component registry exports are valid.') +} + +await validateRegistry()