morffy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/bin/morffy.js +272 -0
- package/dist/assets/elk-worker.min--dAJroGl.js +6401 -0
- package/dist/assets/index-CLYmC0Jb.css +1 -0
- package/dist/assets/index-YkGpd5zb.js +7951 -0
- package/dist/index.html +20 -0
- package/dist/morffy.svg +8 -0
- package/package.json +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tally Barak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# morffy
|
|
2
|
+
|
|
3
|
+
A read-only visualizer that shows how a system-architecture diagram **evolves
|
|
4
|
+
over a timeline**. It pairs a Visio-style graph canvas with a Gantt-style
|
|
5
|
+
timeline, both driven by a single AI-generated JSON/YAML file.
|
|
6
|
+
|
|
7
|
+
See [`SPEC.md`](SPEC.md) for the full design.
|
|
8
|
+
|
|
9
|
+
## Running the app
|
|
10
|
+
|
|
11
|
+
There are two ways to view a diagram.
|
|
12
|
+
|
|
13
|
+
### 1. Browser file picker (no setup)
|
|
14
|
+
|
|
15
|
+
Open the app and click **Load file** (top bar or empty state) to pick a JSON or
|
|
16
|
+
YAML diagram from disk.
|
|
17
|
+
|
|
18
|
+
### 2. CLI — serve a file by path (with live reload)
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
morffy <diagram.json|yaml> [--port 4173] [--no-open] [--no-watch]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The CLI serves the app and **auto-loads** the given file. It also **watches the
|
|
25
|
+
file** — edit it on disk and the diagram refreshes on screen automatically
|
|
26
|
+
(disable with `--no-watch`).
|
|
27
|
+
|
|
28
|
+
> [!IMPORTANT]
|
|
29
|
+
> **The CLI serves a built app, so a build must exist first.**
|
|
30
|
+
> When you install the published npm package this is already done for you — it
|
|
31
|
+
> ships a prebuilt `dist/`, so `npx morffy diagram.json` just works.
|
|
32
|
+
>
|
|
33
|
+
> When working **from this repo**, build first (the CLI will tell you if you
|
|
34
|
+
> forget):
|
|
35
|
+
>
|
|
36
|
+
> ```sh
|
|
37
|
+
> npm run serve -- diagram.json # build, then serve (one step)
|
|
38
|
+
> # or, if dist/ is already built:
|
|
39
|
+
> npm run serve:built -- diagram.json # serve without rebuilding
|
|
40
|
+
> ```
|
|
41
|
+
>
|
|
42
|
+
> Note: the CLI watches your **diagram file**, not the app source. After editing
|
|
43
|
+
> app source, rebuild (`npm run build`) to see those changes. For live app-source
|
|
44
|
+
> reloading, use the Vite dev server (`npm run dev`) instead.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm install
|
|
50
|
+
npm run dev # vite dev server (http://localhost:5173)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Quality gate
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npm run typecheck # tsc -b --noEmit
|
|
57
|
+
npm test # vitest (unit + CLI integration)
|
|
58
|
+
npm run test:e2e # playwright
|
|
59
|
+
npm run lint # eslint
|
|
60
|
+
npm run build # tsc -b && vite build
|
|
61
|
+
npm run format # prettier --write
|
|
62
|
+
```
|
package/bin/morffy.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* morffy CLI — serve the built app against a diagram file by path.
|
|
4
|
+
*
|
|
5
|
+
* morffy <diagram.json|yaml> [--port 4173] [--no-open]
|
|
6
|
+
* morffy --file <path> [--port 4173]
|
|
7
|
+
*
|
|
8
|
+
* Morffy is a client-side SPA; there is no server-side rendering. This CLI is a
|
|
9
|
+
* tiny zero-dependency static server that:
|
|
10
|
+
*
|
|
11
|
+
* 1. serves the built `dist/` bundle,
|
|
12
|
+
* 2. exposes the chosen diagram file's RAW TEXT at `/__morffy/diagram`
|
|
13
|
+
* (with the original filename in a header so the app picks JSON vs YAML),
|
|
14
|
+
* 3. exposes `/__morffy/config.json` describing what to auto-load,
|
|
15
|
+
* 4. exposes `/__morffy/events` (Server-Sent Events) and watches the data file
|
|
16
|
+
* with chokidar — on a change it pushes a `reload` event so the app
|
|
17
|
+
* re-fetches and live-refreshes the diagram (disable with `--no-watch`).
|
|
18
|
+
*
|
|
19
|
+
* The app's boot effect (see AppShell) fetches `/__morffy/config.json`; when a
|
|
20
|
+
* diagram is configured it auto-loads it and subscribes to `/__morffy/events`.
|
|
21
|
+
* Under plain static hosting / dev those endpoints 404, so the app stays empty
|
|
22
|
+
* exactly as before.
|
|
23
|
+
*/
|
|
24
|
+
import { createServer } from 'node:http';
|
|
25
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
26
|
+
import { createReadStream } from 'node:fs';
|
|
27
|
+
import { extname, join, resolve, basename, dirname } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import chokidar from 'chokidar';
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const DIST_DIR = resolve(__dirname, '..', 'dist');
|
|
33
|
+
|
|
34
|
+
const MIME = {
|
|
35
|
+
'.html': 'text/html; charset=utf-8',
|
|
36
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
37
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
38
|
+
'.css': 'text/css; charset=utf-8',
|
|
39
|
+
'.json': 'application/json; charset=utf-8',
|
|
40
|
+
'.svg': 'image/svg+xml',
|
|
41
|
+
'.png': 'image/png',
|
|
42
|
+
'.jpg': 'image/jpeg',
|
|
43
|
+
'.jpeg': 'image/jpeg',
|
|
44
|
+
'.gif': 'image/gif',
|
|
45
|
+
'.ico': 'image/x-icon',
|
|
46
|
+
'.woff': 'font/woff',
|
|
47
|
+
'.woff2': 'font/woff2',
|
|
48
|
+
'.ttf': 'font/ttf',
|
|
49
|
+
'.map': 'application/json; charset=utf-8',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function parseArgs(argv) {
|
|
53
|
+
const opts = { file: null, port: 4173, open: true, watch: true };
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const a = argv[i];
|
|
56
|
+
if (a === '--help' || a === '-h') opts.help = true;
|
|
57
|
+
else if (a === '--version' || a === '-v') opts.version = true;
|
|
58
|
+
else if (a === '--port' || a === '-p') opts.port = Number(argv[++i]);
|
|
59
|
+
else if (a === '--no-open') opts.open = false;
|
|
60
|
+
else if (a === '--no-watch') opts.watch = false;
|
|
61
|
+
else if (a === '--file' || a === '-f') opts.file = argv[++i];
|
|
62
|
+
else if (a.startsWith('--port=')) opts.port = Number(a.slice('--port='.length));
|
|
63
|
+
else if (a.startsWith('--file=')) opts.file = a.slice('--file='.length);
|
|
64
|
+
else if (!a.startsWith('-') && !opts.file) opts.file = a;
|
|
65
|
+
}
|
|
66
|
+
return opts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const HELP = `morffy — serve the architecture-timeline visualizer against a diagram file.
|
|
70
|
+
|
|
71
|
+
Usage:
|
|
72
|
+
morffy <diagram.json|yaml> [options]
|
|
73
|
+
morffy --file <path> [options]
|
|
74
|
+
|
|
75
|
+
Options:
|
|
76
|
+
-f, --file <path> Diagram file to load (JSON or YAML). Also positional.
|
|
77
|
+
-p, --port <n> Port to listen on (default: 4173).
|
|
78
|
+
--no-open Do not open the browser automatically.
|
|
79
|
+
--no-watch Do not live-reload the app when the file changes.
|
|
80
|
+
-h, --help Show this help.
|
|
81
|
+
-v, --version Print the morffy version.
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
morffy ./architecture.json
|
|
85
|
+
morffy --file evolution.yaml --port 8080 --no-open
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
function send(res, status, body, headers = {}) {
|
|
89
|
+
res.writeHead(status, { 'Cache-Control': 'no-store', ...headers });
|
|
90
|
+
res.end(body);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve a URL path to a file inside dist/, guarding against traversal. */
|
|
94
|
+
function safeDistPath(urlPath) {
|
|
95
|
+
const clean = decodeURIComponent(urlPath.split('?')[0]).replace(/^\/+/, '');
|
|
96
|
+
const full = resolve(DIST_DIR, clean);
|
|
97
|
+
if (full !== DIST_DIR && !full.startsWith(DIST_DIR + '/')) return null; // traversal
|
|
98
|
+
return full;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
103
|
+
|
|
104
|
+
if (opts.help) {
|
|
105
|
+
process.stdout.write(HELP);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (opts.version) {
|
|
109
|
+
const pkg = JSON.parse(await readFile(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
110
|
+
process.stdout.write(`${pkg.version}\n`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The app must be built first.
|
|
115
|
+
try {
|
|
116
|
+
await stat(join(DIST_DIR, 'index.html'));
|
|
117
|
+
} catch {
|
|
118
|
+
process.stderr.write(
|
|
119
|
+
`morffy: no build found at ${DIST_DIR}.\n` +
|
|
120
|
+
`Build first, then serve: npm run build && npm run serve:built -- <file>\n` +
|
|
121
|
+
`Or do both in one step: npm run serve -- <file>\n` +
|
|
122
|
+
`(The published npm package ships a prebuilt dist, so end users never build.)\n`,
|
|
123
|
+
);
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let dataPath = null;
|
|
129
|
+
let dataName = null;
|
|
130
|
+
if (opts.file) {
|
|
131
|
+
dataPath = resolve(process.cwd(), opts.file);
|
|
132
|
+
try {
|
|
133
|
+
await stat(dataPath);
|
|
134
|
+
} catch {
|
|
135
|
+
process.stderr.write(`morffy: diagram file not found: ${dataPath}\n`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
dataName = basename(dataPath);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Open Server-Sent-Events connections, notified when the file changes.
|
|
143
|
+
const sseClients = new Set();
|
|
144
|
+
const broadcast = (event) => {
|
|
145
|
+
for (const client of sseClients) {
|
|
146
|
+
try {
|
|
147
|
+
client.write(`event: ${event}\ndata: ${dataName ?? ''}\n\n`);
|
|
148
|
+
} catch {
|
|
149
|
+
sseClients.delete(client);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const server = createServer(async (req, res) => {
|
|
155
|
+
try {
|
|
156
|
+
const url = req.url || '/';
|
|
157
|
+
|
|
158
|
+
// --- morffy control endpoints -------------------------------------
|
|
159
|
+
if (url === '/__morffy/config.json') {
|
|
160
|
+
return send(
|
|
161
|
+
res,
|
|
162
|
+
200,
|
|
163
|
+
JSON.stringify({ autoLoad: Boolean(dataPath), filename: dataName, watch: opts.watch }),
|
|
164
|
+
{ 'Content-Type': MIME['.json'] },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (url.split('?')[0] === '/__morffy/events') {
|
|
168
|
+
res.writeHead(200, {
|
|
169
|
+
'Content-Type': 'text/event-stream',
|
|
170
|
+
'Cache-Control': 'no-store',
|
|
171
|
+
Connection: 'keep-alive',
|
|
172
|
+
});
|
|
173
|
+
res.write('event: ready\ndata: connected\n\n');
|
|
174
|
+
sseClients.add(res);
|
|
175
|
+
req.on('close', () => sseClients.delete(res));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (url.split('?')[0] === '/__morffy/diagram') {
|
|
179
|
+
if (!dataPath) return send(res, 404, 'no diagram configured');
|
|
180
|
+
const text = await readFile(dataPath);
|
|
181
|
+
const type = /\.ya?ml$/i.test(dataName) ? 'text/yaml; charset=utf-8' : MIME['.json'];
|
|
182
|
+
return send(res, 200, text, {
|
|
183
|
+
'Content-Type': type,
|
|
184
|
+
'X-Morffy-Filename': dataName,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- static dist/ assets ------------------------------------------
|
|
189
|
+
const pathname = url.split('?')[0];
|
|
190
|
+
let filePath = safeDistPath(pathname === '/' ? '/index.html' : pathname);
|
|
191
|
+
if (!filePath) return send(res, 403, 'forbidden');
|
|
192
|
+
|
|
193
|
+
let info = null;
|
|
194
|
+
try {
|
|
195
|
+
info = await stat(filePath);
|
|
196
|
+
} catch {
|
|
197
|
+
info = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// SPA fallback: unknown non-asset routes serve index.html.
|
|
201
|
+
if (!info || info.isDirectory()) {
|
|
202
|
+
filePath = join(DIST_DIR, 'index.html');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const type = MIME[extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
206
|
+
res.writeHead(200, { 'Content-Type': type });
|
|
207
|
+
createReadStream(filePath).pipe(res);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
send(res, 500, `morffy: ${String(err)}`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
let watcher = null;
|
|
214
|
+
server.listen(opts.port, () => {
|
|
215
|
+
const at = `http://localhost:${opts.port}`;
|
|
216
|
+
if (dataPath) process.stdout.write(`morffy: serving ${dataName} at ${at}\n`);
|
|
217
|
+
else process.stdout.write(`morffy: serving (no diagram — use the Load file button) at ${at}\n`);
|
|
218
|
+
|
|
219
|
+
// Watch the data file and live-reload the app on every change.
|
|
220
|
+
if (dataPath && opts.watch) {
|
|
221
|
+
watcher = chokidar.watch(dataPath, {
|
|
222
|
+
ignoreInitial: true,
|
|
223
|
+
// Wait for the writer to finish (editors save in bursts) but stay snappy.
|
|
224
|
+
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
|
|
225
|
+
});
|
|
226
|
+
watcher
|
|
227
|
+
.on('change', () => {
|
|
228
|
+
process.stdout.write(`morffy: ${dataName} changed — reloading\n`);
|
|
229
|
+
broadcast('reload');
|
|
230
|
+
})
|
|
231
|
+
.on('add', () => broadcast('reload')) // re-created after an atomic save
|
|
232
|
+
.on('error', () => {});
|
|
233
|
+
process.stdout.write('morffy: watching for changes (use --no-watch to disable)\n');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (opts.open) openBrowser(at);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const shutdown = () => {
|
|
240
|
+
if (watcher) watcher.close();
|
|
241
|
+
server.close();
|
|
242
|
+
process.exit(0);
|
|
243
|
+
};
|
|
244
|
+
process.on('SIGINT', shutdown);
|
|
245
|
+
process.on('SIGTERM', shutdown);
|
|
246
|
+
|
|
247
|
+
server.on('error', (err) => {
|
|
248
|
+
if (err && err.code === 'EADDRINUSE') {
|
|
249
|
+
process.stderr.write(`morffy: port ${opts.port} is in use. Try --port <n>.\n`);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
} else {
|
|
252
|
+
process.stderr.write(`morffy: ${String(err)}\n`);
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Best-effort browser open; never fatal. */
|
|
259
|
+
function openBrowser(url) {
|
|
260
|
+
import('node:child_process')
|
|
261
|
+
.then(({ spawn }) => {
|
|
262
|
+
const cmd =
|
|
263
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
264
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
265
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true })
|
|
266
|
+
.on('error', () => {})
|
|
267
|
+
.unref();
|
|
268
|
+
})
|
|
269
|
+
.catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
main();
|