mendix-ruby-bridge 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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.mxcli-version +2 -0
  3. data/LICENSE +21 -0
  4. data/README.md +77 -0
  5. data/bin/git-mendix +4 -0
  6. data/bin/mendix-apply +145 -0
  7. data/bin/mendix-desktop +26 -0
  8. data/bin/mendix-git +121 -0
  9. data/bin/mendix-ruby +580 -0
  10. data/bin/mxcli +20 -0
  11. data/bin/setup-tools +25 -0
  12. data/lib/mendix_bridge/backend_server.rb +1404 -0
  13. data/lib/mendix_bridge/change_planner.rb +594 -0
  14. data/lib/mendix_bridge/config.rb +89 -0
  15. data/lib/mendix_bridge/dependency_index.rb +188 -0
  16. data/lib/mendix_bridge/desktop_app.rb +121 -0
  17. data/lib/mendix_bridge/document_parser.rb +120 -0
  18. data/lib/mendix_bridge/domain_parser.rb +103 -0
  19. data/lib/mendix_bridge/dsl.rb +540 -0
  20. data/lib/mendix_bridge/enumeration_parser.rb +32 -0
  21. data/lib/mendix_bridge/git_workflow.rb +321 -0
  22. data/lib/mendix_bridge/html_viewer.rb +672 -0
  23. data/lib/mendix_bridge/importer.rb +415 -0
  24. data/lib/mendix_bridge/inventory.rb +93 -0
  25. data/lib/mendix_bridge/mdl_generator.rb +343 -0
  26. data/lib/mendix_bridge/microflow_parser.rb +131 -0
  27. data/lib/mendix_bridge/migration.rb +290 -0
  28. data/lib/mendix_bridge/migration_executor.rb +234 -0
  29. data/lib/mendix_bridge/model.rb +204 -0
  30. data/lib/mendix_bridge/page_parser.rb +95 -0
  31. data/lib/mendix_bridge/presenter.rb +35 -0
  32. data/lib/mendix_bridge/project_creator.rb +181 -0
  33. data/lib/mendix_bridge/ruby_inventory_generator.rb +63 -0
  34. data/lib/mendix_bridge/security_parser.rb +72 -0
  35. data/lib/mendix_bridge/snapshot_diff.rb +58 -0
  36. data/lib/mendix_bridge/validator.rb +46 -0
  37. data/lib/mendix_bridge/version.rb +5 -0
  38. data/lib/mendix_bridge/visual_entity_plan.rb +176 -0
  39. data/lib/mendix_bridge.rb +127 -0
  40. data/share/applications/mendix-ruby-bridge.desktop +12 -0
  41. data/share/icons/hicolor/128x128/apps/mendix-ruby-bridge.png +0 -0
  42. data/share/icons/hicolor/256x256/apps/mendix-ruby-bridge.png +0 -0
  43. data/share/icons/hicolor/32x32/apps/mendix-ruby-bridge.png +0 -0
  44. data/share/icons/hicolor/48x48/apps/mendix-ruby-bridge.png +0 -0
  45. data/share/icons/hicolor/512x512/apps/mendix-ruby-bridge.png +0 -0
  46. data/share/icons/hicolor/64x64/apps/mendix-ruby-bridge.png +0 -0
  47. data/web/dist/apple-touch-icon.png +0 -0
  48. data/web/dist/assets/index-BO6iPT1P.css +1 -0
  49. data/web/dist/assets/index-JGODw81W.js +27 -0
  50. data/web/dist/brand/mendix-ruby-bridge.png +0 -0
  51. data/web/dist/favicon.png +0 -0
  52. data/web/dist/icons.svg +24 -0
  53. data/web/dist/index.html +17 -0
  54. metadata +179 -0
