mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Add schema validation for JSON UI
This commit is contained in:
58
package-lock.json
generated
58
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
297
scripts/validate-json-schemas.ts
Normal file
297
scripts/validate-json-schemas.ts
Normal file
@@ -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<string | number | boolean>
|
||||
}
|
||||
|
||||
interface ComponentDefinition {
|
||||
type: string
|
||||
props?: ComponentDefinitionProp[]
|
||||
}
|
||||
|
||||
interface ComponentNode {
|
||||
component: Record<string, unknown>
|
||||
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<string>()
|
||||
|
||||
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<string>(
|
||||
(jsonRegistry.components ?? [])
|
||||
.map((entry) => entry.type ?? entry.name ?? entry.export)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
|
||||
const validComponentTypes = new Set<string>([
|
||||
...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<string, unknown> => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidate = value as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>,
|
||||
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<string, unknown>).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.')
|
||||
Reference in New Issue
Block a user