diff --git a/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md b/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md index 123b43b..a9d8a1a 100644 --- a/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md +++ b/dev-docs/tasks/phase-10-ai-powered-development/PROGRESS-dishant.md @@ -1,19 +1,92 @@ -# Phase 10 Progress — Dishant -**Branch:** cline-dev-dishant +# Phase 10A — AI-Powered Development: Dishant's Progress + +**Developer:** Dishant +**Branch:** `cline-dev-dishant` **Last Updated:** 2026-02-18 -## Completed This Sprint +--- -| Task | Name | Completed | Notes | -|------|------|-----------|-------| -| — | — | — | — | +## Task Status -## In Progress +| Task ID | Title | Status | Notes | +|------------|------------------------------|-------------|--------------------------------------------| +| STRUCT-001 | JSON Schema Definition | ✅ Complete | 8 schemas + validator + tests (33/33 pass) | +| STRUCT-002 | Export Engine Core | 🔜 Next | Depends on STRUCT-001 | -| Task | Name | Started | Blocker | -|------|------|---------|---------| -| — | — | — | Starting after CF11-006/007 | +--- -## Decisions & Learnings +## STRUCT-001 — JSON Schema Definition ✅ -- [2026-02-18] Sprint 1 started. Phase 10 work begins after CF11-006 and CF11-007 are complete. Starting point will be STRUCT-001 (JSON Schema Definition) — the critical path entry point for all AI features. +**Completed:** 2026-02-18 +**Scope:** Define JSON schemas for the v2 multi-file project format + +### What was built + +All files live under `packages/noodl-editor/src/editor/src/schemas/`: + +#### Schema files (8 total) + +| File | Schema ID | Describes | +|------|-----------|-----------| +| `project-v2.schema.json` | `https://opennoodl.dev/schemas/project-v2.json` | Root project metadata (`nodegx.project.json`) | +| `component.schema.json` | `https://opennoodl.dev/schemas/component-v2.json` | Component metadata (`component.json`) | +| `nodes.schema.json` | `https://opennoodl.dev/schemas/nodes-v2.json` | Node graph definitions (`nodes.json`) | +| `connections.schema.json` | `https://opennoodl.dev/schemas/connections-v2.json` | Connection/wire definitions (`connections.json`) | +| `registry.schema.json` | `https://opennoodl.dev/schemas/registry-v2.json` | Component index (`_registry.json`) | +| `routes.schema.json` | `https://opennoodl.dev/schemas/routes-v2.json` | URL route definitions (`nodegx.routes.json`) | +| `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants (`nodegx.styles.json`) | +| `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions (`models/.json`) | + +#### Validator (`validator.ts`) + +- `SchemaValidator` singleton class — compiles all 8 schemas once, reuses validators +- `SCHEMA_IDS` const — typed schema ID map +- `validateSchema()` convenience function +- `validateOrThrow()` — throws with context on failure +- Per-schema convenience methods: `validateProject()`, `validateComponent()`, etc. +- `formatValidationErrors()` — human-readable error formatting +- Ajv v8 with `ajv-formats` for `date-time` format validation +- `allErrors: true` — collects all errors, not just first + +#### Index (`index.ts`) + +- Re-exports all schemas, validator, and TypeScript interfaces +- Full TS interfaces for all 8 file types: `ProjectV2File`, `ComponentV2File`, `NodesV2File`, `ConnectionsV2File`, `RegistryV2File`, `RoutesV2File`, `StylesV2File`, `ModelV2File` + +#### Tests (`tests/schemas/schema-validator.test.ts`) + +- 33 test cases covering all 8 schemas +- Valid minimal fixtures, full fixtures with all optional fields +- Invalid cases: missing required fields, wrong enum values, invalid formats +- Edge cases: legacy component refs (`/#Header`), complex port type objects, deeply nested metadata +- Registered in `tests/index.ts` → `tests/schemas/index.ts` + +### Dependencies added + +```json +"ajv": "^8.x", +"ajv-formats": "^2.x" +``` + +Added to `packages/noodl-editor/package.json` dependencies. + +### Key design decisions + +1. **`additionalProperties: true` on nodes/connections** — node parameters and connection metadata are open-ended by design; the schema validates structure, not content +2. **Port type is `oneOf [string, object]`** — Noodl uses both `"string"` and `{ name: "stringlist", ... }` type formats +3. **`strict: false` on Ajv** — schemas use `description` in `definitions` which Ajv strict mode rejects +4. **`require()` for `ajv-formats`** — avoids TS type conflict between root-level `ajv-formats` (which bundles its own Ajv) and the package-local Ajv v8 + +### Verification + +``` +33/33 smoke tests passed (node smoke-test-schemas.js) +0 TypeScript errors +``` + +--- + +## Next: STRUCT-002 — Export Engine Core + +**Unblocked by:** STRUCT-001 ✅ +**Goal:** Build the engine that converts the legacy `project.json` format into the v2 multi-file directory structure, using the schemas defined in STRUCT-001 for validation. diff --git a/packages/noodl-editor/package.json b/packages/noodl-editor/package.json index dbb514d..249a9b7 100644 --- a/packages/noodl-editor/package.json +++ b/packages/noodl-editor/package.json @@ -1 +1 @@ -{"name":"noodl-editor","productName":"OpenNoodl","description":"Full stack low code React app builder","author":"The Low Code Foundation","homepage":"https://thelowcodefoundation.com","version":"1.1.0","main":"src/main/main.bundle.js","scripts":{"build":"npx ts-node -P ./tsconfig.build.json ./scripts/build.ts","start":"webpack-dev-server --config=webpackconfigs/webpack.renderer.dev.js","start:_dev":"electron . --dev","test":"webpack-dev-server --config=webpackconfigs/webpack.test.js","test:_start_electron":"electron test.js","test:ci":"webpack-cli --config=webpackconfigs/webpack.test-ci.js && electron test.js"},"build":{"appId":"com.opennoodl.app","afterSign":"./build/macos-notarize.js","mac":{"hardenedRuntime":true,"entitlements":"build/entitlements.mac.plist","extendInfo":{"LSMultipleInstancesProhibited":true,"NSMicrophoneUsageDescription":"Allow OpenNoodl apps that you create and run to access the microphone?","NSCameraUsageDescription":"Allow OpenNoodl apps that you create and run to access the camera?"}},"win":{"target":"nsis"},"nsis":{"guid":"com.opennoodl.app"},"linux":{"target":"deb"},"protocols":{"name":"opennoodl","schemes":["opennoodl"]},"npmRebuild":false,"files":["*.js","src","node_modules","!node_modules/monaco-editor","node_modules/monaco-editor/esm","node_modules/dugite","!test.js","!src/main/main.js","!src/main/src","!src/editor/src","!src/frames/viewer-frame/src","!src/shared"]},"dependencies":{"@anthropic-ai/sdk":"^0.71.2","@babel/parser":"^7.28.5","@blockly/theme-dark":"^8.0.3","@electron/remote":"^2.1.3","@jaames/iro":"^5.5.2","@microlink/react-json-view":"^1.27.0","@microsoft/fetch-event-source":"^2.0.1","@noodl/git":"file:../noodl-git","@noodl/noodl-parse-dashboard":"file:../noodl-parse-dashboard","@noodl/platform":"file:../noodl-platform","@noodl/platform-electron":"file:../noodl-platform-electron","@octokit/auth-oauth-device":"^7.1.5","@octokit/rest":"^20.1.2","about-window":"^1.15.2","algoliasearch":"^5.35.0","archiver":"^5.3.2","async":"^3.2.6","blockly":"^12.3.1","classnames":"^2.5.1","dagre":"^0.8.5","diff3":"0.0.4","electron-store":"^8.2.0","electron-updater":"^6.6.2","express":"^4.21.2","highlight.js":"^11.11.1","isbinaryfile":"^5.0.4","md5":"^2.3.0","md5-file":"^5.0.0","mixpanel-browser":"^2.69.1","mkdirp":"0.5.1","mkdirp-sync":"0.0.2","monaco-editor":"^0.34.1","react":"19.0.0","react-dom":"19.0.0","react-hot-toast":"^2.6.0","react-instantsearch":"^7.16.2","react-markdown":"^9.1.0","react-rnd":"^10.5.2","remark-gfm":"^4.0.1","remarkable":"^2.0.1","s3":"github:noodlapp/node-s3-client","string.prototype.matchall":"^4.0.12","underscore":"^1.13.7","webpack":"^5.101.3","websocket-stream":"^5.5.2","ws":"^8.18.3"},"devDependencies":{"@babel/core":"^7.28.3","@babel/preset-react":"^7.27.1","@svgr/webpack":"^6.5.1","@types/checksum":"^0.1.35","@types/dagre":"^0.7.52","@types/jasmine":"^4.6.5","@types/jquery":"^3.5.33","@types/react":"^19.2.7","@types/react-dom":"^19.2.3","@types/remarkable":"^2.0.8","@types/rimraf":"^3.0.2","@types/split2":"^3.2.1","@types/string.prototype.matchall":"^4.0.4","@types/underscore":"^1.13.0","@types/webpack-env":"^1.18.8","babel-loader":"^8.4.1","concurrently":"^7.6.0","css-loader":"^6.11.0","electron":"31.3.1","electron-builder":"^24.13.3","file-loader":"^6.2.0","html-loader":"^3.1.2","monaco-editor-webpack-plugin":"^7.1.0","ncp":"^2.0.0","rimraf":"^3.0.2","sass":"^1.90.0","sass-loader":"^12.6.0","stringify":"^5.2.0","style-loader":"^3.3.4","ts-loader":"^9.5.4","ts-node":"^10.9.2","typescript":"^5.9.3","url-loader":"^4.1.1","webpack":"^5.101.3","webpack-cli":"^4.10.0","webpack-dev-server":"^4.15.2","webpack-merge":"^5.10.0"},"engines":{"npm":">=6.0.0","node":">=16.0.0"},"optionalDependencies":{"dmg-license":"^1.0.11"}} \ No newline at end of file +{"name":"noodl-editor","productName":"OpenNoodl","description":"Full stack low code React app builder","author":"The Low Code Foundation","homepage":"https://thelowcodefoundation.com","version":"1.1.0","main":"src/main/main.bundle.js","scripts":{"build":"npx ts-node -P ./tsconfig.build.json ./scripts/build.ts","start":"webpack-dev-server --config=webpackconfigs/webpack.renderer.dev.js","start:_dev":"electron . --dev","test":"webpack-dev-server --config=webpackconfigs/webpack.test.js","test:_start_electron":"electron test.js","test:ci":"webpack-cli --config=webpackconfigs/webpack.test-ci.js && electron test.js"},"build":{"appId":"com.opennoodl.app","afterSign":"./build/macos-notarize.js","mac":{"hardenedRuntime":true,"entitlements":"build/entitlements.mac.plist","extendInfo":{"LSMultipleInstancesProhibited":true,"NSMicrophoneUsageDescription":"Allow OpenNoodl apps that you create and run to access the microphone?","NSCameraUsageDescription":"Allow OpenNoodl apps that you create and run to access the camera?"}},"win":{"target":"nsis"},"nsis":{"guid":"com.opennoodl.app"},"linux":{"target":"deb"},"protocols":{"name":"opennoodl","schemes":["opennoodl"]},"npmRebuild":false,"files":["*.js","src","node_modules","!node_modules/monaco-editor","node_modules/monaco-editor/esm","node_modules/dugite","!test.js","!src/main/main.js","!src/main/src","!src/editor/src","!src/frames/viewer-frame/src","!src/shared"]},"dependencies":{"@anthropic-ai/sdk":"^0.71.2","@babel/parser":"^7.28.5","@blockly/theme-dark":"^8.0.3","@electron/remote":"^2.1.3","@jaames/iro":"^5.5.2","@microlink/react-json-view":"^1.27.0","@microsoft/fetch-event-source":"^2.0.1","@noodl/git":"file:../noodl-git","@noodl/noodl-parse-dashboard":"file:../noodl-parse-dashboard","@noodl/platform":"file:../noodl-platform","@noodl/platform-electron":"file:../noodl-platform-electron","@octokit/auth-oauth-device":"^7.1.5","@octokit/rest":"^20.1.2","about-window":"^1.15.2","ajv":"^8.18.0","ajv-formats":"^2.1.1","algoliasearch":"^5.35.0","archiver":"^5.3.2","async":"^3.2.6","blockly":"^12.3.1","classnames":"^2.5.1","dagre":"^0.8.5","diff3":"0.0.4","electron-store":"^8.2.0","electron-updater":"^6.6.2","express":"^4.21.2","highlight.js":"^11.11.1","isbinaryfile":"^5.0.4","md5":"^2.3.0","md5-file":"^5.0.0","mixpanel-browser":"^2.69.1","mkdirp":"0.5.1","mkdirp-sync":"0.0.2","monaco-editor":"^0.34.1","react":"19.0.0","react-dom":"19.0.0","react-hot-toast":"^2.6.0","react-instantsearch":"^7.16.2","react-markdown":"^9.1.0","react-rnd":"^10.5.2","remark-gfm":"^4.0.1","remarkable":"^2.0.1","s3":"github:noodlapp/node-s3-client","string.prototype.matchall":"^4.0.12","underscore":"^1.13.7","webpack":"^5.101.3","websocket-stream":"^5.5.2","ws":"^8.18.3"},"devDependencies":{"@babel/core":"^7.28.3","@babel/preset-react":"^7.27.1","@svgr/webpack":"^6.5.1","@types/checksum":"^0.1.35","@types/dagre":"^0.7.52","@types/jasmine":"^4.6.5","@types/jquery":"^3.5.33","@types/react":"^19.2.7","@types/react-dom":"^19.2.3","@types/remarkable":"^2.0.8","@types/rimraf":"^3.0.2","@types/split2":"^3.2.1","@types/string.prototype.matchall":"^4.0.4","@types/underscore":"^1.13.0","@types/webpack-env":"^1.18.8","babel-loader":"^8.4.1","concurrently":"^7.6.0","css-loader":"^6.11.0","electron":"31.3.1","electron-builder":"^24.13.3","file-loader":"^6.2.0","html-loader":"^3.1.2","monaco-editor-webpack-plugin":"^7.1.0","ncp":"^2.0.0","rimraf":"^3.0.2","sass":"^1.90.0","sass-loader":"^12.6.0","stringify":"^5.2.0","style-loader":"^3.3.4","ts-loader":"^9.5.4","ts-node":"^10.9.2","typescript":"^5.9.3","url-loader":"^4.1.1","webpack":"^5.101.3","webpack-cli":"^4.10.0","webpack-dev-server":"^4.15.2","webpack-merge":"^5.10.0"},"engines":{"npm":">=6.0.0","node":">=16.0.0"},"optionalDependencies":{"dmg-license":"^1.0.11"}} \ No newline at end of file diff --git a/packages/noodl-editor/src/editor/src/schemas/component.schema.json b/packages/noodl-editor/src/editor/src/schemas/component.schema.json new file mode 100644 index 0000000..df46d88 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/component.schema.json @@ -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"] + } + } + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/connections.schema.json b/packages/noodl-editor/src/editor/src/schemas/connections.schema.json new file mode 100644 index 0000000..3f5f1ff --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/connections.schema.json @@ -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)" + } + } + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/index.ts b/packages/noodl-editor/src/editor/src/schemas/index.ts new file mode 100644 index 0000000..495c041 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/index.ts @@ -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; +} + +/** + * Port definition used in component.json + */ +export interface PortDefinition { + name: string; + type: string | Record; + 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; + created?: string; + modified?: string; + modifiedBy?: string; + metadata?: Record; +} + +/** + * Port definition used in nodes.json + */ +export interface NodePort { + name: string; + type?: string | Record; + 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; + stateParameters?: Record>; + stateTransitions?: Record>; + defaultStateTransitions?: Record; + ports?: NodePort[]; + dynamicports?: NodePort[]; + children?: string[]; + parent?: string; + conflicts?: Record[]; + annotation?: 'Deleted' | 'Created' | 'Changed'; + metadata?: Record; + [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; + 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; +} + +/** + * 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; + textStyles?: Record>; + variants?: Array<{ + name: string; + typename: string; + parameters?: Record; + stateParameters?: Record>; + stateTransitions?: Record>; + defaultStateTransitions?: Record; + [key: string]: unknown; + }>; + tokens?: Record; + metadata?: Record; +} + +/** + * 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/.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; + created?: string; + modified?: string; + metadata?: Record; +} diff --git a/packages/noodl-editor/src/editor/src/schemas/model.schema.json b/packages/noodl-editor/src/editor/src/schemas/model.schema.json new file mode 100644 index 0000000..6a2d5a1 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/model.schema.json @@ -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/.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 + } + } + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/nodes.schema.json b/packages/noodl-editor/src/editor/src/schemas/nodes.schema.json new file mode 100644 index 0000000..333449d --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/nodes.schema.json @@ -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": {} + } + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/project-v2.schema.json b/packages/noodl-editor/src/editor/src/schemas/project-v2.schema.json new file mode 100644 index 0000000..a9af580 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/project-v2.schema.json @@ -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 + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/registry.schema.json b/packages/noodl-editor/src/editor/src/schemas/registry.schema.json new file mode 100644 index 0000000..9b74a21 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/registry.schema.json @@ -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" + } + } + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/routes.schema.json b/packages/noodl-editor/src/editor/src/schemas/routes.schema.json new file mode 100644 index 0000000..1125769 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/routes.schema.json @@ -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.)" + } + } + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/styles.schema.json b/packages/noodl-editor/src/editor/src/schemas/styles.schema.json new file mode 100644 index 0000000..5148b0d --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/styles.schema.json @@ -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 + } + } + } + } +} diff --git a/packages/noodl-editor/src/editor/src/schemas/validator.ts b/packages/noodl-editor/src/editor/src/schemas/validator.ts new file mode 100644 index 0000000..518d09e --- /dev/null +++ b/packages/noodl-editor/src/editor/src/schemas/validator.ts @@ -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; +} + +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; + + 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) ?? {} + })); + + 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/.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'); +} diff --git a/packages/noodl-editor/tests/index.ts b/packages/noodl-editor/tests/index.ts index 9db6932..5c202c3 100644 --- a/packages/noodl-editor/tests/index.ts +++ b/packages/noodl-editor/tests/index.ts @@ -11,3 +11,4 @@ export * from './project'; export * from './projectmerger'; export * from './projectpatcher'; export * from './utils'; +export * from './schemas'; diff --git a/packages/noodl-editor/tests/schemas/index.ts b/packages/noodl-editor/tests/schemas/index.ts new file mode 100644 index 0000000..17988c6 --- /dev/null +++ b/packages/noodl-editor/tests/schemas/index.ts @@ -0,0 +1 @@ +export * from './schema-validator.test'; diff --git a/packages/noodl-editor/tests/schemas/schema-validator.test.ts b/packages/noodl-editor/tests/schemas/schema-validator.test.ts new file mode 100644 index 0000000..7fad7f2 --- /dev/null +++ b/packages/noodl-editor/tests/schemas/schema-validator.test.ts @@ -0,0 +1,745 @@ +/** + * Schema Validator Tests — STRUCT-001 + * + * Tests for the Ajv-based validation utilities covering all 8 v2 format schemas. + * Each schema is tested with a valid minimal fixture and key invalid cases. + */ + +import { SchemaValidator, SCHEMA_IDS, validateSchema, formatValidationErrors } from '../../src/editor/src/schemas/validator'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const validProject = { + name: 'My Conference App', + version: '2.0', + nodegxVersion: '1.1.0' +}; + +const validComponent = { + id: 'comp_header_abc123', + name: 'Header', + type: 'visual' +}; + +const validNodes = { + componentId: 'comp_header_abc123', + version: 1, + nodes: [ + { + id: 'node_root_001', + type: 'Group', + label: 'Header Container', + x: 0, + y: 0, + parameters: { layout: 'row' } + } + ] +}; + +const validConnections = { + componentId: 'comp_header_abc123', + version: 1, + connections: [ + { + fromId: 'node_root_001', + fromProperty: 'onClick', + toId: 'node_root_002', + toProperty: 'trigger' + } + ] +}; + +const validRegistry = { + version: 1, + lastUpdated: '2026-02-18T00:00:00.000Z', + components: { + Header: { + path: 'Header', + type: 'visual', + nodeCount: 10, + connectionCount: 5 + } + }, + stats: { + totalComponents: 1, + totalNodes: 10, + totalConnections: 5 + } +}; + +const validRoutes = { + routes: [ + { path: '/home', component: 'pages/HomePage' }, + { path: '/profile', component: 'pages/ProfilePage', title: 'Profile' } + ] +}; + +const validStyles = { + version: 1, + colors: { + primary: '#3B82F6', + background: '#ffffff' + }, + textStyles: { + heading: { fontSize: '24px', fontWeight: '700' } + } +}; + +const validModel = { + name: 'User', + fields: [ + { name: 'username', type: 'String', required: true }, + { name: 'email', type: 'String', unique: true }, + { name: 'age', type: 'Number' } + ] +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('SchemaValidator', () => { + beforeEach(() => { + // Reset singleton between tests to ensure clean state + SchemaValidator.reset(); + }); + + describe('singleton', () => { + it('returns the same instance on repeated calls', () => { + const a = SchemaValidator.instance; + const b = SchemaValidator.instance; + expect(a).toBe(b); + }); + + it('creates a new instance after reset()', () => { + const a = SchemaValidator.instance; + SchemaValidator.reset(); + const b = SchemaValidator.instance; + expect(a).not.toBe(b); + }); + }); + + // ─── project-v2.schema.json ───────────────────────────────────────────────── + + describe('validateProject', () => { + it('accepts a valid minimal project file', () => { + const result = SchemaValidator.instance.validateProject(validProject); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + }); + + it('accepts a full project file with all optional fields', () => { + const result = SchemaValidator.instance.validateProject({ + ...validProject, + id: 'proj_abc123', + runtimeVersion: 'react19', + created: '2026-01-01T00:00:00.000Z', + modified: '2026-02-18T00:00:00.000Z', + settings: { rootComponent: 'App', defaultRoute: '/home' }, + structure: { componentsDir: 'components', modelsDir: 'models', assetsDir: 'assets' }, + metadata: { styles: {}, cloudservices: {} } + }); + expect(result.valid).toBe(true); + }); + + it('rejects when name is missing', () => { + const result = SchemaValidator.instance.validateProject({ version: '2.0', nodegxVersion: '1.1.0' }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.message.includes('name'))).toBe(true); + }); + + it('rejects when version is missing', () => { + const result = SchemaValidator.instance.validateProject({ name: 'Test', nodegxVersion: '1.1.0' }); + expect(result.valid).toBe(false); + }); + + it('rejects when nodegxVersion is missing', () => { + const result = SchemaValidator.instance.validateProject({ name: 'Test', version: '2.0' }); + expect(result.valid).toBe(false); + }); + + it('rejects invalid runtimeVersion enum value', () => { + const result = SchemaValidator.instance.validateProject({ + ...validProject, + runtimeVersion: 'react18' + }); + expect(result.valid).toBe(false); + }); + + it('rejects invalid date-time format for created', () => { + const result = SchemaValidator.instance.validateProject({ + ...validProject, + created: 'not-a-date' + }); + expect(result.valid).toBe(false); + }); + + it('rejects non-object input', () => { + expect(SchemaValidator.instance.validateProject(null).valid).toBe(false); + expect(SchemaValidator.instance.validateProject('string').valid).toBe(false); + expect(SchemaValidator.instance.validateProject(42).valid).toBe(false); + }); + }); + + // ─── component.schema.json ────────────────────────────────────────────────── + + describe('validateComponent', () => { + it('accepts a valid minimal component file', () => { + const result = SchemaValidator.instance.validateComponent(validComponent); + expect(result.valid).toBe(true); + }); + + it('accepts a full component with ports and dependencies', () => { + const result = SchemaValidator.instance.validateComponent({ + ...validComponent, + displayName: 'Site Header', + path: '/#Header', + description: 'Main navigation header', + category: 'layout', + tags: ['navigation', 'header'], + ports: { + inputs: [ + { name: 'userName', type: 'string', displayName: 'User Name' }, + { name: 'isLoggedIn', type: 'boolean', default: false } + ], + outputs: [ + { name: 'onLogout', type: 'signal' } + ] + }, + dependencies: ['shared/Avatar', 'shared/Button'], + created: '2026-01-01T00:00:00.000Z', + modified: '2026-02-18T00:00:00.000Z' + }); + expect(result.valid).toBe(true); + }); + + it('rejects when id is missing', () => { + const result = SchemaValidator.instance.validateComponent({ name: 'Header', type: 'visual' }); + expect(result.valid).toBe(false); + }); + + it('rejects when name is missing', () => { + const result = SchemaValidator.instance.validateComponent({ id: 'comp_abc', type: 'visual' }); + expect(result.valid).toBe(false); + }); + + it('rejects invalid type enum', () => { + const result = SchemaValidator.instance.validateComponent({ ...validComponent, type: 'widget' }); + expect(result.valid).toBe(false); + }); + + it('accepts all valid type enum values', () => { + const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const; + for (const type of types) { + const result = SchemaValidator.instance.validateComponent({ ...validComponent, type }); + expect(result.valid).toBe(true); + } + }); + + it('rejects port with missing name', () => { + const result = SchemaValidator.instance.validateComponent({ + ...validComponent, + ports: { inputs: [{ type: 'string' }] } + }); + expect(result.valid).toBe(false); + }); + }); + + // ─── nodes.schema.json ────────────────────────────────────────────────────── + + describe('validateNodes', () => { + it('accepts a valid nodes file', () => { + const result = SchemaValidator.instance.validateNodes(validNodes); + expect(result.valid).toBe(true); + }); + + it('accepts an empty nodes array', () => { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + nodes: [] + }); + expect(result.valid).toBe(true); + }); + + it('accepts nodes with all optional fields', () => { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + version: 1, + nodes: [ + { + id: 'node_001', + type: 'Group', + label: 'Container', + x: 100, + y: 200, + variant: 'primary', + parameters: { layout: 'row', gap: '12px' }, + stateParameters: { hover: { opacity: 0.8 } }, + children: ['node_002'], + metadata: { comment: 'Main container' } + }, + { + id: 'node_002', + type: '/#Header', + parent: 'node_001', + dynamicports: [{ name: 'item-0-label', type: 'string' }] + } + ] + }); + expect(result.valid).toBe(true); + }); + + it('rejects when componentId is missing', () => { + const result = SchemaValidator.instance.validateNodes({ nodes: [] }); + expect(result.valid).toBe(false); + }); + + it('rejects when nodes is missing', () => { + const result = SchemaValidator.instance.validateNodes({ componentId: 'comp_abc' }); + expect(result.valid).toBe(false); + }); + + it('rejects a node with missing id', () => { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + nodes: [{ type: 'Group' }] + }); + expect(result.valid).toBe(false); + }); + + it('rejects a node with missing type', () => { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + nodes: [{ id: 'node_001' }] + }); + expect(result.valid).toBe(false); + }); + + it('accepts valid annotation values', () => { + const annotations = ['Deleted', 'Created', 'Changed'] as const; + for (const annotation of annotations) { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + nodes: [{ id: 'node_001', type: 'Group', annotation }] + }); + expect(result.valid).toBe(true); + } + }); + + it('rejects invalid annotation value', () => { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + nodes: [{ id: 'node_001', type: 'Group', annotation: 'Modified' }] + }); + expect(result.valid).toBe(false); + }); + }); + + // ─── connections.schema.json ──────────────────────────────────────────────── + + describe('validateConnections', () => { + it('accepts a valid connections file', () => { + const result = SchemaValidator.instance.validateConnections(validConnections); + expect(result.valid).toBe(true); + }); + + it('accepts an empty connections array', () => { + const result = SchemaValidator.instance.validateConnections({ + componentId: 'comp_abc', + connections: [] + }); + expect(result.valid).toBe(true); + }); + + it('rejects when componentId is missing', () => { + const result = SchemaValidator.instance.validateConnections({ connections: [] }); + expect(result.valid).toBe(false); + }); + + it('rejects when connections is missing', () => { + const result = SchemaValidator.instance.validateConnections({ componentId: 'comp_abc' }); + expect(result.valid).toBe(false); + }); + + it('rejects a connection missing fromId', () => { + const result = SchemaValidator.instance.validateConnections({ + componentId: 'comp_abc', + connections: [{ fromProperty: 'onClick', toId: 'node_002', toProperty: 'trigger' }] + }); + expect(result.valid).toBe(false); + }); + + it('rejects a connection missing toProperty', () => { + const result = SchemaValidator.instance.validateConnections({ + componentId: 'comp_abc', + connections: [{ fromId: 'node_001', fromProperty: 'onClick', toId: 'node_002' }] + }); + expect(result.valid).toBe(false); + }); + + it('accepts connection with annotation', () => { + const result = SchemaValidator.instance.validateConnections({ + componentId: 'comp_abc', + connections: [ + { + fromId: 'node_001', + fromProperty: 'onClick', + toId: 'node_002', + toProperty: 'trigger', + annotation: 'Created' + } + ] + }); + expect(result.valid).toBe(true); + }); + }); + + // ─── registry.schema.json ─────────────────────────────────────────────────── + + describe('validateRegistry', () => { + it('accepts a valid registry file', () => { + const result = SchemaValidator.instance.validateRegistry(validRegistry); + expect(result.valid).toBe(true); + }); + + it('accepts an empty components map', () => { + const result = SchemaValidator.instance.validateRegistry({ version: 1, components: {} }); + expect(result.valid).toBe(true); + }); + + it('rejects when version is missing', () => { + const result = SchemaValidator.instance.validateRegistry({ components: {} }); + expect(result.valid).toBe(false); + }); + + it('rejects when components is missing', () => { + const result = SchemaValidator.instance.validateRegistry({ version: 1 }); + expect(result.valid).toBe(false); + }); + + it('rejects a component entry with invalid type', () => { + const result = SchemaValidator.instance.validateRegistry({ + version: 1, + components: { + Header: { path: 'Header', type: 'widget' } + } + }); + expect(result.valid).toBe(false); + }); + + it('rejects a component entry missing path', () => { + const result = SchemaValidator.instance.validateRegistry({ + version: 1, + components: { + Header: { type: 'visual' } + } + }); + expect(result.valid).toBe(false); + }); + + it('accepts all valid component type values', () => { + const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const; + for (const type of types) { + const result = SchemaValidator.instance.validateRegistry({ + version: 1, + components: { Test: { path: 'Test', type } } + }); + expect(result.valid).toBe(true); + } + }); + }); + + // ─── routes.schema.json ───────────────────────────────────────────────────── + + describe('validateRoutes', () => { + it('accepts a valid routes file', () => { + const result = SchemaValidator.instance.validateRoutes(validRoutes); + expect(result.valid).toBe(true); + }); + + it('accepts an empty routes array', () => { + const result = SchemaValidator.instance.validateRoutes({ routes: [] }); + expect(result.valid).toBe(true); + }); + + it('accepts routes with all optional fields', () => { + const result = SchemaValidator.instance.validateRoutes({ + version: 1, + routes: [ + { + path: '/home', + component: 'pages/HomePage', + title: 'Home', + exact: true, + metadata: { requiresAuth: false } + } + ], + notFound: 'pages/NotFoundPage' + }); + expect(result.valid).toBe(true); + }); + + it('rejects when routes is missing', () => { + const result = SchemaValidator.instance.validateRoutes({}); + expect(result.valid).toBe(false); + }); + + it('rejects a route missing path', () => { + const result = SchemaValidator.instance.validateRoutes({ + routes: [{ component: 'pages/HomePage' }] + }); + expect(result.valid).toBe(false); + }); + + it('rejects a route missing component', () => { + const result = SchemaValidator.instance.validateRoutes({ + routes: [{ path: '/home' }] + }); + expect(result.valid).toBe(false); + }); + }); + + // ─── styles.schema.json ───────────────────────────────────────────────────── + + describe('validateStyles', () => { + it('accepts a valid styles file', () => { + const result = SchemaValidator.instance.validateStyles(validStyles); + expect(result.valid).toBe(true); + }); + + it('accepts an empty styles file', () => { + const result = SchemaValidator.instance.validateStyles({}); + expect(result.valid).toBe(true); + }); + + it('accepts styles with variants', () => { + const result = SchemaValidator.instance.validateStyles({ + variants: [ + { + name: 'primary', + typename: 'Button', + parameters: { backgroundColor: '#3B82F6', color: '#ffffff' }, + stateParameters: { hover: { backgroundColor: '#2563EB' } } + } + ] + }); + expect(result.valid).toBe(true); + }); + + it('rejects a variant missing name', () => { + const result = SchemaValidator.instance.validateStyles({ + variants: [{ typename: 'Button' }] + }); + expect(result.valid).toBe(false); + }); + + it('rejects a variant missing typename', () => { + const result = SchemaValidator.instance.validateStyles({ + variants: [{ name: 'primary' }] + }); + expect(result.valid).toBe(false); + }); + }); + + // ─── model.schema.json ────────────────────────────────────────────────────── + + describe('validateModel', () => { + it('accepts a valid model file', () => { + const result = SchemaValidator.instance.validateModel(validModel); + expect(result.valid).toBe(true); + }); + + it('accepts a model with all field types', () => { + const result = SchemaValidator.instance.validateModel({ + name: 'Session', + fields: [ + { name: 'title', type: 'String' }, + { name: 'capacity', type: 'Number' }, + { name: 'isPublished', type: 'Boolean' }, + { name: 'startTime', type: 'Date' }, + { name: 'metadata', type: 'Object' }, + { name: 'tags', type: 'Array' }, + { name: 'speaker', type: 'Pointer', targetClass: 'User' }, + { name: 'attendees', type: 'Relation', targetClass: 'User' }, + { name: 'thumbnail', type: 'File' }, + { name: 'location', type: 'GeoPoint' } + ] + }); + expect(result.valid).toBe(true); + }); + + it('accepts a model with indexes', () => { + const result = SchemaValidator.instance.validateModel({ + ...validModel, + indexes: [ + { name: 'email_idx', fields: ['email'], unique: true }, + { fields: ['username'] } + ] + }); + expect(result.valid).toBe(true); + }); + + it('rejects when name is missing', () => { + const result = SchemaValidator.instance.validateModel({ fields: [] }); + expect(result.valid).toBe(false); + }); + + it('rejects when fields is missing', () => { + const result = SchemaValidator.instance.validateModel({ name: 'User' }); + expect(result.valid).toBe(false); + }); + + it('rejects a field with invalid type', () => { + const result = SchemaValidator.instance.validateModel({ + name: 'User', + fields: [{ name: 'username', type: 'Text' }] + }); + expect(result.valid).toBe(false); + }); + + it('rejects a field missing name', () => { + const result = SchemaValidator.instance.validateModel({ + name: 'User', + fields: [{ type: 'String' }] + }); + expect(result.valid).toBe(false); + }); + + it('rejects an index with empty fields array', () => { + const result = SchemaValidator.instance.validateModel({ + ...validModel, + indexes: [{ fields: [] }] + }); + expect(result.valid).toBe(false); + }); + }); + + // ─── validateSchema convenience function ──────────────────────────────────── + + describe('validateSchema (convenience function)', () => { + it('delegates to the singleton validator', () => { + const result = validateSchema(SCHEMA_IDS.COMPONENT, validComponent); + expect(result.valid).toBe(true); + }); + + it('returns errors for invalid data', () => { + const result = validateSchema(SCHEMA_IDS.PROJECT, {}); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + // ─── validateOrThrow ──────────────────────────────────────────────────────── + + describe('validateOrThrow', () => { + it('does not throw for valid data', () => { + expect(() => { + SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, validComponent); + }).not.toThrow(); + }); + + it('throws for invalid data', () => { + expect(() => { + SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, { name: 'Header' }); + }).toThrow('Schema validation failed'); + }); + + it('includes context in the error message', () => { + expect(() => { + SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, {}, 'components/Header/component.json'); + }).toThrow('[components/Header/component.json]'); + }); + }); + + // ─── formatValidationErrors ───────────────────────────────────────────────── + + describe('formatValidationErrors', () => { + it('returns "No errors" for empty array', () => { + const { formatValidationErrors } = require('../../src/editor/src/schemas/validator'); + expect(formatValidationErrors([])).toBe('No errors'); + }); + + it('formats errors with path and message', () => { + const { formatValidationErrors } = require('../../src/editor/src/schemas/validator'); + const errors = [ + { path: '/name', message: 'must be string', keyword: 'type', params: {} }, + { path: '/type', message: 'must be equal to one of the allowed values', keyword: 'enum', params: {} } + ]; + const formatted = formatValidationErrors(errors); + expect(formatted).toContain('/name'); + expect(formatted).toContain('must be string'); + expect(formatted).toContain('/type'); + }); + }); + + // ─── SCHEMA_IDS ───────────────────────────────────────────────────────────── + + describe('SCHEMA_IDS', () => { + it('has all 8 schema IDs defined', () => { + expect(Object.keys(SCHEMA_IDS).length).toBe(8); + }); + + it('all IDs are valid opennoodl.dev URLs', () => { + for (const id of Object.values(SCHEMA_IDS)) { + expect(id).toMatch(/^https:\/\/opennoodl\.dev\/schemas\//); + } + }); + }); + + // ─── Edge cases ────────────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('collects multiple errors when allErrors is true', () => { + // Missing name, version, AND nodegxVersion — should get 3 errors + const result = SchemaValidator.instance.validateProject({}); + expect(result.errors.length).toBeGreaterThanOrEqual(3); + }); + + it('handles deeply nested valid node metadata', () => { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + nodes: [ + { + id: 'node_001', + type: 'JavaScriptNode', + metadata: { + merge: { soureCodePorts: ['script'] }, + prompt: { + history: [ + { role: 'user', content: 'Create a function that...' }, + { role: 'assistant', content: 'Here is the code...' } + ] + } + } + } + ] + }); + expect(result.valid).toBe(true); + }); + + it('handles component references in node type (legacy path format)', () => { + const result = SchemaValidator.instance.validateNodes({ + componentId: 'comp_abc', + nodes: [ + { id: 'node_001', type: '/#Header' }, + { id: 'node_002', type: '/#__cloud__/SendGrid/SendEmail' } + ] + }); + expect(result.valid).toBe(true); + }); + + it('handles port type as object (complex type definition)', () => { + const result = SchemaValidator.instance.validateComponent({ + ...validComponent, + ports: { + inputs: [ + { + name: 'items', + type: { name: 'stringlist', allowConnectionsOnly: true }, + displayName: 'Items' + } + ] + } + }); + expect(result.valid).toBe(true); + }); + }); +});