diff --git a/package-lock.json b/package-lock.json index 3fa92d6..fb82796 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "eslint-plugin-react-refresh": "^0.4.19", "globals": "^17.0.0", "tailwindcss": "^4.1.11", + "tsx": "^4.21.0", "typescript": "~5.7.2", "typescript-eslint": "^8.38.0", "vite": "^7.3.1" @@ -5700,6 +5701,19 @@ "node": ">=6" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "dev": true, @@ -6842,6 +6856,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/robust-predicates": { "version": "3.0.2", "license": "Unlicense" @@ -7117,6 +7141,40 @@ "version": "2.8.1", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "license": "MIT", diff --git a/package.json b/package.json index 67726b9..ff91f79 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "kill": "fuser -k 5000/tcp", "prebuild": "mkdir -p /tmp/dist || true", - "build": "tsc -b --noCheck && vite build", + "build": "npm run schemas:validate && tsc -b --noCheck && vite build", "lint": "eslint . --fix", "lint:check": "eslint .", "optimize": "vite optimize", @@ -21,6 +21,7 @@ "pages:list": "node scripts/list-pages.js", "pages:validate": "tsx src/config/validate-config.ts", "pages:generate": "node scripts/generate-page.js", + "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" @@ -107,6 +108,7 @@ "eslint-plugin-react-refresh": "^0.4.19", "globals": "^17.0.0", "tailwindcss": "^4.1.11", + "tsx": "^4.21.0", "typescript": "~5.7.2", "typescript-eslint": "^8.38.0", "vite": "^7.3.1" diff --git a/scripts/validate-json-schemas.ts b/scripts/validate-json-schemas.ts new file mode 100644 index 0000000..f6afb74 --- /dev/null +++ b/scripts/validate-json-schemas.ts @@ -0,0 +1,297 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { UIComponentSchema } from '../src/lib/json-ui/schema' + +interface ComponentDefinitionProp { + name: string + type: 'string' | 'number' | 'boolean' + options?: Array +} + +interface ComponentDefinition { + type: string + props?: ComponentDefinitionProp[] +} + +interface ComponentNode { + component: Record + path: string +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') + +const componentDefinitionsPath = path.join(rootDir, 'src/lib/component-definitions.json') +const componentRegistryPath = path.join(rootDir, 'src/lib/json-ui/component-registry.ts') +const jsonRegistryPath = path.join(rootDir, 'json-components-registry.json') + +const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8')) +const readText = (filePath: string) => fs.readFileSync(filePath, 'utf8') + +const componentDefinitions = readJson(componentDefinitionsPath) as ComponentDefinition[] +const componentDefinitionMap = new Map(componentDefinitions.map((def) => [def.type, def])) + +const jsonRegistry = readJson(jsonRegistryPath) as { + components?: Array<{ type?: string; name?: string; export?: string }> +} + +const extractObjectLiteral = (content: string, marker: string) => { + const markerIndex = content.indexOf(marker) + if (markerIndex === -1) { + throw new Error(`Unable to locate ${marker} in component registry file`) + } + const braceStart = content.indexOf('{', markerIndex) + if (braceStart === -1) { + throw new Error(`Unable to locate opening brace for ${marker}`) + } + let depth = 0 + for (let i = braceStart; i < content.length; i += 1) { + const char = content[i] + if (char === '{') depth += 1 + if (char === '}') depth -= 1 + if (depth === 0) { + return content.slice(braceStart, i + 1) + } + } + throw new Error(`Unable to locate closing brace for ${marker}`) +} + +const extractKeysFromObjectLiteral = (literal: string) => { + const body = literal.trim().replace(/^\{/, '').replace(/\}$/, '') + const entries = body + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const keys = new Set() + + entries.forEach((entry) => { + if (entry.startsWith('...')) { + return + } + const [keyPart] = entry.split(':') + const key = keyPart.trim() + if (key) { + keys.add(key) + } + }) + + return keys +} + +const componentRegistryContent = readText(componentRegistryPath) +const primitiveKeys = extractKeysFromObjectLiteral( + extractObjectLiteral(componentRegistryContent, 'export const primitiveComponents') +) +const shadcnKeys = extractKeysFromObjectLiteral( + extractObjectLiteral(componentRegistryContent, 'export const shadcnComponents') +) +const wrapperKeys = extractKeysFromObjectLiteral( + extractObjectLiteral(componentRegistryContent, 'export const jsonWrapperComponents') +) +const iconKeys = extractKeysFromObjectLiteral( + extractObjectLiteral(componentRegistryContent, 'export const iconComponents') +) + +const registryTypes = new Set( + (jsonRegistry.components ?? []) + .map((entry) => entry.type ?? entry.name ?? entry.export) + .filter((value): value is string => Boolean(value)) +) + +const validComponentTypes = new Set([ + ...primitiveKeys, + ...shadcnKeys, + ...wrapperKeys, + ...iconKeys, + ...componentDefinitions.map((def) => def.type), + ...registryTypes, +]) + +const schemaRoots = [ + path.join(rootDir, 'src/config'), + path.join(rootDir, 'src/data'), +] + +const collectJsonFiles = (dir: string, files: string[] = []) => { + if (!fs.existsSync(dir)) { + return files + } + const entries = fs.readdirSync(dir, { withFileTypes: true }) + entries.forEach((entry) => { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + collectJsonFiles(fullPath, files) + return + } + if (entry.isFile() && entry.name.endsWith('.json')) { + files.push(fullPath) + } + }) + return files +} + +const isComponentNode = (value: unknown): value is Record => { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Record + if (typeof candidate.id !== 'string' || typeof candidate.type !== 'string') { + return false + } + return ( + 'props' in candidate || + 'children' in candidate || + 'className' in candidate || + 'bindings' in candidate || + 'events' in candidate || + 'dataBinding' in candidate || + 'style' in candidate + ) +} + +const findComponents = (value: unknown, currentPath: string): ComponentNode[] => { + const components: ComponentNode[] = [] + if (Array.isArray(value)) { + value.forEach((item, index) => { + components.push(...findComponents(item, `${currentPath}[${index}]`)) + }) + return components + } + if (!value || typeof value !== 'object') { + return components + } + + const candidate = value as Record + if (isComponentNode(candidate)) { + components.push({ component: candidate, path: currentPath }) + } + + Object.entries(candidate).forEach(([key, child]) => { + const nextPath = currentPath ? `${currentPath}.${key}` : key + components.push(...findComponents(child, nextPath)) + }) + + return components +} + +const isTemplateBinding = (value: unknown) => + typeof value === 'string' && value.includes('{{') && value.includes('}}') + +const validateProps = ( + component: Record, + filePath: string, + componentPath: string, + errors: string[] +) => { + const definition = componentDefinitionMap.get(component.type as string) + const props = component.props + + if (!definition || !definition.props || !props || typeof props !== 'object') { + return + } + + const propDefinitions = new Map(definition.props.map((prop) => [prop.name, prop])) + + Object.entries(props as Record).forEach(([propName, propValue]) => { + const propDefinition = propDefinitions.get(propName) + if (!propDefinition) { + errors.push( + `${filePath} -> ${componentPath}: Unknown prop "${propName}" for component type "${component.type}"` + ) + return + } + + const expectedType = propDefinition.type + const actualType = Array.isArray(propValue) ? 'array' : typeof propValue + + if ( + expectedType === 'string' && + actualType !== 'string' && + propValue !== undefined + ) { + errors.push( + `${filePath} -> ${componentPath}: Prop "${propName}" expected string but got ${actualType}` + ) + return + } + + if ( + expectedType === 'number' && + actualType !== 'number' && + !isTemplateBinding(propValue) + ) { + errors.push( + `${filePath} -> ${componentPath}: Prop "${propName}" expected number but got ${actualType}` + ) + return + } + + if ( + expectedType === 'boolean' && + actualType !== 'boolean' && + !isTemplateBinding(propValue) + ) { + errors.push( + `${filePath} -> ${componentPath}: Prop "${propName}" expected boolean but got ${actualType}` + ) + return + } + + if (propDefinition.options && propValue !== undefined) { + if (!propDefinition.options.includes(propValue as string | number | boolean)) { + errors.push( + `${filePath} -> ${componentPath}: Prop "${propName}" value must be one of ${propDefinition.options.join(', ')}` + ) + } + } + }) +} + +const validateComponentsInFile = (filePath: string, errors: string[]) => { + let parsed: unknown + try { + parsed = readJson(filePath) + } catch (error) { + errors.push(`${filePath}: Unable to parse JSON - ${(error as Error).message}`) + return + } + + const components = findComponents(parsed, 'root') + if (components.length === 0) { + return + } + + components.forEach(({ component, path: componentPath }) => { + const parseResult = UIComponentSchema.safeParse(component) + if (!parseResult.success) { + const issueMessages = parseResult.error.issues + .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) + .join('\n') + errors.push( + `${filePath} -> ${componentPath}: Schema validation failed\n${issueMessages}` + ) + } + + if (!validComponentTypes.has(component.type as string)) { + errors.push( + `${filePath} -> ${componentPath}: Unknown component type "${component.type}"` + ) + } + + validateProps(component, filePath, componentPath, errors) + }) +} + +const jsonFiles = schemaRoots.flatMap((dir) => collectJsonFiles(dir)) +const errors: string[] = [] + +jsonFiles.forEach((filePath) => validateComponentsInFile(filePath, errors)) + +if (errors.length > 0) { + console.error('JSON schema validation failed:') + errors.forEach((error) => console.error(`- ${error}`)) + process.exit(1) +} + +console.log('JSON schema validation passed.')