@@ -0,0 +1,672 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MendixBridge
6
+ # Generates a self-contained HTML viewer for an imported Mendix inventory.
7
+ #
8
+ # The viewer embeds the project tree and per-element details as JSON, so the
9
+ # produced file works offline from a plain file:// URL with no server. It is a
10
+ # read-only view over the artifacts written by `mendix-ruby import`.
11
+ module HtmlViewer
12
+ TREE_FILE = File.join("inventory", "project-tree.json")
13
+ DETAILS_FILE = File.join("inventory", "element-details.json")
14
+ METADATA_FILE = "mendix-project.json"
15
+
16
+ module_function
17
+
18
+ # Render the viewer HTML for an imported directory.
19
+ def render(imported_dir)
20
+ tree = load_json(File.join(imported_dir, TREE_FILE))
21
+ details = load_json(File.join(imported_dir, DETAILS_FILE))
22
+ metadata = optional_json(File.join(imported_dir, METADATA_FILE))
23
+
24
+ TEMPLATE
25
+ .gsub("__TREE_JSON__", embed(tree))
26
+ .gsub("__DETAILS_JSON__", embed(details))
27
+ .gsub("__META_JSON__", embed(metadata))
28
+ end
29
+
30
+ # Render and write the viewer, returning the output path.
31
+ def write(imported_dir, output = nil)
32
+ output ||= File.join(imported_dir, "inventory-viewer.html")
33
+ File.write(output, render(imported_dir))
34
+ output
35
+ end
36
+
37
+ def load_json(path)
38
+ raise ArgumentError, "missing inventory file: #{path}" unless File.file?(path)
39
+
40
+ JSON.parse(File.read(path))
41
+ end
42
+
43
+ def optional_json(path)
44
+ File.file?(path) ? JSON.parse(File.read(path)) : {}
45
+ end
46
+
47
+ # Escape a JSON payload so it can live inside a <script> tag safely.
48
+ def embed(data)
49
+ JSON.generate(data).gsub("</", %q{<\/})
50
+ end
51
+
52
+ TEMPLATE = <<~'HTML'
53
+ <!doctype html>
54
+ <html lang="en">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <title>Mendix Inventory Viewer</title>
59
+ <style>
60
+ :root {
61
+ --bg: #0f1420; --panel: #161d2b; --panel2: #1d2636; --line: #2a3547;
62
+ --fg: #d7e0ee; --muted: #8595ac; --accent: #6ea8fe; --accent2: #4ade80;
63
+ --chip: #24304a; --code: #0c1018;
64
+ }
65
+ * { box-sizing: border-box; }
66
+ body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg); }
67
+ header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
68
+ padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
69
+ header h1 { font-size: 15px; margin: 0; font-weight: 600; }
70
+ header .meta { color: var(--muted); font-size: 12px; }
71
+ header .spacer { flex: 1; }
72
+ header input[type=search] { background: var(--panel2); border: 1px solid var(--line);
73
+ color: var(--fg); padding: 6px 10px; border-radius: 6px; width: 240px; }
74
+ header label { color: var(--muted); font-size: 12px; user-select: none; cursor: pointer; }
75
+ .layout { display: flex; height: calc(100vh - 49px); }
76
+ aside { width: 380px; min-width: 240px; overflow: auto; border-right: 1px solid var(--line);
77
+ background: var(--panel); padding: 8px 0; resize: horizontal; }
78
+ main { flex: 1; overflow: auto; padding: 20px 28px; }
79
+ ul.tree { list-style: none; margin: 0; padding: 0; }
80
+ ul.tree ul { list-style: none; margin: 0; padding-left: 16px; }
81
+ .node { display: flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 5px;
82
+ cursor: pointer; white-space: nowrap; }
83
+ .node:hover { background: var(--panel2); }
84
+ .node.selected { background: #23406b; }
85
+ .node .twist { width: 12px; color: var(--muted); font-size: 10px; flex: none; }
86
+ .node .icon { flex: none; }
87
+ .node .label { overflow: hidden; text-overflow: ellipsis; }
88
+ .node .count { color: var(--muted); font-size: 11px; }
89
+ .collapsed > ul { display: none; }
90
+ .badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px;
91
+ background: var(--chip); color: var(--muted); }
92
+ .hidden { display: none !important; }
93
+ main h2 { margin: 0 0 2px; font-size: 20px; }
94
+ main .qn { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; margin-bottom: 18px; }
95
+ section.block { background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
96
+ margin-bottom: 16px; overflow: hidden; }
97
+ section.block > h3 { margin: 0; padding: 8px 14px; font-size: 12px; text-transform: uppercase;
98
+ letter-spacing: .04em; color: var(--muted); background: var(--panel2); border-bottom: 1px solid var(--line); }
99
+ section.block .body { padding: 12px 14px; }
100
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
101
+ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
102
+ th { color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; }
103
+ tr:last-child td { border-bottom: none; }
104
+ pre { margin: 0; padding: 12px 14px; background: var(--code); border-radius: 6px; overflow: auto;
105
+ font: 12px/1.5 ui-monospace, monospace; color: #cbd5e1; }
106
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }
107
+ .kv dt { color: var(--muted); }
108
+ .kv dd { margin: 0; }
109
+ code.inline { background: var(--code); padding: 1px 5px; border-radius: 4px;
110
+ font: 12px ui-monospace, monospace; }
111
+ .empty { color: var(--muted); font-style: italic; }
112
+ .pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; background: var(--chip); margin-left: 6px; }
113
+ .pill.ok { background: #14351f; color: var(--accent2); }
114
+ .pill.warn { background: #3a2a12; color: #fbbf24; }
115
+ a.ref { color: var(--accent); cursor: pointer; text-decoration: none; }
116
+ a.ref:hover { text-decoration: underline; }
117
+ .diagram { width: 100%; overflow: auto; background: var(--code); border-radius: 6px; }
118
+ .diagram svg { display: block; }
119
+ .diagram .box { fill: var(--panel2); stroke: var(--line); stroke-width: 1; }
120
+ .diagram .box.decision { fill: #2a2340; stroke: #6d5bd0; }
121
+ .diagram .box.terminal { fill: #14351f; stroke: #2f7d4d; }
122
+ .diagram .box.validation { fill: #3a2a12; stroke: #b5851f; }
123
+ .diagram .box.entity-head { fill: #23406b; stroke: #3a5ea8; }
124
+ .diagram .box.entity-body { fill: var(--panel2); stroke: #3a5ea8; }
125
+ .diagram text { fill: var(--fg); font: 11px system-ui, sans-serif; }
126
+ .diagram text.cap { fill: var(--muted); }
127
+ .diagram text.attr { fill: #cbd5e1; font: 10px ui-monospace, monospace; }
128
+ .diagram .edge { stroke: #6a7a94; stroke-width: 1.4; fill: none; }
129
+ .diagram .edge-label { fill: var(--muted); font: 10px system-ui; }
130
+ .diagram .nodeg { cursor: pointer; }
131
+ .diagram .nodeg:hover .box { stroke: var(--accent); }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <header>
136
+ <h1>Mendix Inventory Viewer</h1>
137
+ <span class="meta" id="meta"></span>
138
+ <span class="spacer"></span>
139
+ <input type="search" id="search" placeholder="Filter by name…" autocomplete="off">
140
+ <label><input type="checkbox" id="pagesOnly"> Pages only</label>
141
+ </header>
142
+ <div class="layout">
143
+ <aside><ul class="tree" id="tree"></ul></aside>
144
+ <main id="detail"><p class="empty">Select an element on the left to see its details.</p></main>
145
+ </div>
146
+
147
+ <script type="application/json" id="data-tree">__TREE_JSON__</script>
148
+ <script type="application/json" id="data-details">__DETAILS_JSON__</script>
149
+ <script type="application/json" id="data-meta">__META_JSON__</script>
150
+ <script>
151
+ (function () {
152
+ const TREE = JSON.parse(document.getElementById("data-tree").textContent);
153
+ const DETAILS = JSON.parse(document.getElementById("data-details").textContent);
154
+ const META = JSON.parse(document.getElementById("data-meta").textContent);
155
+
156
+ const ICONS = {
157
+ module: "📦", folder: "📁", page: "📄", entity: "🗄️", association: "🔗",
158
+ microflow: "⚙️", nanoflow: "🔧", enumeration: "🔤", layout: "🧩", snippet: "✂️",
159
+ domainmodel: "🗂️", security: "🔒", projectsecurity: "🔒", userrole: "👤",
160
+ modulerole: "👤", navigation: "🧭", settings: "⚙️", javascriptaction: "🟨",
161
+ javaaction: "☕", buildingblock: "🧱", pagetemplate: "📑", imagecollection: "🖼️",
162
+ constant: "🔢", jsonstructure: "{}", importmapping: "⬇️", exportmapping: "⬆️",
163
+ systemoverview: "🌐", workflow: "🔀"
164
+ };
165
+ const icon = (t) => ICONS[t] || "•";
166
+
167
+ const nodes = document.getElementById("tree");
168
+ const detail = document.getElementById("detail");
169
+ const search = document.getElementById("search");
170
+ const pagesOnly = document.getElementById("pagesOnly");
171
+
172
+ // ---- metadata line -------------------------------------------------
173
+ const metaBits = [];
174
+ if (META.source_project) metaBits.push(META.source_project.split("/").pop());
175
+ if (META.imported_at) metaBits.push("imported " + META.imported_at);
176
+ if (META.element_count) metaBits.push(META.element_count + " elements");
177
+ document.getElementById("meta").textContent = metaBits.join(" · ");
178
+
179
+ // ---- tree building -------------------------------------------------
180
+ let selectedEl = null;
181
+ const byQn = {}; // qualifiedName -> node <li>
182
+
183
+ function childrenOf(n) { return n.children || n.nodes || []; }
184
+
185
+ function makeNode(n) {
186
+ const li = document.createElement("li");
187
+ li.dataset.type = n.type || "";
188
+ li.dataset.qn = n.qualifiedName || "";
189
+ li.dataset.label = (n.label || n.qualifiedName || "").toLowerCase();
190
+
191
+ const kids = childrenOf(n);
192
+ const row = document.createElement("div");
193
+ row.className = "node";
194
+
195
+ const twist = document.createElement("span");
196
+ twist.className = "twist";
197
+ twist.textContent = kids.length ? "▶" : "";
198
+ row.appendChild(twist);
199
+
200
+ const ic = document.createElement("span");
201
+ ic.className = "icon";
202
+ ic.textContent = icon(n.type);
203
+ row.appendChild(ic);
204
+
205
+ const label = document.createElement("span");
206
+ label.className = "label";
207
+ label.textContent = n.label || n.qualifiedName || "(unnamed)";
208
+ row.appendChild(label);
209
+
210
+ if (kids.length) {
211
+ const c = document.createElement("span");
212
+ c.className = "count";
213
+ c.textContent = kids.length;
214
+ row.appendChild(c);
215
+ }
216
+
217
+ const hasDetail = n.qualifiedName && DETAILS[n.qualifiedName];
218
+ row.addEventListener("click", (ev) => {
219
+ ev.stopPropagation();
220
+ if (kids.length && ev.target === twist) { li.classList.toggle("collapsed"); return; }
221
+ if (kids.length && !hasDetail) { li.classList.toggle("collapsed"); return; }
222
+ if (hasDetail) selectNode(li, n);
223
+ else if (kids.length) li.classList.toggle("collapsed");
224
+ });
225
+
226
+ li.appendChild(row);
227
+ if (n.qualifiedName) byQn[n.qualifiedName] = li;
228
+
229
+ if (kids.length) {
230
+ const ul = document.createElement("ul");
231
+ kids.forEach((k) => ul.appendChild(makeNode(k)));
232
+ li.appendChild(ul);
233
+ if (n.type === "module" || n.type === "folder") li.classList.add("collapsed");
234
+ }
235
+ return li;
236
+ }
237
+
238
+ (Array.isArray(TREE) ? TREE : [TREE]).forEach((n) => nodes.appendChild(makeNode(n)));
239
+
240
+ function selectNode(li, n) {
241
+ if (selectedEl) selectedEl.classList.remove("selected");
242
+ selectedEl = li.querySelector(".node");
243
+ selectedEl.classList.add("selected");
244
+ renderDetail(n);
245
+ }
246
+
247
+ function selectByQn(qn) {
248
+ const li = byQn[qn];
249
+ if (!li) return;
250
+ // expand ancestors
251
+ let p = li.parentElement;
252
+ while (p && p !== nodes) {
253
+ if (p.tagName === "LI") p.classList.remove("collapsed");
254
+ p = p.parentElement;
255
+ }
256
+ li.querySelector(".node").click();
257
+ li.scrollIntoView({ block: "center" });
258
+ }
259
+ window.__selectByQn = selectByQn;
260
+
261
+ // ---- filtering -----------------------------------------------------
262
+ function applyFilter() {
263
+ const q = search.value.trim().toLowerCase();
264
+ const only = pagesOnly.checked;
265
+ function visit(li) {
266
+ const type = li.dataset.type;
267
+ const label = li.dataset.label;
268
+ const childLis = Array.from(li.children).filter((c) => c.tagName === "UL")
269
+ .flatMap((ul) => Array.from(ul.children));
270
+ let anyChild = false;
271
+ childLis.forEach((c) => { if (visit(c)) anyChild = true; });
272
+ const selfMatch = (!only || type === "page") && (!q || label.includes(q));
273
+ const show = selfMatch || anyChild;
274
+ li.classList.toggle("hidden", !show);
275
+ if (anyChild && (q || only)) li.classList.remove("collapsed");
276
+ return show;
277
+ }
278
+ Array.from(nodes.children).forEach(visit);
279
+ }
280
+ search.addEventListener("input", applyFilter);
281
+ pagesOnly.addEventListener("change", applyFilter);
282
+
283
+ // ---- detail rendering ---------------------------------------------
284
+ const el = (tag, cls, txt) => {
285
+ const e = document.createElement(tag);
286
+ if (cls) e.className = cls;
287
+ if (txt != null) e.textContent = txt;
288
+ return e;
289
+ };
290
+
291
+ // fields that are references to other elements (clickable)
292
+ const REF_KEYS = new Set(["microflow_calls", "nanoflow_calls", "page_links", "calls", "generalization"]);
293
+ const isQn = (s) => typeof s === "string" && /^[A-Za-z0-9_]+\.[A-Za-z0-9_]+/.test(s);
294
+
295
+ function refLink(qn) {
296
+ const a = el("a", "ref", qn);
297
+ a.addEventListener("click", () => window.__selectByQn(qn.split(/[\s(]/)[0]));
298
+ return a;
299
+ }
300
+
301
+ function renderScalar(v) {
302
+ if (typeof v === "string" && isQn(v) && DETAILS[v]) return refLink(v);
303
+ const span = el("span");
304
+ span.textContent = String(v);
305
+ return span;
306
+ }
307
+
308
+ function renderArray(key, arr) {
309
+ if (arr.length === 0) return el("p", "empty", "none");
310
+ const objs = arr.filter((x) => x && typeof x === "object" && !Array.isArray(x));
311
+ if (objs.length === arr.length) {
312
+ // table with the union of keys
313
+ const cols = [];
314
+ arr.forEach((o) => Object.keys(o).forEach((k) => { if (!cols.includes(k)) cols.push(k); }));
315
+ const table = el("table");
316
+ const thead = el("thead"); const htr = el("tr");
317
+ cols.forEach((c) => htr.appendChild(el("th", null, c)));
318
+ thead.appendChild(htr); table.appendChild(thead);
319
+ const tbody = el("tbody");
320
+ arr.forEach((o) => {
321
+ const tr = el("tr");
322
+ cols.forEach((c) => {
323
+ const td = el("td");
324
+ const v = o[c];
325
+ if (v == null) td.appendChild(el("span", "empty", "—"));
326
+ else if (typeof v === "object") td.appendChild(el("code", "inline", JSON.stringify(v)));
327
+ else td.appendChild(renderScalar(v));
328
+ tr.appendChild(td);
329
+ });
330
+ tbody.appendChild(tr);
331
+ });
332
+ table.appendChild(tbody);
333
+ return table;
334
+ }
335
+ // list of scalars
336
+ const ul = el("ul");
337
+ ul.style.margin = "0"; ul.style.paddingLeft = "18px";
338
+ arr.forEach((v) => {
339
+ const li = el("li");
340
+ li.appendChild(REF_KEYS.has(key) || isQn(v) ? renderScalar(v) : el("span", null, String(v)));
341
+ ul.appendChild(li);
342
+ });
343
+ return ul;
344
+ }
345
+
346
+ // ---- SVG helpers ---------------------------------------------------
347
+ const SVGNS = "http://www.w3.org/2000/svg";
348
+ const svg = (tag, attrs, txt) => {
349
+ const e = document.createElementNS(SVGNS, tag);
350
+ if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);
351
+ if (txt != null) e.textContent = txt;
352
+ return e;
353
+ };
354
+ const clip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : (s || ""));
355
+
356
+ // ---- microflow / nanoflow flowchart (parsed from MDL) --------------
357
+ // Best-effort structured parse of the MDL body into nodes + edges.
358
+ function parseFlow(mdl) {
359
+ const begin = mdl.indexOf("\nbegin");
360
+ if (begin === -1) return null;
361
+ let body = mdl.slice(begin + 6);
362
+ body = body.replace(/\nend;\s*$/, "\n");
363
+
364
+ const nodes = [];
365
+ const edges = [];
366
+ let counter = 0;
367
+ const add = (kind, label, pos) => {
368
+ const id = counter++;
369
+ nodes.push({ id, kind, label: clip(label, 46), x: pos ? pos.x : null, y: pos ? pos.y : null });
370
+ return id;
371
+ };
372
+ const startId = add("terminal", "Start", null);
373
+ let prevs = [{ id: startId, label: null }];
374
+ const stack = [];
375
+ let pos = null, caption = null;
376
+ const connect = (to) => prevs.forEach((p) => edges.push({ from: p.id, to, label: p.label }));
377
+ const kindOf = (t) => {
378
+ if (/^return\b/.test(t)) return "terminal";
379
+ if (/^validation\b/.test(t)) return "validation";
380
+ if (/\bcall\b/.test(t)) return "action";
381
+ if (/^(declare|set)\b/.test(t)) return "assign";
382
+ return "action";
383
+ };
384
+
385
+ // tokenize into logical statements, keeping if/else/end-if structure
386
+ const lines = body.split("\n");
387
+ let buf = "";
388
+ const flush = (raw) => {
389
+ const t = raw.trim();
390
+ if (!t) return;
391
+ if (/^if\b/.test(t)) {
392
+ const expr = t.replace(/^if\s+/, "").replace(/\s+then$/, "");
393
+ const id = add("decision", caption || expr, pos);
394
+ connect(id); pos = null; caption = null;
395
+ stack.push({ id, inElse: false, trueTails: [] });
396
+ prevs = [{ id, label: "true" }];
397
+ } else if (/^else$/.test(t)) {
398
+ const ctx = stack[stack.length - 1];
399
+ ctx.trueTails = prevs; ctx.inElse = true;
400
+ prevs = [{ id: ctx.id, label: "false" }];
401
+ } else if (/^end if;?$/.test(t)) {
402
+ const ctx = stack.pop();
403
+ const falseTails = ctx.inElse ? prevs : [{ id: ctx.id, label: "false" }];
404
+ const trueTails = ctx.inElse ? ctx.trueTails : prevs;
405
+ prevs = trueTails.concat(falseTails);
406
+ } else if (/^(begin|end)\b/.test(t) || t.startsWith("@")) {
407
+ // annotations handled separately; skip
408
+ } else {
409
+ const id = add(kindOf(t), caption || t, pos);
410
+ connect(id); pos = null; caption = null;
411
+ prevs = [{ id, label: null }];
412
+ }
413
+ };
414
+
415
+ lines.forEach((line) => {
416
+ const l = line.trim();
417
+ const mp = l.match(/@position\((-?\d+),\s*(-?\d+)\)/);
418
+ if (mp) { pos = { x: +mp[1], y: +mp[2] }; return; }
419
+ const mc = l.match(/@caption\s+'([^']*)'/);
420
+ if (mc) { caption = mc[1]; return; }
421
+ if (l.startsWith("@")) return; // other annotations
422
+ // accumulate until a statement terminator (; or `then`) or control kw
423
+ buf += (buf ? " " : "") + l;
424
+ if (/;$/.test(l) || /\bthen$/.test(l) || /^else$/.test(l) || /^end if;?$/.test(l) ||
425
+ /^if\b.*\bthen$/.test(buf)) {
426
+ flush(buf); buf = "";
427
+ }
428
+ });
429
+ if (buf.trim()) flush(buf);
430
+ return { nodes, edges };
431
+ }
432
+
433
+ function renderFlow(mdl) {
434
+ const g = parseFlow(mdl);
435
+ if (!g || !g.nodes.length) return null;
436
+ const W = 168, H = 46, PADX = 26, PADY = 22;
437
+ // place nodes lacking @position by inheriting from their predecessor
438
+ const byId = Object.fromEntries(g.nodes.map((n) => [n.id, n]));
439
+ const posNodes = g.nodes.filter((n) => n.x != null);
440
+ const baseX = posNodes.length ? Math.round(posNodes.reduce((s, n) => s + n.x, 0) / posNodes.length) : 0;
441
+ const topY = posNodes.length ? Math.min(...posNodes.map((n) => n.y)) : 0;
442
+ if (byId[0] && byId[0].x == null) { byId[0].x = baseX; byId[0].y = topY - 110; }
443
+ for (let pass = 0; pass < 5; pass++) {
444
+ g.edges.forEach((e) => {
445
+ const to = byId[e.to], from = byId[e.from];
446
+ if (to && to.x == null && from && from.x != null) { to.x = from.x; to.y = from.y + 110; }
447
+ });
448
+ }
449
+ let autoY = topY;
450
+ g.nodes.forEach((n) => { if (n.x == null) { n.x = baseX + 230; n.y = autoY; autoY += 90; } });
451
+ const xs = g.nodes.map((n) => n.x), ys = g.nodes.map((n) => n.y);
452
+ const minX = Math.min(...xs), minY = Math.min(...ys);
453
+ const maxX = Math.max(...xs), maxY = Math.max(...ys);
454
+ const sx = 0.42, sy = 0.62; // Mendix coords are roomy; compress
455
+ const px = (x) => (x - minX) * sx + PADX;
456
+ const py = (y) => (y - minY) * sy + PADY;
457
+ const width = (maxX - minX) * sx + W + PADX * 2;
458
+ const height = (maxY - minY) * sy + H + PADY * 2;
459
+ const pos = {}; g.nodes.forEach((n) => { pos[n.id] = { x: px(n.x), y: py(n.y) }; });
460
+
461
+ const s = svg("svg", { width, height, viewBox: `0 0 ${width} ${height}` });
462
+ const defs = svg("defs");
463
+ const marker = svg("marker", { id: "arrow", markerWidth: 8, markerHeight: 8,
464
+ refX: 7, refY: 3, orient: "auto", markerUnits: "strokeWidth" });
465
+ marker.appendChild(svg("path", { d: "M0,0 L7,3 L0,6 Z", fill: "#6a7a94" }));
466
+ defs.appendChild(marker); s.appendChild(defs);
467
+
468
+ g.edges.forEach((e) => {
469
+ const a = pos[e.from], b = pos[e.to];
470
+ if (!a || !b) return;
471
+ const x1 = a.x + W / 2, y1 = a.y + H, x2 = b.x + W / 2, y2 = b.y;
472
+ const my = (y1 + y2) / 2;
473
+ const d = `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`;
474
+ s.appendChild(svg("path", { class: "edge", d, "marker-end": "url(#arrow)" }));
475
+ if (e.label) s.appendChild(svg("text", { class: "edge-label", x: (x1 + x2) / 2 + 4, y: my - 2 }, e.label));
476
+ });
477
+ g.nodes.forEach((n) => {
478
+ const p = pos[n.id];
479
+ const grp = svg("g", { class: "nodeg" });
480
+ const cls = "box " + (n.kind === "decision" ? "decision" : n.kind === "terminal" ? "terminal"
481
+ : n.kind === "validation" ? "validation" : "");
482
+ const r = n.kind === "terminal" ? H / 2 : 6;
483
+ grp.appendChild(svg("rect", { class: cls, x: p.x, y: p.y, width: W, height: H, rx: r, ry: r }));
484
+ grp.appendChild(svg("text", { x: p.x + W / 2, y: p.y + H / 2 + 4, "text-anchor": "middle" }, n.label));
485
+ s.appendChild(grp);
486
+ });
487
+ const wrap = el("div", "diagram");
488
+ wrap.appendChild(s);
489
+ return wrap;
490
+ }
491
+
492
+ // ---- domain ER diagram (selected entity + direct neighbours) -------
493
+ const ASSOCS = Object.entries(DETAILS)
494
+ .filter(([, v]) => v && v.from && v.to)
495
+ .map(([qn, v]) => ({ qn, from: v.from, to: v.to, type: v.association_type, owner: v.owner }));
496
+
497
+ function entityBox(qn, x, y) {
498
+ const d = DETAILS[qn] || {};
499
+ const attrs = Array.isArray(d.attributes) ? d.attributes : [];
500
+ const shown = attrs.slice(0, 8);
501
+ const name = qn.split(".").pop();
502
+ const W = 190, HEAD = 26, ROW = 16;
503
+ const h = HEAD + Math.max(shown.length, 1) * ROW + 6;
504
+ const g = svg("g", { class: "nodeg" });
505
+ g.setAttribute("data-qn", qn);
506
+ g.appendChild(svg("rect", { class: "box entity-body", x, y, width: W, height: h, rx: 6, ry: 6 }));
507
+ g.appendChild(svg("rect", { class: "box entity-head", x, y, width: W, height: HEAD, rx: 6, ry: 6 }));
508
+ g.appendChild(svg("text", { x: x + 9, y: y + 17, style: "font-weight:600" }, clip(name, 24)));
509
+ shown.forEach((a, i) => {
510
+ const label = (a.name || a.Name || "?") + (a.type ? " : " + a.type : "");
511
+ g.appendChild(svg("text", { class: "attr", x: x + 9, y: y + HEAD + 13 + i * ROW }, clip(label, 30)));
512
+ });
513
+ if (attrs.length > shown.length) {
514
+ g.appendChild(svg("text", { class: "cap", x: x + 9, y: y + h - 4 }, "+" + (attrs.length - shown.length) + " more"));
515
+ }
516
+ return { g, w: W, h };
517
+ }
518
+
519
+ function renderER(centerQn) {
520
+ const related = ASSOCS.filter((a) => a.from === centerQn || a.to === centerQn);
521
+ if (!related.length && !DETAILS[centerQn]) return null;
522
+ const neighbours = [];
523
+ related.forEach((a) => {
524
+ const other = a.from === centerQn ? a.to : a.from;
525
+ if (other !== centerQn && !neighbours.includes(other)) neighbours.push(other);
526
+ });
527
+
528
+ const colX = [30, 320, 610];
529
+ const layout = {}; // qn -> {x,y}
530
+ layout[centerQn] = { col: 1, i: 0 };
531
+ let li = 0, ri = 0;
532
+ neighbours.forEach((qn, idx) => {
533
+ if (idx % 2 === 0) layout[qn] = { col: 0, i: li++ };
534
+ else layout[qn] = { col: 2, i: ri++ };
535
+ });
536
+ const boxes = {};
537
+ const wrap = el("div", "diagram");
538
+ const s = svg("svg", {});
539
+ const defs = svg("defs");
540
+ const m = svg("marker", { id: "erarrow", markerWidth: 9, markerHeight: 9, refX: 8, refY: 3,
541
+ orient: "auto", markerUnits: "strokeWidth" });
542
+ m.appendChild(svg("path", { d: "M0,0 L8,3 L0,6 Z", fill: "#6a7a94" }));
543
+ defs.appendChild(m); s.appendChild(defs);
544
+
545
+ let maxBottom = 0, maxRight = 0;
546
+ const place = (qn) => {
547
+ const lo = layout[qn]; if (!lo || boxes[qn]) return;
548
+ const x = colX[lo.col];
549
+ const y = 20 + lo.i * 130;
550
+ const b = entityBox(qn, x, y);
551
+ b.x = x; b.y = y; b.cx = x + b.w / 2; b.cy = y + b.h / 2;
552
+ boxes[qn] = b;
553
+ maxBottom = Math.max(maxBottom, y + b.h);
554
+ maxRight = Math.max(maxRight, x + b.w);
555
+ };
556
+ place(centerQn); neighbours.forEach(place);
557
+
558
+ related.forEach((a) => {
559
+ const from = boxes[a.from], to = boxes[a.to];
560
+ if (!from || !to) return;
561
+ const x1 = from.cx, y1 = from.cy, x2 = to.cx, y2 = to.cy;
562
+ s.appendChild(svg("line", { class: "edge", x1, y1, x2, y2, "marker-end": "url(#erarrow)" }));
563
+ const lbl = (a.type === "ReferenceSet" ? "* " : "1 ") + (a.qn.split(".").pop());
564
+ s.appendChild(svg("text", { class: "edge-label", x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 3, "text-anchor": "middle" }, clip(lbl, 28)));
565
+ });
566
+ Object.values(boxes).forEach((b) => s.appendChild(b.g));
567
+
568
+ const width = Math.max(maxRight + 30, 640), height = maxBottom + 30;
569
+ s.setAttribute("width", width); s.setAttribute("height", height);
570
+ s.setAttribute("viewBox", `0 0 ${width} ${height}`);
571
+ // clicking a neighbour navigates to it
572
+ s.addEventListener("click", (ev) => {
573
+ const g = ev.target.closest("g[data-qn]");
574
+ if (g && g.getAttribute("data-qn") !== centerQn) window.__selectByQn(g.getAttribute("data-qn"));
575
+ });
576
+ wrap.appendChild(s);
577
+ return wrap;
578
+ }
579
+
580
+ function buildDiagram(n, d) {
581
+ const t = n.type;
582
+ if ((t === "microflow" || t === "nanoflow") && d.mdl) return renderFlow(d.mdl);
583
+ if (t === "entity") return renderER(n.qualifiedName);
584
+ return null;
585
+ }
586
+
587
+ function block(title, node) {
588
+ const s = el("section", "block");
589
+ s.appendChild(el("h3", null, title.replace(/_/g, " ")));
590
+ const body = el("div", "body");
591
+ body.appendChild(node);
592
+ s.appendChild(body);
593
+ return s;
594
+ }
595
+
596
+ function renderDetail(n) {
597
+ const qn = n.qualifiedName;
598
+ const d = DETAILS[qn] || {};
599
+ detail.innerHTML = "";
600
+
601
+ const h = el("h2", null, n.label || qn);
602
+ detail.appendChild(h);
603
+ const sub = el("div", "qn");
604
+ sub.textContent = (n.type || "element") + " · " + qn;
605
+ if (d.parse_status) {
606
+ const p = el("span", "pill " + (d.parse_status === "parsed" ? "ok" : "warn"), d.parse_status);
607
+ sub.appendChild(p);
608
+ }
609
+ detail.appendChild(sub);
610
+
611
+ // visual diagram first (flowchart for flows, ER for entities)
612
+ const diagram = buildDiagram(n, d);
613
+ if (diagram) {
614
+ const label = (n.type === "entity") ? "Domain (neighbourhood)" : "Flowchart";
615
+ detail.appendChild(block(label, diagram));
616
+ }
617
+
618
+ // scalar summary (short string/number/bool fields) as a key/value grid
619
+ const scalars = Object.entries(d).filter(([k, v]) =>
620
+ k !== "parse_status" && (typeof v !== "object") && !(typeof v === "string" && v.length > 120));
621
+ if (scalars.length) {
622
+ const dl = el("dl", "kv");
623
+ scalars.forEach(([k, v]) => {
624
+ dl.appendChild(el("dt", null, k.replace(/_/g, " ")));
625
+ const dd = el("dd");
626
+ dd.appendChild(renderScalar(v));
627
+ dl.appendChild(dd);
628
+ });
629
+ detail.appendChild(block("summary", dl));
630
+ }
631
+
632
+ // everything else, in a stable, page-friendly order
633
+ const ORDER = ["parameters", "widget_types", "widgets", "data_sources", "attributes",
634
+ "actions", "microflow_calls", "nanoflow_calls", "page_links", "view_roles",
635
+ "access_rules", "execute_roles", "variables", "activities", "calls", "generalization"];
636
+ const keys = Object.keys(d).filter((k) =>
637
+ !scalars.some(([sk]) => sk === k) && k !== "parse_status" && k !== "mdl");
638
+ keys.sort((a, b) => {
639
+ const ia = ORDER.indexOf(a), ib = ORDER.indexOf(b);
640
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
641
+ });
642
+
643
+ keys.forEach((k) => {
644
+ const v = d[k];
645
+ let node;
646
+ if (Array.isArray(v)) node = renderArray(k, v);
647
+ else if (v && typeof v === "object") {
648
+ const dl = el("dl", "kv");
649
+ Object.entries(v).forEach(([kk, vv]) => {
650
+ dl.appendChild(el("dt", null, kk));
651
+ const dd = el("dd");
652
+ dd.appendChild(renderScalar(vv));
653
+ dl.appendChild(dd);
654
+ });
655
+ node = dl;
656
+ } else node = renderScalar(v);
657
+ detail.appendChild(block(k, node));
658
+ });
659
+
660
+ if (d.mdl) detail.appendChild(block("MDL", el("pre", null, d.mdl)));
661
+
662
+ if (!Object.keys(d).length) {
663
+ detail.appendChild(el("p", "empty", "This node is a container; it has no element details."));
664
+ }
665
+ }
666
+ })();
667
+ </script>
668
+ </body>
669
+ </html>
670
+ HTML
671
+ end
672
+ end