@modudraft/core 0.1.1

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,118 @@
1
+ import type { SysNode, SysEdge, LayoutDirection } from "./types";
2
+ import { getArchetype } from "./catalog";
3
+
4
+ export type MermaidParseResult =
5
+ | { ok: true; nodes: SysNode[]; edges: SysEdge[]; direction: LayoutDirection }
6
+ | { ok: false; error: string };
7
+
8
+ const HEADER_RE = /^(?:flowchart|graph)\s+(LR|RL|TD|TB|BT)\s*;?\s*$/i;
9
+ const IGNORED_RE =
10
+ /^(?:subgraph\b|end\b|classDef\b|class\b|style\b|linkStyle\b|click\b|%%)/;
11
+ const ARROW_RE = /\s*(?:--\s+([^>]+?)\s+-->|-->\|([^|]*)\||-->)\s*/;
12
+
13
+ const stripQuotes = (s: string): string => {
14
+ const t = s.trim();
15
+ return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t;
16
+ };
17
+
18
+ type ParsedToken = {
19
+ id: string;
20
+ label: string;
21
+ archetype: string;
22
+ explicit: boolean;
23
+ };
24
+
25
+ function parseNodeToken(token: string): ParsedToken | null {
26
+ const t = token.trim();
27
+ const shapes: [RegExp, string][] = [
28
+ [/^(\w[\w-]*)\[\((.+)\)\]$/, "database"],
29
+ [/^(\w[\w-]*)\{\{(.+)\}\}$/, "gateway"],
30
+ [/^(\w[\w-]*)\(\[(.+)\]\)$/, "compute"],
31
+ [/^(\w[\w-]*)\[\/(.+)\/\]$/, "compute"],
32
+ [/^(\w[\w-]*)\(\((.+)\)\)$/, "cache"],
33
+ [/^(\w[\w-]*)>(.+)\]$/, "queue"],
34
+ [/^(\w[\w-]*)\[(.+)\]$/, "compute"],
35
+ [/^(\w[\w-]*)\{(.+)\}$/, "compute"],
36
+ [/^(\w[\w-]*)\((.+)\)$/, "compute"],
37
+ ];
38
+ for (const [re, archetype] of shapes) {
39
+ const m = t.match(re);
40
+ if (m)
41
+ return { id: m[1], label: stripQuotes(m[2]), archetype, explicit: true };
42
+ }
43
+ const bare = t.match(/^(\w[\w-]*)$/);
44
+ if (bare)
45
+ return {
46
+ id: bare[1],
47
+ label: bare[1],
48
+ archetype: "compute",
49
+ explicit: false,
50
+ };
51
+ return null;
52
+ }
53
+
54
+ export function parseMermaid(text: string): MermaidParseResult {
55
+ let direction: LayoutDirection = "LR";
56
+ const nodesById = new Map<string, SysNode>();
57
+ const explicitIds = new Set<string>();
58
+ const edges: SysEdge[] = [];
59
+
60
+ const ensureNode = (token: string): string | null => {
61
+ const parsed = parseNodeToken(token);
62
+ if (!parsed) return null;
63
+ const existing = nodesById.get(parsed.id);
64
+ if (!existing || (parsed.explicit && !explicitIds.has(parsed.id))) {
65
+ const archetype = parsed.archetype;
66
+ nodesById.set(parsed.id, {
67
+ id: parsed.id,
68
+ type: "sysNode",
69
+ position: { x: 0, y: 0 },
70
+ data: {
71
+ archetype,
72
+ concreteTool: getArchetype(archetype)?.defaultTool ?? "",
73
+ label: parsed.label,
74
+ },
75
+ });
76
+ if (parsed.explicit) explicitIds.add(parsed.id);
77
+ }
78
+ return parsed.id;
79
+ };
80
+
81
+ for (const raw of text.split(/\r?\n/)) {
82
+ const line = raw.trim();
83
+ if (!line) continue;
84
+ const header = line.match(HEADER_RE);
85
+ if (header) {
86
+ const dir = header[1].toUpperCase();
87
+ direction = dir === "LR" || dir === "RL" ? "LR" : "TB";
88
+ continue;
89
+ }
90
+ if (IGNORED_RE.test(line)) continue;
91
+ const normalized = line.replace(/-\.+->/g, "-->").replace(/==>/g, "-->");
92
+ const parts = normalized.split(ARROW_RE);
93
+ if (parts.length === 1) {
94
+ ensureNode(line);
95
+ continue;
96
+ }
97
+ let prevId = ensureNode(parts[0]);
98
+ for (let i = 1; i < parts.length - 1; i += 3) {
99
+ const label = parts[i] ?? parts[i + 1];
100
+ const nextId = ensureNode(parts[i + 2]);
101
+ if (prevId && nextId) {
102
+ edges.push({
103
+ id: `mermaid-e${edges.length}`,
104
+ source: prevId,
105
+ target: nextId,
106
+ type: "sysEdge",
107
+ data: label !== undefined ? { label: label.trim() } : {},
108
+ });
109
+ }
110
+ prevId = nextId ?? prevId;
111
+ }
112
+ }
113
+
114
+ if (nodesById.size === 0) {
115
+ return { ok: false, error: "No nodes found — check your Mermaid syntax." };
116
+ }
117
+ return { ok: true, nodes: [...nodesById.values()], edges, direction };
118
+ }
@@ -0,0 +1,250 @@
1
+ import { z } from "zod";
2
+ import type {
3
+ AppNode,
4
+ SysEdge,
5
+ ViewMode,
6
+ NodeStyle,
7
+ SysNode,
8
+ StepNode,
9
+ ArrowNode,
10
+ } from "./types";
11
+ import { absolutizeAll } from "./grouping";
12
+
13
+ export const SYSDRAW_VERSION = "1.3.0";
14
+
15
+ const positionSchema = z.object({ x: z.number(), y: z.number() });
16
+
17
+ const sysNodeSchema = z.object({
18
+ id: z.string().min(1),
19
+ type: z.literal("sysNode").catch("sysNode"),
20
+ position: positionSchema,
21
+ data: z.object({
22
+ archetype: z.string().min(1),
23
+ concreteTool: z.string().min(1),
24
+ label: z.string(),
25
+ customProperties: z.record(z.string(), z.string()).optional(),
26
+ }),
27
+ });
28
+
29
+ const noteNodeSchema = z.object({
30
+ id: z.string().min(1),
31
+ type: z.literal("noteNode"),
32
+ position: positionSchema,
33
+ data: z.object({
34
+ text: z.string(),
35
+ color: z.string().optional(),
36
+ size: z.enum(["small", "normal", "title"]).optional(),
37
+ }),
38
+ });
39
+
40
+ const boundaryNodeSchema = z.object({
41
+ id: z.string().min(1),
42
+ type: z.literal("boundaryNode"),
43
+ position: positionSchema,
44
+ width: z.number().optional(),
45
+ height: z.number().optional(),
46
+ data: z.object({
47
+ label: z.string(),
48
+ color: z.string().optional(),
49
+ }),
50
+ });
51
+
52
+ const stepNodeSchema = z.object({
53
+ id: z.string().min(1),
54
+ type: z.literal("stepNode"),
55
+ position: positionSchema,
56
+ data: z.object({
57
+ n: z.number().optional(),
58
+ label: z.string().optional(),
59
+ color: z.string().optional(),
60
+ }),
61
+ });
62
+
63
+ const arrowNodeSchema = z.object({
64
+ id: z.string().min(1),
65
+ type: z.literal("arrowNode"),
66
+ position: positionSchema,
67
+ data: z.object({
68
+ dx: z.number(),
69
+ dy: z.number(),
70
+ color: z.string().optional(),
71
+ lineStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
72
+ }),
73
+ });
74
+
75
+ const anyNodeSchema = z.union([
76
+ sysNodeSchema,
77
+ noteNodeSchema,
78
+ boundaryNodeSchema,
79
+ stepNodeSchema,
80
+ arrowNodeSchema,
81
+ ]);
82
+
83
+ const edgeSchema = z.object({
84
+ id: z.string().min(1),
85
+ source: z.string().min(1),
86
+ target: z.string().min(1),
87
+ type: z.literal("sysEdge").catch("sysEdge"),
88
+ animated: z.boolean().optional(),
89
+ data: z
90
+ .object({
91
+ label: z.string().optional(),
92
+ protocol: z.string().optional(),
93
+ color: z.string().optional(),
94
+ customProperties: z.record(z.string(), z.string()).optional(),
95
+ })
96
+ .optional(),
97
+ });
98
+
99
+ const metaSchema = z
100
+ .object({
101
+ title: z.string().optional(),
102
+ lastModified: z.string().optional(),
103
+ viewMode: z.enum(["minimalist", "real"]).optional(),
104
+ nodeStyle: z.enum(["symbol", "card", "plate"]).optional(),
105
+ locked: z.boolean().optional(),
106
+ })
107
+ .catch({});
108
+
109
+ export const sysdrawFileSchema = z.object({
110
+ version: z.string(),
111
+ meta: metaSchema.default({}),
112
+ nodes: z.array(anyNodeSchema),
113
+ edges: z.array(edgeSchema),
114
+ });
115
+
116
+ export type SysdrawFile = z.infer<typeof sysdrawFileSchema>;
117
+
118
+ export function serializeSysdraw(state: {
119
+ nodes: AppNode[];
120
+ edges: SysEdge[];
121
+ viewMode: ViewMode;
122
+ nodeStyle: NodeStyle;
123
+ title?: string;
124
+ locked?: boolean;
125
+ }): string {
126
+ const sourceNodes = absolutizeAll(state.nodes);
127
+ const serializedNodes = sourceNodes.map((n) => {
128
+ if (n.type === "noteNode") {
129
+ return {
130
+ id: n.id,
131
+ type: "noteNode" as const,
132
+ position: n.position,
133
+ data: {
134
+ text: n.data.text,
135
+ ...(n.data.color !== undefined ? { color: n.data.color } : {}),
136
+ ...(n.data.size !== undefined ? { size: n.data.size } : {}),
137
+ },
138
+ };
139
+ }
140
+ if (n.type === "boundaryNode") {
141
+ return {
142
+ id: n.id,
143
+ type: "boundaryNode" as const,
144
+ position: n.position,
145
+ ...(n.width !== undefined ? { width: n.width } : {}),
146
+ ...(n.height !== undefined ? { height: n.height } : {}),
147
+ data: {
148
+ label: n.data.label,
149
+ ...(n.data.color !== undefined ? { color: n.data.color } : {}),
150
+ },
151
+ };
152
+ }
153
+ if (n.type === "stepNode") {
154
+ const sn = n as StepNode;
155
+ return {
156
+ id: sn.id,
157
+ type: "stepNode" as const,
158
+ position: sn.position,
159
+ data: {
160
+ n: sn.data.n ?? 0,
161
+ ...(sn.data.label !== undefined ? { label: sn.data.label } : {}),
162
+ ...(sn.data.color !== undefined ? { color: sn.data.color } : {}),
163
+ },
164
+ };
165
+ }
166
+ if (n.type === "arrowNode") {
167
+ const an = n as ArrowNode;
168
+ return {
169
+ id: an.id,
170
+ type: "arrowNode" as const,
171
+ position: an.position,
172
+ data: {
173
+ dx: an.data.dx,
174
+ dy: an.data.dy,
175
+ ...(an.data.color !== undefined ? { color: an.data.color } : {}),
176
+ ...(an.data.lineStyle !== undefined
177
+ ? { lineStyle: an.data.lineStyle }
178
+ : {}),
179
+ },
180
+ };
181
+ }
182
+ // sysNode
183
+ const sn = n as SysNode;
184
+ return {
185
+ id: sn.id,
186
+ type: "sysNode" as const,
187
+ position: sn.position,
188
+ data: {
189
+ archetype: sn.data.archetype,
190
+ concreteTool: sn.data.concreteTool,
191
+ label: sn.data.label,
192
+ ...(sn.data.customProperties
193
+ ? { customProperties: sn.data.customProperties }
194
+ : {}),
195
+ },
196
+ };
197
+ });
198
+
199
+ const file = {
200
+ version: SYSDRAW_VERSION,
201
+ meta: {
202
+ title: state.title ?? "architecture",
203
+ lastModified: new Date().toISOString(),
204
+ viewMode: state.viewMode,
205
+ nodeStyle: state.nodeStyle,
206
+ ...(state.locked ? { locked: true } : {}),
207
+ },
208
+ nodes: serializedNodes,
209
+ edges: state.edges.map((e) => ({
210
+ id: e.id,
211
+ source: e.source,
212
+ target: e.target,
213
+ type: "sysEdge" as const,
214
+ ...(e.animated ? { animated: true } : {}),
215
+ data: {
216
+ ...(e.data?.label !== undefined ? { label: e.data.label } : {}),
217
+ ...(e.data?.protocol !== undefined
218
+ ? { protocol: e.data.protocol }
219
+ : {}),
220
+ ...(e.data?.color !== undefined ? { color: e.data.color } : {}),
221
+ ...(e.data?.customProperties !== undefined
222
+ ? { customProperties: e.data.customProperties }
223
+ : {}),
224
+ },
225
+ })),
226
+ };
227
+ return JSON.stringify(file, null, 2);
228
+ }
229
+
230
+ export type ParseResult =
231
+ | { ok: true; data: SysdrawFile }
232
+ | { ok: false; error: string };
233
+
234
+ export function parseSysdraw(text: string): ParseResult {
235
+ let json: unknown;
236
+ try {
237
+ json = JSON.parse(text);
238
+ } catch {
239
+ return { ok: false, error: "File is not valid JSON." };
240
+ }
241
+ const result = sysdrawFileSchema.safeParse(json);
242
+ if (!result.success) {
243
+ const issue = result.error.issues[0];
244
+ return {
245
+ ok: false,
246
+ error: `Invalid file — ${issue.path.join(".") || "root"}: ${issue.message}`,
247
+ };
248
+ }
249
+ return { ok: true, data: result.data };
250
+ }
package/src/types.ts ADDED
@@ -0,0 +1,75 @@
1
+ export type ViewMode = "minimalist" | "real";
2
+ export type NodeStyle = "symbol" | "card" | "plate";
3
+ export type LayoutDirection = "LR" | "TB";
4
+
5
+ export type SysNodeData = {
6
+ archetype: string;
7
+ concreteTool: string;
8
+ label: string;
9
+ customProperties?: Record<string, string>;
10
+ };
11
+
12
+ export type NoteNodeData = {
13
+ text: string;
14
+ color?: string;
15
+ size?: "small" | "normal" | "title";
16
+ };
17
+
18
+ export type BoundaryNodeData = {
19
+ label: string;
20
+ color?: string;
21
+ };
22
+
23
+ export type StepNodeData = {
24
+ n?: number;
25
+ label?: string;
26
+ color?: string;
27
+ };
28
+
29
+ export type ArrowNodeData = {
30
+ dx: number;
31
+ dy: number;
32
+ color?: string;
33
+ lineStyle?: "solid" | "dashed" | "dotted";
34
+ };
35
+
36
+ export type SysEdgeData = {
37
+ label?: string;
38
+ protocol?: string;
39
+ color?: string;
40
+ customProperties?: Record<string, string>;
41
+ };
42
+
43
+ // Structural subset of @xyflow/react Node<TData, TType> — no React dep.
44
+ // type is required so core return values are directly assignable to RF's
45
+ // discriminated AppNode union (required→optional is valid structurally;
46
+ // when RF nodes go IN to core functions the call site casts with "as").
47
+ type CoreNode<TData, TType extends string = string> = {
48
+ id: string;
49
+ type: TType;
50
+ position: { x: number; y: number };
51
+ data: TData;
52
+ parentId?: string;
53
+ width?: number;
54
+ height?: number;
55
+ measured?: { width?: number; height?: number };
56
+ zIndex?: number;
57
+ selected?: boolean;
58
+ };
59
+
60
+ export type SysNode = CoreNode<SysNodeData, "sysNode">;
61
+ export type NoteNode = CoreNode<NoteNodeData, "noteNode">;
62
+ export type BoundaryNode = CoreNode<BoundaryNodeData, "boundaryNode">;
63
+ export type StepNode = CoreNode<StepNodeData, "stepNode">;
64
+ export type ArrowNode = CoreNode<ArrowNodeData, "arrowNode">;
65
+ export type AppNode = SysNode | NoteNode | BoundaryNode | StepNode | ArrowNode;
66
+
67
+ export type SysEdge = {
68
+ id: string;
69
+ source: string;
70
+ target: string;
71
+ type?: string;
72
+ animated?: boolean;
73
+ selected?: boolean;
74
+ data?: SysEdgeData;
75
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "verbatimModuleSyntax": true,
12
+ "resolveJsonModule": true,
13
+ "skipLibCheck": true,
14
+ "composite": true
15
+ },
16
+ "include": ["src/**/*.ts", "src/**/*.json"]
17
+ }