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:
dishant-kumar-thakur
2026-02-18 23:45:51 +05:30
parent 83278b4370
commit f8d59cce0b
15 changed files with 2195 additions and 13 deletions

View File

@@ -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"]
}
}
}
}
}

View File

@@ -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)"
}
}
}
}
}

View 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>;
}

View 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
}
}
}
}
}

View 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": {}
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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"
}
}
}
}
}

View File

@@ -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.)"
}
}
}
}
}

View File

@@ -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
}
}
}
}
}

View 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');
}