diff --git a/scripts/package.json b/scripts/package.json index 627aa00..ba3d991 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@maxgraph/core": "^0.23.0", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.1", "jsdom": "^25.0.0", "pako": "^2.1.0" }, diff --git a/scripts/pnpm-lock.yaml b/scripts/pnpm-lock.yaml index 7c75cae..b494285 100644 --- a/scripts/pnpm-lock.yaml +++ b/scripts/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.23.0 version: 0.23.0 js-yaml: - specifier: ^4.1.0 - version: 4.3.0 + specifier: ^4.3.1 + version: 4.3.1 jsdom: specifier: ^25.0.0 version: 25.0.1 @@ -373,8 +373,8 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsdom@25.0.1: @@ -779,7 +779,7 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 diff --git a/scripts/src/actions/build/action.test.ts b/scripts/src/actions/build/action.test.ts index 4263645..7cce73b 100644 --- a/scripts/src/actions/build/action.test.ts +++ b/scripts/src/actions/build/action.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { JSDOM } from "jsdom"; import { parseAllPages } from "../../services/drawio-parser/parser.js"; import { run } from "./action.js"; @@ -101,3 +102,45 @@ test("build serializes every v2 page and honors explicit geometry and waypoints" rmSync(dir, { recursive: true, force: true }); } }); + +test("build emits parseable XML when generic model attributes collide with fixed attributes", () => { + const dir = mkdtempSync(join(tmpdir(), "drawio-build-model-attributes-test-")); + const input = join(dir, "model-attributes.yaml"); + const output = join(dir, "model-attributes.drawio"); + try { + writeFileSync(input, `version: 2 +pages: + - id: page + title: Page + layout: { type: manual, gridSize: 20 } + extensions: + $drawio: + attributes: + model:grid: "0" + model:gridSize: "99" + model:page: "0" + model:pageWidth: "1" + model:pageHeight: "2" + model:background: "#ffffff" + nodes: + - { id: node, label: Node, geometry: { x: 0, y: 0, width: 100, height: 60 } } + edges: [] +`, "utf8"); + run(input, 0, output); + + const xml = readFileSync(output, "utf8"); + const dom = new JSDOM(""); + const document = new dom.window.DOMParser().parseFromString(xml, "text/xml"); + assert.equal(document.querySelector("parsererror"), null); + const model = document.querySelector("mxGraphModel"); + assert.ok(model); + assert.equal(model.getAttribute("grid"), "1"); + assert.equal(model.getAttribute("gridSize"), "20"); + assert.equal(model.getAttribute("page"), "1"); + assert.equal(model.getAttribute("pageWidth"), "1169"); + assert.equal(model.getAttribute("pageHeight"), "827"); + assert.equal(model.getAttribute("background"), "#ffffff"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/authoring/ir-to-drawio.ts b/scripts/src/authoring/ir-to-drawio.ts index 81378fd..9abcb5e 100644 --- a/scripts/src/authoring/ir-to-drawio.ts +++ b/scripts/src/authoring/ir-to-drawio.ts @@ -6,6 +6,7 @@ import { layoutPage } from "../services/layout/layout-engine.js"; const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;"; +const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]); function escapeXml(value: string): string { return value @@ -33,6 +34,19 @@ function dataAttributes(values: Record): string { .join(""); } +function rawAttributes(extensions: Record | undefined, prefix: string, reserved = new Set()): string { + const drawio = extensions?.$drawio; + const attributes = drawio && typeof drawio === "object" && !Array.isArray(drawio) + ? (drawio as Record).attributes + : undefined; + if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) return ""; + return Object.entries(attributes as Record) + .filter(([key, value]) => key.startsWith(prefix) && !reserved.has(key.slice(prefix.length)) && /^[A-Za-z_][\w:.-]*$/.test(key.slice(prefix.length)) + && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")) + .map(([key, value]) => ` ${key.slice(prefix.length)}="${escapeXml(String(value))}"`) + .join(""); +} + function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } { if (node.geometry) return node.geometry; const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal"; @@ -91,7 +105,8 @@ function serializePage(input: DiagramPage): string { const pageWidth = Math.max(page.width ?? 1169, fit(extent.width)); const pageHeight = Math.max(page.height ?? 827, fit(extent.height)); const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions }); - return ` \n \n \n \n \n${[...nodeCells, ...edgeCells].join("\n")}\n \n \n `; + const modelAttributes = rawAttributes(page.extensions, "model:", FIXED_MODEL_ATTRIBUTES); + return ` \n \n \n \n \n${[...nodeCells, ...edgeCells].join("\n")}\n \n \n `; } export function diagramIRToDrawio(ir: DiagramIR): string { diff --git a/scripts/src/services/shape-catalog/shape-catalog.test.ts b/scripts/src/services/shape-catalog/shape-catalog.test.ts new file mode 100644 index 0000000..e68790c --- /dev/null +++ b/scripts/src/services/shape-catalog/shape-catalog.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js"; + +test("offline catalog entries carry explicit public provenance and licensing", () => { + assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6); + for (const entry of OFFLINE_SHAPE_CATALOG) { + assert.equal(entry.provenance.license, "Apache-2.0"); + assert.match(entry.provenance.sourceUrl, /^https:\/\//); + assert.match(entry.style, /shape=/); + } +}); + +test("shape search ranks exact, alias, and fuzzy matches deterministically", () => { + assert.deepEqual(searchShapes("database"), searchShapes("database")); + assert.equal(searchShapes("database").matches[0].id, "cylinder"); + assert.equal(searchShapes("database").matches[0].matchType, "alias"); + assert.equal(searchShapes("rhombus").matches[0].matchType, "exact"); + const fuzzy = searchShapes("proces box").matches[0]; + assert.equal(fuzzy.id, "rectangle"); + assert.equal(fuzzy.matchType, "fuzzy"); + assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9); +}); + +test("shape search returns an explicit generic fallback for unknown or blank queries", () => { + for (const query of ["quantum-flibbertigibbet", " "]) { + const result = searchShapes(query); + assert.equal(result.matches.length, 0); + assert.equal(result.fallback.id, "rectangle"); + assert.equal(result.fallback.matchType, "fallback"); + assert.equal(result.fallback.confidence, 0); + } +}); diff --git a/scripts/src/services/shape-catalog/shape-catalog.ts b/scripts/src/services/shape-catalog/shape-catalog.ts new file mode 100644 index 0000000..e9c0610 --- /dev/null +++ b/scripts/src/services/shape-catalog/shape-catalog.ts @@ -0,0 +1,98 @@ +export interface ShapeProvenance { + sourceName: string; + sourceUrl: string; + license: "Apache-2.0"; + note: string; +} + +export interface ShapeCatalogEntry { + id: string; + name: string; + aliases: readonly string[]; + style: string; + provenance: ShapeProvenance; +} + +export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback"; +export interface ShapeSearchMatch extends ShapeCatalogEntry { + confidence: number; + matchType: ShapeMatchType; +} +export interface ShapeSearchResult { + query: string; + matches: ShapeSearchMatch[]; + fallback: ShapeSearchMatch; +} + +const PROVENANCE: ShapeProvenance = { + sourceName: "diagrams.net generic built-in shape vocabulary", + sourceUrl: "https://github.com/jgraph/drawio", + license: "Apache-2.0", + note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.", +}; + +export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([ + entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"), + entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"), + entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"), + entry("document", "Document", ["file", "report", "paper"], "shape=document;"), + entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"), + entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"), + entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"), + entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"), +]); + +function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry { + return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) }); +} + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " "); +} + +function similarity(left: string, right: string): number { + if (left === right) return 1; + const a = [...left]; + const b = [...right]; + if (a.length === 0 || b.length === 0) return 0; + const previous = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let row = 1; row <= a.length; row += 1) { + const current = [row]; + for (let column = 1; column <= b.length; column += 1) { + current[column] = Math.min( + current[column - 1] + 1, + previous[column] + 1, + previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1), + ); + } + previous.splice(0, previous.length, ...current); + } + return 1 - previous[b.length] / Math.max(a.length, b.length); +} + +function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined { + const id = normalize(entryValue.id); + const name = normalize(entryValue.name); + const aliases = entryValue.aliases.map(normalize); + if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" }; + if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" }; + const terms = [id, name, ...aliases]; + const score = Math.max(...terms.map((term) => similarity(query, term))); + if (score < 0.45) return undefined; + return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" }; +} + +export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult { + if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer"); + const query = normalize(rawQuery); + const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!; + const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" }; + const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG + .map((candidate) => match(candidate, query)) + .filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined) + .sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US")) + .slice(0, limit); + return { query: rawQuery, matches, fallback }; +} + +export const searchShapeCatalog = searchShapes; diff --git a/scripts/src/services/themes/theme-service.test.ts b/scripts/src/services/themes/theme-service.test.ts new file mode 100644 index 0000000..6aa24b6 --- /dev/null +++ b/scripts/src/services/themes/theme-service.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { BUILT_IN_THEMES, applyTheme, contrastRatio, validateTheme } from "./theme-service.js"; + +const IR: DiagramIRV2 = { + version: 2, + pages: [{ + id: "p", title: "Page", + nodes: [ + { id: "service", label: "Service", kind: "service", style: "shape=hexagon;fillColor=#123456;rounded=1;" }, + { id: "transparent", label: "Transparent", kind: "container", style: "swimlane;fillColor=none;horizontal=0;" }, + ], + edges: [{ id: "edge", source: "service", target: "transparent", style: "edgeStyle=orthogonalEdgeStyle;dashed=1;" }], + }], +}; + +test("built-in themes validate and meet role contrast requirements", () => { + assert.deepEqual(Object.keys(BUILT_IN_THEMES), ["default", "corporate", "dark", "colorblind-safe", "handdrawn"]); + for (const theme of Object.values(BUILT_IN_THEMES)) { + assert.doesNotThrow(() => validateTheme(theme)); + assert.ok(contrastRatio(theme.node.fontColor, theme.node.fillColor) >= 4.5, theme.name); + } +}); + +test("built-in theme registry and nested role styles reject mutation", () => { + const dark = BUILT_IN_THEMES.dark; + assert.ok(Object.isFrozen(dark)); + assert.ok(Object.isFrozen(dark.node)); + assert.ok(Object.isFrozen(dark.container)); + assert.ok(Object.isFrozen(dark.edge)); + assert.throws(() => { (dark.node as { fillColor: string }).fillColor = "#FFFFFF"; }, TypeError); + assert.equal(BUILT_IN_THEMES.dark.node.fillColor, "#243447"); +}); + +test("theme application is deterministic, immutable, role based, and preserves structural style", () => { + const snapshot = structuredClone(IR); + const first = applyTheme(IR, "corporate"); + assert.deepEqual(first, applyTheme(IR, "corporate")); + assert.deepEqual(IR, snapshot); + assert.match(first.pages[0].nodes[0].style!, /shape=hexagon/); + assert.match(first.pages[0].nodes[0].style!, /rounded=1/); + assert.match(first.pages[0].nodes[1].style!, /fillColor=none/); + assert.match(first.pages[0].nodes[1].style!, /swimlane/); + assert.match(first.pages[0].edges[0].style!, /edgeStyle=orthogonalEdgeStyle/); + assert.doesNotMatch(first.pages[0].nodes[0].style!, /rounded=0/); +}); + +test("theme result is deeply isolated from source objects", () => { + const source = structuredClone(IR); + source.assets = { icons: [{ name: "api" }] }; + source.pages[0].nodes[0].properties = { config: { retries: 3 } }; + source.pages[0].edges[0].extensions = { route: { owner: "network" } }; + const snapshot = structuredClone(source); + + const themed = applyTheme(source, "corporate"); + ((themed.assets!.icons as Array<{ name: string }>)[0]).name = "changed"; + ((themed.pages[0].nodes[0].properties!.config as { retries: number })).retries = 9; + ((themed.pages[0].edges[0].extensions!.route as { owner: string })).owner = "changed"; + + assert.deepEqual(source, snapshot); +}); + +test("theme application emits each visual style key exactly once", () => { + const source = structuredClone(IR); + source.pages[0].nodes[0].style = "shape=hexagon;fillColor=#000000;fillColor=#FFFFFF;strokeColor=#000000;strokeColor=#FFFFFF;fontColor=#000000;fontColor=#FFFFFF;sketch=0;sketch=0;"; + + const style = applyTheme(source, "handdrawn").pages[0].nodes[0].style!; + + for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) { + assert.equal(style.split(";").filter((token) => token.startsWith(`${key}=`)).length, 1, style); + } + assert.match(style, /fillColor=#FFFDF5;/); + assert.match(style, /strokeColor=#444444;/); + assert.match(style, /fontColor=#222222;/); + assert.match(style, /sketch=1;/); +}); + +test("theme application preserves page extensions and serializes its canvas background", () => { + const source = structuredClone(IR); + source.pages[0].extensions = { owner: "platform", $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25" } } }; + + const themed = applyTheme(source, "dark"); + const xml = diagramIRToDrawio(themed); + + assert.deepEqual(themed.pages[0].extensions, { + owner: "platform", + $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25", "model:background": "#111827" } }, + }); + assert.match(xml, /]* background="#111827"/); + assert.match(xml, /]* pageScale="1.25"/); + assert.match(xml, /data-extensions="\{"\$drawio":\{"attributes":\{"custom":"kept","model:background":"#111827","model:pageScale":"1.25"\}\},"owner":"platform"\}"/); +}); + +test("transparent nodes use a readable dark font on a light canvas", () => { + const custom = structuredClone(BUILT_IN_THEMES.dark); + custom.name = "custom-light-canvas"; + custom.backgroundColor = "#FFFFFF"; + custom.edge.fontColor = "#000000"; + + const style = applyTheme(IR, custom).pages[0].nodes[1].style!; + + assert.match(style, /fillColor=none;/); + assert.match(style, /fontColor=#000000;/); + assert.ok(contrastRatio("#000000", custom.backgroundColor) >= 4.5); +}); + +test("transparent nodes use a readable light font on a dark canvas", () => { + const custom = structuredClone(BUILT_IN_THEMES.default); + custom.name = "custom-dark-canvas"; + custom.backgroundColor = "#000000"; + custom.edge.fontColor = "#FFFFFF"; + + const source = structuredClone(IR); + source.pages[0].nodes[0].style = "shape=hexagon;fillColor=none;rounded=1;"; + const style = applyTheme(source, custom).pages[0].nodes[0].style!; + + assert.match(style, /fillColor=none;/); + assert.match(style, /fontColor=#FFFFFF;/); + assert.ok(contrastRatio("#FFFFFF", custom.backgroundColor) >= 4.5); +}); + +test("theme validation rejects invalid colors and insufficient contrast", () => { + const invalidColor = structuredClone(BUILT_IN_THEMES.default); + invalidColor.node.fillColor = "red"; + assert.throws(() => validateTheme(invalidColor), /color/i); + const lowContrast = structuredClone(BUILT_IN_THEMES.default); + lowContrast.node.fontColor = "#777777"; + lowContrast.node.fillColor = "#777777"; + assert.throws(() => validateTheme(lowContrast), /contrast/i); +}); diff --git a/scripts/src/services/themes/theme-service.ts b/scripts/src/services/themes/theme-service.ts new file mode 100644 index 0000000..901a8f8 --- /dev/null +++ b/scripts/src/services/themes/theme-service.ts @@ -0,0 +1,148 @@ +import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js"; + +export type BuiltInThemeName = "default" | "corporate" | "dark" | "colorblind-safe" | "handdrawn"; + +export interface ThemeRoleStyle { + fillColor: string; + strokeColor: string; + fontColor: string; +} + +export interface ThemeDefinition { + name: BuiltInThemeName | (string & {}); + backgroundColor: string; + node: ThemeRoleStyle; + container: ThemeRoleStyle; + edge: Pick; + sketch?: boolean; +} + +function deepFreeze(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) deepFreeze(nested); + Object.freeze(value); + } + return value; +} + +export const BUILT_IN_THEMES: Readonly> = deepFreeze({ + default: theme("default", "#FFFFFF", ["#FFFFFF", "#4A5568", "#1A202C"], ["#F7FAFC", "#718096", "#1A202C"], ["#4A5568", "#1A202C"]), + corporate: theme("corporate", "#FFFFFF", ["#E8F0FE", "#1F5AA6", "#102A43"], ["#F5F8FC", "#526D82", "#102A43"], ["#1F5AA6", "#102A43"]), + dark: theme("dark", "#111827", ["#243447", "#94A3B8", "#FFFFFF"], ["#1F2937", "#64748B", "#FFFFFF"], ["#CBD5E1", "#FFFFFF"]), + "colorblind-safe": theme("colorblind-safe", "#FFFFFF", ["#FFF4CC", "#0072B2", "#1A1A1A"], ["#E8F3F8", "#009E73", "#1A1A1A"], ["#0072B2", "#1A1A1A"]), + handdrawn: { ...theme("handdrawn", "#FFFDF5", ["#FFFDF5", "#444444", "#222222"], ["#F6F1E5", "#555555", "#222222"], ["#444444", "#222222"]), sketch: true }, +}); + +function theme(name: BuiltInThemeName, backgroundColor: string, node: [string, string, string], container: [string, string, string], edge: [string, string]): ThemeDefinition { + return { + name, + backgroundColor, + node: { fillColor: node[0], strokeColor: node[1], fontColor: node[2] }, + container: { fillColor: container[0], strokeColor: container[1], fontColor: container[2] }, + edge: { strokeColor: edge[0], fontColor: edge[1] }, + }; +} + +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +export function contrastRatio(left: string, right: string): number { + const luminance = (color: string): number => { + if (!HEX_COLOR.test(color)) throw new Error(`Invalid color: ${color}`); + const values = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255) + .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); + return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; + }; + const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); +} + +export function validateTheme(themeDefinition: ThemeDefinition): ThemeDefinition { + if (!themeDefinition || typeof themeDefinition.name !== "string" || themeDefinition.name.trim() === "") throw new Error("Theme requires a name"); + const colors: Array<[string, string]> = [ + ["backgroundColor", themeDefinition.backgroundColor], + ["node.fillColor", themeDefinition.node?.fillColor], ["node.strokeColor", themeDefinition.node?.strokeColor], ["node.fontColor", themeDefinition.node?.fontColor], + ["container.fillColor", themeDefinition.container?.fillColor], ["container.strokeColor", themeDefinition.container?.strokeColor], ["container.fontColor", themeDefinition.container?.fontColor], + ["edge.strokeColor", themeDefinition.edge?.strokeColor], ["edge.fontColor", themeDefinition.edge?.fontColor], + ]; + for (const [path, color] of colors) if (typeof color !== "string" || !HEX_COLOR.test(color)) throw new Error(`Theme ${path} must be a #RRGGBB color`); + for (const role of ["node", "container"] as const) { + if (contrastRatio(themeDefinition[role].fontColor, themeDefinition[role].fillColor) < 4.5) throw new Error(`Theme ${role} text has insufficient contrast`); + } + if (contrastRatio(themeDefinition.edge.fontColor, themeDefinition.backgroundColor) < 4.5) throw new Error("Theme edge text has insufficient contrast"); + return themeDefinition; +} + +interface StyleEntry { key: string; value?: string } + +function parseStyle(style = ""): StyleEntry[] { + return style.split(";").filter(Boolean).map((token) => { + const separator = token.indexOf("="); + return separator < 0 ? { key: token } : { key: token.slice(0, separator), value: token.slice(separator + 1) }; + }); +} + +function themedStyle(style: string | undefined, colors: Partial, sketch: boolean, backgroundColor?: string): string { + const entries = parseStyle(style); + const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none"); + const visual = new Map(); + if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor); + if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor); + if (colors.fontColor) { + let fontColor = colors.fontColor; + if (originalFillNone && backgroundColor && contrastRatio(fontColor, backgroundColor) < 4.5) { + fontColor = contrastRatio("#000000", backgroundColor) >= 4.5 ? "#000000" : "#FFFFFF"; + } + visual.set("fontColor", fontColor); + } + visual.set("sketch", sketch ? "1" : "0"); + const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "sketch"]); + const handled = new Set(); + const output = entries.flatMap((entry) => { + if (!visualKeys.has(entry.key)) return [entry]; + if (handled.has(entry.key)) return []; + handled.add(entry.key); + const replacement = visual.get(entry.key); + if (replacement === undefined) return [entry]; + visual.delete(entry.key); + return [{ key: entry.key, value: replacement }]; + }); + for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) { + const value = visual.get(key); + if (value !== undefined) output.push({ key, value }); + } + return output.map(({ key, value }) => value === undefined ? key : `${key}=${value}`).join(";") + ";"; +} + +export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDefinition): DiagramIRV2 { + const selectedTheme = typeof selected === "string" ? BUILT_IN_THEMES[selected] : selected; + if (!selectedTheme) throw new Error(`Unknown built-in theme: ${selected}`); + validateTheme(selectedTheme); + const isolated = structuredClone(ir); + const applyNode = (node: DiagramNode): DiagramNode => ({ + ...node, + style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor), + }); + const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true) }); + return { + ...isolated, + theme: selectedTheme.name, + pages: isolated.pages.map((page) => { + const extensions = page.extensions ?? {}; + const drawio = extensions.$drawio && typeof extensions.$drawio === "object" && !Array.isArray(extensions.$drawio) + ? extensions.$drawio as Record + : {}; + const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes) + ? drawio.attributes as Record + : {}; + return { + ...page, + extensions: { + ...extensions, + $drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } }, + }, + nodes: page.nodes.map(applyNode), + edges: page.edges.map(applyEdge), + }; + }), + }; +} diff --git a/scripts/src/services/transforms/heatmap.test.ts b/scripts/src/services/transforms/heatmap.test.ts new file mode 100644 index 0000000..eddfe79 --- /dev/null +++ b/scripts/src/services/transforms/heatmap.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { applyMetricsHeatmap } from "./heatmap.js"; +import { contrastRatio } from "../themes/theme-service.js"; + +const IR: DiagramIRV2 = { + version: 2, + pages: [{ id: "p", title: "Page", nodes: [ + { id: "low", label: "Low", style: "shape=hexagon;strokeColor=#000000;", geometry: { x: 0, y: 0, width: 100, height: 60 } }, + { id: "high", label: "High", style: "shape=ellipse;", geometry: { x: 200, y: 0, width: 100, height: 60 } }, + ], edges: [{ id: "flow", source: "low", target: "high", waypoints: [{ x: 150, y: 30 }] }] }], +}; + +test("metrics heatmap is deterministic with legend metadata and preserves topology", () => { + const request = { metric: "latency_ms", min: 0, max: 100, values: { "p/low": 0, "p/high": 100 } }; + const snapshot = structuredClone(IR); + const first = applyMetricsHeatmap(IR, request); + assert.deepEqual(first, applyMetricsHeatmap(IR, request)); + assert.deepEqual(IR, snapshot); + assert.deepEqual(first.legend, { + metric: "latency_ms", min: 0, max: 100, + stops: [ + { value: 0, color: "#440154", fontColor: "#FFFFFF" }, + { value: 25, color: "#3B528B", fontColor: "#FFFFFF" }, + { value: 50, color: "#21918C", fontColor: "#1A1A1A" }, + { value: 75, color: "#5EC962", fontColor: "#1A1A1A" }, + { value: 100, color: "#FDE725", fontColor: "#1A1A1A" }, + ], + }); + assert.match(first.diagram.pages[0].nodes[0].style!, /shape=hexagon/); + assert.match(first.diagram.pages[0].nodes[0].style!, /fillColor=#440154/); + assert.match(first.diagram.pages[0].nodes[1].style!, /fillColor=#FDE725/); + assert.deepEqual(first.diagram.pages[0].edges, IR.pages[0].edges); + assert.deepEqual(first.diagram.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })), IR.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry }))); +}); + +test("metrics heatmap sets readable text for every palette stop", () => { + const result = applyMetricsHeatmap(IR, { + metric: "latency_ms", min: 0, max: 100, + values: { "p/low": 0, "p/high": 100 }, + }); + + for (const stop of result.legend.stops) { + assert.ok(contrastRatio(stop.fontColor, stop.color) >= 4.5, `${stop.fontColor} on ${stop.color}`); + } + assert.match(result.diagram.pages[0].nodes[0].style!, /fontColor=#FFFFFF/); + assert.match(result.diagram.pages[0].nodes[1].style!, /fontColor=#1A1A1A/); +}); + +test("metrics heatmap switches colors at the legend stop boundaries", () => { + const colorsAt = (low: number, high: number): string[] => applyMetricsHeatmap(IR, { + metric: "latency_ms", min: 0, max: 100, values: { "p/low": low, "p/high": high }, + }).diagram.pages[0].nodes.map((node) => /fillColor=(#[0-9A-F]{6})/i.exec(node.style!)?.[1] ?? ""); + + assert.deepEqual(colorsAt(24.999, 25), ["#440154", "#3B528B"]); + assert.deepEqual(colorsAt(49.999, 50), ["#3B528B", "#21918C"]); + assert.deepEqual(colorsAt(74.999, 75), ["#21918C", "#5EC962"]); + assert.deepEqual(colorsAt(99.999, 100), ["#5EC962", "#FDE725"]); +}); + +test("metrics heatmap rejects palettes without a readable text color", () => { + assert.throws(() => applyMetricsHeatmap(IR, { + metric: "x", min: 0, max: 1, values: {}, colors: ["#777777", "#777777"], + }), /no readable font color/i); +}); + +test("metrics heatmap rejects invalid bounds, colors, keys, and out-of-range values", () => { + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 1, max: 1, values: {} }), /min.*max/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { "p/low": 2 } }), /bounded/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { missing: 0 } }), /unknown/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: {}, colors: ["red"] }), /color/i); +}); diff --git a/scripts/src/services/transforms/heatmap.ts b/scripts/src/services/transforms/heatmap.ts new file mode 100644 index 0000000..173090c --- /dev/null +++ b/scripts/src/services/transforms/heatmap.ts @@ -0,0 +1,85 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; + +export interface HeatmapRequest { + metric: string; + min: number; + max: number; + values: Readonly>; + colors?: readonly string[]; +} +export interface HeatmapLegendStop { value: number; color: string; fontColor: string } +export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] } +export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend } + +export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]); +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string { + const entries = (style ?? "").split(";").filter(Boolean); + const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]); + const targetKeys = new Set(replacements.keys()); + const output = entries.map((entry) => { + const key = entry.split("=", 1)[0]; + if (!targetKeys.has(key)) return entry; + const replacement = replacements.get(key); + if (replacement === undefined) return undefined; + replacements.delete(key); + return `${key}=${replacement}`; + }).filter((entry): entry is string => entry !== undefined); + for (const [key, value] of replacements) output.push(`${key}=${value}`); + return `${output.join(";")};`; +} + +function contrastRatio(left: string, right: string): number { + const luminance = (color: string): number => [1, 3, 5] + .map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255) + .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0); + const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); +} + +const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const; + +function readableFontColor(background: string): string { + const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5); + if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`); + return color; +} + +function exactNumber(value: number): number { + return Number(value.toFixed(12)); +} + +export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult { + if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string"); + if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max"); + if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object"); + const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)]; + if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors"); + const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`))); + for (const [key, value] of Object.entries(request.values)) { + if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`); + if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) { + throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`); + } + } + const stops = colors.map((color, index) => ({ + value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)), + color, + fontColor: readableFontColor(color), + })); + const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops }; + const diagram = structuredClone(ir); + for (const page of diagram.pages) for (const node of page.nodes) { + const value = request.values[`${page.id}/${node.id}`]; + if (value === undefined) continue; + const nextStopIndex = stops.findIndex((stop) => value < stop.value); + const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1); + node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor); + } + diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend }; + return { diagram, legend }; +} + +export const transformMetricsHeatmap = applyMetricsHeatmap; diff --git a/scripts/src/services/transforms/relabel.test.ts b/scripts/src/services/transforms/relabel.test.ts new file mode 100644 index 0000000..9f9fd68 --- /dev/null +++ b/scripts/src/services/transforms/relabel.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { relabelDiagram } from "./relabel.js"; + +const IR: DiagramIRV2 = { + version: 2, + title: "Original", + pages: [{ id: "p", title: "Page", layout: { type: "manual" }, nodes: [ + { id: "a", label: "A", style: "shape=hexagon;", geometry: { x: 10, y: 20, width: 100, height: 60 } }, + { id: "b", label: "B", style: "shape=ellipse;", geometry: { x: 200, y: 20, width: 100, height: 60 } }, + ], edges: [{ id: "flow", source: "a", target: "b", label: "old", style: "dashed=1;", waypoints: [{ x: 150, y: 50 }] }] }], +}; + +test("complete relabel changes only labels while preserving IDs, topology, layout, and style", () => { + const snapshot = structuredClone(IR); + const result = relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new" } }); + assert.deepEqual(result.pages[0].nodes.map(({ id, label }) => ({ id, label })), [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }]); + assert.equal(result.pages[0].edges[0].label, "new"); + const restored = structuredClone(result); + restored.pages[0].nodes[0].label = "A"; + restored.pages[0].nodes[1].label = "B"; + restored.pages[0].edges[0].label = "old"; + assert.deepEqual(restored, IR); + assert.deepEqual(IR, snapshot); +}); + +test("relabel requires a complete exact explicit map", () => { + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha" } }), /missing.*p\/b.*p\/flow/i); + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new", extra: "no" } }), /unknown.*extra/i); + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "", "p/b": "Beta", "p/flow": "new" } }), /non-empty/i); +}); diff --git a/scripts/src/services/transforms/relabel.ts b/scripts/src/services/transforms/relabel.ts new file mode 100644 index 0000000..8de4f09 --- /dev/null +++ b/scripts/src/services/transforms/relabel.ts @@ -0,0 +1,35 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; + +export interface CompleteRelabelRequest { + mode: "complete"; + labels: Readonly>; +} + +export function relabelDiagram(ir: DiagramIRV2, request: CompleteRelabelRequest): DiagramIRV2 { + if (!request || request.mode !== "complete" || !request.labels || typeof request.labels !== "object" || Array.isArray(request.labels)) { + throw new Error("Relabel requires an explicit complete map mode"); + } + const required: string[] = []; + for (const page of ir.pages) { + for (const node of page.nodes) required.push(`${page.id}/${node.id}`); + for (const edge of page.edges) if (edge.label !== undefined) required.push(`${page.id}/${edge.id}`); + } + required.sort((left, right) => left.localeCompare(right, "en-US")); + const provided = Object.keys(request.labels).sort((left, right) => left.localeCompare(right, "en-US")); + const missing = required.filter((key) => !Object.hasOwn(request.labels, key)); + if (missing.length > 0) throw new Error(`Relabel map is missing: ${missing.join(", ")}`); + const unknown = provided.filter((key) => !required.includes(key)); + if (unknown.length > 0) throw new Error(`Relabel map has unknown keys: ${unknown.join(", ")}`); + for (const key of required) { + const label = request.labels[key]; + if (typeof label !== "string" || label.trim() === "") throw new Error(`Relabel value for ${key} must be a non-empty string`); + } + const output = structuredClone(ir); + for (const page of output.pages) { + for (const node of page.nodes) node.label = request.labels[`${page.id}/${node.id}`]; + for (const edge of page.edges) if (edge.label !== undefined) edge.label = request.labels[`${page.id}/${edge.id}`]; + } + return output; +} + +export const relabelDiagramIR = relabelDiagram; diff --git a/scripts/src/services/transforms/reverse.test.ts b/scripts/src/services/transforms/reverse.test.ts new file mode 100644 index 0000000..5b41925 --- /dev/null +++ b/scripts/src/services/transforms/reverse.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { diagramIRToMermaid, diagramIRToStructuredMarkdown } from "./reverse.js"; + +const IR: DiagramIRV2 = { + version: 2, + title: "Platform", + pages: [{ + id: "system", title: "System", + nodes: [ + { id: "a", label: "Client" }, + { id: "b", label: "API \"danger\"]\n%%{init: {}}%% [click](javascript:alert(1))" }, + ], + edges: [{ id: "call", source: "a", target: "b", label: "calls | inject" }], + }], +}; + +test("reverse Mermaid flowchart is deterministic and escapes untrusted labels", () => { + const output = diagramIRToMermaid(IR); + assert.equal(output, diagramIRToMermaid(IR)); + assert.match(output, /^flowchart LR/m); + assert.match(output, /n0\["Client"\]/); + assert.match(output, /n0 -->\|"calls | inject"\| n1/); + assert.doesNotMatch(output, /