diff --git a/editors/code/cli/README.md b/editors/code/cli/README.md new file mode 100644 index 00000000..a23178c6 --- /dev/null +++ b/editors/code/cli/README.md @@ -0,0 +1,43 @@ +# fpp-diagram-render + +Command-line rendering of FPP diagrams to SVG, using the same pipeline as the +VS Code extension's diagram panel: + +1. `fpp-diagram --format sprotty` (the Rust `fpp_diagram` lowering the language + server uses) produces a sprotty `SGraph`. +2. Labels are sized from font metrics (the webview measures them in the DOM). +3. The extension's ELK layout (`src/diagram/layout.ts`, + `src/diagram/layout-config.ts`) places nodes, ports and labels. +4. The laid-out graph is written as standalone SVG with the webview's shapes + and stylesheet (`webview/css/diagram.css`, theme variables resolved). + +## Build + +```sh +cargo build --release -p fpp_diagram --bin fpp-diagram # repo root +cd editors/code && yarn install && yarn build # emits dist/fpp-diagram-render.js +``` + +## Use + +```sh +# One component from the F Prime framework sources +node dist/fpp-diagram-render.js --name Svc.CommandDispatcher -o CommandDispatcher.svg \ + $FPRIME/cmake/platform/unix/Platform/PlatformTypes.fpp \ + $(find $FPRIME/Fpp $FPRIME/Fw $FPRIME/Svc $FPRIME/Drv $FPRIME/Os $FPRIME/Utils $FPRIME/default/config -name '*.fpp') + +# A topology, pruning unconnected ports +node dist/fpp-diagram-render.js --kind topology --name Ref.Ref --hide-unused-ports -o Ref.svg + +# PNG, if wanted (any SVG rasterizer works; headless Chrome shown) +google-chrome --headless=new --screenshot=CommandDispatcher.png --window-size=400,1100 CommandDispatcher.svg +``` + +Multiple `.fpp` files may be given; `include "..."` directives are resolved +relative to each file and inlined before analysis, so a whole source tree can be +passed. The `fpp-diagram` binary is found in `target/{release,debug}` of the +workspace or on `PATH`; override with `--fpp-diagram`. + +`--kind` accepts `component` (default), `topology`, and `connectionGroup` +(`--name .`). State machines are not rendered by this tool; +`fpp-diagram --format mermaid` already emits them as Mermaid text. diff --git a/editors/code/cli/src/fpp-source.ts b/editors/code/cli/src/fpp-source.ts new file mode 100644 index 00000000..717c292a --- /dev/null +++ b/editors/code/cli/src/fpp-source.ts @@ -0,0 +1,94 @@ +/** + * Front end of the CLI: turn a set of FPP files into a sprotty `SGraph` using + * the existing `fpp-diagram` binary (the same `fpp_diagram` lowering the + * language server runs for the VS Code diagram panel). + * + * `fpp-diagram` reads a single translation unit, so the input files are + * concatenated into one temporary file. `include "..."` directives are + * resolved relative to the file that contains them and inlined, which keeps + * the concatenated unit self-contained. + */ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { spawnSync } from "child_process"; +import { SGraph } from "sprotty-protocol"; + +const INCLUDE_RE = /^(\s*)include\s+"([^"]+)"\s*$/; + +/** Inline `include` directives of a single FPP file, recursively. */ +export function inlineIncludes(file: string, seen: Set = new Set()): string { + const resolved = path.resolve(file); + if (seen.has(resolved)) { + throw new Error(`include cycle at ${resolved}`); + } + seen.add(resolved); + const dir = path.dirname(resolved); + const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/); + const out: string[] = []; + for (const line of lines) { + const m = INCLUDE_RE.exec(line); + if (m) { + out.push(inlineIncludes(path.join(dir, m[2]), seen)); + } else { + out.push(line); + } + } + seen.delete(resolved); + return out.join("\n"); +} + +/** Concatenate FPP files into one translation unit with includes inlined. */ +export function concatenateSources(files: string[]): string { + return files.map(f => inlineIncludes(f)).join("\n\n"); +} + +/** Candidate locations for `fpp-diagram` when none is given explicitly. */ +export function findFppDiagram(explicit?: string): string { + if (explicit) { + return explicit; + } + // A workspace build next to this extension (editors/code -> repo root). + const repoRoot = path.resolve(__dirname, "..", "..", ".."); + for (const profile of ["release", "debug"]) { + const candidate = path.join(repoRoot, "target", profile, "fpp-diagram"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return "fpp-diagram"; // rely on PATH +} + +export interface LowerOptions { + fppDiagram: string; + kind: string; + name: string; + hideUnusedPorts: boolean; +} + +/** Run `fpp-diagram` on the FPP files and parse its sprotty JSON. */ +export function lowerToSGraph(files: string[], opts: LowerOptions): SGraph { + const unit = concatenateSources(files); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fpp-diagram-render-")); + const tmpFile = path.join(tmpDir, "unit.fpp"); + try { + fs.writeFileSync(tmpFile, unit); + const args = ["--kind", opts.kind, "--name", opts.name, "--format", "sprotty"]; + if (opts.hideUnusedPorts) { + args.push("--hide-unused-ports"); + } + args.push(tmpFile); + const result = spawnSync(opts.fppDiagram, args, { encoding: "utf8", maxBuffer: 1 << 28 }); + if (result.error) { + throw new Error(`failed to run ${opts.fppDiagram}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `${opts.fppDiagram} exited with ${result.status}\n${result.stderr}${result.stdout}` + ); + } + return JSON.parse(result.stdout) as SGraph; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} diff --git a/editors/code/cli/src/main.ts b/editors/code/cli/src/main.ts new file mode 100644 index 00000000..34d72ac5 --- /dev/null +++ b/editors/code/cli/src/main.ts @@ -0,0 +1,135 @@ +/** + * `fpp-diagram-render`: command-line rendering of FPP diagrams to SVG. + * + * Pipeline (same stages as the VS Code diagram panel, minus the webview): + * 1. `fpp-diagram --format sprotty` lowers the FPP model to a sprotty SGraph. + * 2. Labels get sizes (the webview measures them in the DOM; here they are + * estimated from font metrics). + * 3. `FppLayoutEngine` + `FppDiagramConfig` (the extension's ELK layout) place + * nodes, ports and labels. + * 4. The laid-out graph is written as SVG using the webview's shapes and CSS. + */ +import * as fs from "fs"; +import ELK from "elkjs/lib/elk.bundled.js"; +import { SGraph, SLabel, SModelElement } from "sprotty-protocol"; +import { FppLayoutEngine } from "../../src/diagram/layout"; +import { FppDiagramConfig, DiagramType } from "../../src/diagram/layout-config"; +import { findFppDiagram, lowerToSGraph } from "./fpp-source"; +import { textHeight, textWidth } from "./text-metrics"; +import { Theme, toSvg } from "./svg"; + +interface CliArgs { + name?: string; + kind: DiagramType; + output?: string; + json?: string; + fppDiagram?: string; + hideUnusedPorts: boolean; + theme: Theme; + files: string[]; +} + +const USAGE = `usage: fpp-diagram-render --name [options] ... + +Render an FPP component, topology, or connection group diagram to SVG using the +same lowering (fpp-diagram) and layout (ELK) as the VS Code extension. + +options: + --name fully qualified name, e.g. Svc.CommandDispatcher + (connection group: .) + --kind component | topology | connectionGroup [component] + -o, --output SVG output path [stdout] + --json read a sprotty SGraph JSON instead of running fpp-diagram + --fpp-diagram fpp-diagram binary [target/{release,debug}/fpp-diagram, PATH] + --hide-unused-ports prune ports with no connections (topologies) + --theme color palette [dark] + -h, --help show this help + +Multiple .fpp files may be given; \`include\` directives are resolved relative to +each file and inlined before analysis.`; + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { kind: DiagramType.component, hideUnusedPorts: false, theme: "dark", files: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => { + if (i + 1 >= argv.length) throw new Error(`${a} requires a value`); + return argv[++i]; + }; + switch (a) { + case "--name": args.name = next(); break; + case "--kind": { + const k = next(); + if (!Object.values(DiagramType).includes(k as DiagramType) || k === DiagramType.stateMachine) { + throw new Error(`unsupported kind '${k}' (component | topology | connectionGroup)`); + } + args.kind = k as DiagramType; + break; + } + case "-o": case "--output": args.output = next(); break; + case "--json": args.json = next(); break; + case "--fpp-diagram": args.fppDiagram = next(); break; + case "--hide-unused-ports": args.hideUnusedPorts = true; break; + case "--theme": { + const t = next(); + if (t !== "dark" && t !== "light") throw new Error(`unknown theme '${t}'`); + args.theme = t; + break; + } + case "-h": case "--help": console.log(USAGE); process.exit(0); + default: + if (a.startsWith("-")) throw new Error(`unknown option '${a}'`); + args.files.push(a); + } + } + return args; +} + +/** Give every label a size, as the webview's bounds round-trip would. */ +function measureLabels(element: SModelElement): void { + if (element.type.startsWith("label")) { + const label = element as SLabel; + label.size = { width: textWidth(label.text), height: textHeight() }; + } + for (const child of element.children ?? []) { + measureLabels(child); + } +} + +async function layout(graph: SGraph, kind: DiagramType, hideUnusedPorts: boolean): Promise { + const config = new FppDiagramConfig(); + config.currentDiagramType = kind; + config.hideUnusedPorts = hideUnusedPorts; + const engine = new FppLayoutEngine(() => new ELK(), undefined, config); + return engine.layout(graph); +} + +export async function run(argv: string[]): Promise { + const args = parseArgs(argv); + let graph: SGraph; + if (args.json) { + graph = JSON.parse(fs.readFileSync(args.json, "utf8")) as SGraph; + } else { + if (!args.name) throw new Error("--name is required"); + if (args.files.length === 0) throw new Error("at least one .fpp file is required"); + graph = lowerToSGraph(args.files, { + fppDiagram: findFppDiagram(args.fppDiagram), + kind: args.kind, + name: args.name, + hideUnusedPorts: args.hideUnusedPorts, + }); + } + measureLabels(graph); + const laidOut = await layout(graph, args.kind, args.hideUnusedPorts); + const svg = toSvg(laidOut, args.theme); + if (args.output) { + fs.writeFileSync(args.output, svg); + } else { + process.stdout.write(svg); + } +} + +run(process.argv.slice(2)).catch(e => { + console.error(`fpp-diagram-render: ${e instanceof Error ? e.message : e}`); + process.exit(1); +}); diff --git a/editors/code/cli/src/svg.ts b/editors/code/cli/src/svg.ts new file mode 100644 index 00000000..9072be57 --- /dev/null +++ b/editors/code/cli/src/svg.ts @@ -0,0 +1,160 @@ +/** + * Serialize a laid-out sprotty `SGraph` to standalone SVG. + * + * The markup mirrors the webview views (`webview/src/views.tsx`): component + * nodes are rounded rectangles classed by component kind, ports are small + * rectangles classed by port kind, labels are `` elements, and edges are + * polylines with an arrow marker. The stylesheet is `webview/css/diagram.css` + * with the VS Code theme variables resolved to fixed colors. + */ +import { SModelElement, SNode, SPort, SLabel, SEdge, SGraph, Point } from "sprotty-protocol"; +import { FONT_FAMILY, FONT_SIZE_PX, textBaseline } from "./text-metrics"; + +export type Theme = "dark" | "light"; + +interface Palette { + background: string; + nodeFill: string; + foreground: string; + edge: string; +} + +const PALETTES: Record = { + // VS Code "Dark Modern" defaults for the variables diagram.css uses. + dark: { background: "#1f1f1f", nodeFill: "#0078d4", foreground: "#cccccc", edge: "#cccccc" }, + // VS Code "Light Modern" defaults. + light: { background: "#ffffff", nodeFill: "#005fb8", foreground: "#3b3b3b", edge: "#171184" }, +}; + +function stylesheet(p: Palette): string { + return ` + text { font-family: ${FONT_FAMILY}; font-size: ${FONT_SIZE_PX}px; font-weight: normal; } + .sprotty-node { fill: ${p.nodeFill}; stroke-width: 1; } + .sprotty-label { fill: ${p.foreground}; } + .sprotty-port { fill: ${p.foreground}; stroke: #7b337d; stroke-width: 1; } + .sprotty-edge { fill: none; stroke: ${p.edge}; } + .sprotty-edge-arrow { fill: ${p.edge}; stroke-width: 0; } + .component-active { stroke: #3FB984; } + .component-passive { stroke: #fe92a8; } + .port-sync { fill: #fe92a8; } + .port-guarded { fill: #FDD35C; } + `; +} + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function fmt(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(2); +} + +function basicType(e: SModelElement): string { + const i = e.type.indexOf(":"); + return i >= 0 ? e.type.substring(0, i) : e.type; +} + +type Shaped = SModelElement & { position?: Point; size?: { width: number; height: number } }; + +function renderLabel(label: SLabel): string { + const pos = label.position ?? { x: 0, y: 0 }; + const y = pos.y + textBaseline(); + return `${esc(label.text)}`; +} + +function renderPort(port: SPort & { kind?: string }): string { + const pos = port.position ?? { x: 0, y: 0 }; + const size = port.size ?? { width: 10, height: 10 }; + const classes = ["sprotty-port"]; + if (port.kind === "sync") classes.push("port-sync"); + if (port.kind === "async") classes.push("port-async"); + if (port.kind === "guarded") classes.push("port-guarded"); + const children = (port.children ?? []).map(renderChild).join(""); + return `` + + `${esc(port.id)}${port.kind ? ` (${esc(port.kind)})` : ""}` + + `` + + children + ``; +} + +function renderNode(node: SNode & { kind?: string }): string { + const pos = node.position ?? { x: 0, y: 0 }; + const size = node.size ?? { width: 0, height: 0 }; + const classes = ["sprotty-node", "task"]; + if (node.kind === "active") classes.push("component-active"); + if (node.kind === "queued") classes.push("component-queued"); + if (node.kind === "passive") classes.push("component-passive"); + const children = (node.children ?? []).map(renderChild).join(""); + return `` + + `` + + children + ``; +} + +function renderEdge(edge: SEdge & { detail?: string }): string { + const pts = edge.routingPoints ?? []; + if (pts.length < 2) { + return ""; + } + const d = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${fmt(p.x)},${fmt(p.y)}`).join(" "); + const title = edge.detail ? `${esc(edge.detail)}` : ""; + const labels = (edge.children ?? []).map(renderChild).join(""); + return `${title}${labels}`; +} + +function renderChild(e: SModelElement): string { + switch (basicType(e)) { + case "node": return renderNode(e as SNode); + case "port": return renderPort(e as SPort); + case "label": return renderLabel(e as SLabel); + case "edge": return renderEdge(e as SEdge); + default: return ""; + } +} + +/** + * Bounding box of everything drawn, for the viewBox. Port labels sit outside + * their node, so nested shapes are walked with their accumulated offsets. + */ +function bounds(graph: SGraph): { x: number; y: number; width: number; height: number } { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const add = (x: number, y: number) => { + minX = Math.min(minX, x); minY = Math.min(minY, y); + maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + }; + const walk = (e: SModelElement, dx: number, dy: number) => { + if (basicType(e) === "edge") { + for (const p of (e as SEdge).routingPoints ?? []) add(dx + p.x, dy + p.y); + for (const c of e.children ?? []) walk(c, dx, dy); + return; + } + const s = e as Shaped; + const pos = s.position ?? { x: 0, y: 0 }; + const size = s.size ?? { width: 0, height: 0 }; + const x = dx + pos.x, y = dy + pos.y; + add(x, y); + add(x + size.width, y + size.height); + for (const c of e.children ?? []) walk(c, x, y); + }; + for (const c of graph.children ?? []) walk(c, 0, 0); + if (!Number.isFinite(minX)) { + return { x: 0, y: 0, width: 1, height: 1 }; + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +export function toSvg(graph: SGraph, theme: Theme = "dark", padding: number = 20): string { + const p = PALETTES[theme]; + const b = bounds(graph); + const x = b.x - padding, y = b.y - padding; + const w = b.width + 2 * padding, h = b.height + 2 * padding; + const body = (graph.children ?? []).map(renderChild).join("\n"); + return ` + + + + + + +${body} + +`; +} diff --git a/editors/code/cli/src/text-metrics.ts b/editors/code/cli/src/text-metrics.ts new file mode 100644 index 00000000..9d4bec46 --- /dev/null +++ b/editors/code/cli/src/text-metrics.ts @@ -0,0 +1,43 @@ +/** + * Approximate text measurement for headless rendering. + * + * The webview measures label bounds with the browser's `getBBox()`; there is no + * DOM in the CLI, so label sizes are estimated from Helvetica advance widths + * (AFM metrics, 1/1000 em). The diagram CSS forces an 11 px sans-serif font, + * which is what the generated SVG declares, so the estimate is close to what a + * viewer draws. + */ + +export const FONT_SIZE_PX = 11; +export const FONT_FAMILY = "Helvetica, Arial, sans-serif"; + +/** Advance widths for printable ASCII 0x20..0x7E, in 1/1000 em (Helvetica). */ +const ADVANCES: number[] = [ + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, // ' ' .. '/' + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, // '0' .. '?' + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, // '@' .. 'O' + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, // 'P' .. '_' + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // '`' .. 'o' + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, // 'p' .. '~' +]; +const DEFAULT_ADVANCE = 556; + +export function textWidth(text: string, fontSize: number = FONT_SIZE_PX): number { + let units = 0; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + const idx = code - 0x20; + units += idx >= 0 && idx < ADVANCES.length ? ADVANCES[idx] : DEFAULT_ADVANCE; + } + return Math.ceil((units / 1000) * fontSize); +} + +/** Line box for a single line of text (line-height ~1.2). */ +export function textHeight(fontSize: number = FONT_SIZE_PX): number { + return Math.ceil(fontSize * 1.2); +} + +/** Baseline offset from the top of the line box (ascent ~0.9 em in a 1.2 line box). */ +export function textBaseline(fontSize: number = FONT_SIZE_PX): number { + return Math.round(fontSize * 0.95); +} diff --git a/editors/code/cli/tsconfig.json b/editors/code/cli/tsconfig.json new file mode 100644 index 00000000..1d455c8e --- /dev/null +++ b/editors/code/cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "..", + "outDir": "../out/cli", + "lib": ["es2021"], + "types": ["node"], + "declaration": false, + "declarationMap": false, + "strict": true + }, + "include": [ + ".", + "../src/diagram/layout.ts", + "../src/diagram/layout-config.ts", + "../common" + ] +} diff --git a/editors/code/package.json b/editors/code/package.json index fb04f033..42924f23 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -576,14 +576,20 @@ } ] }, + "bin": { + "fpp-diagram-render": "./dist/fpp-diagram-render.js" + }, "scripts": { "vscode:prepublish": "rm -rf dist && yarn package", "package": "webpack --mode production", "build": "webpack --mode development", "watch": "webpack --mode development --watch", "compile": "tsc -p ./src --noEmit", + "compile:cli": "tsc -p ./cli --noEmit", + "render": "node ./dist/fpp-diagram-render.js", "test:smoke": "node ./test/smoke.js", - "test": "yarn compile && yarn build && yarn test:smoke", + "test:render": "node ./test/smoke-render.js", + "test": "yarn compile && yarn compile:cli && yarn build && yarn test:smoke && yarn test:render", "vsix": "vsce package" }, "dependencies": { diff --git a/editors/code/test/fixtures/component.fpp b/editors/code/test/fixtures/component.fpp new file mode 100644 index 00000000..919f41df --- /dev/null +++ b/editors/code/test/fixtures/component.fpp @@ -0,0 +1,14 @@ +module Fw { + port Cmd + port Tlm + port Log +} +module Demo { + active component Widget { + async input port cmdIn: Fw.Cmd + guarded input port tlmIn: Fw.Tlm + sync input port logIn: Fw.Log + output port tlmOut: Fw.Tlm + output port logOut: [2] Fw.Log + } +} diff --git a/editors/code/test/fixtures/component.json b/editors/code/test/fixtures/component.json new file mode 100644 index 00000000..426f463d --- /dev/null +++ b/editors/code/test/fixtures/component.json @@ -0,0 +1,6 @@ +{"id":"root","type":"graph","children":[{"id":"Demo.Widget","type":"node:component","kind":"active","children":[ +{"id":"Demo.Widget.label.name","type":"label:node:component","text":"Widget","size":{"width":100,"height":15}}, +{"id":"Demo.Widget.cmdIn.0","type":"port","kind":"async","isOutput":false,"size":{"width":10,"height":10},"children":[{"id":"Demo.Widget.cmdIn.0.label","type":"label:port","text":"cmdIn","size":{"width":50,"height":10}}]}, +{"id":"Demo.Widget.tlmIn.0","type":"port","kind":"guarded","isOutput":false,"size":{"width":10,"height":10},"children":[{"id":"Demo.Widget.tlmIn.0.label","type":"label:port","text":"tlmIn","size":{"width":50,"height":10}}]}, +{"id":"Demo.Widget.tlmOut.0","type":"port","kind":"output","isOutput":true,"size":{"width":10,"height":10},"children":[{"id":"Demo.Widget.tlmOut.0.label","type":"label:port","text":"tlmOut","size":{"width":50,"height":10}}]} +]}]} diff --git a/editors/code/test/smoke-render.js b/editors/code/test/smoke-render.js new file mode 100644 index 00000000..1b860af9 --- /dev/null +++ b/editors/code/test/smoke-render.js @@ -0,0 +1,75 @@ +// Smoke test for the `fpp-diagram-render` CLI bundle (dist/fpp-diagram-render.js). +// +// Always: lay out a canned sprotty SGraph (`--json`) and check the SVG has the +// component box, every port, and port labels placed on the expected side. +// When an `fpp-diagram` binary is available (workspace build or PATH): also run +// the full pipeline on a small FPP model. + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const here = __dirname; +const cli = path.join(here, "..", "dist", "fpp-diagram-render.js"); +const fixtures = path.join(here, "fixtures"); + +function run(args) { + const result = spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" }); + assert.strictEqual(result.status, 0, `cli failed: ${result.stderr}`); + return result.stdout; +} + +function translateX(svg, id) { + const m = new RegExp(`tlmOut"), "port label text"); + // Input ports sit on the west edge (x <= 0 within the node), outputs on the east. + const widgetWidth = Number(/= widgetWidth - 10, "output port on east side"); + // Light theme swaps the palette. + const light = run(["--json", path.join(fixtures, "component.json"), "--theme", "light"]); + assert.ok(light.includes('fill="#ffffff"'), "light background"); + console.log("smoke-render: layout + svg OK"); +} + +// 2. Full pipeline through fpp-diagram, when a binary is available. +{ + const repoRoot = path.resolve(here, "..", "..", ".."); + const candidates = [ + path.join(repoRoot, "target", "release", "fpp-diagram"), + path.join(repoRoot, "target", "debug", "fpp-diagram"), + ]; + let bin = candidates.find(p => fs.existsSync(p)); + if (!bin) { + const which = spawnSync("fpp-diagram", ["--version"], { encoding: "utf8" }); + if (!which.error && which.status === 0) { + bin = "fpp-diagram"; + } + } + if (!bin) { + console.log("smoke-render: fpp-diagram not found, skipping full pipeline"); + } else { + const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "fpp-render-smoke-")), "widget.svg"); + run(["--fpp-diagram", bin, "--name", "Demo.Widget", "-o", out, path.join(fixtures, "component.fpp")]); + const svg = fs.readFileSync(out, "utf8"); + assert.ok(svg.includes('id="Demo.Widget"'), "component node"); + assert.ok(svg.includes('id="Demo.Widget.logOut.1"'), "array port index 1"); + assert.ok(/class="sprotty-port port-sync"/.test(svg), "sync port styling"); + console.log(`smoke-render: full pipeline OK (${bin})`); + } +} diff --git a/editors/code/webpack.config.js b/editors/code/webpack.config.js index 268b29ae..4e3a3b90 100644 --- a/editors/code/webpack.config.js +++ b/editors/code/webpack.config.js @@ -97,8 +97,35 @@ const stateMachineWebviewConfig = { ...commonConfig, }; +/**@type {import('webpack').Configuration}*/ +const renderCliConfig = { + target: 'node', + entry: './cli/src/main.ts', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'fpp-diagram-render.js', + devtoolModuleFilenameTemplate: "../[resource-path]", + }, + plugins: [ + new (require('webpack').BannerPlugin)({ banner: '#!/usr/bin/env node', raw: true }), + ], + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [{ loader: 'ts-loader', options: { configFile: path.resolve(__dirname, 'cli/tsconfig.json') } }], + }, + ], + }, +}; + module.exports = [ extensionConfig, diagramWebviewConfig, - stateMachineWebviewConfig + stateMachineWebviewConfig, + renderCliConfig ];