Skip to content

Commit 1d42b48

Browse files
committed
Add .xircuits minifier, zoom/background controls, bump to v0.2.0
1 parent c19e857 commit 1d42b48

11 files changed

Lines changed: 2234 additions & 13 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFileSync } from 'fs';
3+
import { resolve } from 'path';
4+
import { parse } from '../packages/core/src/parser/parse.js';
5+
import {
6+
minify,
7+
minifyWithStats,
8+
} from '../packages/core/src/minifier/index.js';
9+
10+
const fixtureDir = resolve(__dirname, 'fixtures');
11+
12+
function loadFixture(name: string): unknown {
13+
return JSON.parse(readFileSync(resolve(fixtureDir, name), 'utf-8'));
14+
}
15+
16+
function collectIds(obj: unknown, out: Set<string> = new Set()): Set<string> {
17+
if (obj && typeof obj === 'object') {
18+
if (!Array.isArray(obj)) {
19+
const o = obj as Record<string, unknown>;
20+
if (typeof o.id === 'string') out.add(o.id);
21+
for (const v of Object.values(o)) collectIds(v, out);
22+
} else {
23+
for (const v of obj) collectIds(v, out);
24+
}
25+
}
26+
return out;
27+
}
28+
29+
describe('minify (safe mode)', () => {
30+
const input = loadFixture('HelloXircuits.xircuits');
31+
32+
it('produces valid .xircuits JSON that parse() accepts', () => {
33+
const out = minify(input);
34+
const reparsed = JSON.parse(out);
35+
expect(() => parse(reparsed)).not.toThrow();
36+
});
37+
38+
it('preserves every original ID byte-for-byte', () => {
39+
const out = minify(input);
40+
const reparsed = JSON.parse(out);
41+
const originalIds = collectIds(input);
42+
const outputIds = collectIds(reparsed);
43+
for (const id of originalIds) {
44+
expect(outputIds.has(id)).toBe(true);
45+
}
46+
});
47+
48+
it('strips selected and locked keys throughout', () => {
49+
const out = minify(input);
50+
expect(out).not.toMatch(/"selected":/);
51+
expect(out).not.toMatch(/"locked":/);
52+
});
53+
54+
it('rounds coordinates to the requested precision', () => {
55+
const out = minify(input, { precision: 0 });
56+
const reparsed = JSON.parse(out) as Record<string, unknown>;
57+
const layers = reparsed.layers as Array<Record<string, unknown>>;
58+
const nodeLayer = layers.find(l => l.type === 'diagram-nodes')!;
59+
for (const node of Object.values(nodeLayer.models as Record<string, Record<string, unknown>>)) {
60+
expect(Number.isInteger(node.x)).toBe(true);
61+
expect(Number.isInteger(node.y)).toBe(true);
62+
}
63+
});
64+
65+
it('honors non-zero precision', () => {
66+
const out = minify(input, { precision: 1 });
67+
const reparsed = JSON.parse(out) as Record<string, unknown>;
68+
const layers = reparsed.layers as Array<Record<string, unknown>>;
69+
const nodeLayer = layers.find(l => l.type === 'diagram-nodes')!;
70+
for (const node of Object.values(nodeLayer.models as Record<string, Record<string, unknown>>)) {
71+
const x = node.x as number;
72+
expect(Math.abs(x * 10 - Math.round(x * 10))).toBeLessThan(1e-9);
73+
}
74+
});
75+
76+
it('reports consistent stats', () => {
77+
const { output, stats } = minifyWithStats(input);
78+
expect(stats.outputBytes).toBe(new TextEncoder().encode(output).length);
79+
expect(stats.mode).toBe('safe');
80+
expect(stats.nodes).toBeGreaterThan(0);
81+
expect(stats.edges).toBeGreaterThan(0);
82+
expect(stats.ports).toBeGreaterThan(0);
83+
});
84+
});
85+
86+
describe('minify (aggressive mode)', () => {
87+
const input = loadFixture('HelloXircuits.xircuits');
88+
89+
it('produces valid .xircuits JSON that parse() accepts', () => {
90+
const out = minify(input, { aggressive: true });
91+
const reparsed = JSON.parse(out);
92+
expect(() => parse(reparsed)).not.toThrow();
93+
});
94+
95+
it('replaces every original ID with a short form', () => {
96+
const out = minify(input, { aggressive: true });
97+
const reparsed = JSON.parse(out);
98+
const originalIds = collectIds(input);
99+
const outputIds = collectIds(reparsed);
100+
for (const id of originalIds) {
101+
expect(outputIds.has(id)).toBe(false);
102+
}
103+
for (const id of outputIds) {
104+
expect(id === 'g' || /^[nep]\d+$/.test(id)).toBe(true);
105+
}
106+
});
107+
108+
it('still resolves every link endpoint to a real node + port', () => {
109+
const out = minify(input, { aggressive: true });
110+
const graph = parse(JSON.parse(out));
111+
const nodeIds = new Set(graph.nodes.map(n => n.id));
112+
const portIds = new Set(graph.nodes.flatMap(n => [...n.portsIn, ...n.portsOut]).map(p => p.id));
113+
114+
for (const edge of graph.edges) {
115+
expect(nodeIds.has(edge.sourceNodeId)).toBe(true);
116+
expect(nodeIds.has(edge.targetNodeId)).toBe(true);
117+
expect(portIds.has(edge.sourcePortId)).toBe(true);
118+
expect(portIds.has(edge.targetPortId)).toBe(true);
119+
}
120+
});
121+
122+
it('preserves port ordering after rename', () => {
123+
const src = input as Record<string, unknown>;
124+
const origLayers = src.layers as Array<Record<string, unknown>>;
125+
const origNodeLayer = origLayers.find(l => l.type === 'diagram-nodes')!;
126+
const origModels = origNodeLayer.models as Record<string, Record<string, unknown>>;
127+
128+
const out = minify(input, { aggressive: true });
129+
const reparsed = JSON.parse(out) as Record<string, unknown>;
130+
const newLayers = reparsed.layers as Array<Record<string, unknown>>;
131+
const newNodeLayer = newLayers.find(l => l.type === 'diagram-nodes')!;
132+
const newModels = newNodeLayer.models as Record<string, Record<string, unknown>>;
133+
134+
const origNodes = Object.values(origModels);
135+
const newNodes = Object.values(newModels);
136+
expect(newNodes.length).toBe(origNodes.length);
137+
138+
for (let i = 0; i < origNodes.length; i++) {
139+
const origInCount = (origNodes[i].portsInOrder as string[]).length;
140+
const origOutCount = (origNodes[i].portsOutOrder as string[]).length;
141+
expect((newNodes[i].portsInOrder as string[]).length).toBe(origInCount);
142+
expect((newNodes[i].portsOutOrder as string[]).length).toBe(origOutCount);
143+
}
144+
});
145+
146+
it('strips viewer-unused fields', () => {
147+
const out = minify(input, { aggressive: true });
148+
expect(out).not.toMatch(/"curvyness":/);
149+
expect(out).not.toMatch(/"selectedColor":/);
150+
expect(out).not.toMatch(/"isSvg":/);
151+
expect(out).not.toMatch(/"transformed":/);
152+
expect(out).not.toMatch(/"alignment":/);
153+
expect(out).not.toMatch(/"parentNode":/);
154+
});
155+
156+
it('beats safe mode on size', () => {
157+
const safe = minify(input, { aggressive: false });
158+
const aggr = minify(input, { aggressive: true });
159+
expect(aggr.length).toBeLessThan(safe.length);
160+
});
161+
162+
it('tags stats as aggressive', () => {
163+
const { stats } = minifyWithStats(input, { aggressive: true });
164+
expect(stats.mode).toBe('aggressive');
165+
});
166+
});

