From 05b4391b768029f9d45827432db63007241128db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 17:35:22 +0000 Subject: [PATCH 1/4] feat: add DOT-LD import/export with LODE-style HTML exporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/core/src/io/dot-ld.js: parse ::config blocks (type defs + entity assignments), ::rel blocks (→, ←, ↔), [[EntityName]] refs; export to DOT-LD markdown; importer/exporter descriptors - packages/web/public/io/exporters/dot-ld-html.js: standalone LODE-style HTML page with sidebar TOC, entity-types section, per-entity detail (properties + in/out relationships), relationships table, ontology metadata from owl:Ontology node or graph title/description - packages/core/src/index.js: export importFromDotLD, exportToDotLD, dotLdImporter, dotLdExporter - packages/web/public/app.js: register dotld importer and dotld / dotld-html exporters - packages/core/tests/dot-ld-io.test.js: 36 tests covering import, export, bidirectional edges, backward arrows, multi-config blocks, entity properties, undefined entities, and round-trip fidelity Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/cd481b95-9eea-429a-82cc-5c359ff556ac Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/index.js | 3 + packages/core/src/io/dot-ld.js | 448 ++++++++++++++++ packages/core/tests/dot-ld-io.test.js | 403 +++++++++++++++ packages/web/public/app.js | 5 + .../web/public/io/exporters/dot-ld-html.js | 488 ++++++++++++++++++ 5 files changed, 1347 insertions(+) create mode 100644 packages/core/src/io/dot-ld.js create mode 100644 packages/core/tests/dot-ld-io.test.js create mode 100644 packages/web/public/io/exporters/dot-ld-html.js 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..4b5d7c1 --- /dev/null +++ b/packages/core/src/io/dot-ld.js @@ -0,0 +1,448 @@ +/** + * 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 +const PROP_PAIR_RE = /,\s*([\w-]+)\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[\w-]+)/g; + +/** + * Parse all ::config ... :: blocks from a DOT-LD document. + * Returns { typeDefs: Map, + * entityAssignments: Map } + */ +function parseConfigBlocks(text) { + const typeDefs = new Map(); + const entityAssignments = new Map(); + + // Config blocks: ::config\n...\n:: (:: on its own line) + const CONFIG_BLOCK_RE = /::config\s*\n([\s\S]*?)\n::/g; + let blockMatch; + while ((blockMatch = CONFIG_BLOCK_RE.exec(text)) !== null) { + for (const rawLine of blockMatch[1].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: '->' | '<-' | '<->' + */ +function parseRelBlocks(text) { + const rels = []; + const REL_RE = /::rel\s+([\w-]+)\s+(->|<-|<->)\s+([\w-]+)\s+\[([^\]]*)\]\s*::/g; + 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; +} + +/** + * Collect all [[EntityName]] references from prose (outside config/rel blocks). + * Returns a Set of entity name strings. + */ +function parseEntityRefs(text) { + const stripped = text + .replace(/::config[\s\S]*?::/g, '') + .replace(/::rel[^\n]*::/g, ''); + 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 = text + .replace(/::config[\s\S]*?::/g, '') + .replace(/::rel[^\n]*::/g, '') + .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..544edc1 100644 --- a/packages/web/public/app.js +++ b/packages/web/public/app.js @@ -3,11 +3,13 @@ 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'; import { svgExporter } from './io/exporters/svg.js'; import { pdfExporter } from './io/exporters/pdf.js'; +import { dotLdHtmlExporter } from './io/exporters/dot-ld-html.js'; import { startTour, isTourDone } from './tour.js'; // ── Register built-in I/O plugins ─────────────────────────────────────────── @@ -15,11 +17,14 @@ 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); +registerExporter('dotld-html', dotLdHtmlExporter); let editor = null; let currentFileName = 'graph.json'; diff --git a/packages/web/public/io/exporters/dot-ld-html.js b/packages/web/public/io/exporters/dot-ld-html.js new file mode 100644 index 0000000..79288ae --- /dev/null +++ b/packages/web/public/io/exporters/dot-ld-html.js @@ -0,0 +1,488 @@ +/** + * DOT-LD HTML Page exporter for Boxes graph editor. + * + * Produces a standalone ontology-browsing HTML page in the style of LODE + * (Live OWL Documentation Environment), using graph and ontology-level + * metadata for the introductory elements. + * + * The page includes: + * - Metadata header (title, description, generation date, entity/relationship counts) + * - Table of contents + * - Entity Types section (palette node types → analogous to OWL Classes) + * - Entities section (nodes grouped by type, with properties and relationships) + * - Relationships section (tabular index of all edges) + */ + +// ─── HTML utilities ─────────────────────────────────────────────────────────── + +function esc(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function slugify(str) { + return String(str ?? '').replace(/[^A-Za-z0-9_-]/g, '_'); +} + +function entityAnchor(id) { + return `entity-${slugify(id)}`; +} + +function typeAnchor(id) { + return `type-${slugify(id)}`; +} + +function entityLink(id) { + return `${esc(id)}`; +} + +function typeLink(id) { + return `${esc(id)}`; +} + +// ─── Colour swatch ──────────────────────────────────────────────────────────── + +function colorSwatch(hex) { + if (!hex) return ''; + return ``; +} + +// ─── Ontology metadata extraction ──────────────────────────────────────────── + +/** + * Find ontology-level metadata from the graph. + * + * Looks for a node whose @type is 'owl:Ontology' or whose _dotldType indicates + * it is an ontology node. Falls back to graph.title / graph.description. + */ +function extractOntologyMeta(graph) { + const nodes = graph.elements?.nodes || []; + const meta = {}; + + // Look for an owl:Ontology node or similarly typed node + const ontNode = nodes.find(n => { + const t = n.data['@type'] || n.data._dotldType || ''; + return t === 'owl:Ontology' || t.toLowerCase().includes('ontology'); + }); + + if (ontNode) { + const d = ontNode.data; + meta.iri = d['@id'] || d.iri || ''; + meta.versionIRI = d['owl:versionIRI'] || d.versionIRI || ''; + meta.versionInfo = d['owl:versionInfo'] || d.versionInfo || ''; + meta.creator = d['dcterms:creator'] || d.creator || d['dc:creator'] || ''; + meta.publisher = d['dcterms:publisher'] || d.publisher || ''; + meta.license = d['dcterms:license'] || d.license || ''; + meta.issued = d['dcterms:issued'] || d.issued || ''; + // Use node label/id as title fallback + meta.title = graph.title || d.label || d['@id'] || 'Knowledge Graph'; + meta.description = graph.description + || d['dcterms:description'] || d.description + || d['rdfs:comment'] || d.comment || ''; + } else { + meta.title = graph.title || 'Knowledge Graph'; + meta.description = graph.description || ''; + } + + return meta; +} + +// ─── Main page builder ──────────────────────────────────────────────────────── + +function buildHtmlPage(graph) { + const meta = extractOntologyMeta(graph); + const nodes = graph.elements?.nodes || []; + const edges = graph.elements?.edges || []; + const nodeTypes = (graph.palette?.nodeTypes || []).filter(nt => nt.id !== '_undefined'); + const generated = new Date().toISOString().slice(0, 10); + + // ── Build lookup structures ────────────────────────────────────────────── + const nodeById = new Map(nodes.map(n => [n.data.id, n])); + + // Index incoming / outgoing edges per node + const outgoing = new Map(nodes.map(n => [n.data.id, []])); + const incoming = new Map(nodes.map(n => [n.data.id, []])); + for (const edge of edges) { + const { source, target } = edge.data; + if (outgoing.has(source)) outgoing.get(source).push(edge); + if (incoming.has(target)) incoming.get(target).push(edge); + } + + // Group nodes by type + const nodesByType = new Map(); + for (const nt of nodeTypes) nodesByType.set(nt.id, []); + const untypedNodes = []; + + for (const node of nodes) { + const typeName = node.data._dotldType + || (node.data['@type'] && nodeTypes.find(nt => nt.id === node.data['@type'])?.id) + || null; + if (typeName && nodesByType.has(typeName)) { + nodesByType.get(typeName).push(node); + } else { + untypedNodes.push(node); + } + } + + // ── Sections ───────────────────────────────────────────────────────────── + + // 1. Entity types + const entityTypesSections = nodeTypes.map(nt => { + const members = nodesByType.get(nt.id) || []; + const dotldShape = nt.shape + ? { roundrectangle: 'round-rectangle', rectangle: 'rectangle', ellipse: 'ellipse', diamond: 'diamond' }[nt.shape] || nt.shape + : 'round-rectangle'; + + const memberLinks = members.length + ? members.map(n => entityLink(n.data.id)).join(', ') + : '(no entities)'; + + return ` +
+

${colorSwatch(nt.color)}${esc(nt.id)}

+
+
Shape
${esc(dotldShape)}
+
Color
${colorSwatch(nt.color)} ${esc(nt.color || '—')}
+ ${nt._dotldSize ? `
Size
${esc(nt._dotldSize)}
` : ''} +
Members (${members.length})
${memberLinks}
+
+
`; + }).join('\n'); + + // 2. Entities + const renderNode = (node) => { + const d = node.data; + const typeName = d._dotldType || d['@type'] || ''; + const typeLabel = typeName && nodeTypes.find(nt => nt.id === typeName) + ? typeLink(typeName) : esc(typeName) || 'untyped'; + + // User properties (non-internal) + const internalKeys = new Set(['id', 'label', '_dotldType', '_style', '_classes', '_dotldBidi']); + const propRows = Object.entries(d) + .filter(([k]) => !internalKeys.has(k) && !k.startsWith('_')) + .map(([k, v]) => `${esc(k)}${esc(v)}`) + .join(''); + + const propTable = propRows + ? `${propRows}
PropertyValue
` + : '(no additional properties)'; + + // Outgoing relationships + const outEdges = outgoing.get(d.id) || []; + const outList = outEdges.length + ? '' + : '(none)'; + + // Incoming relationships + const inEdges = incoming.get(d.id) || []; + const inList = inEdges.length + ? '' + : '(none)'; + + return ` +
+

${esc(d.label || d.id)}

+
+
Type
${typeLabel}
+
Properties
${propTable}
+
Outgoing relationships
${outList}
+
Incoming relationships
${inList}
+
+
`; + }; + + const typedEntitiesHtml = nodeTypes.map(nt => { + const members = nodesByType.get(nt.id) || []; + if (members.length === 0) return ''; + return `
+

${colorSwatch(nt.color)}${esc(nt.id)}

+ ${members.map(renderNode).join('\n')} +
`; + }).join('\n'); + + const untypedEntitiesHtml = untypedNodes.length + ? `
+

Untyped Entities

+ ${untypedNodes.map(renderNode).join('\n')} +
` + : ''; + + // 3. Relationships table + const relRows = edges.map(e => { + const { source, target, label } = e.data; + const srcLink = nodeById.has(source) ? entityLink(source) : esc(source); + const tgtLink = nodeById.has(target) ? entityLink(target) : esc(target); + return `${srcLink}${esc(label || '—')}${tgtLink}`; + }).join('\n'); + + const relTable = relRows + ? ` + + ${relRows} +
SourceRelationshipTarget
` + : '

No relationships defined.

'; + + // ── TOC entries ─────────────────────────────────────────────────────────── + let tocCounter = 1; + const tocItems = []; + if (nodeTypes.length > 0) tocItems.push({ n: tocCounter++, id: 'entity-types', label: 'Entity Types' }); + if (nodes.length > 0) tocItems.push({ n: tocCounter++, id: 'entities', label: 'Entities' }); + if (edges.length > 0) tocItems.push({ n: tocCounter++, id: 'relationships', label: 'Relationships' }); + + const tocHtml = tocItems.map(t => + `
  • ${t.n}. ${t.label}
  • ` + ).join('\n'); + + // ── Metadata DL entries ─────────────────────────────────────────────────── + const metaRows = [ + meta.iri && `
    IRI
    ${esc(meta.iri)}
    `, + meta.versionIRI && `
    Version IRI
    ${esc(meta.versionIRI)}
    `, + meta.versionInfo && `
    Version
    ${esc(meta.versionInfo)}
    `, + meta.creator && `
    Author
    ${esc(meta.creator)}
    `, + meta.publisher && `
    Publisher
    ${esc(meta.publisher)}
    `, + meta.license && `
    License
    ${esc(meta.license)}
    `, + meta.issued && `
    Issued
    ${esc(meta.issued)}
    `, + `
    Generated
    ${esc(generated)}
    `, + `
    Entities
    ${esc(nodes.length)}
    `, + `
    Relationships
    ${esc(edges.length)}
    `, + ].filter(Boolean).join('\n'); + + // ── Assemble page ───────────────────────────────────────────────────────── + return ` + + + + + ${esc(meta.title)} + + + +
    + + + + + +
    + + + + + ${nodeTypes.length > 0 ? ` + +
    +

    1. Entity Types

    +

    The following types are defined in this knowledge graph. Each type specifies the visual shape and colour used for its member entities.

    + ${entityTypesSections} +
    ` : ''} + + ${nodes.length > 0 ? ` + +
    +

    ${nodeTypes.length > 0 ? '2' : '1'}. Entities

    +

    Detailed view of all ${nodes.length} entities, grouped by type, showing properties and relationships.

    + ${typedEntitiesHtml} + ${untypedEntitiesHtml} +
    ` : ''} + + ${edges.length > 0 ? ` + +
    +

    ${(nodeTypes.length > 0 ? 2 : 1) + (nodes.length > 0 ? 1 : 0)}. Relationships

    +

    All ${edges.length} relationships defined in this knowledge graph.

    + ${relTable} +
    ` : ''} + +
    +
    + +`; +} + +// ─── Exporter descriptor ────────────────────────────────────────────────────── + +export const dotLdHtmlExporter = { + name: 'DOT-LD HTML Page', + extension: '.html', + mimeType: 'text/html', + + export(editor) { + const graph = editor.exportGraph(); + if ((!graph.elements?.nodes?.length) && (!graph.elements?.edges?.length)) { + throw new Error('Nothing to export — graph is empty'); + } + return buildHtmlPage(graph); + }, +}; From 47da0221a4ce25bb883ff50f8cec1361bf22c5b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 17:37:55 +0000 Subject: [PATCH 2/4] fix: address CodeQL ReDoS and code review issues in DOT-LD parser - Replace CONFIG_BLOCK_RE lazy [\s\S]*? with string-based block extraction to eliminate polynomial backtracking on malformed config blocks - Anchor REL_RE to single lines (^...$, m flag) and use [^\]\n]* instead of [^\]]* to prevent cross-line backtracking - Tighten darkenColor hex validation from < 7 to !== 7 Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/cd481b95-9eea-429a-82cc-5c359ff556ac Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/io/dot-ld.js | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/core/src/io/dot-ld.js b/packages/core/src/io/dot-ld.js index 4b5d7c1..7b0b518 100644 --- a/packages/core/src/io/dot-ld.js +++ b/packages/core/src/io/dot-ld.js @@ -44,7 +44,7 @@ const BOXES_INTERNAL = new Set([ // ─── Colour helper ──────────────────────────────────────────────────────────── function darkenColor(hex, factor = 0.65) { - if (!hex || !hex.startsWith('#') || hex.length < 7) return '#444444'; + 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); @@ -69,16 +69,31 @@ const PROP_PAIR_RE = /,\s*([\w-]+)\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[\ * 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(); - // Config blocks: ::config\n...\n:: (:: on its own line) - const CONFIG_BLOCK_RE = /::config\s*\n([\s\S]*?)\n::/g; - let blockMatch; - while ((blockMatch = CONFIG_BLOCK_RE.exec(text)) !== null) { - for (const rawLine of blockMatch[1].split('\n')) { + // 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; @@ -123,10 +138,13 @@ function parseConfigBlocks(text) { * 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 = []; - const REL_RE = /::rel\s+([\w-]+)\s+(->|<-|<->)\s+([\w-]+)\s+\[([^\]]*)\]\s*::/g; + // Anchored: each ::rel must be on a single line; [^\]\n]* prevents newline-spanning + const REL_RE = /^[ \t]*::rel[ \t]+([\w-]+)[ \t]+(->|<-|<->)[ \t]+([\w-]+)[ \t]+\[([^\]\n]*)\][ \t]*::[ \t]*$/gm; let m; while ((m = REL_RE.exec(text)) !== null) { rels.push({ From 3d44bc8b358815071e6a8207ce4661bf3e6f37e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 17:40:07 +0000 Subject: [PATCH 3/4] fix: further harden DOT-LD parser regexes and improve HTML exporter - Cap PROP_PAIR_RE quoted string lengths at 500 chars - Cap REL_RE label length at 200 chars - Replace remaining [\s\S]*? config-stripping regexes with string-based stripConfigBlocks() helper in parseEntityRefs and extractDescription - Use toISOString().split('T')[0] for date formatting in HTML exporter - Derive section numbers from tocItems array instead of ad-hoc arithmetic Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/cd481b95-9eea-429a-82cc-5c359ff556ac Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/core/src/io/dot-ld.js | 40 ++++++++++++++----- .../web/public/io/exporters/dot-ld-html.js | 8 ++-- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/core/src/io/dot-ld.js b/packages/core/src/io/dot-ld.js index 7b0b518..4e86eb9 100644 --- a/packages/core/src/io/dot-ld.js +++ b/packages/core/src/io/dot-ld.js @@ -63,7 +63,8 @@ const TYPE_DEF_RE = /^([\w-]+)\s*:\s*([\w-]+)\s*,\s*(#[0-9A-Fa-f]{6})\s*,\s*(\d+ const ENTITY_ASSIGN_RE = /^([\w-]+)\s*:\s*type=([\w-]+)((?:\s*,\s*[\w-]+=(?:"[^"]*"|'[^']*'|[\w-]+))*)\s*(?:\/\/.*)?$/; // Matches property pairs inside the extra part: , key=value -const PROP_PAIR_RE = /,\s*([\w-]+)\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[\w-]+)/g; +// 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. @@ -143,8 +144,9 @@ function parseConfigBlocks(text) { */ function parseRelBlocks(text) { const rels = []; - // Anchored: each ::rel must be on a single line; [^\]\n]* prevents newline-spanning - const REL_RE = /^[ \t]*::rel[ \t]+([\w-]+)[ \t]+(->|<-|<->)[ \t]+([\w-]+)[ \t]+\[([^\]\n]*)\][ \t]*::[ \t]*$/gm; + // 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({ @@ -157,14 +159,35 @@ function parseRelBlocks(text) { 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 = text - .replace(/::config[\s\S]*?::/g, '') - .replace(/::rel[^\n]*::/g, ''); + const stripped = stripConfigBlocks(text) + .replace(/^[ \t]*::rel[^\n]*::[ \t]*$/gm, ''); const refs = new Set(); const REF_RE = /\[\[([\w-]+)\]\]/g; let m; @@ -185,9 +208,8 @@ function extractTitle(text) { * block, not a list marker) to use as the document description. */ function extractDescription(text) { - const stripped = text - .replace(/::config[\s\S]*?::/g, '') - .replace(/::rel[^\n]*::/g, '') + const stripped = stripConfigBlocks(text) + .replace(/^[ \t]*::rel[^\n]*::[ \t]*$/gm, '') .replace(/^#+.*/gm, '') .replace(/\[\[([\w-]+)\]\]/g, '$1'); diff --git a/packages/web/public/io/exporters/dot-ld-html.js b/packages/web/public/io/exporters/dot-ld-html.js index 79288ae..d8d9a59 100644 --- a/packages/web/public/io/exporters/dot-ld-html.js +++ b/packages/web/public/io/exporters/dot-ld-html.js @@ -97,7 +97,7 @@ function buildHtmlPage(graph) { const nodes = graph.elements?.nodes || []; const edges = graph.elements?.edges || []; const nodeTypes = (graph.palette?.nodeTypes || []).filter(nt => nt.id !== '_undefined'); - const generated = new Date().toISOString().slice(0, 10); + const generated = new Date().toISOString().split('T')[0]; // ── Build lookup structures ────────────────────────────────────────────── const nodeById = new Map(nodes.map(n => [n.data.id, n])); @@ -443,7 +443,7 @@ function buildHtmlPage(graph) { ${nodeTypes.length > 0 ? `
    -

    1. Entity Types

    +

    ${tocItems.find(t => t.id === 'entity-types')?.n ?? 1}. Entity Types

    The following types are defined in this knowledge graph. Each type specifies the visual shape and colour used for its member entities.

    ${entityTypesSections}
    ` : ''} @@ -451,7 +451,7 @@ function buildHtmlPage(graph) { ${nodes.length > 0 ? `
    -

    ${nodeTypes.length > 0 ? '2' : '1'}. Entities

    +

    ${tocItems.find(t => t.id === 'entities')?.n ?? 1}. Entities

    Detailed view of all ${nodes.length} entities, grouped by type, showing properties and relationships.

    ${typedEntitiesHtml} ${untypedEntitiesHtml} @@ -460,7 +460,7 @@ function buildHtmlPage(graph) { ${edges.length > 0 ? `
    -

    ${(nodeTypes.length > 0 ? 2 : 1) + (nodes.length > 0 ? 1 : 0)}. Relationships

    +

    ${tocItems.find(t => t.id === 'relationships')?.n ?? 1}. Relationships

    All ${edges.length} relationships defined in this knowledge graph.

    ${relTable}
    ` : ''} From 5222c5295932c9ad224de33d7cea0bc4c4cd8402 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 17:40:48 +0000 Subject: [PATCH 4/4] remove DOT-LD HTML exporter; keep only markdown export The DOT-LD exporter now produces only pure DOT-LD markdown. Deleted dot-ld-html.js and removed its import/registration from app.js. Agent-Logs-Url: https://github.com/jpmccu/boxes/sessions/cd481b95-9eea-429a-82cc-5c359ff556ac Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- packages/web/public/app.js | 2 - .../web/public/io/exporters/dot-ld-html.js | 488 ------------------ 2 files changed, 490 deletions(-) delete mode 100644 packages/web/public/io/exporters/dot-ld-html.js diff --git a/packages/web/public/app.js b/packages/web/public/app.js index 544edc1..adfcfad 100644 --- a/packages/web/public/app.js +++ b/packages/web/public/app.js @@ -9,7 +9,6 @@ import { registerImporter, registerExporter, getImporters, getExporters, runImpo import { lucidchartCSVImporter } from './io/importers/lucidchart-csv.js'; import { svgExporter } from './io/exporters/svg.js'; import { pdfExporter } from './io/exporters/pdf.js'; -import { dotLdHtmlExporter } from './io/exporters/dot-ld-html.js'; import { startTour, isTourDone } from './tour.js'; // ── Register built-in I/O plugins ─────────────────────────────────────────── @@ -24,7 +23,6 @@ registerExporter('rdf', rdfExporter); registerExporter('jsonld', jsonldExporter); registerExporter('rdfxml', rdfXmlExporter); registerExporter('dotld', dotLdExporter); -registerExporter('dotld-html', dotLdHtmlExporter); let editor = null; let currentFileName = 'graph.json'; diff --git a/packages/web/public/io/exporters/dot-ld-html.js b/packages/web/public/io/exporters/dot-ld-html.js deleted file mode 100644 index d8d9a59..0000000 --- a/packages/web/public/io/exporters/dot-ld-html.js +++ /dev/null @@ -1,488 +0,0 @@ -/** - * DOT-LD HTML Page exporter for Boxes graph editor. - * - * Produces a standalone ontology-browsing HTML page in the style of LODE - * (Live OWL Documentation Environment), using graph and ontology-level - * metadata for the introductory elements. - * - * The page includes: - * - Metadata header (title, description, generation date, entity/relationship counts) - * - Table of contents - * - Entity Types section (palette node types → analogous to OWL Classes) - * - Entities section (nodes grouped by type, with properties and relationships) - * - Relationships section (tabular index of all edges) - */ - -// ─── HTML utilities ─────────────────────────────────────────────────────────── - -function esc(str) { - return String(str ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function slugify(str) { - return String(str ?? '').replace(/[^A-Za-z0-9_-]/g, '_'); -} - -function entityAnchor(id) { - return `entity-${slugify(id)}`; -} - -function typeAnchor(id) { - return `type-${slugify(id)}`; -} - -function entityLink(id) { - return `${esc(id)}`; -} - -function typeLink(id) { - return `${esc(id)}`; -} - -// ─── Colour swatch ──────────────────────────────────────────────────────────── - -function colorSwatch(hex) { - if (!hex) return ''; - return ``; -} - -// ─── Ontology metadata extraction ──────────────────────────────────────────── - -/** - * Find ontology-level metadata from the graph. - * - * Looks for a node whose @type is 'owl:Ontology' or whose _dotldType indicates - * it is an ontology node. Falls back to graph.title / graph.description. - */ -function extractOntologyMeta(graph) { - const nodes = graph.elements?.nodes || []; - const meta = {}; - - // Look for an owl:Ontology node or similarly typed node - const ontNode = nodes.find(n => { - const t = n.data['@type'] || n.data._dotldType || ''; - return t === 'owl:Ontology' || t.toLowerCase().includes('ontology'); - }); - - if (ontNode) { - const d = ontNode.data; - meta.iri = d['@id'] || d.iri || ''; - meta.versionIRI = d['owl:versionIRI'] || d.versionIRI || ''; - meta.versionInfo = d['owl:versionInfo'] || d.versionInfo || ''; - meta.creator = d['dcterms:creator'] || d.creator || d['dc:creator'] || ''; - meta.publisher = d['dcterms:publisher'] || d.publisher || ''; - meta.license = d['dcterms:license'] || d.license || ''; - meta.issued = d['dcterms:issued'] || d.issued || ''; - // Use node label/id as title fallback - meta.title = graph.title || d.label || d['@id'] || 'Knowledge Graph'; - meta.description = graph.description - || d['dcterms:description'] || d.description - || d['rdfs:comment'] || d.comment || ''; - } else { - meta.title = graph.title || 'Knowledge Graph'; - meta.description = graph.description || ''; - } - - return meta; -} - -// ─── Main page builder ──────────────────────────────────────────────────────── - -function buildHtmlPage(graph) { - const meta = extractOntologyMeta(graph); - const nodes = graph.elements?.nodes || []; - const edges = graph.elements?.edges || []; - const nodeTypes = (graph.palette?.nodeTypes || []).filter(nt => nt.id !== '_undefined'); - const generated = new Date().toISOString().split('T')[0]; - - // ── Build lookup structures ────────────────────────────────────────────── - const nodeById = new Map(nodes.map(n => [n.data.id, n])); - - // Index incoming / outgoing edges per node - const outgoing = new Map(nodes.map(n => [n.data.id, []])); - const incoming = new Map(nodes.map(n => [n.data.id, []])); - for (const edge of edges) { - const { source, target } = edge.data; - if (outgoing.has(source)) outgoing.get(source).push(edge); - if (incoming.has(target)) incoming.get(target).push(edge); - } - - // Group nodes by type - const nodesByType = new Map(); - for (const nt of nodeTypes) nodesByType.set(nt.id, []); - const untypedNodes = []; - - for (const node of nodes) { - const typeName = node.data._dotldType - || (node.data['@type'] && nodeTypes.find(nt => nt.id === node.data['@type'])?.id) - || null; - if (typeName && nodesByType.has(typeName)) { - nodesByType.get(typeName).push(node); - } else { - untypedNodes.push(node); - } - } - - // ── Sections ───────────────────────────────────────────────────────────── - - // 1. Entity types - const entityTypesSections = nodeTypes.map(nt => { - const members = nodesByType.get(nt.id) || []; - const dotldShape = nt.shape - ? { roundrectangle: 'round-rectangle', rectangle: 'rectangle', ellipse: 'ellipse', diamond: 'diamond' }[nt.shape] || nt.shape - : 'round-rectangle'; - - const memberLinks = members.length - ? members.map(n => entityLink(n.data.id)).join(', ') - : '(no entities)'; - - return ` -
    -

    ${colorSwatch(nt.color)}${esc(nt.id)}

    -
    -
    Shape
    ${esc(dotldShape)}
    -
    Color
    ${colorSwatch(nt.color)} ${esc(nt.color || '—')}
    - ${nt._dotldSize ? `
    Size
    ${esc(nt._dotldSize)}
    ` : ''} -
    Members (${members.length})
    ${memberLinks}
    -
    -
    `; - }).join('\n'); - - // 2. Entities - const renderNode = (node) => { - const d = node.data; - const typeName = d._dotldType || d['@type'] || ''; - const typeLabel = typeName && nodeTypes.find(nt => nt.id === typeName) - ? typeLink(typeName) : esc(typeName) || 'untyped'; - - // User properties (non-internal) - const internalKeys = new Set(['id', 'label', '_dotldType', '_style', '_classes', '_dotldBidi']); - const propRows = Object.entries(d) - .filter(([k]) => !internalKeys.has(k) && !k.startsWith('_')) - .map(([k, v]) => `${esc(k)}${esc(v)}`) - .join(''); - - const propTable = propRows - ? `${propRows}
    PropertyValue
    ` - : '(no additional properties)'; - - // Outgoing relationships - const outEdges = outgoing.get(d.id) || []; - const outList = outEdges.length - ? '
      ' + outEdges.map(e => { - const lbl = e.data.label || '—'; - const tgt = e.data.target; - return `
    • ${esc(lbl)} → ${nodeById.has(tgt) ? entityLink(tgt) : esc(tgt)}
    • `; - }).join('') + '
    ' - : '(none)'; - - // Incoming relationships - const inEdges = incoming.get(d.id) || []; - const inList = inEdges.length - ? '
      ' + inEdges.map(e => { - const lbl = e.data.label || '—'; - const src = e.data.source; - return `
    • ${nodeById.has(src) ? entityLink(src) : esc(src)} ${esc(lbl)}
    • `; - }).join('') + '
    ' - : '(none)'; - - return ` -
    -

    ${esc(d.label || d.id)}

    -
    -
    Type
    ${typeLabel}
    -
    Properties
    ${propTable}
    -
    Outgoing relationships
    ${outList}
    -
    Incoming relationships
    ${inList}
    -
    -
    `; - }; - - const typedEntitiesHtml = nodeTypes.map(nt => { - const members = nodesByType.get(nt.id) || []; - if (members.length === 0) return ''; - return `
    -

    ${colorSwatch(nt.color)}${esc(nt.id)}

    - ${members.map(renderNode).join('\n')} -
    `; - }).join('\n'); - - const untypedEntitiesHtml = untypedNodes.length - ? `
    -

    Untyped Entities

    - ${untypedNodes.map(renderNode).join('\n')} -
    ` - : ''; - - // 3. Relationships table - const relRows = edges.map(e => { - const { source, target, label } = e.data; - const srcLink = nodeById.has(source) ? entityLink(source) : esc(source); - const tgtLink = nodeById.has(target) ? entityLink(target) : esc(target); - return `${srcLink}${esc(label || '—')}${tgtLink}`; - }).join('\n'); - - const relTable = relRows - ? ` - - ${relRows} -
    SourceRelationshipTarget
    ` - : '

    No relationships defined.

    '; - - // ── TOC entries ─────────────────────────────────────────────────────────── - let tocCounter = 1; - const tocItems = []; - if (nodeTypes.length > 0) tocItems.push({ n: tocCounter++, id: 'entity-types', label: 'Entity Types' }); - if (nodes.length > 0) tocItems.push({ n: tocCounter++, id: 'entities', label: 'Entities' }); - if (edges.length > 0) tocItems.push({ n: tocCounter++, id: 'relationships', label: 'Relationships' }); - - const tocHtml = tocItems.map(t => - `
  • ${t.n}. ${t.label}
  • ` - ).join('\n'); - - // ── Metadata DL entries ─────────────────────────────────────────────────── - const metaRows = [ - meta.iri && `
    IRI
    ${esc(meta.iri)}
    `, - meta.versionIRI && `
    Version IRI
    ${esc(meta.versionIRI)}
    `, - meta.versionInfo && `
    Version
    ${esc(meta.versionInfo)}
    `, - meta.creator && `
    Author
    ${esc(meta.creator)}
    `, - meta.publisher && `
    Publisher
    ${esc(meta.publisher)}
    `, - meta.license && `
    License
    ${esc(meta.license)}
    `, - meta.issued && `
    Issued
    ${esc(meta.issued)}
    `, - `
    Generated
    ${esc(generated)}
    `, - `
    Entities
    ${esc(nodes.length)}
    `, - `
    Relationships
    ${esc(edges.length)}
    `, - ].filter(Boolean).join('\n'); - - // ── Assemble page ───────────────────────────────────────────────────────── - return ` - - - - - ${esc(meta.title)} - - - -
    - - - - - -
    - - - - - ${nodeTypes.length > 0 ? ` - -
    -

    ${tocItems.find(t => t.id === 'entity-types')?.n ?? 1}. Entity Types

    -

    The following types are defined in this knowledge graph. Each type specifies the visual shape and colour used for its member entities.

    - ${entityTypesSections} -
    ` : ''} - - ${nodes.length > 0 ? ` - -
    -

    ${tocItems.find(t => t.id === 'entities')?.n ?? 1}. Entities

    -

    Detailed view of all ${nodes.length} entities, grouped by type, showing properties and relationships.

    - ${typedEntitiesHtml} - ${untypedEntitiesHtml} -
    ` : ''} - - ${edges.length > 0 ? ` - -
    -

    ${tocItems.find(t => t.id === 'relationships')?.n ?? 1}. Relationships

    -

    All ${edges.length} relationships defined in this knowledge graph.

    - ${relTable} -
    ` : ''} - -
    -
    - -`; -} - -// ─── Exporter descriptor ────────────────────────────────────────────────────── - -export const dotLdHtmlExporter = { - name: 'DOT-LD HTML Page', - extension: '.html', - mimeType: 'text/html', - - export(editor) { - const graph = editor.exportGraph(); - if ((!graph.elements?.nodes?.length) && (!graph.elements?.edges?.length)) { - throw new Error('Nothing to export — graph is empty'); - } - return buildHtmlPage(graph); - }, -};