mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Merge branch 'main' into codex/validate-json-schemas-at-build-time
This commit is contained in:
252
scripts/lint-json-ui-schemas.cjs
Normal file
252
scripts/lint-json-ui-schemas.cjs
Normal file
@@ -0,0 +1,252 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const definitionsPath = path.join(rootDir, 'src', 'lib', 'component-definitions.json')
|
||||
const schemaDirs = [
|
||||
path.join(rootDir, 'src', 'schemas'),
|
||||
path.join(rootDir, 'public', 'schemas'),
|
||||
]
|
||||
|
||||
const commonProps = new Set(['className', 'style', 'children'])
|
||||
const bindingSourceTypes = new Set(['data', 'bindings', 'state'])
|
||||
|
||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
const fileExists = (filePath) => fs.existsSync(filePath)
|
||||
|
||||
const componentDefinitions = readJson(definitionsPath)
|
||||
const definitionsByType = new Map(
|
||||
componentDefinitions
|
||||
.filter((definition) => definition.type)
|
||||
.map((definition) => [definition.type, definition])
|
||||
)
|
||||
|
||||
const errors = []
|
||||
|
||||
const reportError = (file, pathLabel, message) => {
|
||||
errors.push({ file, path: pathLabel, message })
|
||||
}
|
||||
|
||||
const collectSchemaFiles = (dirs) => {
|
||||
const files = []
|
||||
dirs.forEach((dir) => {
|
||||
if (!fileExists(dir)) return
|
||||
fs.readdirSync(dir).forEach((entry) => {
|
||||
if (!entry.endsWith('.json')) return
|
||||
files.push(path.join(dir, entry))
|
||||
})
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
const isPageSchema = (schema) =>
|
||||
schema
|
||||
&& typeof schema === 'object'
|
||||
&& schema.layout
|
||||
&& Array.isArray(schema.components)
|
||||
|
||||
const extractSchemas = (data, filePath) => {
|
||||
if (isPageSchema(data)) {
|
||||
return [{ name: filePath, schema: data }]
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const schemas = Object.entries(data)
|
||||
.filter(([, value]) => isPageSchema(value))
|
||||
.map(([key, value]) => ({ name: `${filePath}:${key}`, schema: value }))
|
||||
if (schemas.length > 0) {
|
||||
return schemas
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const validateBindings = (bindings, fileLabel, pathLabel, contextVars, dataSourceIds, definition) => {
|
||||
if (!bindings) return
|
||||
|
||||
const propDefinitions = definition?.props
|
||||
? new Map(definition.props.map((prop) => [prop.name, prop]))
|
||||
: null
|
||||
|
||||
Object.entries(bindings).forEach(([propName, binding]) => {
|
||||
if (propDefinitions) {
|
||||
if (!propDefinitions.has(propName) && !commonProps.has(propName)) {
|
||||
reportError(fileLabel, `${pathLabel}.bindings.${propName}`, `Invalid binding for unknown prop "${propName}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const propDefinition = propDefinitions.get(propName)
|
||||
if (propDefinition && propDefinition.supportsBinding !== true) {
|
||||
reportError(fileLabel, `${pathLabel}.bindings.${propName}`, `Binding not supported for prop "${propName}"`)
|
||||
}
|
||||
}
|
||||
|
||||
if (binding && typeof binding === 'object') {
|
||||
const sourceType = binding.sourceType ?? 'data'
|
||||
if (!bindingSourceTypes.has(sourceType)) {
|
||||
reportError(
|
||||
fileLabel,
|
||||
`${pathLabel}.bindings.${propName}.sourceType`,
|
||||
`Unsupported binding sourceType "${sourceType}"`
|
||||
)
|
||||
}
|
||||
|
||||
const source = binding.source
|
||||
if (source && sourceType !== 'state') {
|
||||
const isKnownSource = dataSourceIds.has(source) || contextVars.has(source)
|
||||
if (!isKnownSource) {
|
||||
reportError(
|
||||
fileLabel,
|
||||
`${pathLabel}.bindings.${propName}.source`,
|
||||
`Binding source "${source}" is not defined in dataSources or loop context`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const validateDataBinding = (dataBinding, fileLabel, pathLabel, contextVars, dataSourceIds) => {
|
||||
if (!dataBinding || typeof dataBinding !== 'object') return
|
||||
|
||||
const sourceType = dataBinding.sourceType ?? 'data'
|
||||
if (!bindingSourceTypes.has(sourceType)) {
|
||||
reportError(
|
||||
fileLabel,
|
||||
`${pathLabel}.dataBinding.sourceType`,
|
||||
`Unsupported dataBinding sourceType "${sourceType}"`
|
||||
)
|
||||
}
|
||||
|
||||
if (dataBinding.source && sourceType !== 'state') {
|
||||
const isKnownSource = dataSourceIds.has(dataBinding.source) || contextVars.has(dataBinding.source)
|
||||
if (!isKnownSource) {
|
||||
reportError(
|
||||
fileLabel,
|
||||
`${pathLabel}.dataBinding.source`,
|
||||
`Data binding source "${dataBinding.source}" is not defined in dataSources or loop context`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validateRequiredProps = (component, fileLabel, pathLabel, definition, bindings) => {
|
||||
if (!definition?.props) return
|
||||
|
||||
definition.props.forEach((prop) => {
|
||||
if (!prop.required) return
|
||||
|
||||
const hasProp = component.props && Object.prototype.hasOwnProperty.call(component.props, prop.name)
|
||||
const hasBinding = bindings && Object.prototype.hasOwnProperty.call(bindings, prop.name)
|
||||
|
||||
if (!hasProp && (!prop.supportsBinding || !hasBinding)) {
|
||||
reportError(
|
||||
fileLabel,
|
||||
`${pathLabel}.props.${prop.name}`,
|
||||
`Missing required prop "${prop.name}" for component type "${component.type}"`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const validateProps = (component, fileLabel, pathLabel, definition) => {
|
||||
if (!component.props || !definition?.props) return
|
||||
|
||||
const allowedProps = new Set(definition.props.map((prop) => prop.name))
|
||||
commonProps.forEach((prop) => allowedProps.add(prop))
|
||||
|
||||
Object.keys(component.props).forEach((propName) => {
|
||||
if (!allowedProps.has(propName)) {
|
||||
reportError(
|
||||
fileLabel,
|
||||
`${pathLabel}.props.${propName}`,
|
||||
`Invalid prop "${propName}" for component type "${component.type}"`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const lintComponent = (component, fileLabel, pathLabel, contextVars, dataSourceIds) => {
|
||||
if (!component || typeof component !== 'object') return
|
||||
|
||||
if (!component.id) {
|
||||
reportError(fileLabel, pathLabel, 'Missing required component id')
|
||||
}
|
||||
|
||||
if (!component.type) {
|
||||
reportError(fileLabel, pathLabel, 'Missing required component type')
|
||||
return
|
||||
}
|
||||
|
||||
const definition = definitionsByType.get(component.type)
|
||||
|
||||
validateProps(component, fileLabel, pathLabel, definition)
|
||||
validateRequiredProps(component, fileLabel, pathLabel, definition, component.bindings)
|
||||
validateBindings(component.bindings, fileLabel, pathLabel, contextVars, dataSourceIds, definition)
|
||||
validateDataBinding(component.dataBinding, fileLabel, pathLabel, contextVars, dataSourceIds)
|
||||
|
||||
const nextContextVars = new Set(contextVars)
|
||||
const repeatConfig = component.loop ?? component.repeat
|
||||
if (repeatConfig) {
|
||||
if (repeatConfig.itemVar) {
|
||||
nextContextVars.add(repeatConfig.itemVar)
|
||||
}
|
||||
if (repeatConfig.indexVar) {
|
||||
nextContextVars.add(repeatConfig.indexVar)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(component.children)) {
|
||||
component.children.forEach((child, index) => {
|
||||
if (typeof child === 'string') return
|
||||
lintComponent(child, fileLabel, `${pathLabel}.children[${index}]`, nextContextVars, dataSourceIds)
|
||||
})
|
||||
}
|
||||
|
||||
if (component.conditional) {
|
||||
const branches = [component.conditional.then, component.conditional.else]
|
||||
branches.forEach((branch, branchIndex) => {
|
||||
if (!branch) return
|
||||
if (typeof branch === 'string') return
|
||||
if (Array.isArray(branch)) {
|
||||
branch.forEach((child, index) => {
|
||||
if (typeof child === 'string') return
|
||||
lintComponent(child, fileLabel, `${pathLabel}.conditional.${branchIndex}[${index}]`, nextContextVars, dataSourceIds)
|
||||
})
|
||||
} else {
|
||||
lintComponent(branch, fileLabel, `${pathLabel}.conditional.${branchIndex}`, nextContextVars, dataSourceIds)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const lintSchema = (schema, fileLabel) => {
|
||||
const dataSourceIds = new Set(
|
||||
Array.isArray(schema.dataSources)
|
||||
? schema.dataSources.map((source) => source.id).filter(Boolean)
|
||||
: []
|
||||
)
|
||||
|
||||
schema.components.forEach((component, index) => {
|
||||
lintComponent(component, fileLabel, `components[${index}]`, new Set(), dataSourceIds)
|
||||
})
|
||||
}
|
||||
|
||||
const schemaFiles = collectSchemaFiles(schemaDirs)
|
||||
|
||||
schemaFiles.forEach((filePath) => {
|
||||
const data = readJson(filePath)
|
||||
const schemas = extractSchemas(data, filePath)
|
||||
schemas.forEach(({ name, schema }) => lintSchema(schema, name))
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('JSON UI lint errors found:')
|
||||
errors.forEach((error) => {
|
||||
console.error(`- ${error.file} :: ${error.path} :: ${error.message}`)
|
||||
})
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('JSON UI lint passed.')
|
||||
Reference in New Issue
Block a user