xircuits-graph-viewer/packages/core/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,33 @@ import { getCanvasStyle } from '@xpressai/xircuits-viewer';
5151
container.style.cssText = getCanvasStyle('dark');
5252
```
5353

54+
## Minifier
55+
56+
`.xircuits` files from the Xircuits editor carry full UUIDs and high-precision coordinates that the viewer doesn't need. A bundled minifier cuts file size 57–85% so workflows load faster over the network.
57+
58+
### CLI
59+
60+
```bash
61+
# Safe mode (best-effort compatible with the JupyterLab editor)
62+
npx xircuits-minify MyWorkflow.xircuits
63+
64+
# Viewer-only; shortens UUIDs and strips cosmetic fields
65+
npx xircuits-minify MyWorkflow.xircuits --aggressive
66+
67+
# Tune coordinate precision (default 0 = integer pixels)
68+
npx xircuits-minify MyWorkflow.xircuits --precision 1
69+
```
70+
71+
### Library
72+
73+
```js
74+
import { minify, minifyWithStats } from '@xpressai/xircuits-viewer';
75+
76+
const compact = minify(workflowJson, { aggressive: true });
77+
const { output, stats } = minifyWithStats(workflowJson);
78+
// stats: { inputBytes, outputBytes, ratio, nodes, edges, ports, mode, precision }
79+
```
80+
5481
## API
5582

5683
| Function | Description |
@@ -63,6 +90,8 @@ container.style.cssText = getCanvasStyle('dark');
6390
| `validate(json)` | Validate without parsing |
6491
| `getCanvasStyle(theme?)` | CSS string for the dot-grid container background |
6592
| `attachPanZoom(svg, options?)` | Add pan/zoom, returns `{ destroy, zoomBy, fitView }` |
93+
| `minify(json, options?)` | Minify a `.xircuits` JSON, returns compact string |
94+
| `minifyWithStats(json, options?)` | Same, plus size/counts stats |
6695

6796
### RenderOptions
6897

xircuits-graph-viewer/packages/core/package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@xpressai/xircuits-viewer",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",
@@ -14,12 +14,18 @@
1414
"import": "./dist/interaction/panZoom.js"
1515
}
1616
},
17-
"files": ["dist"],
17+
"bin": {
18+
"xircuits-minify": "./dist/minifier/cli.js"
19+
},
20+
"files": [
21+
"dist"
22+
],
1823
"scripts": {
19-
"build": "tsc",
24+
"build": "tsc && chmod +x ./dist/minifier/cli.js",
2025
"dev": "tsc --watch"
2126
},
2227
"devDependencies": {
28+
"@types/node": "^25.6.0",
2329
"typescript": "^5.8.3"
2430
}
2531
}

xircuits-graph-viewer/packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export { computeViewBox } from './layout/viewBox.js';
2323
// Renderer
2424
export { renderToString, renderToElement } from './render/renderGraph.js';
2525

26+
// Minifier
27+
export { minify, minifyWithStats } from './minifier/index.js';
28+
export type { MinifyOptions, MinifyStats } from './minifier/index.js';
29+
2630
// Convenience: one-shot parse + render
2731
export { renderXircuits } from './render/renderGraph.js';
2832

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/usr/bin/env node
2+
import { readFileSync, writeFileSync } from 'node:fs';
3+
import { basename, dirname, extname, join } from 'node:path';
4+
import { validate } from '../parser/validate.js';
5+
import { minifyWithStats } from './index.js';
6+
7+
function formatBytes(n: number): string {
8+
if (n < 1024) return `${n} B`;
9+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
10+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
11+
}
12+
13+
function printUsage() {
14+
process.stderr.write(
15+
[
16+
'Usage: xircuits-minify <input.xircuits> [-o <output>] [--precision N] [--aggressive]',
17+
'',
18+
'Options:',
19+
' -o, --output <path> Output path (default: <input>.min.xircuits)',
20+
' --precision N Coordinate decimal places (default: 0)',
21+
' --aggressive Shorten IDs and strip viewer-unused fields (viewer-only)',
22+
' -h, --help Show this help',
23+
'',
24+
].join('\n')
25+
);
26+
}
27+
28+
interface Args {
29+
input?: string;
30+
output?: string;
31+
precision: number;
32+
aggressive: boolean;
33+
help: boolean;
34+
}
35+
36+
function parseArgs(argv: string[]): Args {
37+
const args: Args = { precision: 0, aggressive: false, help: false };
38+
for (let i = 0; i < argv.length; i++) {
39+
const a = argv[i];
40+
if (a === '-h' || a === '--help') {
41+
args.help = true;
42+
} else if (a === '-o' || a === '--output') {
43+
args.output = argv[++i];
44+
} else if (a === '--precision') {
45+
args.precision = Number(argv[++i]);
46+
if (!Number.isFinite(args.precision) || args.precision < 0) {
47+
throw new Error(`Invalid --precision value: must be a non-negative number`);
48+
}
49+
} else if (a === '--aggressive') {
50+
args.aggressive = true;
51+
} else if (a.startsWith('-')) {
52+
throw new Error(`Unknown flag: ${a}`);
53+
} else if (!args.input) {
54+
args.input = a;
55+
} else {
56+
throw new Error(`Unexpected positional argument: ${a}`);
57+
}
58+
}
59+
return args;
60+
}
61+
62+
function defaultOutput(input: string): string {
63+
const ext = extname(input);
64+
const base = basename(input, ext);
65+
return join(dirname(input), `${base}.min${ext || '.xircuits'}`);
66+
}
67+
68+
function main() {
69+
let args: Args;
70+
try {
71+
args = parseArgs(process.argv.slice(2));
72+
} catch (e) {
73+
process.stderr.write(`Error: ${(e as Error).message}\n\n`);
74+
printUsage();
75+
process.exit(1);
76+
}
77+
78+
if (args.help || !args.input) {
79+
printUsage();
80+
process.exit(args.help ? 0 : 1);
81+
}
82+
83+
const inputPath = args.input;
84+
const outputPath = args.output ?? defaultOutput(inputPath);
85+
86+
let raw: string;
87+
try {
88+
raw = readFileSync(inputPath, 'utf8');
89+
} catch (e) {
90+
process.stderr.write(`Error reading ${inputPath}: ${(e as Error).message}\n`);
91+
process.exit(1);
92+
}
93+
94+
let json: unknown;
95+
try {
96+
json = JSON.parse(raw);
97+
} catch (e) {
98+
process.stderr.write(`Error parsing ${inputPath}: ${(e as Error).message}\n`);
99+
process.exit(1);
100+
}
101+
102+
const { valid, errors } = validate(json);
103+
if (!valid) {
104+
process.stderr.write(`Invalid .xircuits file: ${errors.join(', ')}\n`);
105+
process.exit(1);
106+
}
107+
108+
const { output, stats } = minifyWithStats(json, {
109+
precision: args.precision,
110+
aggressive: args.aggressive,
111+
});
112+
113+
writeFileSync(outputPath, output, 'utf8');
114+
115+
const onDiskInputBytes = Buffer.byteLength(raw, 'utf8');
116+
const savings = onDiskInputBytes - stats.outputBytes;
117+
const pct = onDiskInputBytes === 0 ? 0 : (1 - stats.outputBytes / onDiskInputBytes) * 100;
118+
const inName = basename(inputPath);
119+
const outName = basename(outputPath);
120+
const pad = Math.max(inName.length, outName.length);
121+
122+
process.stderr.write(
123+
[
124+
`Input: ${inName.padEnd(pad)} ${formatBytes(onDiskInputBytes)}`,
125+
`Output: ${outName.padEnd(pad)} ${formatBytes(stats.outputBytes)}`,
126+
`Savings: ${formatBytes(savings)} (${pct.toFixed(1)}% reduction)`,
127+
'',
128+
`Mode: ${stats.mode}`,
129+
`Nodes: ${stats.nodes} Edges: ${stats.edges} Ports: ${stats.ports}`,
130+
`Precision: ${stats.precision} decimals`,
131+
'',
132+
].join('\n')
133+
);
134+
}
135+
136+
main();

0 commit comments

Comments
 (0)