mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 10:03:31 +01:00
feat(schemas): STRUCT-001 - JSON Schema Definition for v2 project format
Add 8 JSON schemas, SchemaValidator with Ajv v8, TS interfaces, and 33 tests. All smoke tests passing. Critical path for STRUCT-002 (Export Engine).
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/component-v2.json",
|
||||
"title": "OpenNoodl Component",
|
||||
"description": "Component metadata file (component.json) in the v2 multi-file format",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Unique component identifier (preserved from legacy project.json)"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Component folder name (last segment of the path)"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"description": "Human-readable display name"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Original Noodl legacy path (e.g. '/#Header') — preserved for cross-component references"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["root", "page", "visual", "logic", "cloud"],
|
||||
"description": "Component classification"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "UI category for the component type (e.g. 'visual', 'logic')"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Searchable tags"
|
||||
},
|
||||
"ports": {
|
||||
"type": "object",
|
||||
"description": "Component input/output port definitions",
|
||||
"properties": {
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/port" }
|
||||
},
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/port" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Paths to other components this component uses (for dependency graph)"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": "Component-level settings",
|
||||
"properties": {
|
||||
"canHaveChildren": { "type": "boolean" },
|
||||
"allowedInRoutes": { "type": "boolean" },
|
||||
"defaultDimensions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"width": { "type": "string" },
|
||||
"height": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modified": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Arbitrary component metadata (AI history, migration notes, etc.)",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"port": {
|
||||
"type": "object",
|
||||
"required": ["name", "type"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"type": {
|
||||
"description": "Port type — can be a string ('string', 'boolean', 'number', 'signal', '*') or a type object",
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "object" }
|
||||
]
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"description": "Default value for this port"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
"index": {
|
||||
"type": "number"
|
||||
},
|
||||
"plug": {
|
||||
"type": "string",
|
||||
"enum": ["input", "output", "input/output"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/connections-v2.json",
|
||||
"title": "OpenNoodl Component Connections",
|
||||
"description": "Connection (wire) definitions for a single component (connections.json) in the v2 format",
|
||||
"type": "object",
|
||||
"required": ["componentId", "connections"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"componentId": {
|
||||
"type": "string",
|
||||
"description": "ID of the parent component — must match component.json id"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Schema version for future migrations"
|
||||
},
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"description": "All connections (wires) in this component's graph",
|
||||
"items": { "$ref": "#/definitions/connection" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"connection": {
|
||||
"type": "object",
|
||||
"required": ["fromId", "fromProperty", "toId", "toProperty"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"fromId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "ID of the source node"
|
||||
},
|
||||
"fromProperty": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Output port name on the source node"
|
||||
},
|
||||
"toId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "ID of the target node"
|
||||
},
|
||||
"toProperty": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Input port name on the target node"
|
||||
},
|
||||
"annotation": {
|
||||
"type": "string",
|
||||
"enum": ["Deleted", "Changed", "Created"],
|
||||
"description": "Diff annotation (set by project differ)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
287
packages/noodl-editor/src/editor/src/schemas/index.ts
Normal file
287
packages/noodl-editor/src/editor/src/schemas/index.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* OpenNoodl v2 Project Format — Schema Exports
|
||||
*
|
||||
* Central export point for all JSON schemas, the validator, and related types.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { SchemaValidator, SCHEMA_IDS, validateSchema } from '@noodl-schemas';
|
||||
* // or
|
||||
* import { SchemaValidator } from '../../schemas';
|
||||
* ```
|
||||
*
|
||||
* @module noodl-editor/schemas
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
// ─── Schema JSON files ────────────────────────────────────────────────────────
|
||||
|
||||
export { default as projectV2Schema } from './project-v2.schema.json';
|
||||
export { default as componentSchema } from './component.schema.json';
|
||||
export { default as nodesSchema } from './nodes.schema.json';
|
||||
export { default as connectionsSchema } from './connections.schema.json';
|
||||
export { default as registrySchema } from './registry.schema.json';
|
||||
export { default as routesSchema } from './routes.schema.json';
|
||||
export { default as stylesSchema } from './styles.schema.json';
|
||||
export { default as modelSchema } from './model.schema.json';
|
||||
|
||||
// ─── Validator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
SchemaValidator,
|
||||
validateSchema,
|
||||
formatValidationErrors,
|
||||
SCHEMA_IDS
|
||||
} from './validator';
|
||||
|
||||
export type { ValidationResult, ValidationError, SchemaId } from './validator';
|
||||
|
||||
// ─── TypeScript interfaces for v2 format files ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Root project metadata (nodegx.project.json)
|
||||
*/
|
||||
export interface ProjectV2File {
|
||||
$schema?: string;
|
||||
name: string;
|
||||
id?: string;
|
||||
version: string;
|
||||
nodegxVersion: string;
|
||||
runtimeVersion?: 'react17' | 'react19';
|
||||
created?: string;
|
||||
modified?: string;
|
||||
settings?: {
|
||||
rootComponent?: string;
|
||||
defaultRoute?: string;
|
||||
bodyScroll?: boolean;
|
||||
headCode?: string;
|
||||
htmlTitle?: string;
|
||||
navigationPathType?: string;
|
||||
responsive?: { breakpoints?: string[] };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
structure?: {
|
||||
componentsDir?: string;
|
||||
modelsDir?: string;
|
||||
assetsDir?: string;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port definition used in component.json
|
||||
*/
|
||||
export interface PortDefinition {
|
||||
name: string;
|
||||
type: string | Record<string, unknown>;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
group?: string;
|
||||
index?: number;
|
||||
plug?: 'input' | 'output' | 'input/output';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component metadata file (component.json)
|
||||
*/
|
||||
export interface ComponentV2File {
|
||||
$schema?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
path?: string;
|
||||
type: 'root' | 'page' | 'visual' | 'logic' | 'cloud';
|
||||
description?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
ports?: {
|
||||
inputs?: PortDefinition[];
|
||||
outputs?: PortDefinition[];
|
||||
};
|
||||
dependencies?: string[];
|
||||
settings?: Record<string, unknown>;
|
||||
created?: string;
|
||||
modified?: string;
|
||||
modifiedBy?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port definition used in nodes.json
|
||||
*/
|
||||
export interface NodePort {
|
||||
name: string;
|
||||
type?: string | Record<string, unknown>;
|
||||
displayName?: string;
|
||||
plug?: string;
|
||||
group?: string;
|
||||
index?: number;
|
||||
default?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single node entry in nodes.json
|
||||
*/
|
||||
export interface NodeV2 {
|
||||
id: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
variant?: string;
|
||||
version?: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
stateParameters?: Record<string, Record<string, unknown>>;
|
||||
stateTransitions?: Record<string, Record<string, unknown>>;
|
||||
defaultStateTransitions?: Record<string, unknown>;
|
||||
ports?: NodePort[];
|
||||
dynamicports?: NodePort[];
|
||||
children?: string[];
|
||||
parent?: string;
|
||||
conflicts?: Record<string, unknown>[];
|
||||
annotation?: 'Deleted' | 'Created' | 'Changed';
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes file (nodes.json)
|
||||
*/
|
||||
export interface NodesV2File {
|
||||
$schema?: string;
|
||||
componentId: string;
|
||||
version?: number;
|
||||
nodes: NodeV2[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single connection entry in connections.json
|
||||
*/
|
||||
export interface ConnectionV2 {
|
||||
fromId: string;
|
||||
fromProperty: string;
|
||||
toId: string;
|
||||
toProperty: string;
|
||||
annotation?: 'Deleted' | 'Changed' | 'Created';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connections file (connections.json)
|
||||
*/
|
||||
export interface ConnectionsV2File {
|
||||
$schema?: string;
|
||||
componentId: string;
|
||||
version?: number;
|
||||
connections: ConnectionV2[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry in the component registry
|
||||
*/
|
||||
export interface RegistryComponentEntry {
|
||||
path: string;
|
||||
type: 'root' | 'page' | 'visual' | 'logic' | 'cloud';
|
||||
route?: string;
|
||||
created?: string;
|
||||
modified?: string;
|
||||
nodeCount?: number;
|
||||
connectionCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component registry file (_registry.json)
|
||||
*/
|
||||
export interface RegistryV2File {
|
||||
$schema?: string;
|
||||
version: number;
|
||||
lastUpdated?: string;
|
||||
components: Record<string, RegistryComponentEntry>;
|
||||
stats?: {
|
||||
totalComponents: number;
|
||||
totalNodes: number;
|
||||
totalConnections: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single route definition in routes.json
|
||||
*/
|
||||
export interface RouteDefinition {
|
||||
path: string;
|
||||
component: string;
|
||||
title?: string;
|
||||
exact?: boolean;
|
||||
redirect?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes file (nodegx.routes.json)
|
||||
*/
|
||||
export interface RoutesV2File {
|
||||
$schema?: string;
|
||||
version?: number;
|
||||
routes: RouteDefinition[];
|
||||
notFound?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Styles file (nodegx.styles.json)
|
||||
*/
|
||||
export interface StylesV2File {
|
||||
$schema?: string;
|
||||
version?: number;
|
||||
colors?: Record<string, string>;
|
||||
textStyles?: Record<string, Record<string, unknown>>;
|
||||
variants?: Array<{
|
||||
name: string;
|
||||
typename: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
stateParameters?: Record<string, Record<string, unknown>>;
|
||||
stateTransitions?: Record<string, Record<string, unknown>>;
|
||||
defaultStateTransitions?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
tokens?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model field definition
|
||||
*/
|
||||
export interface ModelField {
|
||||
name: string;
|
||||
type: 'String' | 'Number' | 'Boolean' | 'Date' | 'Object' | 'Array' | 'Pointer' | 'Relation' | 'File' | 'GeoPoint';
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
targetClass?: string;
|
||||
unique?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model definition file (models/<Name>.json)
|
||||
*/
|
||||
export interface ModelV2File {
|
||||
$schema?: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
fields: ModelField[];
|
||||
indexes?: Array<{
|
||||
name?: string;
|
||||
fields: string[];
|
||||
unique?: boolean;
|
||||
}>;
|
||||
acl?: Record<string, unknown>;
|
||||
created?: string;
|
||||
modified?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
116
packages/noodl-editor/src/editor/src/schemas/model.schema.json
Normal file
116
packages/noodl-editor/src/editor/src/schemas/model.schema.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/model-v2.json",
|
||||
"title": "OpenNoodl Data Model",
|
||||
"description": "Data model definition file (models/<ModelName>.json) describing a backend data schema",
|
||||
"type": "object",
|
||||
"required": ["name", "fields"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Model name (e.g. 'User', 'Session')"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"description": "Human-readable display name"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"className": {
|
||||
"type": "string",
|
||||
"description": "Backend class/collection name (defaults to name)"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"description": "Field definitions for this model",
|
||||
"items": { "$ref": "#/definitions/field" }
|
||||
},
|
||||
"indexes": {
|
||||
"type": "array",
|
||||
"description": "Database index definitions",
|
||||
"items": { "$ref": "#/definitions/index" }
|
||||
},
|
||||
"acl": {
|
||||
"type": "object",
|
||||
"description": "Access control list configuration",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modified": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"field": {
|
||||
"type": "object",
|
||||
"required": ["name", "type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Field name"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["String", "Number", "Boolean", "Date", "Object", "Array", "Pointer", "Relation", "File", "GeoPoint"],
|
||||
"description": "Field data type"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"default": {
|
||||
"description": "Default value for this field"
|
||||
},
|
||||
"targetClass": {
|
||||
"type": "string",
|
||||
"description": "For Pointer/Relation fields: the target model class name"
|
||||
},
|
||||
"unique": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"type": "object",
|
||||
"required": ["fields"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 1
|
||||
},
|
||||
"unique": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
packages/noodl-editor/src/editor/src/schemas/nodes.schema.json
Normal file
146
packages/noodl-editor/src/editor/src/schemas/nodes.schema.json
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/nodes-v2.json",
|
||||
"title": "OpenNoodl Component Nodes",
|
||||
"description": "Node graph definitions for a single component (nodes.json) in the v2 format",
|
||||
"type": "object",
|
||||
"required": ["componentId", "nodes"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"componentId": {
|
||||
"type": "string",
|
||||
"description": "ID of the parent component — must match component.json id"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Schema version for future migrations"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"description": "All nodes in this component's graph (roots and children flattened)",
|
||||
"items": { "$ref": "#/definitions/node" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"node": {
|
||||
"type": "object",
|
||||
"required": ["id", "type"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Unique node ID within the component"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Node type name — built-in (e.g. 'Group', 'Text') or component ref (e.g. '/#Header')"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "User-defined label for the node"
|
||||
},
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "Canvas X position"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"description": "Canvas Y position"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"description": "Variant name if this node uses a variant"
|
||||
},
|
||||
"version": {
|
||||
"type": "number",
|
||||
"description": "Node type version"
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": "Node parameter values (property bag)",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"stateParameters": {
|
||||
"type": "object",
|
||||
"description": "Visual state parameter overrides keyed by state name",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"stateTransitions": {
|
||||
"type": "object",
|
||||
"description": "Transition curves per state and parameter",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"defaultStateTransitions": {
|
||||
"type": "object",
|
||||
"description": "Default transition curves per state",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"ports": {
|
||||
"type": "array",
|
||||
"description": "User-defined instance ports on this node",
|
||||
"items": { "$ref": "#/definitions/port" }
|
||||
},
|
||||
"dynamicports": {
|
||||
"type": "array",
|
||||
"description": "Dynamically generated ports (e.g. from StringList inputs)",
|
||||
"items": { "$ref": "#/definitions/port" }
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"description": "IDs of child nodes (visual hierarchy)",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"parent": {
|
||||
"type": "string",
|
||||
"description": "ID of parent node (null/absent for root nodes)"
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
"description": "Merge conflict data (set by project differ)",
|
||||
"items": { "type": "object", "additionalProperties": true }
|
||||
},
|
||||
"annotation": {
|
||||
"type": "string",
|
||||
"enum": ["Deleted", "Created", "Changed"],
|
||||
"description": "Diff annotation (set by project differ)"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Arbitrary node metadata (AI prompt history, merge info, comments)",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"port": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"type": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "object" }
|
||||
]
|
||||
},
|
||||
"displayName": { "type": "string" },
|
||||
"plug": { "type": "string" },
|
||||
"group": { "type": "string" },
|
||||
"index": { "type": "number" },
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/project-v2.json",
|
||||
"title": "OpenNoodl Project",
|
||||
"description": "Root project metadata file for the v2 multi-file project format",
|
||||
"type": "object",
|
||||
"required": ["name", "version", "nodegxVersion"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Human-readable project name"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique project identifier"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Project data format version (e.g. '2.0')"
|
||||
},
|
||||
"nodegxVersion": {
|
||||
"type": "string",
|
||||
"description": "OpenNoodl editor version that created/last saved this project"
|
||||
},
|
||||
"runtimeVersion": {
|
||||
"type": "string",
|
||||
"enum": ["react17", "react19"],
|
||||
"description": "React runtime version for the viewer"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 creation timestamp"
|
||||
},
|
||||
"modified": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 last-modified timestamp"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": "Project-level settings",
|
||||
"properties": {
|
||||
"rootComponent": {
|
||||
"type": "string",
|
||||
"description": "Name of the root component"
|
||||
},
|
||||
"defaultRoute": {
|
||||
"type": "string",
|
||||
"description": "Default navigation route"
|
||||
},
|
||||
"bodyScroll": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"headCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"htmlTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"navigationPathType": {
|
||||
"type": "string"
|
||||
},
|
||||
"responsive": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"breakpoints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"structure": {
|
||||
"type": "object",
|
||||
"description": "Directory layout configuration",
|
||||
"properties": {
|
||||
"componentsDir": {
|
||||
"type": "string",
|
||||
"default": "components"
|
||||
},
|
||||
"modelsDir": {
|
||||
"type": "string",
|
||||
"default": "models"
|
||||
},
|
||||
"assetsDir": {
|
||||
"type": "string",
|
||||
"default": "assets"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Arbitrary project metadata (styles, cloudservices, appConfig, etc.)",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/registry-v2.json",
|
||||
"title": "OpenNoodl Component Registry",
|
||||
"description": "Component index/manifest file (_registry.json) listing all components in the project",
|
||||
"type": "object",
|
||||
"required": ["version", "components"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Registry format version"
|
||||
},
|
||||
"lastUpdated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 timestamp of last registry update"
|
||||
},
|
||||
"components": {
|
||||
"type": "object",
|
||||
"description": "Map of component name/path to component summary info",
|
||||
"additionalProperties": { "$ref": "#/definitions/componentEntry" }
|
||||
},
|
||||
"stats": {
|
||||
"type": "object",
|
||||
"description": "Aggregate project statistics",
|
||||
"properties": {
|
||||
"totalComponents": { "type": "integer", "minimum": 0 },
|
||||
"totalNodes": { "type": "integer", "minimum": 0 },
|
||||
"totalConnections": { "type": "integer", "minimum": 0 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"componentEntry": {
|
||||
"type": "object",
|
||||
"required": ["path", "type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Relative path to the component folder from the components/ directory"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["root", "page", "visual", "logic", "cloud"],
|
||||
"description": "Component classification"
|
||||
},
|
||||
"route": {
|
||||
"type": "string",
|
||||
"description": "URL route for page components (e.g. '/home')"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modified": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"nodeCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Number of nodes in this component"
|
||||
},
|
||||
"connectionCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Number of connections in this component"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/routes-v2.json",
|
||||
"title": "OpenNoodl Routes",
|
||||
"description": "Route definitions file (nodegx.routes.json) mapping URL paths to page components",
|
||||
"type": "object",
|
||||
"required": ["routes"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"routes": {
|
||||
"type": "array",
|
||||
"description": "Ordered list of route definitions",
|
||||
"items": { "$ref": "#/definitions/route" }
|
||||
},
|
||||
"notFound": {
|
||||
"type": "string",
|
||||
"description": "Component path to render for 404 / not-found routes"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"route": {
|
||||
"type": "object",
|
||||
"required": ["path", "component"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "URL path pattern (e.g. '/home', '/user/:id')"
|
||||
},
|
||||
"component": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Component name/path that handles this route"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Page title for this route"
|
||||
},
|
||||
"exact": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the path must match exactly"
|
||||
},
|
||||
"redirect": {
|
||||
"type": "string",
|
||||
"description": "Redirect target path (if this is a redirect route)"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Arbitrary route metadata (SEO, auth requirements, etc.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://opennoodl.dev/schemas/styles-v2.json",
|
||||
"title": "OpenNoodl Global Styles",
|
||||
"description": "Global styles and design tokens file (nodegx.styles.json)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"description": "Named color tokens",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"description": "CSS color value"
|
||||
}
|
||||
},
|
||||
"textStyles": {
|
||||
"type": "object",
|
||||
"description": "Named text style presets",
|
||||
"additionalProperties": { "$ref": "#/definitions/textStyle" }
|
||||
},
|
||||
"variants": {
|
||||
"type": "array",
|
||||
"description": "Visual variants (responsive overrides) for node types",
|
||||
"items": { "$ref": "#/definitions/variant" }
|
||||
},
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
"description": "Design token overrides (CSS custom properties)",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"textStyle": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"fontFamily": { "type": "string" },
|
||||
"fontSize": { "type": ["string", "number"] },
|
||||
"fontWeight": { "type": ["string", "number"] },
|
||||
"lineHeight": { "type": ["string", "number"] },
|
||||
"letterSpacing": { "type": ["string", "number"] },
|
||||
"color": { "type": "string" },
|
||||
"textTransform": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"variant": {
|
||||
"type": "object",
|
||||
"required": ["name", "typename"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Variant name"
|
||||
},
|
||||
"typename": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Node type this variant applies to"
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Parameter overrides for this variant"
|
||||
},
|
||||
"stateParameters": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"stateTransitions": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"defaultStateTransitions": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
258
packages/noodl-editor/src/editor/src/schemas/validator.ts
Normal file
258
packages/noodl-editor/src/editor/src/schemas/validator.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Schema Validator
|
||||
*
|
||||
* Ajv-based validation utilities for the v2 multi-file project format.
|
||||
* Validates project files against their JSON schemas before reading/writing.
|
||||
*
|
||||
* @module noodl-editor/schemas/validator
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import Ajv, { ValidateFunction } from 'ajv';
|
||||
// ajv-formats may resolve to a different Ajv version at the root node_modules.
|
||||
// Using require() + type assertion avoids the TS type mismatch while keeping
|
||||
// the same runtime behaviour (both are Ajv v8 compatible).
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const addFormats = require('ajv-formats') as (ajv: Ajv) => Ajv;
|
||||
|
||||
// Import schemas
|
||||
import projectV2Schema from './project-v2.schema.json';
|
||||
import componentSchema from './component.schema.json';
|
||||
import nodesSchema from './nodes.schema.json';
|
||||
import connectionsSchema from './connections.schema.json';
|
||||
import registrySchema from './registry.schema.json';
|
||||
import routesSchema from './routes.schema.json';
|
||||
import stylesSchema from './styles.schema.json';
|
||||
import modelSchema from './model.schema.json';
|
||||
|
||||
// ─── Schema IDs ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const SCHEMA_IDS = {
|
||||
PROJECT: 'https://opennoodl.dev/schemas/project-v2.json',
|
||||
COMPONENT: 'https://opennoodl.dev/schemas/component-v2.json',
|
||||
NODES: 'https://opennoodl.dev/schemas/nodes-v2.json',
|
||||
CONNECTIONS: 'https://opennoodl.dev/schemas/connections-v2.json',
|
||||
REGISTRY: 'https://opennoodl.dev/schemas/registry-v2.json',
|
||||
ROUTES: 'https://opennoodl.dev/schemas/routes-v2.json',
|
||||
STYLES: 'https://opennoodl.dev/schemas/styles-v2.json',
|
||||
MODEL: 'https://opennoodl.dev/schemas/model-v2.json'
|
||||
} as const;
|
||||
|
||||
export type SchemaId = (typeof SCHEMA_IDS)[keyof typeof SCHEMA_IDS];
|
||||
|
||||
// ─── Validation Result ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ValidationError {
|
||||
/** JSON path to the failing field (e.g. '/components/Header/type') */
|
||||
path: string;
|
||||
/** Human-readable error message */
|
||||
message: string;
|
||||
/** Ajv keyword that triggered the error */
|
||||
keyword: string;
|
||||
/** Additional error params from Ajv */
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
// ─── Validator class ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Singleton validator that compiles all schemas once and reuses them.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = SchemaValidator.instance.validate('component', myComponentJson);
|
||||
* if (!result.valid) {
|
||||
* console.error(result.errors);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SchemaValidator {
|
||||
private static _instance: SchemaValidator | undefined;
|
||||
|
||||
public static get instance(): SchemaValidator {
|
||||
if (!SchemaValidator._instance) {
|
||||
SchemaValidator._instance = new SchemaValidator();
|
||||
}
|
||||
return SchemaValidator._instance;
|
||||
}
|
||||
|
||||
private ajv: Ajv;
|
||||
private validators: Map<string, ValidateFunction>;
|
||||
|
||||
constructor() {
|
||||
this.ajv = new Ajv({
|
||||
allErrors: true, // Collect all errors, not just the first
|
||||
strict: false, // Allow unknown keywords (e.g. 'description' in definitions)
|
||||
validateFormats: true
|
||||
});
|
||||
|
||||
addFormats(this.ajv);
|
||||
|
||||
// Register all schemas
|
||||
this.ajv.addSchema(projectV2Schema);
|
||||
this.ajv.addSchema(componentSchema);
|
||||
this.ajv.addSchema(nodesSchema);
|
||||
this.ajv.addSchema(connectionsSchema);
|
||||
this.ajv.addSchema(registrySchema);
|
||||
this.ajv.addSchema(routesSchema);
|
||||
this.ajv.addSchema(stylesSchema);
|
||||
this.ajv.addSchema(modelSchema);
|
||||
|
||||
// Pre-compile validators for each schema
|
||||
this.validators = new Map([
|
||||
[SCHEMA_IDS.PROJECT, this.ajv.compile(projectV2Schema)],
|
||||
[SCHEMA_IDS.COMPONENT, this.ajv.compile(componentSchema)],
|
||||
[SCHEMA_IDS.NODES, this.ajv.compile(nodesSchema)],
|
||||
[SCHEMA_IDS.CONNECTIONS, this.ajv.compile(connectionsSchema)],
|
||||
[SCHEMA_IDS.REGISTRY, this.ajv.compile(registrySchema)],
|
||||
[SCHEMA_IDS.ROUTES, this.ajv.compile(routesSchema)],
|
||||
[SCHEMA_IDS.STYLES, this.ajv.compile(stylesSchema)],
|
||||
[SCHEMA_IDS.MODEL, this.ajv.compile(modelSchema)]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate data against a named schema.
|
||||
*
|
||||
* @param schemaId - One of the SCHEMA_IDS values
|
||||
* @param data - The parsed JSON object to validate
|
||||
* @returns ValidationResult with valid flag and any errors
|
||||
*/
|
||||
validate(schemaId: SchemaId, data: unknown): ValidationResult {
|
||||
const validator = this.validators.get(schemaId);
|
||||
|
||||
if (!validator) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
path: '',
|
||||
message: `Unknown schema ID: ${schemaId}`,
|
||||
keyword: 'schema',
|
||||
params: { schemaId }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const valid = validator(data) as boolean;
|
||||
|
||||
if (valid) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
const errors: ValidationError[] = (validator.errors ?? []).map((err) => ({
|
||||
path: err.instancePath || '/',
|
||||
message: err.message ?? 'Validation error',
|
||||
keyword: err.keyword,
|
||||
params: (err.params as Record<string, unknown>) ?? {}
|
||||
}));
|
||||
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and throw if invalid. Useful for strict import paths.
|
||||
*
|
||||
* @throws Error with formatted validation messages
|
||||
*/
|
||||
validateOrThrow(schemaId: SchemaId, data: unknown, context?: string): void {
|
||||
const result = this.validate(schemaId, data);
|
||||
|
||||
if (!result.valid) {
|
||||
const prefix = context ? `[${context}] ` : '';
|
||||
const messages = result.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
|
||||
throw new Error(`${prefix}Schema validation failed for ${schemaId}:\n${messages}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a project file (nodegx.project.json).
|
||||
*/
|
||||
validateProject(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.PROJECT, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a component metadata file (component.json).
|
||||
*/
|
||||
validateComponent(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.COMPONENT, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a nodes file (nodes.json).
|
||||
*/
|
||||
validateNodes(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.NODES, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a connections file (connections.json).
|
||||
*/
|
||||
validateConnections(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.CONNECTIONS, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a component registry file (_registry.json).
|
||||
*/
|
||||
validateRegistry(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.REGISTRY, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a routes file (nodegx.routes.json).
|
||||
*/
|
||||
validateRoutes(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.ROUTES, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a styles file (nodegx.styles.json).
|
||||
*/
|
||||
validateStyles(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.STYLES, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a model definition file (models/<Name>.json).
|
||||
*/
|
||||
validateModel(data: unknown): ValidationResult {
|
||||
return this.validate(SCHEMA_IDS.MODEL, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton (useful for testing).
|
||||
*/
|
||||
static reset(): void {
|
||||
SchemaValidator._instance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Convenience functions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate data against a schema. Uses the singleton validator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { valid, errors } = validateSchema(SCHEMA_IDS.COMPONENT, data);
|
||||
* ```
|
||||
*/
|
||||
export function validateSchema(schemaId: SchemaId, data: unknown): ValidationResult {
|
||||
return SchemaValidator.instance.validate(schemaId, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation errors into a human-readable string.
|
||||
*/
|
||||
export function formatValidationErrors(errors: ValidationError[]): string {
|
||||
if (errors.length === 0) return 'No errors';
|
||||
return errors.map((e) => ` • ${e.path || '/'}: ${e.message}`).join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user