diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 9a10307..028ab01 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -9,3 +9,6 @@ export { export { importFromArrows, exportToArrows, arrowsImporter, arrowsExporter, } from './io/arrows.js'; +export { + importFromDotLD, exportToDotLD, dotLdImporter, dotLdExporter, +} from './io/dot-ld.js'; diff --git a/packages/core/src/io/dot-ld.js b/packages/core/src/io/dot-ld.js new file mode 100644 index 0000000..4e86eb9 --- /dev/null +++ b/packages/core/src/io/dot-ld.js @@ -0,0 +1,488 @@ +/** + * DOT-LD import/export for Boxes graph editor. + * + * DOT-LD (DOT Linked Data) is a markdown extension that enables embedding + * formal knowledge graph structures within technical documentation. + * + * Spec: https://github.com/aws-samples/sample-dot-ld-knowledge-graph-syntax + * + * Syntax elements: + * ::config ... :: – type definitions and entity assignments + * [[EntityName]] – entity references in prose + * ::rel A -> B [label] :: – directed relationship (also <- and <->) + */ + +// ─── Shape mapping ──────────────────────────────────────────────────────────── + +const DOTLD_TO_CY_SHAPE = { + 'round-rectangle': 'roundrectangle', + 'rectangle': 'rectangle', + 'ellipse': 'ellipse', + 'circle': 'ellipse', + 'diamond': 'diamond', +}; + +const CY_TO_DOTLD_SHAPE = { + 'roundrectangle': 'round-rectangle', + 'rectangle': 'rectangle', + 'ellipse': 'ellipse', + 'diamond': 'diamond', +}; + +const DEFAULT_DOTLD_SHAPE = 'round-rectangle'; +const DEFAULT_COLOR = '#888888'; +const DEFAULT_SIZE = 80; + +// ─── Internal field list ────────────────────────────────────────────────────── + +/** Boxes fields that are not user properties and must not appear as DOT-LD entity properties */ +const BOXES_INTERNAL = new Set([ + 'id', 'source', 'target', 'label', 'labels', + '_style', '_classes', '_arrowsStyle', '_dotldType', '_dotldBidi', +]); + +// ─── Colour helper ──────────────────────────────────────────────────────────── + +function darkenColor(hex, factor = 0.65) { + if (!hex || !hex.startsWith('#') || hex.length !== 7) return '#444444'; + const r = Math.round(parseInt(hex.slice(1, 3), 16) * factor); + const g = Math.round(parseInt(hex.slice(3, 5), 16) * factor); + const b = Math.round(parseInt(hex.slice(5, 7), 16) * factor); + return '#' + + r.toString(16).padStart(2, '0') + + g.toString(16).padStart(2, '0') + + b.toString(16).padStart(2, '0'); +} + +// ─── Config block parser ────────────────────────────────────────────────────── + +// Matches a type definition: name: shape, #RRGGBB, size +const TYPE_DEF_RE = /^([\w-]+)\s*:\s*([\w-]+)\s*,\s*(#[0-9A-Fa-f]{6})\s*,\s*(\d+)\s*(?:\/\/.*)?$/; + +// Matches an entity assignment: name: type=typename[, key=val]* +const ENTITY_ASSIGN_RE = /^([\w-]+)\s*:\s*type=([\w-]+)((?:\s*,\s*[\w-]+=(?:"[^"]*"|'[^']*'|[\w-]+))*)\s*(?:\/\/.*)?$/; + +// Matches property pairs inside the extra part: , key=value +// Quote contents are capped at 500 characters to prevent backtracking on unclosed quotes. +const PROP_PAIR_RE = /,\s*([\w-]+)\s*=\s*("(?:[^"\\]|\\.){0,500}"|'(?:[^'\\]|\\.){0,500}'|[\w-]+)/g; + +/** + * Parse all ::config ... :: blocks from a DOT-LD document. + * Returns { typeDefs: Map, + * entityAssignments: Map } + * + * Uses string-based block extraction (rather than a greedy regex) to avoid + * catastrophic backtracking on malformed input. + */ +function parseConfigBlocks(text) { + const typeDefs = new Map(); + const entityAssignments = new Map(); + + // Extract config block bodies using string search to avoid ReDoS + let searchFrom = 0; + while (searchFrom < text.length) { + const startMarker = text.indexOf('::config', searchFrom); + if (startMarker === -1) break; + + const afterMarker = text.indexOf('\n', startMarker); + if (afterMarker === -1) break; + + // Closing :: must be on its own line (preceded by \n) + const endMarker = text.indexOf('\n::', afterMarker); + if (endMarker === -1) break; + + const blockContent = text.slice(afterMarker + 1, endMarker); + searchFrom = endMarker + 3; + + for (const rawLine of blockContent.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('//')) continue; + + // Try entity assignment first (contains "type=") + const eam = ENTITY_ASSIGN_RE.exec(line); + if (eam) { + const entityName = eam[1]; + const typeName = eam[2]; + const propsStr = eam[3] || ''; + const props = {}; + let pm; + // Reset lastIndex because PROP_PAIR_RE has /g flag + PROP_PAIR_RE.lastIndex = 0; + while ((pm = PROP_PAIR_RE.exec(propsStr)) !== null) { + let val = pm[2]; + if ((val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1).replace(/\\(.)/g, '$1'); + } + props[pm[1]] = val; + } + entityAssignments.set(entityName, { type: typeName, props }); + continue; + } + + // Try type definition + const tdm = TYPE_DEF_RE.exec(line); + if (tdm) { + typeDefs.set(tdm[1], { + shape: tdm[2], + color: tdm[3], + size: parseInt(tdm[4], 10), + }); + } + } + } + + return { typeDefs, entityAssignments }; +} + +/** + * Extract all ::rel blocks from text. + * Returns array of { source, arrow, target, label } + * arrow: '->' | '<-' | '<->' + * + * Anchored to single lines (m flag) to prevent cross-line backtracking. + */ +function parseRelBlocks(text) { + const rels = []; + // Anchored: each ::rel must be on a single line; [^\]\n]{0,200} caps label length + // and prevents cross-line backtracking. + const REL_RE = /^[ \t]*::rel[ \t]+([\w-]+)[ \t]+(->|<-|<->)[ \t]+([\w-]+)[ \t]+\[([^\]\n]{0,200})\][ \t]*::[ \t]*$/gm; + let m; + while ((m = REL_RE.exec(text)) !== null) { + rels.push({ + source: m[1], + arrow: m[2], + target: m[3], + label: m[4].trim(), + }); + } + return rels; +} + +/** + * Remove all ::config ... :: block bodies from text using string search, + * replacing each block (start marker through closing ::) with whitespace + * so that line numbers are preserved. + */ +function stripConfigBlocks(text) { + let result = text; + let offset = 0; + while (offset < result.length) { + const start = result.indexOf('::config', offset); + if (start === -1) break; + const afterStart = result.indexOf('\n', start); + if (afterStart === -1) break; + const end = result.indexOf('\n::', afterStart); + if (end === -1) break; + // Replace the block content with newlines to preserve paragraph structure + result = result.slice(0, start) + result.slice(end + 3); + offset = start; + } + return result; +} + +/** + * Collect all [[EntityName]] references from prose (outside config/rel blocks). + * Returns a Set of entity name strings. + */ +function parseEntityRefs(text) { + const stripped = stripConfigBlocks(text) + .replace(/^[ \t]*::rel[^\n]*::[ \t]*$/gm, ''); + const refs = new Set(); + const REF_RE = /\[\[([\w-]+)\]\]/g; + let m; + while ((m = REF_RE.exec(stripped)) !== null) { + refs.add(m[1]); + } + return refs; +} + +/** Extract the document title from the first level-1 Markdown heading. */ +function extractTitle(text) { + const m = text.match(/^#\s+(.+)/m); + return m ? m[1].trim() : ''; +} + +/** + * Extract the first non-empty prose paragraph (not a heading, not a DOT-LD + * block, not a list marker) to use as the document description. + */ +function extractDescription(text) { + const stripped = stripConfigBlocks(text) + .replace(/^[ \t]*::rel[^\n]*::[ \t]*$/gm, '') + .replace(/^#+.*/gm, '') + .replace(/\[\[([\w-]+)\]\]/g, '$1'); + + for (const chunk of stripped.split(/\n\n+/)) { + const line = chunk.trim(); + if (line && !line.startsWith('#')) return line; + } + return ''; +} + +// ─── Import ─────────────────────────────────────────────────────────────────── + +/** + * Convert a DOT-LD markdown document into the Boxes graph format. + * + * @param {string} text - Raw DOT-LD markdown text + * @returns {{ title, description, palette, elements, userStylesheet, version }} + */ +export function importFromDotLD(text) { + const { typeDefs, entityAssignments } = parseConfigBlocks(text); + const rels = parseRelBlocks(text); + const entityRefs = parseEntityRefs(text); + const title = extractTitle(text); + const description = extractDescription(text); + + // ── Collect all entity names ────────────────────────────────────────────── + const allEntityNames = new Set([ + ...entityAssignments.keys(), + ...entityRefs, + ...rels.flatMap(r => [r.source, r.target]), + ]); + + // ── Determine whether any entities lack an explicit type ────────────────── + const hasUndefined = [...allEntityNames].some(n => !entityAssignments.has(n)); + + // ── Build palette nodeTypes from type definitions ───────────────────────── + const nodeTypes = []; + for (const [typeName, td] of typeDefs) { + const cyShape = DOTLD_TO_CY_SHAPE[td.shape] || 'roundrectangle'; + nodeTypes.push({ + id: typeName, + label: typeName, + data: { _dotldType: typeName }, + color: td.color, + borderColor: darkenColor(td.color), + shape: cyShape, + _dotldSize: td.size, + }); + } + + if (hasUndefined) { + nodeTypes.push({ + id: '_undefined', + label: 'Entity', + data: { _dotldType: '_undefined' }, + color: '#DDDDDD', + borderColor: '#888888', + shape: 'roundrectangle', + }); + } + + // ── Build palette edgeTypes from relationship labels ────────────────────── + const edgeLabelSet = new Set(rels.map(r => r.label).filter(Boolean)); + const edgeTypes = edgeLabelSet.size > 0 + ? [...edgeLabelSet].map(label => ({ + id: label, + label, + data: {}, + color: '#555555', + lineStyle: 'solid', + })) + : [{ id: 'default', label: 'edge', data: {}, color: '#666666', lineStyle: 'solid' }]; + + // ── Build nodes ─────────────────────────────────────────────────────────── + const nodeTypeMap = new Map(nodeTypes.map(nt => [nt.id, nt])); + const nodes = []; + + for (const name of allEntityNames) { + const assignment = entityAssignments.get(name); + const typeName = assignment?.type || '_undefined'; + + const data = { + id: name, + label: name, + _dotldType: typeName, + ...(assignment?.props || {}), + }; + + const nt = nodeTypeMap.get(typeName); + if (nt?._dotldSize) { + data._style = { + 'width': nt._dotldSize, + 'height': Math.round(nt._dotldSize * 0.55), + }; + } + + nodes.push({ data, classes: `dotld-type-${typeName}` }); + } + + // ── Build edges ─────────────────────────────────────────────────────────── + const edges = []; + let edgeCounter = 0; + + for (const rel of rels) { + const { source, arrow, target, label } = rel; + + if (arrow === '<->') { + // Bidirectional: emit two directed edges, both marked for round-trip export + const pairId = `bidi_${edgeCounter++}`; + edges.push({ data: { id: `${pairId}_f`, source, target, label, _dotldBidi: pairId } }); + edges.push({ data: { id: `${pairId}_r`, source: target, target: source, label, _dotldBidi: pairId } }); + } else if (arrow === '<-') { + // Reversed: the named source *receives* the relationship from target + edges.push({ data: { id: `e${edgeCounter++}`, source: target, target: source, label } }); + } else { + edges.push({ data: { id: `e${edgeCounter++}`, source, target, label } }); + } + } + + // ── Build userStylesheet from type definitions ──────────────────────────── + const userStylesheet = []; + for (const [typeName, td] of typeDefs) { + const cyShape = DOTLD_TO_CY_SHAPE[td.shape] || 'roundrectangle'; + userStylesheet.push({ + selector: `.dotld-type-${typeName}`, + style: { + 'background-color': td.color, + 'border-color': darkenColor(td.color), + 'border-width': 2, + 'shape': cyShape, + 'width': td.size, + 'height': Math.round(td.size * 0.55), + 'color': '#000000', + 'font-size': 12, + 'text-valign': 'center', + 'text-halign': 'center', + }, + }); + } + + if (hasUndefined) { + userStylesheet.push({ + selector: '.dotld-type-_undefined', + style: { + 'background-color': '#DDDDDD', + 'border-color': '#888888', + 'border-width': 2, + 'shape': 'roundrectangle', + 'color': '#000000', + 'font-size': 12, + 'text-valign': 'center', + 'text-halign': 'center', + }, + }); + } + + return { + title, + description, + palette: { nodeTypes, edgeTypes }, + elements: { nodes, edges }, + userStylesheet, + version: '1.0.0', + }; +} + +// ─── Export ─────────────────────────────────────────────────────────────────── + +function escapePropertyValue(value) { + const str = String(value); + if (/^[\w-]+$/.test(str)) return str; + return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** + * Convert a Boxes graph (as returned by editor.exportGraph()) into a DOT-LD + * markdown document. + * + * @param {object} boxesGraph - Result of BoxesEditor.exportGraph() + * @returns {string} DOT-LD markdown text + */ +export function exportToDotLD(boxesGraph) { + const { title = '', description = '', palette, elements } = boxesGraph; + const nodes = elements?.nodes || []; + const edges = elements?.edges || []; + const nodeTypes = palette?.nodeTypes || []; + + const lines = []; + + // ── Document title ──────────────────────────────────────────────────────── + lines.push(`# ${title || 'Knowledge Graph'}`, ''); + if (description) lines.push(description, ''); + + // ── Config block ────────────────────────────────────────────────────────── + lines.push('::config'); + + // Type definitions + const typesToEmit = nodeTypes.filter(nt => nt.id !== '_undefined'); + if (typesToEmit.length > 0) { + lines.push('// Type definitions'); + for (const nt of typesToEmit) { + const dotldShape = CY_TO_DOTLD_SHAPE[nt.shape] || DEFAULT_DOTLD_SHAPE; + const color = nt.color || DEFAULT_COLOR; + const size = nt._dotldSize || DEFAULT_SIZE; + lines.push(`${nt.id}: ${dotldShape}, ${color}, ${size}`); + } + lines.push(''); + } + + // Entity assignments + if (nodes.length > 0) { + lines.push('// Entity assignments'); + for (const node of nodes) { + const { id, _dotldType, ...rest } = node.data; + const typeName = _dotldType && _dotldType !== '_undefined' ? _dotldType : 'entity'; + + const propParts = []; + for (const [k, v] of Object.entries(rest)) { + if (BOXES_INTERNAL.has(k)) continue; + if (v === '' || v === undefined || v === null) continue; + propParts.push(`${k}=${escapePropertyValue(v)}`); + } + + const propsStr = propParts.length > 0 ? ', ' + propParts.join(', ') : ''; + lines.push(`${id}: type=${typeName}${propsStr}`); + } + } + + lines.push('::', ''); + + // ── Entity references in prose ──────────────────────────────────────────── + if (nodes.length > 0) { + lines.push('## Entities', ''); + const mentions = nodes.map(n => `[[${n.data.id}]]`).join(', '); + lines.push(`This knowledge graph contains the following entities: ${mentions}.`, ''); + } + + // ── Relationship blocks ─────────────────────────────────────────────────── + if (edges.length > 0) { + lines.push('## Relationships', ''); + // Deduplicate <-> pairs: the importer generates two directed edges sharing + // the same _dotldBidi token; we emit a single <-> for each pair. + const emittedBidiTokens = new Set(); + for (const edge of edges) { + const { source, target, label, _dotldBidi } = edge.data; + const edgeLabel = label || 'related'; + + if (_dotldBidi) { + if (emittedBidiTokens.has(_dotldBidi)) continue; + emittedBidiTokens.add(_dotldBidi); + lines.push(`::rel ${source} <-> ${target} [${edgeLabel}] ::`); + } else { + lines.push(`::rel ${source} -> ${target} [${edgeLabel}] ::`); + } + } + lines.push(''); + } + + return lines.join('\n'); +} + +// ─── Importer / exporter descriptors ───────────────────────────────────────── + +export const dotLdImporter = { + name: 'DOT-LD Markdown', + extensions: ['.md'], + mimeTypes: ['text/markdown', 'text/plain'], + import: (text) => importFromDotLD(text), +}; + +export const dotLdExporter = { + name: 'DOT-LD Markdown', + extension: '.md', + mimeType: 'text/markdown', + export: (editor) => exportToDotLD(editor.exportGraph()), +}; diff --git a/packages/core/tests/dot-ld-io.test.js b/packages/core/tests/dot-ld-io.test.js new file mode 100644 index 0000000..2ed196b --- /dev/null +++ b/packages/core/tests/dot-ld-io.test.js @@ -0,0 +1,403 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { importFromDotLD, exportToDotLD } from '../src/io/dot-ld.js'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const MINIMAL = `# Minimal Example + +::config +thing: circle, #333333, 80 +Item: type=thing +:: + +This document mentions [[Item]]. +`; + +const HVAC = `# HVAC System Documentation + +The primary cooling system uses a pump and cooling tower. + +::config +// Equipment types +equipment: round-rectangle, #2196F3, 120 +component: ellipse, #4CAF50, 80 +control: diamond, #FF9800, 90 + +// Entity assignments +ChillerSystem: type=equipment +CoolingTower: type=equipment +Pump: type=component +Controller: type=control +Valve: type=component +:: + +The [[ChillerSystem]] is the primary cooling equipment. +It uses a [[Pump]] to circulate chilled water. + +::rel ChillerSystem -> Pump [uses] :: +::rel ChillerSystem -> CoolingTower [requires] :: + +A [[Controller]] monitors and adjusts the [[Valve]] position. + +::rel Controller -> Valve [controls] :: +::rel Controller -> ChillerSystem [monitors] :: +`; + +const BIDIRECTIONAL = `# Bidirectional Example + +::config +service: ellipse, #2196F3, 100 +ServiceA: type=service +ServiceB: type=service +:: + +::rel ServiceA <-> ServiceB [communicates_with] :: +`; + +const BACKWARD = `# Backward Arrow Example + +::config +service: ellipse, #2196F3, 100 +Server: type=service +Database: type=service +:: + +::rel Server <- Database [provides_data_to] :: +`; + +const MULTI_CONFIG = `# Multi-config Example + +::config +equipment: round-rectangle, #2196F3, 120 +ChillerSystem: type=equipment +:: + +Some text here. + +::config +component: ellipse, #4CAF50, 80 +Pump: type=component +:: + +::rel ChillerSystem -> Pump [uses] :: +`; + +const WITH_PROPERTIES = `# Properties Example + +::config +equipment: round-rectangle, #2196F3, 120 +Machine: type=equipment, manufacturer="Acme Corp", model=X200 +:: +`; + +// ─── importFromDotLD tests ──────────────────────────────────────────────────── + +describe('importFromDotLD', () => { + + describe('minimal example', () => { + it('returns elements with one node', () => { + const result = importFromDotLD(MINIMAL); + expect(result.elements.nodes).toHaveLength(1); + expect(result.elements.edges).toHaveLength(0); + }); + + it('extracts the title', () => { + const result = importFromDotLD(MINIMAL); + expect(result.title).toBe('Minimal Example'); + }); + + it('creates a node with the entity name as id and label', () => { + const result = importFromDotLD(MINIMAL); + const node = result.elements.nodes[0]; + expect(node.data.id).toBe('Item'); + expect(node.data.label).toBe('Item'); + }); + + it('stores the dot-ld type on the node', () => { + const result = importFromDotLD(MINIMAL); + expect(result.elements.nodes[0].data._dotldType).toBe('thing'); + }); + + it('assigns a CSS class reflecting the type', () => { + const result = importFromDotLD(MINIMAL); + expect(result.elements.nodes[0].classes).toContain('dotld-type-thing'); + }); + + it('includes a palette nodeType for the type definition', () => { + const result = importFromDotLD(MINIMAL); + const nt = result.palette.nodeTypes.find(t => t.id === 'thing'); + expect(nt).toBeTruthy(); + expect(nt.color).toBe('#333333'); + expect(nt.shape).toBe('ellipse'); // circle → ellipse + }); + + it('generates a stylesheet rule for the type', () => { + const result = importFromDotLD(MINIMAL); + const rule = result.userStylesheet.find(r => r.selector === '.dotld-type-thing'); + expect(rule).toBeTruthy(); + expect(rule.style['background-color']).toBe('#333333'); + expect(rule.style['shape']).toBe('ellipse'); + }); + }); + + describe('HVAC example', () => { + let result; + beforeAll(() => { result = importFromDotLD(HVAC); }); + + it('imports all five entities', () => { + expect(result.elements.nodes).toHaveLength(5); + }); + + it('imports four edges', () => { + expect(result.elements.edges).toHaveLength(4); + }); + + it('maps round-rectangle to roundrectangle', () => { + const nt = result.palette.nodeTypes.find(t => t.id === 'equipment'); + expect(nt.shape).toBe('roundrectangle'); + }); + + it('maps diamond shape correctly', () => { + const nt = result.palette.nodeTypes.find(t => t.id === 'control'); + expect(nt.shape).toBe('diamond'); + }); + + it('creates edges with correct source, target and label', () => { + const edge = result.elements.edges.find( + e => e.data.source === 'ChillerSystem' && e.data.target === 'Pump' + ); + expect(edge).toBeTruthy(); + expect(edge.data.label).toBe('uses'); + }); + + it('generates edge types from relationship labels', () => { + const labels = result.palette.edgeTypes.map(et => et.id); + expect(labels).toContain('uses'); + expect(labels).toContain('controls'); + expect(labels).toContain('monitors'); + }); + }); + + describe('bidirectional arrow <->', () => { + it('emits two directed edges for <->', () => { + const result = importFromDotLD(BIDIRECTIONAL); + expect(result.elements.edges).toHaveLength(2); + }); + + it('marks both edges with a shared _dotldBidi token', () => { + const result = importFromDotLD(BIDIRECTIONAL); + const [e1, e2] = result.elements.edges; + expect(e1.data._dotldBidi).toBeTruthy(); + expect(e1.data._dotldBidi).toBe(e2.data._dotldBidi); + }); + + it('creates both forward and reverse directed edges', () => { + const result = importFromDotLD(BIDIRECTIONAL); + const srcA = result.elements.edges.find(e => e.data.source === 'ServiceA' && e.data.target === 'ServiceB'); + const srcB = result.elements.edges.find(e => e.data.source === 'ServiceB' && e.data.target === 'ServiceA'); + expect(srcA).toBeTruthy(); + expect(srcB).toBeTruthy(); + }); + }); + + describe('backward arrow <-', () => { + it('reverses the edge direction', () => { + const result = importFromDotLD(BACKWARD); + // ::rel Server <- Database :: means Database provides_data_to Server + // → edge from Database to Server + const edge = result.elements.edges[0]; + expect(edge.data.source).toBe('Database'); + expect(edge.data.target).toBe('Server'); + expect(edge.data.label).toBe('provides_data_to'); + }); + }); + + describe('multiple ::config blocks', () => { + it('merges type definitions from both blocks', () => { + const result = importFromDotLD(MULTI_CONFIG); + const typeIds = result.palette.nodeTypes.map(t => t.id); + expect(typeIds).toContain('equipment'); + expect(typeIds).toContain('component'); + }); + + it('merges entity assignments from both blocks', () => { + const result = importFromDotLD(MULTI_CONFIG); + const nodeIds = result.elements.nodes.map(n => n.data.id); + expect(nodeIds).toContain('ChillerSystem'); + expect(nodeIds).toContain('Pump'); + }); + }); + + describe('entity properties', () => { + it('stores extra properties on the node data', () => { + const result = importFromDotLD(WITH_PROPERTIES); + const node = result.elements.nodes.find(n => n.data.id === 'Machine'); + expect(node).toBeTruthy(); + expect(node.data.manufacturer).toBe('Acme Corp'); + expect(node.data.model).toBe('X200'); + }); + }); + + describe('undefined entities', () => { + it('creates nodes for entities only referenced in [[]] but not in config', () => { + const text = `# Test\n::config\nthing: ellipse, #333333, 80\n::\nThe [[Unknown]] exists.\n`; + const result = importFromDotLD(text); + const node = result.elements.nodes.find(n => n.data.id === 'Unknown'); + expect(node).toBeTruthy(); + expect(node.data._dotldType).toBe('_undefined'); + }); + + it('creates nodes for entities only referenced in ::rel but not in config', () => { + const text = `# Test\n::config\nthing: ellipse, #333333, 80\nKnown: type=thing\n::\n::rel Known -> Ghost [uses] ::\n`; + const result = importFromDotLD(text); + const node = result.elements.nodes.find(n => n.data.id === 'Ghost'); + expect(node).toBeTruthy(); + }); + }); + + describe('return shape', () => { + it('always returns elements, palette, userStylesheet, and version', () => { + const result = importFromDotLD(MINIMAL); + expect(result).toHaveProperty('elements'); + expect(result).toHaveProperty('palette'); + expect(result).toHaveProperty('userStylesheet'); + expect(result).toHaveProperty('version'); + }); + + it('palette has nodeTypes and edgeTypes arrays', () => { + const result = importFromDotLD(HVAC); + expect(Array.isArray(result.palette.nodeTypes)).toBe(true); + expect(Array.isArray(result.palette.edgeTypes)).toBe(true); + }); + }); +}); + +// ─── exportToDotLD tests ────────────────────────────────────────────────────── + +describe('exportToDotLD', () => { + + const SIMPLE_GRAPH = { + title: 'Test Graph', + description: 'A test knowledge graph.', + palette: { + nodeTypes: [ + { id: 'equipment', label: 'equipment', color: '#2196F3', borderColor: '#1760a3', shape: 'roundrectangle', _dotldSize: 120 }, + { id: 'component', label: 'component', color: '#4CAF50', borderColor: '#2e7d32', shape: 'ellipse', _dotldSize: 80 }, + ], + edgeTypes: [ + { id: 'uses', label: 'uses', color: '#555', lineStyle: 'solid' }, + ], + }, + elements: { + nodes: [ + { data: { id: 'ChillerSystem', label: 'ChillerSystem', _dotldType: 'equipment' } }, + { data: { id: 'Pump', label: 'Pump', _dotldType: 'component' } }, + ], + edges: [ + { data: { id: 'e0', source: 'ChillerSystem', target: 'Pump', label: 'uses' } }, + ], + }, + userStylesheet: [], + }; + + it('produces a string', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(typeof output).toBe('string'); + }); + + it('includes the graph title as a level-1 heading', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(output).toContain('# Test Graph'); + }); + + it('includes the description', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(output).toContain('A test knowledge graph.'); + }); + + it('contains a ::config block', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(output).toContain('::config'); + expect(output).toMatch(/\n::/m); + }); + + it('emits type definitions for palette node types', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(output).toContain('equipment: round-rectangle, #2196F3, 120'); + expect(output).toContain('component: ellipse, #4CAF50, 80'); + }); + + it('emits entity assignments for nodes', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(output).toContain('ChillerSystem: type=equipment'); + expect(output).toContain('Pump: type=component'); + }); + + it('includes [[EntityName]] references in prose', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(output).toContain('[[ChillerSystem]]'); + expect(output).toContain('[[Pump]]'); + }); + + it('emits a ::rel block for edges', () => { + const output = exportToDotLD(SIMPLE_GRAPH); + expect(output).toContain('::rel ChillerSystem -> Pump [uses] ::'); + }); + + it('emits <-> notation for bidirectional edges', () => { + const graph = { + ...SIMPLE_GRAPH, + elements: { + nodes: [ + { data: { id: 'A', label: 'A', _dotldType: 'equipment' } }, + { data: { id: 'B', label: 'B', _dotldType: 'equipment' } }, + ], + edges: [ + { data: { id: 'bidi_0_f', source: 'A', target: 'B', label: 'linked', _dotldBidi: 'bidi_0' } }, + { data: { id: 'bidi_0_r', source: 'B', target: 'A', label: 'linked', _dotldBidi: 'bidi_0' } }, + ], + }, + }; + const output = exportToDotLD(graph); + expect(output).toContain('<->'); + // The bidi pair should appear only once + const count = (output.match(/::rel A <-> B \[linked\] ::/g) || []).length; + expect(count).toBe(1); + }); + + it('uses a fallback title when none is provided', () => { + const graph = { ...SIMPLE_GRAPH, title: '' }; + const output = exportToDotLD(graph); + expect(output).toContain('# Knowledge Graph'); + }); +}); + +// ─── Round-trip tests ───────────────────────────────────────────────────────── + +describe('DOT-LD round-trip', () => { + it('can re-import the markdown produced by exportToDotLD', () => { + const original = importFromDotLD(HVAC); + const markdown = exportToDotLD(original); + const roundtrip = importFromDotLD(markdown); + + const origIds = original.elements.nodes.map(n => n.data.id).sort(); + const rtIds = roundtrip.elements.nodes.map(n => n.data.id).sort(); + expect(rtIds).toEqual(origIds); + + expect(roundtrip.elements.edges).toHaveLength(original.elements.edges.length); + }); + + it('preserves type definitions through the round-trip', () => { + const original = importFromDotLD(HVAC); + const markdown = exportToDotLD(original); + const roundtrip = importFromDotLD(markdown); + + const origTypes = original.palette.nodeTypes + .filter(t => t.id !== '_undefined') + .map(t => t.id).sort(); + const rtTypes = roundtrip.palette.nodeTypes + .filter(t => t.id !== '_undefined') + .map(t => t.id).sort(); + expect(rtTypes).toEqual(origTypes); + }); +}); diff --git a/packages/web/public/app.js b/packages/web/public/app.js index 30a6786..adfcfad 100644 --- a/packages/web/public/app.js +++ b/packages/web/public/app.js @@ -3,6 +3,7 @@ import { rdfImporter, rdfExporter, jsonldImporter, jsonldExporter, rdfXmlImporter, rdfXmlExporter, + dotLdImporter, dotLdExporter, } from '/core/boxes-core.js'; import { registerImporter, registerExporter, getImporters, getExporters, runImport, runExport } from './io/io-manager.js'; import { lucidchartCSVImporter } from './io/importers/lucidchart-csv.js'; @@ -15,11 +16,13 @@ registerImporter('lucidchart-csv', lucidchartCSVImporter); registerImporter('rdf', rdfImporter); registerImporter('jsonld', jsonldImporter); registerImporter('rdfxml', rdfXmlImporter); +registerImporter('dotld', dotLdImporter); registerExporter('svg', svgExporter); registerExporter('pdf', pdfExporter); registerExporter('rdf', rdfExporter); registerExporter('jsonld', jsonldExporter); registerExporter('rdfxml', rdfXmlExporter); +registerExporter('dotld', dotLdExporter); let editor = null; let currentFileName = 'graph.json';