@creately/rdm-canvas 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.
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * rdm-canvas CLI
4
+ *
5
+ * Usage:
6
+ * npx rdm-canvas diagram.rdm # Single-file mode
7
+ * npx rdm-canvas ./diagrams/ # Directory mode — index + routing
8
+ * npx rdm-canvas --examples # Built-in examples gallery
9
+ * npx rdm-canvas diagram.rdm --port 3456
10
+ * npx rdm-canvas diagram.rdm --no-open
11
+ */
12
+
13
+ import { startServer, startDirectoryServer } from '../server/index';
14
+ import { openBrowser } from '../server/browserLauncher';
15
+ import { existsSync, statSync } from 'fs';
16
+ import { resolve, dirname } from 'path';
17
+
18
+ const args = process.argv.slice(2);
19
+ const flags = new Set(args.filter((a) => a.startsWith('--')));
20
+ const positional = args.filter((a) => !a.startsWith('--'));
21
+
22
+ const portIdx = args.indexOf('--port');
23
+ const preferredPort = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 4983;
24
+ const noOpen = flags.has('--no-open');
25
+
26
+ // Determine mode: --examples, directory, or single-file
27
+ let mode: 'file' | 'directory';
28
+ let targetPath: string;
29
+ let title = 'rdm-canvas';
30
+
31
+ if (flags.has('--examples')) {
32
+ mode = 'directory';
33
+ targetPath = findExamplesDir();
34
+ title = 'RDM Examples';
35
+ } else {
36
+ const input = positional[0];
37
+ if (!input) {
38
+ console.error(
39
+ 'Usage: rdm-canvas <file.rdm | directory> [--examples] [--port <port>] [--no-open]',
40
+ );
41
+ process.exit(1);
42
+ }
43
+ targetPath = resolve(input);
44
+ if (!existsSync(targetPath)) {
45
+ console.error(`Not found: ${input}`);
46
+ process.exit(1);
47
+ }
48
+ mode = statSync(targetPath).isDirectory() ? 'directory' : 'file';
49
+ }
50
+
51
+ // Start server with port scanning
52
+ let server;
53
+ let port = preferredPort;
54
+ for (let attempt = 0; attempt < 10; attempt++) {
55
+ try {
56
+ if (mode === 'directory') {
57
+ server = startDirectoryServer({ rootDir: targetPath, port, title });
58
+ } else {
59
+ server = startServer({ filePath: targetPath, port });
60
+ }
61
+ break;
62
+ } catch (e: any) {
63
+ if (e?.code === 'EADDRINUSE') {
64
+ port++;
65
+ continue;
66
+ }
67
+ throw e;
68
+ }
69
+ }
70
+ if (!server) {
71
+ console.error(`Could not find an open port (tried ${preferredPort}-${port})`);
72
+ process.exit(1);
73
+ }
74
+
75
+ console.log(`rdm-canvas v0.1.0`);
76
+ if (mode === 'directory') {
77
+ console.log(`Directory: ${targetPath}`);
78
+ } else {
79
+ console.log(`Watching: ${targetPath}`);
80
+ }
81
+ console.log(`Server: http://localhost:${port}`);
82
+ console.log();
83
+
84
+ if (!noOpen) {
85
+ setTimeout(() => {
86
+ openBrowser(`http://localhost:${port}`);
87
+ }, 300);
88
+ }
89
+
90
+ process.on('SIGINT', () => {
91
+ console.log('\nShutting down...');
92
+ server.stop();
93
+ process.exit(0);
94
+ });
95
+
96
+ function findExamplesDir(): string {
97
+ // Walk up from this script's directory to find the project root with src/domains/rdm/examples
98
+ let dir = dirname(new URL(import.meta.url).pathname);
99
+ for (let i = 0; i < 10; i++) {
100
+ const candidate = resolve(dir, 'src/domains/rdm/examples');
101
+ if (existsSync(candidate)) return candidate;
102
+ const parent = dirname(dir);
103
+ if (parent === dir) break;
104
+ dir = parent;
105
+ }
106
+ console.error('Could not find built-in examples directory');
107
+ process.exit(1);
108
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@creately/rdm-canvas",
3
+ "version": "0.1.0",
4
+ "description": "Local server for live RDM diagram rendering with bidirectional file sync",
5
+ "bin": {
6
+ "rdm-canvas": "./bin/rdm-canvas.ts"
7
+ },
8
+ "type": "module",
9
+ "scripts": {
10
+ "start": "bun run bin/rdm-canvas.ts",
11
+ "dev": "bun run --watch bin/rdm-canvas.ts"
12
+ },
13
+ "dependencies": {
14
+ "@creately/rdm-core": ">=0.2.0"
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.0.0"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "server/",
22
+ "static/"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/creately/rdm",
28
+ "directory": "packages/rdm-canvas"
29
+ },
30
+ "keywords": ["rdm", "canvas", "diagram", "viewer", "bun"]
31
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Browser Launcher — Cross-platform browser opening
3
+ */
4
+
5
+ import { spawn } from 'child_process';
6
+
7
+ export function openBrowser(url: string): void {
8
+ const platform = process.platform;
9
+ let cmd: string;
10
+ let args: string[];
11
+
12
+ switch (platform) {
13
+ case 'darwin':
14
+ cmd = 'open';
15
+ args = [url];
16
+ break;
17
+ case 'win32':
18
+ cmd = 'start';
19
+ args = ['', url];
20
+ break;
21
+ default:
22
+ cmd = 'xdg-open';
23
+ args = [url];
24
+ }
25
+
26
+ try {
27
+ const proc = spawn(cmd, args, { detached: true, stdio: 'ignore' });
28
+ proc.unref();
29
+ } catch {
30
+ console.log(`Open ${url} in your browser`);
31
+ }
32
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * File Scanner — Recursively scan a directory for .rdm files with metadata
3
+ */
4
+
5
+ import { readdirSync, statSync, readFileSync } from 'fs';
6
+ import { join, relative, dirname, basename } from 'path';
7
+
8
+ export interface RdmFileInfo {
9
+ relativePath: string;
10
+ name: string;
11
+ directory: string;
12
+ title?: string;
13
+ type?: string;
14
+ size: number;
15
+ mtime: Date;
16
+ }
17
+
18
+ export function scanRdmFiles(rootDir: string): RdmFileInfo[] {
19
+ const files: RdmFileInfo[] = [];
20
+ walkDir(rootDir, rootDir, files);
21
+ files.sort((a, b) => {
22
+ if (a.directory !== b.directory) return a.directory.localeCompare(b.directory);
23
+ return a.name.localeCompare(b.name);
24
+ });
25
+ return files;
26
+ }
27
+
28
+ function walkDir(dir: string, rootDir: string, results: RdmFileInfo[]): void {
29
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
30
+ const fullPath = join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ if (entry.name === 'output' || entry.name.startsWith('.')) continue;
33
+ walkDir(fullPath, rootDir, results);
34
+ } else if (entry.name.endsWith('.rdm')) {
35
+ const stat = statSync(fullPath);
36
+ const relPath = relative(rootDir, fullPath);
37
+ const frontmatter = parseFrontmatter(fullPath);
38
+ results.push({
39
+ relativePath: relPath,
40
+ name: basename(fullPath),
41
+ directory: dirname(relPath) === '.' ? '' : dirname(relPath),
42
+ title: frontmatter.title,
43
+ type: frontmatter.type,
44
+ size: stat.size,
45
+ mtime: stat.mtime,
46
+ });
47
+ }
48
+ }
49
+ }
50
+
51
+ function parseFrontmatter(filePath: string): { title?: string; type?: string } {
52
+ try {
53
+ const content = readFileSync(filePath, 'utf-8');
54
+ const lines = content.split('\n', 10);
55
+ if (lines[0]?.trim() !== '---') return {};
56
+ const result: { title?: string; type?: string } = {};
57
+ for (let i = 1; i < lines.length; i++) {
58
+ const line = lines[i].trim();
59
+ if (line === '---') break;
60
+ const typeMatch = line.match(/^type:\s*["']?(\w+)["']?/);
61
+ if (typeMatch) result.type = typeMatch[1];
62
+ const titleMatch = line.match(/^title:\s*["']?(.+?)["']?\s*$/);
63
+ if (titleMatch) result.title = titleMatch[1];
64
+ }
65
+ return result;
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * File Watcher — Polling-based watcher for .rdm files
3
+ *
4
+ * Uses fs.watchFile (stat polling) instead of fs.watch because macOS's
5
+ * FSEvents doesn't reliably detect atomic writes (write-to-temp + rename),
6
+ * which is what most editors and CLI tools do.
7
+ *
8
+ * Calls onChange when the file is modified externally.
9
+ * Suppresses echoes during write-back (bidirectional sync).
10
+ */
11
+
12
+ import { watchFile, unwatchFile, readFileSync } from 'fs';
13
+
14
+ export interface FileWatcherOptions {
15
+ filePath: string;
16
+ debounceMs?: number;
17
+ onChange: (content: string) => void;
18
+ }
19
+
20
+ export class FileWatcher {
21
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
22
+ private suppressed = false;
23
+ private filePath: string;
24
+ private debounceMs: number;
25
+ private onChange: (content: string) => void;
26
+ private lastContent: string;
27
+
28
+ constructor(options: FileWatcherOptions) {
29
+ this.filePath = options.filePath;
30
+ this.debounceMs = options.debounceMs ?? 100;
31
+ this.onChange = options.onChange;
32
+ this.lastContent = '';
33
+ try {
34
+ this.lastContent = readFileSync(this.filePath, 'utf-8');
35
+ } catch { /* file may not exist yet */ }
36
+ }
37
+
38
+ start(): void {
39
+ watchFile(this.filePath, { interval: 300 }, () => {
40
+ if (this.suppressed) return;
41
+
42
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
43
+ this.debounceTimer = setTimeout(() => {
44
+ try {
45
+ const content = readFileSync(this.filePath, 'utf-8');
46
+ if (content !== this.lastContent) {
47
+ this.lastContent = content;
48
+ this.onChange(content);
49
+ }
50
+ } catch {
51
+ // File might be mid-write, ignore
52
+ }
53
+ }, this.debounceMs);
54
+ });
55
+ }
56
+
57
+ /** Suppress watcher during a write-back to avoid echo loops */
58
+ async writeBack(content: string): Promise<void> {
59
+ this.suppressed = true;
60
+ try {
61
+ await Bun.write(this.filePath, content);
62
+ this.lastContent = content;
63
+ // Keep suppressed briefly to absorb the stat poll
64
+ await new Promise((resolve) => setTimeout(resolve, 500));
65
+ } finally {
66
+ this.suppressed = false;
67
+ }
68
+ }
69
+
70
+ stop(): void {
71
+ unwatchFile(this.filePath);
72
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
73
+ }
74
+ }