From fbce66e0db7874373036cb1d6fcf2ebca0b8f864 Mon Sep 17 00:00:00 2001 From: dishant-kumar-thakur Date: Thu, 19 Feb 2026 00:23:33 +0530 Subject: [PATCH] feat(phase-10): STRUCT-002 export engine core Task: STRUCT-002 Branch: cline-dev-dishant Cross-branch notes: none no shared dependencies with Richard's phase 9/6 work - ProjectExporter class (pure, filesystem-agnostic) - Converts legacy project.json to v2 multi-file format - Outputs: nodegx.project.json, nodegx.routes.json, nodegx.styles.json, components/_registry.json, per-component component.json/nodes.json/connections.json - Helper exports: legacyNameToPath, inferComponentType, flattenNodes, countNodes - Full legacy type definitions (LegacyProject, LegacyComponent, LegacyNode, etc.) - 50 unit tests in tests/io/ProjectExporter.test.ts - Registered in tests/index.ts --- .../PROGRESS-dishant.md | 124 +++- .../src/editor/src/io/ProjectExporter.ts | 560 ++++++++++++++++++ packages/noodl-editor/tests/index.ts | 1 + .../tests/io/ProjectExporter.test.ts | 448 ++++++++++++++ packages/noodl-editor/tests/io/index.ts | 1 + 5 files changed, 1116 insertions(+), 18 deletions(-) create mode 100644 packages/noodl-editor/src/editor/src/io/ProjectExporter.ts create mode 100644 packages/noodl-editor/tests/io/ProjectExporter.test.ts create mode 100644 packages/noodl-editor/tests/io/index.ts 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 a9d8a1a..aa520ef 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,8 +1,8 @@ -# Phase 10A β€” AI-Powered Development: Dishant's Progress +ο»Ώ# Phase 10A AI-Powered Development: Dishant's Progress **Developer:** Dishant **Branch:** `cline-dev-dishant` -**Last Updated:** 2026-02-18 +**Last Updated:** 2026-02-19 --- @@ -10,12 +10,12 @@ | 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 | +| STRUCT-001 | JSON Schema Definition | Complete | 8 schemas + validator + tests (33/33 pass) | +| STRUCT-002 | Export Engine Core | Complete | ProjectExporter + 50 unit tests | --- -## STRUCT-001 β€” JSON Schema Definition βœ… +## STRUCT-001 JSON Schema Definition **Completed:** 2026-02-18 **Scope:** Define JSON schemas for the v2 multi-file project format @@ -39,14 +39,14 @@ All files live under `packages/noodl-editor/src/editor/src/schemas/`: #### Validator (`validator.ts`) -- `SchemaValidator` singleton class β€” compiles all 8 schemas once, reuses validators -- `SCHEMA_IDS` const β€” typed schema ID map +- `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 +- `validateOrThrow()` throws with context on failure - Per-schema convenience methods: `validateProject()`, `validateComponent()`, etc. -- `formatValidationErrors()` β€” human-readable error formatting +- `formatValidationErrors()` human-readable error formatting - Ajv v8 with `ajv-formats` for `date-time` format validation -- `allErrors: true` β€” collects all errors, not just first +- `allErrors: true` collects all errors, not just first #### Index (`index.ts`) @@ -59,7 +59,7 @@ All files live under `packages/noodl-editor/src/editor/src/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` +- Registered in `tests/index.ts` `tests/schemas/index.ts` ### Dependencies added @@ -72,10 +72,10 @@ 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 +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 @@ -86,7 +86,95 @@ Added to `packages/noodl-editor/package.json` dependencies. --- -## Next: STRUCT-002 β€” Export Engine Core +## 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. +**Completed:** 2026-02-19 +**Scope:** Build the engine that converts the legacy `project.json` format into the v2 multi-file directory structure + +### What was built + +All files: + +| File | Purpose | +|------|---------| +| `packages/noodl-editor/src/editor/src/io/ProjectExporter.ts` | Core exporter class + legacy type definitions + helper functions | +| `packages/noodl-editor/tests/io/ProjectExporter.test.ts` | 50 unit tests | +| `packages/noodl-editor/tests/io/index.ts` | Test barrel export | + +### Architecture + +`ProjectExporter` is a **pure class** it does not touch the filesystem. It takes a `LegacyProject` object (the parsed `project.json`) and returns an `ExportResult` containing all files to write. + +``` +ProjectExporter.export(legacyProject) + ExportResult { + files: ExportFile[], // { relativePath, content }[] + stats: { totalComponents, totalNodes, totalConnections } + } +``` + +Output file layout: +``` +nodegx.project.json project metadata +nodegx.routes.json route definitions (if any in metadata) +nodegx.styles.json global styles + variants (if any) +components/ + _registry.json component index + Header/ + component.json component metadata + nodes.json flat node list (tree flattened) + connections.json connection list + Pages/Home/ + component.json + nodes.json + connections.json + ... +``` + +### Key functions exported + +| Function | Purpose | +|----------|---------| +| `legacyNameToPath(name)` | Converts `/#Header` `Header`, `/Pages/Home` `Pages/Home` | +| `inferComponentType(name)` | Heuristic: root/page/cloud/visual from legacy name | +| `flattenNodes(roots)` | Flattens recursive legacy node tree flat `NodeV2[]` with parent/children IDs | +| `countNodes(roots)` | Counts all nodes including nested children | + +### Key design decisions + +1. **Pure / filesystem-agnostic** caller writes files; exporter just produces the data. Easy to test, easy to use in different contexts (Electron, Node, tests). +2. **Node tree flattening** legacy format stores nodes as a recursive tree (children embedded). v2 format stores a flat array with `parent` and `children` (as ID arrays). `flattenNodes()` handles this. +3. **Styles file is conditional** only produced when `metadata.styles` has content OR variants exist. Same for routes. +4. **Legacy `stateParamaters` typo normalised** VariantModel.toJSON() uses `stateParamaters` (typo). The exporter maps this to `stateParameters` in the v2 format. +5. **Metadata split** `styles` and `routes` are extracted from `project.metadata` into their own files; remaining metadata keys stay in `nodegx.project.json`. +6. **Original legacy path preserved** `component.json` stores `path: "/#Header"` for round-trip fidelity (STRUCT-003 import engine will need this). +7. **Empty fields omitted** nodes with empty `parameters: {}` don't get a `parameters` key in the output, keeping files lean. + +### Tests + +50 unit tests in `tests/io/ProjectExporter.test.ts` covering: + +- `legacyNameToPath` 6 cases (slash stripping, hash stripping, cloud paths, nested paths) +- `inferComponentType` 6 cases (root, cloud, page, case-insensitive, visual) +- `countNodes` 4 cases (empty, single, recursive, multiple roots) +- `flattenNodes` 11 cases (empty, single, nested, parent/child IDs, field omission, metadata) +- `ProjectExporter.export()` 23 cases across: + - Basic structure (always-present files, conditional files, stats) + - Project file (name, version, runtimeVersion, settings, $schema, metadata stripping) + - Routes file (conditional, content, non-array guard) + - Styles file (conditional, colors, variant typo normalisation) + - Component files (3 files per component, paths, IDs, type inference, node flattening, connections) + - Registry (all components listed, path/type/nodeCount/connectionCount, stats, version) + - ExportResult stats + - Edge cases (no graph, empty graph, multiple components, cloud type, variant preservation) + +### Registered in + +- `tests/io/index.ts` `tests/index.ts` + +--- + +## Next: STRUCT-003 Import Engine + +**Unblocked by:** STRUCT-002 +**Goal:** Build the engine that converts the v2 multi-file format back to the legacy `project.json` format, with round-trip validation. diff --git a/packages/noodl-editor/src/editor/src/io/ProjectExporter.ts b/packages/noodl-editor/src/editor/src/io/ProjectExporter.ts new file mode 100644 index 0000000..571f01a --- /dev/null +++ b/packages/noodl-editor/src/editor/src/io/ProjectExporter.ts @@ -0,0 +1,560 @@ +/** + * STRUCT-002 β€” Export Engine Core + * + * Converts a legacy monolithic project.json into the v2 multi-file directory + * structure defined by the JSON schemas in STRUCT-001. + * + * Output layout: + * / + * nodegx.project.json ← project metadata + * nodegx.routes.json ← route definitions (if any) + * nodegx.styles.json ← global styles + variants + * components/ + * _registry.json ← component index + * / + * component.json ← component metadata + ports + * nodes.json ← node graph + * connections.json ← wiring + * + * @module noodl-editor/io/ProjectExporter + * @since 1.2.0 + */ + +import type { + ProjectV2File, + ComponentV2File, + NodesV2File, + ConnectionsV2File, + RegistryV2File, + RegistryComponentEntry, + RoutesV2File, + StylesV2File, + NodeV2, + ConnectionV2, + PortDefinition +} from '../schemas'; + +// ─── Legacy format types (what project.toJSON() produces) ───────────────────── + +/** A single connection in the legacy graph format */ +export interface LegacyConnection { + fromId: string; + fromProperty: string; + toId: string; + toProperty: string; + annotation?: 'Deleted' | 'Changed' | 'Created'; +} + +/** A single node in the legacy graph format (recursive via children) */ +export interface LegacyNode { + id: string; + type: string; + variant?: string; + version?: number; + label?: string; + x?: number; + y?: number; + parameters?: Record; + stateParameters?: Record>; + stateTransitions?: Record>; + defaultStateTransitions?: Record; + ports?: unknown[]; + dynamicports?: unknown[]; + conflicts?: unknown[]; + children?: LegacyNode[]; + metadata?: Record; + [key: string]: unknown; +} + +/** The graph object inside a legacy component */ +export interface LegacyGraph { + roots: LegacyNode[]; + connections: LegacyConnection[]; + visualRoots?: string[]; + comments?: unknown[]; +} + +/** A single component in the legacy project.json */ +export interface LegacyComponent { + name: string; + id: string; + metadata?: Record; + graph: LegacyGraph; +} + +/** A variant entry in the legacy project.json */ +export interface LegacyVariant { + name: string; + typename: string; + parameters?: Record; + stateParamaters?: Record>; // note: legacy typo + stateTransitions?: Record>; + defaultStateTransitions?: Record; + conflicts?: unknown[]; +} + +/** The full legacy project.json structure */ +export interface LegacyProject { + name: string; + version?: string; + runtimeVersion?: 'react17' | 'react19'; + settings?: Record; + rootNodeId?: string; + metadata?: Record; + lesson?: unknown; + variants?: LegacyVariant[]; + components: LegacyComponent[]; +} + +// ─── Export result types ─────────────────────────────────────────────────────── + +/** A single file to be written as part of the export */ +export interface ExportFile { + /** Relative path from the output directory root */ + relativePath: string; + /** Parsed JSON content (caller serialises with JSON.stringify) */ + content: unknown; +} + +/** The complete result of a ProjectExporter.export() call */ +export interface ExportResult { + /** All files that should be written to disk */ + files: ExportFile[]; + /** Summary statistics */ + stats: { + totalComponents: number; + totalNodes: number; + totalConnections: number; + }; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Converts a legacy component name (e.g. "/#Header", "/Pages/Home") to a + * filesystem-safe directory name relative to the components/ folder. + * + * Rules: + * - Strip leading "/" + * - Strip leading "#" (root component marker) + * - Replace remaining "/" with OS path separator (we use "/" for portability) + * - Strip "__cloud__/" prefix used by cloud function components + * + * Examples: + * "/#Header" β†’ "Header" + * "/%rootcomponent" β†’ "%rootcomponent" + * "/Pages/Home" β†’ "Pages/Home" + * "/#__cloud__/SendGrid/Send" β†’ "__cloud__/SendGrid/Send" + */ +export function legacyNameToPath(legacyName: string): string { + // Remove leading slash + let path = legacyName.startsWith('/') ? legacyName.slice(1) : legacyName; + // Remove leading # (root component marker in legacy format) + if (path.startsWith('#')) { + path = path.slice(1); + } + return path; +} + +/** + * Infers the component type from its legacy name and graph structure. + * + * Heuristics (in priority order): + * 1. "/%rootcomponent" β†’ 'root' + * 2. Name contains "/Pages/" or starts with "Pages/" β†’ 'page' + * 3. Name contains "/__cloud__/" β†’ 'cloud' + * 4. Default β†’ 'visual' + */ +export function inferComponentType( + legacyName: string +): 'root' | 'page' | 'visual' | 'logic' | 'cloud' { + const lower = legacyName.toLowerCase(); + if (lower.includes('%rootcomponent')) return 'root'; + if (lower.includes('__cloud__')) return 'cloud'; + if (lower.includes('/pages/') || lower.startsWith('/pages/')) return 'page'; + return 'visual'; +} + +/** + * Flattens a tree of legacy nodes (which embed children recursively) into a + * flat array of NodeV2 objects, preserving parent/child relationships via IDs. + */ +export function flattenNodes(roots: LegacyNode[]): NodeV2[] { + const result: NodeV2[] = []; + + function visit(node: LegacyNode, parentId?: string): void { + const childIds = (node.children ?? []).map((c) => c.id); + + const v2Node: NodeV2 = { + id: node.id, + type: node.type + }; + + // Only include optional fields when they have meaningful values + if (node.label !== undefined) v2Node.label = node.label; + if (node.x !== undefined) v2Node.x = node.x; + if (node.y !== undefined) v2Node.y = node.y; + if (node.variant !== undefined) v2Node.variant = node.variant; + if (node.version !== undefined) v2Node.version = node.version; + if (node.parameters && Object.keys(node.parameters).length > 0) { + v2Node.parameters = node.parameters; + } + if (node.stateParameters && Object.keys(node.stateParameters).length > 0) { + v2Node.stateParameters = node.stateParameters; + } + if (node.stateTransitions && Object.keys(node.stateTransitions).length > 0) { + v2Node.stateTransitions = node.stateTransitions; + } + if (node.defaultStateTransitions && Object.keys(node.defaultStateTransitions).length > 0) { + v2Node.defaultStateTransitions = node.defaultStateTransitions; + } + if (node.ports && (node.ports as unknown[]).length > 0) { + v2Node.ports = node.ports as NodeV2['ports']; + } + if (node.dynamicports && (node.dynamicports as unknown[]).length > 0) { + v2Node.dynamicports = node.dynamicports as NodeV2['dynamicports']; + } + if (node.conflicts && (node.conflicts as unknown[]).length > 0) { + v2Node.conflicts = node.conflicts as NodeV2['conflicts']; + } + if (node.metadata && Object.keys(node.metadata).length > 0) { + v2Node.metadata = node.metadata; + } + if (childIds.length > 0) v2Node.children = childIds; + if (parentId !== undefined) v2Node.parent = parentId; + + result.push(v2Node); + + // Recurse into children + for (const child of node.children ?? []) { + visit(child, node.id); + } + } + + for (const root of roots) { + visit(root); + } + + return result; +} + +/** + * Counts all nodes in a legacy component graph (including nested children). + */ +export function countNodes(roots: LegacyNode[]): number { + let count = 0; + function visit(node: LegacyNode): void { + count++; + for (const child of node.children ?? []) visit(child); + } + for (const root of roots) visit(root); + return count; +} + +/** + * Extracts component ports from the legacy component metadata. + * Legacy components don't store ports directly β€” they're derived at runtime + * from nodes with `haveComponentPorts`. We preserve whatever is in metadata. + */ +function extractPorts(component: LegacyComponent): ComponentV2File['ports'] | undefined { + // Legacy format doesn't serialise computed ports into component JSON. + // If the component metadata has port info, preserve it. + const meta = component.metadata; + if (!meta) return undefined; + + // Some components store port info in metadata under 'ports' key + if (meta.ports && typeof meta.ports === 'object') { + return meta.ports as ComponentV2File['ports']; + } + + return undefined; +} + +// ─── ProjectExporter ────────────────────────────────────────────────────────── + +/** + * Converts a legacy project.json object into the v2 multi-file format. + * + * This class is pure β€” it does not touch the filesystem. The caller receives + * an `ExportResult` containing all files to write, and is responsible for + * actually writing them (e.g. via `fs.writeFile` or the platform abstraction). + * + * @example + * ```ts + * const exporter = new ProjectExporter(); + * const result = exporter.export(legacyProjectJson); + * + * for (const file of result.files) { + * await fs.writeFile( + * path.join(outputDir, file.relativePath), + * JSON.stringify(file.content, null, 2), + * 'utf-8' + * ); + * } + * ``` + */ +export class ProjectExporter { + /** + * Exports a legacy project JSON object to the v2 multi-file format. + * + * @param project - The parsed legacy project.json object + * @returns ExportResult containing all files to write and summary stats + */ + export(project: LegacyProject): ExportResult { + const files: ExportFile[] = []; + const now = new Date().toISOString(); + + // ── 1. Project metadata file ───────────────────────────────────────────── + files.push({ + relativePath: 'nodegx.project.json', + content: this.buildProjectFile(project, now) + }); + + // ── 2. Routes file (extracted from metadata if present) ────────────────── + const routesFile = this.buildRoutesFile(project); + if (routesFile !== null) { + files.push({ + relativePath: 'nodegx.routes.json', + content: routesFile + }); + } + + // ── 3. Styles file (colors, textStyles, variants) ──────────────────────── + const stylesFile = this.buildStylesFile(project); + if (stylesFile !== null) { + files.push({ + relativePath: 'nodegx.styles.json', + content: stylesFile + }); + } + + // ── 4. Components ──────────────────────────────────────────────────────── + const registry: RegistryV2File = { + $schema: 'https://opennoodl.dev/schemas/registry-v2.json', + version: 1, + lastUpdated: now, + components: {}, + stats: { + totalComponents: 0, + totalNodes: 0, + totalConnections: 0 + } + }; + + let totalNodes = 0; + let totalConnections = 0; + + for (const component of project.components) { + const componentPath = legacyNameToPath(component.name); + const nodeCount = countNodes(component.graph?.roots ?? []); + const connectionCount = (component.graph?.connections ?? []).length; + + totalNodes += nodeCount; + totalConnections += connectionCount; + + // component.json + files.push({ + relativePath: `components/${componentPath}/component.json`, + content: this.buildComponentFile(component, now) + }); + + // nodes.json + files.push({ + relativePath: `components/${componentPath}/nodes.json`, + content: this.buildNodesFile(component) + }); + + // connections.json + files.push({ + relativePath: `components/${componentPath}/connections.json`, + content: this.buildConnectionsFile(component) + }); + + // Registry entry + const registryEntry: RegistryComponentEntry = { + path: componentPath, + type: inferComponentType(component.name), + nodeCount, + connectionCount, + modified: now + }; + + // Preserve created timestamp from metadata if available + if (component.metadata?.created && typeof component.metadata.created === 'string') { + registryEntry.created = component.metadata.created; + } + + registry.components[componentPath] = registryEntry; + } + + // Update registry stats + registry.stats = { + totalComponents: project.components.length, + totalNodes, + totalConnections + }; + + // _registry.json + files.push({ + relativePath: 'components/_registry.json', + content: registry + }); + + return { + files, + stats: { + totalComponents: project.components.length, + totalNodes, + totalConnections + } + }; + } + + // ─── Private builders ────────────────────────────────────────────────────── + + private buildProjectFile(project: LegacyProject, now: string): ProjectV2File { + const file: ProjectV2File = { + $schema: 'https://opennoodl.dev/schemas/project-v2.json', + name: project.name, + version: project.version ?? '4', + nodegxVersion: '1.1.0', + modified: now, + structure: { + componentsDir: 'components', + assetsDir: 'assets' + } + }; + + if (project.runtimeVersion) { + file.runtimeVersion = project.runtimeVersion; + } + + if (project.settings && Object.keys(project.settings).length > 0) { + file.settings = project.settings as ProjectV2File['settings']; + } + + // Preserve non-styles, non-routes metadata + if (project.metadata) { + const { styles, routes, ...rest } = project.metadata as Record; + if (Object.keys(rest).length > 0) { + file.metadata = rest; + } + } + + return file; + } + + private buildRoutesFile(project: LegacyProject): RoutesV2File | null { + const routes = (project.metadata as Record | undefined)?.routes; + if (!routes) return null; + + // If routes is already an array of route objects, use it directly + if (Array.isArray(routes)) { + return { + $schema: 'https://opennoodl.dev/schemas/routes-v2.json', + version: 1, + routes: routes as RoutesV2File['routes'] + }; + } + + return null; + } + + private buildStylesFile(project: LegacyProject): StylesV2File | null { + const metaStyles = (project.metadata as Record | undefined)?.styles as + | Record + | undefined; + const hasVariants = project.variants && project.variants.length > 0; + const hasStyles = metaStyles && Object.keys(metaStyles).length > 0; + + if (!hasStyles && !hasVariants) return null; + + const file: StylesV2File = { + $schema: 'https://opennoodl.dev/schemas/styles-v2.json', + version: 1 + }; + + if (metaStyles) { + if (metaStyles.colors && typeof metaStyles.colors === 'object') { + file.colors = metaStyles.colors as Record; + } + if (metaStyles.textStyles && typeof metaStyles.textStyles === 'object') { + file.textStyles = metaStyles.textStyles as Record>; + } + } + + if (project.variants && project.variants.length > 0) { + file.variants = project.variants.map((v) => ({ + name: v.name, + typename: v.typename, + parameters: v.parameters, + // Note: legacy uses "stateParamaters" (typo) β€” we normalise here + stateParameters: v.stateParamaters, + stateTransitions: v.stateTransitions, + defaultStateTransitions: v.defaultStateTransitions + })); + } + + return file; + } + + private buildComponentFile(component: LegacyComponent, now: string): ComponentV2File { + const componentPath = legacyNameToPath(component.name); + const localName = componentPath.split('/').pop() ?? componentPath; + + const file: ComponentV2File = { + $schema: 'https://opennoodl.dev/schemas/component-v2.json', + id: component.id, + name: localName, + path: component.name, // preserve original legacy path for round-trip + type: inferComponentType(component.name), + modified: now + }; + + // Preserve metadata fields as component settings + if (component.metadata && Object.keys(component.metadata).length > 0) { + file.metadata = component.metadata; + } + + // Extract ports if available in metadata + const ports = extractPorts(component); + if (ports) { + file.ports = ports; + } + + return file; + } + + private buildNodesFile(component: LegacyComponent): NodesV2File { + const nodes = flattenNodes(component.graph?.roots ?? []); + + return { + $schema: 'https://opennoodl.dev/schemas/nodes-v2.json', + componentId: component.id, + version: 1, + nodes + }; + } + + private buildConnectionsFile(component: LegacyComponent): ConnectionsV2File { + const legacyConnections = component.graph?.connections ?? []; + + const connections: ConnectionV2[] = legacyConnections.map((c) => { + const conn: ConnectionV2 = { + fromId: c.fromId, + fromProperty: c.fromProperty, + toId: c.toId, + toProperty: c.toProperty + }; + if (c.annotation) conn.annotation = c.annotation; + return conn; + }); + + return { + $schema: 'https://opennoodl.dev/schemas/connections-v2.json', + componentId: component.id, + version: 1, + connections + }; + } +} diff --git a/packages/noodl-editor/tests/index.ts b/packages/noodl-editor/tests/index.ts index 5c202c3..ad7246f 100644 --- a/packages/noodl-editor/tests/index.ts +++ b/packages/noodl-editor/tests/index.ts @@ -12,3 +12,4 @@ export * from './projectmerger'; export * from './projectpatcher'; export * from './utils'; export * from './schemas'; +export * from './io'; diff --git a/packages/noodl-editor/tests/io/ProjectExporter.test.ts b/packages/noodl-editor/tests/io/ProjectExporter.test.ts new file mode 100644 index 0000000..d66c96f --- /dev/null +++ b/packages/noodl-editor/tests/io/ProjectExporter.test.ts @@ -0,0 +1,448 @@ +ο»Ώ/** + * ProjectExporter Tests -- STRUCT-002 + * + * Tests for the export engine that converts legacy project.json + * to the v2 multi-file directory structure. + */ + +import { + ProjectExporter, + legacyNameToPath, + inferComponentType, + flattenNodes, + countNodes, + LegacyProject, + LegacyComponent, + LegacyNode +} from '../../src/editor/src/io/ProjectExporter'; + +// ---- Fixtures ---------------------------------------------------------------- + +const makeNode = (id: string, type = 'Group', overrides: Partial = {}): LegacyNode => ({ + id, + type, + x: 0, + y: 0, + parameters: {}, + children: [], + ...overrides +}); + +const makeComponent = (name: string, overrides: Partial = {}): LegacyComponent => ({ + name, + id: 'comp_' + name.replace(/[^a-z0-9]/gi, '_'), + graph: { roots: [], connections: [] }, + ...overrides +}); + +const makeProject = (overrides: Partial = {}): LegacyProject => ({ + name: 'Test Project', + version: '4', + components: [], + variants: [], + ...overrides +}); + +// ---- legacyNameToPath -------------------------------------------------------- + +describe('legacyNameToPath', () => { + it('strips leading slash and hash', () => { + expect(legacyNameToPath('/#Header')).toBe('Header'); + }); + it('strips leading slash only', () => { + expect(legacyNameToPath('/Pages/Home')).toBe('Pages/Home'); + }); + it('handles root component marker', () => { + expect(legacyNameToPath('/%rootcomponent')).toBe('%rootcomponent'); + }); + it('handles cloud component path', () => { + expect(legacyNameToPath('/#__cloud__/SendGrid/Send')).toBe('__cloud__/SendGrid/Send'); + }); + it('handles name without leading slash', () => { + expect(legacyNameToPath('Header')).toBe('Header'); + }); + it('handles nested path', () => { + expect(legacyNameToPath('/Shared/Button')).toBe('Shared/Button'); + }); +}); + +// ---- inferComponentType ------------------------------------------------------ + +describe('inferComponentType', () => { + it('returns root for %rootcomponent', () => { + expect(inferComponentType('/%rootcomponent')).toBe('root'); + }); + it('returns cloud for __cloud__ paths', () => { + expect(inferComponentType('/#__cloud__/SendGrid/Send')).toBe('cloud'); + }); + it('returns page for /Pages/ paths', () => { + expect(inferComponentType('/Pages/Home')).toBe('page'); + }); + it('returns page case-insensitively', () => { + expect(inferComponentType('/pages/profile')).toBe('page'); + }); + it('returns visual for regular components', () => { + expect(inferComponentType('/#Header')).toBe('visual'); + }); + it('returns visual for shared components', () => { + expect(inferComponentType('/Shared/Button')).toBe('visual'); + }); +}); + +// ---- countNodes -------------------------------------------------------------- + +describe('countNodes', () => { + it('returns 0 for empty roots', () => { + expect(countNodes([])).toBe(0); + }); + it('counts a single root node', () => { + expect(countNodes([makeNode('n1')])).toBe(1); + }); + it('counts root + children recursively', () => { + const root = makeNode('n1', 'Group', { + children: [ + makeNode('n2', 'Text', { children: [makeNode('n3')] }), + makeNode('n4') + ] + }); + expect(countNodes([root])).toBe(4); + }); + it('counts multiple roots', () => { + expect(countNodes([makeNode('n1'), makeNode('n2'), makeNode('n3')])).toBe(3); + }); +}); + +// ---- flattenNodes ------------------------------------------------------------ + +describe('flattenNodes', () => { + it('returns empty array for no roots', () => { + expect(flattenNodes([])).toEqual([]); + }); + it('flattens a single root node', () => { + const nodes = flattenNodes([makeNode('n1', 'Group')]); + expect(nodes).toHaveLength(1); + expect(nodes[0].id).toBe('n1'); + expect(nodes[0].type).toBe('Group'); + }); + it('flattens nested children into flat array', () => { + const root = makeNode('root', 'Group', { + children: [makeNode('child1', 'Text'), makeNode('child2', 'Image')] + }); + const nodes = flattenNodes([root]); + expect(nodes).toHaveLength(3); + expect(nodes.map((n) => n.id)).toEqual(['root', 'child1', 'child2']); + }); + it('sets parent id on child nodes', () => { + const root = makeNode('root', 'Group', { children: [makeNode('child1')] }); + const nodes = flattenNodes([root]); + const child = nodes.find((n) => n.id === 'child1'); + expect(child?.parent).toBe('root'); + }); + it('root nodes have no parent field', () => { + const nodes = flattenNodes([makeNode('root')]); + expect(nodes[0].parent).toBeUndefined(); + }); + it('sets children array of ids on parent node', () => { + const root = makeNode('root', 'Group', { children: [makeNode('c1'), makeNode('c2')] }); + const nodes = flattenNodes([root]); + const rootNode = nodes.find((n) => n.id === 'root'); + expect(rootNode?.children).toEqual(['c1', 'c2']); + }); + it('omits empty parameters object', () => { + const nodes = flattenNodes([makeNode('n1', 'Group', { parameters: {} })]); + expect(nodes[0].parameters).toBeUndefined(); + }); + it('preserves non-empty parameters', () => { + const nodes = flattenNodes([makeNode('n1', 'Group', { parameters: { layout: 'row' } })]); + expect(nodes[0].parameters).toEqual({ layout: 'row' }); + }); + it('preserves stateParameters', () => { + const nodes = flattenNodes([makeNode('n1', 'Group', { stateParameters: { hover: { opacity: 0.5 } } })]); + expect(nodes[0].stateParameters).toEqual({ hover: { opacity: 0.5 } }); + }); + it('preserves metadata', () => { + const nodes = flattenNodes([makeNode('n1', 'Group', { metadata: { comment: 'hello' } })]); + expect(nodes[0].metadata).toEqual({ comment: 'hello' }); + }); + it('handles deeply nested children', () => { + const deep = makeNode('l3'); + const mid = makeNode('l2', 'Group', { children: [deep] }); + const root = makeNode('l1', 'Group', { children: [mid] }); + const nodes = flattenNodes([root]); + expect(nodes).toHaveLength(3); + const l3 = nodes.find((n) => n.id === 'l3'); + expect(l3?.parent).toBe('l2'); + }); +}); + +// ---- ProjectExporter --------------------------------------------------------- + +describe('ProjectExporter', () => { + let exporter: ProjectExporter; + + beforeEach(() => { + exporter = new ProjectExporter(); + }); + + describe('export() basic structure', () => { + it('always produces nodegx.project.json', () => { + const result = exporter.export(makeProject()); + expect(result.files.map((f) => f.relativePath)).toContain('nodegx.project.json'); + }); + it('always produces components/_registry.json', () => { + const result = exporter.export(makeProject()); + expect(result.files.map((f) => f.relativePath)).toContain('components/_registry.json'); + }); + it('does not produce routes file when no routes in metadata', () => { + const result = exporter.export(makeProject()); + expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.routes.json'); + }); + it('does not produce styles file when no styles or variants', () => { + const result = exporter.export(makeProject()); + expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.styles.json'); + }); + it('returns correct stats for empty project', () => { + const result = exporter.export(makeProject()); + expect(result.stats).toEqual({ totalComponents: 0, totalNodes: 0, totalConnections: 0 }); + }); + }); + + describe('nodegx.project.json', () => { + const getProjectFile = (project: LegacyProject) => { + const result = exporter.export(project); + return result.files.find((f) => f.relativePath === 'nodegx.project.json')?.content as any; + }; + + it('includes project name', () => { + expect(getProjectFile(makeProject({ name: 'My App' })).name).toBe('My App'); + }); + it('includes version', () => { + expect(getProjectFile(makeProject({ version: '4' })).version).toBe('4'); + }); + it('includes runtimeVersion when set', () => { + expect(getProjectFile(makeProject({ runtimeVersion: 'react19' })).runtimeVersion).toBe('react19'); + }); + it('omits runtimeVersion when not set', () => { + expect(getProjectFile(makeProject()).runtimeVersion).toBeUndefined(); + }); + it('includes settings when present', () => { + expect(getProjectFile(makeProject({ settings: { bodyScroll: true } })).settings).toEqual({ bodyScroll: true }); + }); + it('includes $schema field', () => { + expect(getProjectFile(makeProject())['$schema']).toContain('project-v2'); + }); + it('strips styles and routes from metadata but keeps other keys', () => { + const meta = getProjectFile(makeProject({ + metadata: { styles: { colors: {} }, routes: [], cloudservices: { id: 'abc' } } + })).metadata; + expect(meta?.styles).toBeUndefined(); + expect(meta?.routes).toBeUndefined(); + expect(meta?.cloudservices).toEqual({ id: 'abc' }); + }); + }); + + describe('nodegx.routes.json', () => { + it('produces routes file when metadata.routes is an array', () => { + const result = exporter.export(makeProject({ metadata: { routes: [{ path: '/home', component: 'pages/Home' }] } })); + expect(result.files.map((f) => f.relativePath)).toContain('nodegx.routes.json'); + }); + it('routes file contains the routes array', () => { + const routes = [{ path: '/home', component: 'pages/Home' }]; + const result = exporter.export(makeProject({ metadata: { routes } })); + const file = result.files.find((f) => f.relativePath === 'nodegx.routes.json')?.content as any; + expect(file.routes).toEqual(routes); + }); + it('does not produce routes file when metadata.routes is not an array', () => { + const result = exporter.export(makeProject({ metadata: { routes: {} } })); + expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.routes.json'); + }); + }); + + describe('nodegx.styles.json', () => { + it('produces styles file when metadata.styles has colors', () => { + const result = exporter.export(makeProject({ metadata: { styles: { colors: { primary: '#3B82F6' } } } })); + expect(result.files.map((f) => f.relativePath)).toContain('nodegx.styles.json'); + }); + it('produces styles file when variants exist', () => { + const result = exporter.export(makeProject({ variants: [{ name: 'primary', typename: 'Button' }] })); + expect(result.files.map((f) => f.relativePath)).toContain('nodegx.styles.json'); + }); + it('includes colors from metadata.styles', () => { + const result = exporter.export(makeProject({ metadata: { styles: { colors: { primary: '#3B82F6' } } } })); + const file = result.files.find((f) => f.relativePath === 'nodegx.styles.json')?.content as any; + expect(file.colors).toEqual({ primary: '#3B82F6' }); + }); + it('normalises legacy stateParamaters typo to stateParameters', () => { + const result = exporter.export(makeProject({ + variants: [{ name: 'primary', typename: 'Button', stateParamaters: { hover: { opacity: 0.8 } } }] + })); + const file = result.files.find((f) => f.relativePath === 'nodegx.styles.json')?.content as any; + expect(file.variants[0].stateParameters).toEqual({ hover: { opacity: 0.8 } }); + expect(file.variants[0].stateParamaters).toBeUndefined(); + }); + }); + + describe('component files', () => { + it('produces 3 files per component', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] })); + const paths = result.files.map((f) => f.relativePath); + expect(paths).toContain('components/Header/component.json'); + expect(paths).toContain('components/Header/nodes.json'); + expect(paths).toContain('components/Header/connections.json'); + }); + it('uses correct path for nested component', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] })); + expect(result.files.map((f) => f.relativePath)).toContain('components/Pages/Home/component.json'); + }); + it('component.json has correct id', () => { + const comp = makeComponent('/#Header'); + comp.id = 'comp_header_123'; + const result = exporter.export(makeProject({ components: [comp] })); + const file = result.files.find((f) => f.relativePath === 'components/Header/component.json')?.content as any; + expect(file.id).toBe('comp_header_123'); + }); + it('component.json preserves original legacy path', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] })); + const file = result.files.find((f) => f.relativePath === 'components/Header/component.json')?.content as any; + expect(file.path).toBe('/#Header'); + }); + it('component.json infers correct type for page', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] })); + const file = result.files.find((f) => f.relativePath === 'components/Pages/Home/component.json')?.content as any; + expect(file.type).toBe('page'); + }); + it('nodes.json has componentId', () => { + const comp = makeComponent('/#Header'); + comp.id = 'comp_header_123'; + const result = exporter.export(makeProject({ components: [comp] })); + const file = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any; + expect(file.componentId).toBe('comp_header_123'); + }); + it('nodes.json flattens nested nodes', () => { + const comp = makeComponent('/#Header'); + comp.graph.roots = [makeNode('root', 'Group', { children: [makeNode('child', 'Text')] })]; + const result = exporter.export(makeProject({ components: [comp] })); + const file = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any; + expect(file.nodes).toHaveLength(2); + }); + it('connections.json maps legacy connections correctly', () => { + const comp = makeComponent('/#Header'); + comp.graph.connections = [{ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' }]; + const result = exporter.export(makeProject({ components: [comp] })); + const file = result.files.find((f) => f.relativePath === 'components/Header/connections.json')?.content as any; + expect(file.connections).toHaveLength(1); + expect(file.connections[0]).toEqual({ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' }); + }); + it('connections.json preserves annotation field', () => { + const comp = makeComponent('/#Header'); + comp.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in', annotation: 'Created' }]; + const result = exporter.export(makeProject({ components: [comp] })); + const file = result.files.find((f) => f.relativePath === 'components/Header/connections.json')?.content as any; + expect(file.connections[0].annotation).toBe('Created'); + }); + }); + + describe('_registry.json', () => { + it('lists all components', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/#Header'), makeComponent('/Pages/Home')] })); + const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any; + expect(Object.keys(file.components)).toHaveLength(2); + expect(file.components['Header']).toBeDefined(); + expect(file.components['Pages/Home']).toBeDefined(); + }); + it('registry entry has correct path', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] })); + const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any; + expect(file.components['Header'].path).toBe('Header'); + }); + it('registry entry has correct type', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] })); + const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any; + expect(file.components['Pages/Home'].type).toBe('page'); + }); + it('registry entry has nodeCount', () => { + const comp = makeComponent('/#Header'); + comp.graph.roots = [makeNode('n1'), makeNode('n2')]; + const result = exporter.export(makeProject({ components: [comp] })); + const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any; + expect(file.components['Header'].nodeCount).toBe(2); + }); + it('registry entry has connectionCount', () => { + const comp = makeComponent('/#Header'); + comp.graph.connections = [ + { fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' }, + { fromId: 'n2', fromProperty: 'out', toId: 'n3', toProperty: 'in' } + ]; + const result = exporter.export(makeProject({ components: [comp] })); + const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any; + expect(file.components['Header'].connectionCount).toBe(2); + }); + it('registry stats are correct', () => { + const comp1 = makeComponent('/#Header'); + comp1.graph.roots = [makeNode('n1')]; + comp1.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' }]; + const comp2 = makeComponent('/Pages/Home'); + comp2.graph.roots = [makeNode('n2'), makeNode('n3')]; + const result = exporter.export(makeProject({ components: [comp1, comp2] })); + const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any; + expect(file.stats.totalComponents).toBe(2); + expect(file.stats.totalNodes).toBe(3); + expect(file.stats.totalConnections).toBe(1); + }); + it('registry has version 1', () => { + const result = exporter.export(makeProject()); + const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any; + expect(file.version).toBe(1); + }); + }); + + describe('ExportResult stats', () => { + it('stats match actual component/node/connection counts', () => { + const comp = makeComponent('/#Header'); + comp.graph.roots = [makeNode('n1', 'Group', { children: [makeNode('n2')] })]; + comp.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' }]; + const result = exporter.export(makeProject({ components: [comp] })); + expect(result.stats.totalComponents).toBe(1); + expect(result.stats.totalNodes).toBe(2); + expect(result.stats.totalConnections).toBe(1); + }); + }); + + describe('edge cases', () => { + it('handles component with no graph gracefully', () => { + const comp = makeComponent('/#Header'); + (comp as any).graph = undefined; + expect(() => exporter.export(makeProject({ components: [comp] }))).not.toThrow(); + }); + it('handles component with empty graph', () => { + const comp = makeComponent('/#Header'); + comp.graph = { roots: [], connections: [] }; + const result = exporter.export(makeProject({ components: [comp] })); + const nodesFile = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any; + expect(nodesFile.nodes).toHaveLength(0); + }); + it('handles multiple components correctly', () => { + const project = makeProject({ + components: [makeComponent('/#Header'), makeComponent('/Pages/Home'), makeComponent('/Shared/Button')] + }); + const result = exporter.export(project); + expect(result.stats.totalComponents).toBe(3); + const componentFiles = result.files.filter( + (f) => f.relativePath.startsWith('components/') && !f.relativePath.endsWith('_registry.json') + ); + expect(componentFiles).toHaveLength(9); + }); + it('cloud component gets correct type', () => { + const result = exporter.export(makeProject({ components: [makeComponent('/#__cloud__/SendGrid/Send')] })); + const file = result.files.find((f) => f.relativePath.includes('component.json'))?.content as any; + expect(file.type).toBe('cloud'); + }); + it('node with variant is preserved', () => { + const comp = makeComponent('/#Header'); + comp.graph.roots = [makeNode('n1', 'Button', { variant: 'primary' })]; + const result = exporter.export(makeProject({ components: [comp] })); + const nodesFile = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any; + expect(nodesFile.nodes[0].variant).toBe('primary'); + }); + }); +}); diff --git a/packages/noodl-editor/tests/io/index.ts b/packages/noodl-editor/tests/io/index.ts new file mode 100644 index 0000000..1ca7dac --- /dev/null +++ b/packages/noodl-editor/tests/io/index.ts @@ -0,0 +1 @@ +ο»Ώexport * from './ProjectExporter.test';