Module: MendixBridge::HtmlViewer
- Defined in:
- lib/mendix_bridge/html_viewer.rb
Overview
Generates a self-contained HTML viewer for an imported Mendix inventory.
The viewer embeds the project tree and per-element details as JSON, so the
produced file works offline from a plain file:// URL with no server. It is a
read-only view over the artifacts written by mendix-ruby import.
Constant Summary collapse
- TREE_FILE =
File.join("inventory", "project-tree.json")
- DETAILS_FILE =
File.join("inventory", "element-details.json")
- METADATA_FILE =
"mendix-project.json"- TEMPLATE =
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Mendix Inventory Viewer</title>\n<style>\n :root {\n --bg: #0f1420; --panel: #161d2b; --panel2: #1d2636; --line: #2a3547;\n --fg: #d7e0ee; --muted: #8595ac; --accent: #6ea8fe; --accent2: #4ade80;\n --chip: #24304a; --code: #0c1018;\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg); }\n header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;\n padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }\n header h1 { font-size: 15px; margin: 0; font-weight: 600; }\n header .meta { color: var(--muted); font-size: 12px; }\n header .spacer { flex: 1; }\n header input[type=search] { background: var(--panel2); border: 1px solid var(--line);\n color: var(--fg); padding: 6px 10px; border-radius: 6px; width: 240px; }\n header label { color: var(--muted); font-size: 12px; user-select: none; cursor: pointer; }\n .layout { display: flex; height: calc(100vh - 49px); }\n aside { width: 380px; min-width: 240px; overflow: auto; border-right: 1px solid var(--line);\n background: var(--panel); padding: 8px 0; resize: horizontal; }\n main { flex: 1; overflow: auto; padding: 20px 28px; }\n ul.tree { list-style: none; margin: 0; padding: 0; }\n ul.tree ul { list-style: none; margin: 0; padding-left: 16px; }\n .node { display: flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 5px;\n cursor: pointer; white-space: nowrap; }\n .node:hover { background: var(--panel2); }\n .node.selected { background: #23406b; }\n .node .twist { width: 12px; color: var(--muted); font-size: 10px; flex: none; }\n .node .icon { flex: none; }\n .node .label { overflow: hidden; text-overflow: ellipsis; }\n .node .count { color: var(--muted); font-size: 11px; }\n .collapsed > ul { display: none; }\n .badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px;\n background: var(--chip); color: var(--muted); }\n .hidden { display: none !important; }\n main h2 { margin: 0 0 2px; font-size: 20px; }\n main .qn { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; margin-bottom: 18px; }\n section.block { background: var(--panel); border: 1px solid var(--line); border-radius: 8px;\n margin-bottom: 16px; overflow: hidden; }\n section.block > h3 { margin: 0; padding: 8px 14px; font-size: 12px; text-transform: uppercase;\n letter-spacing: .04em; color: var(--muted); background: var(--panel2); border-bottom: 1px solid var(--line); }\n section.block .body { padding: 12px 14px; }\n table { border-collapse: collapse; width: 100%; font-size: 13px; }\n th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }\n th { color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; }\n tr:last-child td { border-bottom: none; }\n pre { margin: 0; padding: 12px 14px; background: var(--code); border-radius: 6px; overflow: auto;\n font: 12px/1.5 ui-monospace, monospace; color: #cbd5e1; }\n .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }\n .kv dt { color: var(--muted); }\n .kv dd { margin: 0; }\n code.inline { background: var(--code); padding: 1px 5px; border-radius: 4px;\n font: 12px ui-monospace, monospace; }\n .empty { color: var(--muted); font-style: italic; }\n .pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; background: var(--chip); margin-left: 6px; }\n .pill.ok { background: #14351f; color: var(--accent2); }\n .pill.warn { background: #3a2a12; color: #fbbf24; }\n a.ref { color: var(--accent); cursor: pointer; text-decoration: none; }\n a.ref:hover { text-decoration: underline; }\n .diagram { width: 100%; overflow: auto; background: var(--code); border-radius: 6px; }\n .diagram svg { display: block; }\n .diagram .box { fill: var(--panel2); stroke: var(--line); stroke-width: 1; }\n .diagram .box.decision { fill: #2a2340; stroke: #6d5bd0; }\n .diagram .box.terminal { fill: #14351f; stroke: #2f7d4d; }\n .diagram .box.validation { fill: #3a2a12; stroke: #b5851f; }\n .diagram .box.entity-head { fill: #23406b; stroke: #3a5ea8; }\n .diagram .box.entity-body { fill: var(--panel2); stroke: #3a5ea8; }\n .diagram text { fill: var(--fg); font: 11px system-ui, sans-serif; }\n .diagram text.cap { fill: var(--muted); }\n .diagram text.attr { fill: #cbd5e1; font: 10px ui-monospace, monospace; }\n .diagram .edge { stroke: #6a7a94; stroke-width: 1.4; fill: none; }\n .diagram .edge-label { fill: var(--muted); font: 10px system-ui; }\n .diagram .nodeg { cursor: pointer; }\n .diagram .nodeg:hover .box { stroke: var(--accent); }\n</style>\n</head>\n<body>\n<header>\n <h1>Mendix Inventory Viewer</h1>\n <span class=\"meta\" id=\"meta\"></span>\n <span class=\"spacer\"></span>\n <input type=\"search\" id=\"search\" placeholder=\"Filter by name\u2026\" autocomplete=\"off\">\n <label><input type=\"checkbox\" id=\"pagesOnly\"> Pages only</label>\n</header>\n<div class=\"layout\">\n <aside><ul class=\"tree\" id=\"tree\"></ul></aside>\n <main id=\"detail\"><p class=\"empty\">Select an element on the left to see its details.</p></main>\n</div>\n\n<script type=\"application/json\" id=\"data-tree\">__TREE_JSON__</script>\n<script type=\"application/json\" id=\"data-details\">__DETAILS_JSON__</script>\n<script type=\"application/json\" id=\"data-meta\">__META_JSON__</script>\n<script>\n(function () {\n const TREE = JSON.parse(document.getElementById(\"data-tree\").textContent);\n const DETAILS = JSON.parse(document.getElementById(\"data-details\").textContent);\n const META = JSON.parse(document.getElementById(\"data-meta\").textContent);\n\n const ICONS = {\n module: \"\u{1F4E6}\", folder: \"\u{1F4C1}\", page: \"\u{1F4C4}\", entity: \"\u{1F5C4}\uFE0F\", association: \"\u{1F517}\",\n microflow: \"\u2699\uFE0F\", nanoflow: \"\u{1F527}\", enumeration: \"\u{1F524}\", layout: \"\u{1F9E9}\", snippet: \"\u2702\uFE0F\",\n domainmodel: \"\u{1F5C2}\uFE0F\", security: \"\u{1F512}\", projectsecurity: \"\u{1F512}\", userrole: \"\u{1F464}\",\n modulerole: \"\u{1F464}\", navigation: \"\u{1F9ED}\", settings: \"\u2699\uFE0F\", javascriptaction: \"\u{1F7E8}\",\n javaaction: \"\u2615\", buildingblock: \"\u{1F9F1}\", pagetemplate: \"\u{1F4D1}\", imagecollection: \"\u{1F5BC}\uFE0F\",\n constant: \"\u{1F522}\", jsonstructure: \"{}\", importmapping: \"\u2B07\uFE0F\", exportmapping: \"\u2B06\uFE0F\",\n systemoverview: \"\u{1F310}\", workflow: \"\u{1F500}\"\n };\n const icon = (t) => ICONS[t] || \"\u2022\";\n\n const nodes = document.getElementById(\"tree\");\n const detail = document.getElementById(\"detail\");\n const search = document.getElementById(\"search\");\n const pagesOnly = document.getElementById(\"pagesOnly\");\n\n // ---- metadata line -------------------------------------------------\n const metaBits = [];\n if (META.source_project) metaBits.push(META.source_project.split(\"/\").pop());\n if (META.imported_at) metaBits.push(\"imported \" + META.imported_at);\n if (META.element_count) metaBits.push(META.element_count + \" elements\");\n document.getElementById(\"meta\").textContent = metaBits.join(\" \u00B7 \");\n\n // ---- tree building -------------------------------------------------\n let selectedEl = null;\n const byQn = {}; // qualifiedName -> node <li>\n\n function childrenOf(n) { return n.children || n.nodes || []; }\n\n function makeNode(n) {\n const li = document.createElement(\"li\");\n li.dataset.type = n.type || \"\";\n li.dataset.qn = n.qualifiedName || \"\";\n li.dataset.label = (n.label || n.qualifiedName || \"\").toLowerCase();\n\n const kids = childrenOf(n);\n const row = document.createElement(\"div\");\n row.className = \"node\";\n\n const twist = document.createElement(\"span\");\n twist.className = \"twist\";\n twist.textContent = kids.length ? \"\u25B6\" : \"\";\n row.appendChild(twist);\n\n const ic = document.createElement(\"span\");\n ic.className = \"icon\";\n ic.textContent = icon(n.type);\n row.appendChild(ic);\n\n const label = document.createElement(\"span\");\n label.className = \"label\";\n label.textContent = n.label || n.qualifiedName || \"(unnamed)\";\n row.appendChild(label);\n\n if (kids.length) {\n const c = document.createElement(\"span\");\n c.className = \"count\";\n c.textContent = kids.length;\n row.appendChild(c);\n }\n\n const hasDetail = n.qualifiedName && DETAILS[n.qualifiedName];\n row.addEventListener(\"click\", (ev) => {\n ev.stopPropagation();\n if (kids.length && ev.target === twist) { li.classList.toggle(\"collapsed\"); return; }\n if (kids.length && !hasDetail) { li.classList.toggle(\"collapsed\"); return; }\n if (hasDetail) selectNode(li, n);\n else if (kids.length) li.classList.toggle(\"collapsed\");\n });\n\n li.appendChild(row);\n if (n.qualifiedName) byQn[n.qualifiedName] = li;\n\n if (kids.length) {\n const ul = document.createElement(\"ul\");\n kids.forEach((k) => ul.appendChild(makeNode(k)));\n li.appendChild(ul);\n if (n.type === \"module\" || n.type === \"folder\") li.classList.add(\"collapsed\");\n }\n return li;\n }\n\n (Array.isArray(TREE) ? TREE : [TREE]).forEach((n) => nodes.appendChild(makeNode(n)));\n\n function selectNode(li, n) {\n if (selectedEl) selectedEl.classList.remove(\"selected\");\n selectedEl = li.querySelector(\".node\");\n selectedEl.classList.add(\"selected\");\n renderDetail(n);\n }\n\n function selectByQn(qn) {\n const li = byQn[qn];\n if (!li) return;\n // expand ancestors\n let p = li.parentElement;\n while (p && p !== nodes) {\n if (p.tagName === \"LI\") p.classList.remove(\"collapsed\");\n p = p.parentElement;\n }\n li.querySelector(\".node\").click();\n li.scrollIntoView({ block: \"center\" });\n }\n window.__selectByQn = selectByQn;\n\n // ---- filtering -----------------------------------------------------\n function applyFilter() {\n const q = search.value.trim().toLowerCase();\n const only = pagesOnly.checked;\n function visit(li) {\n const type = li.dataset.type;\n const label = li.dataset.label;\n const childLis = Array.from(li.children).filter((c) => c.tagName === \"UL\")\n .flatMap((ul) => Array.from(ul.children));\n let anyChild = false;\n childLis.forEach((c) => { if (visit(c)) anyChild = true; });\n const selfMatch = (!only || type === \"page\") && (!q || label.includes(q));\n const show = selfMatch || anyChild;\n li.classList.toggle(\"hidden\", !show);\n if (anyChild && (q || only)) li.classList.remove(\"collapsed\");\n return show;\n }\n Array.from(nodes.children).forEach(visit);\n }\n search.addEventListener(\"input\", applyFilter);\n pagesOnly.addEventListener(\"change\", applyFilter);\n\n // ---- detail rendering ---------------------------------------------\n const el = (tag, cls, txt) => {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (txt != null) e.textContent = txt;\n return e;\n };\n\n // fields that are references to other elements (clickable)\n const REF_KEYS = new Set([\"microflow_calls\", \"nanoflow_calls\", \"page_links\", \"calls\", \"generalization\"]);\n const isQn = (s) => typeof s === \"string\" && /^[A-Za-z0-9_]+\\.[A-Za-z0-9_]+/.test(s);\n\n function refLink(qn) {\n const a = el(\"a\", \"ref\", qn);\n a.addEventListener(\"click\", () => window.__selectByQn(qn.split(/[\\s(]/)[0]));\n return a;\n }\n\n function renderScalar(v) {\n if (typeof v === \"string\" && isQn(v) && DETAILS[v]) return refLink(v);\n const span = el(\"span\");\n span.textContent = String(v);\n return span;\n }\n\n function renderArray(key, arr) {\n if (arr.length === 0) return el(\"p\", \"empty\", \"none\");\n const objs = arr.filter((x) => x && typeof x === \"object\" && !Array.isArray(x));\n if (objs.length === arr.length) {\n // table with the union of keys\n const cols = [];\n arr.forEach((o) => Object.keys(o).forEach((k) => { if (!cols.includes(k)) cols.push(k); }));\n const table = el(\"table\");\n const thead = el(\"thead\"); const htr = el(\"tr\");\n cols.forEach((c) => htr.appendChild(el(\"th\", null, c)));\n thead.appendChild(htr); table.appendChild(thead);\n const tbody = el(\"tbody\");\n arr.forEach((o) => {\n const tr = el(\"tr\");\n cols.forEach((c) => {\n const td = el(\"td\");\n const v = o[c];\n if (v == null) td.appendChild(el(\"span\", \"empty\", \"\u2014\"));\n else if (typeof v === \"object\") td.appendChild(el(\"code\", \"inline\", JSON.stringify(v)));\n else td.appendChild(renderScalar(v));\n tr.appendChild(td);\n });\n tbody.appendChild(tr);\n });\n table.appendChild(tbody);\n return table;\n }\n // list of scalars\n const ul = el(\"ul\");\n ul.style.margin = \"0\"; ul.style.paddingLeft = \"18px\";\n arr.forEach((v) => {\n const li = el(\"li\");\n li.appendChild(REF_KEYS.has(key) || isQn(v) ? renderScalar(v) : el(\"span\", null, String(v)));\n ul.appendChild(li);\n });\n return ul;\n }\n\n // ---- SVG helpers ---------------------------------------------------\n const SVGNS = \"http://www.w3.org/2000/svg\";\n const svg = (tag, attrs, txt) => {\n const e = document.createElementNS(SVGNS, tag);\n if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]);\n if (txt != null) e.textContent = txt;\n return e;\n };\n const clip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + \"\u2026\" : (s || \"\"));\n\n // ---- microflow / nanoflow flowchart (parsed from MDL) --------------\n // Best-effort structured parse of the MDL body into nodes + edges.\n function parseFlow(mdl) {\n const begin = mdl.indexOf(\"\\nbegin\");\n if (begin === -1) return null;\n let body = mdl.slice(begin + 6);\n body = body.replace(/\\nend;\\s*$/, \"\\n\");\n\n const nodes = [];\n const edges = [];\n let counter = 0;\n const add = (kind, label, pos) => {\n const id = counter++;\n nodes.push({ id, kind, label: clip(label, 46), x: pos ? pos.x : null, y: pos ? pos.y : null });\n return id;\n };\n const startId = add(\"terminal\", \"Start\", null);\n let prevs = [{ id: startId, label: null }];\n const stack = [];\n let pos = null, caption = null;\n const connect = (to) => prevs.forEach((p) => edges.push({ from: p.id, to, label: p.label }));\n const kindOf = (t) => {\n if (/^return\\b/.test(t)) return \"terminal\";\n if (/^validation\\b/.test(t)) return \"validation\";\n if (/\\bcall\\b/.test(t)) return \"action\";\n if (/^(declare|set)\\b/.test(t)) return \"assign\";\n return \"action\";\n };\n\n // tokenize into logical statements, keeping if/else/end-if structure\n const lines = body.split(\"\\n\");\n let buf = \"\";\n const flush = (raw) => {\n const t = raw.trim();\n if (!t) return;\n if (/^if\\b/.test(t)) {\n const expr = t.replace(/^if\\s+/, \"\").replace(/\\s+then$/, \"\");\n const id = add(\"decision\", caption || expr, pos);\n connect(id); pos = null; caption = null;\n stack.push({ id, inElse: false, trueTails: [] });\n prevs = [{ id, label: \"true\" }];\n } else if (/^else$/.test(t)) {\n const ctx = stack[stack.length - 1];\n ctx.trueTails = prevs; ctx.inElse = true;\n prevs = [{ id: ctx.id, label: \"false\" }];\n } else if (/^end if;?$/.test(t)) {\n const ctx = stack.pop();\n const falseTails = ctx.inElse ? prevs : [{ id: ctx.id, label: \"false\" }];\n const trueTails = ctx.inElse ? ctx.trueTails : prevs;\n prevs = trueTails.concat(falseTails);\n } else if (/^(begin|end)\\b/.test(t) || t.startsWith(\"@\")) {\n // annotations handled separately; skip\n } else {\n const id = add(kindOf(t), caption || t, pos);\n connect(id); pos = null; caption = null;\n prevs = [{ id, label: null }];\n }\n };\n\n lines.forEach((line) => {\n const l = line.trim();\n const mp = l.match(/@position\\((-?\\d+),\\s*(-?\\d+)\\)/);\n if (mp) { pos = { x: +mp[1], y: +mp[2] }; return; }\n const mc = l.match(/@caption\\s+'([^']*)'/);\n if (mc) { caption = mc[1]; return; }\n if (l.startsWith(\"@\")) return; // other annotations\n // accumulate until a statement terminator (; or `then`) or control kw\n buf += (buf ? \" \" : \"\") + l;\n if (/;$/.test(l) || /\\bthen$/.test(l) || /^else$/.test(l) || /^end if;?$/.test(l) ||\n /^if\\b.*\\bthen$/.test(buf)) {\n flush(buf); buf = \"\";\n }\n });\n if (buf.trim()) flush(buf);\n return { nodes, edges };\n }\n\n function renderFlow(mdl) {\n const g = parseFlow(mdl);\n if (!g || !g.nodes.length) return null;\n const W = 168, H = 46, PADX = 26, PADY = 22;\n // place nodes lacking @position by inheriting from their predecessor\n const byId = Object.fromEntries(g.nodes.map((n) => [n.id, n]));\n const posNodes = g.nodes.filter((n) => n.x != null);\n const baseX = posNodes.length ? Math.round(posNodes.reduce((s, n) => s + n.x, 0) / posNodes.length) : 0;\n const topY = posNodes.length ? Math.min(...posNodes.map((n) => n.y)) : 0;\n if (byId[0] && byId[0].x == null) { byId[0].x = baseX; byId[0].y = topY - 110; }\n for (let pass = 0; pass < 5; pass++) {\n g.edges.forEach((e) => {\n const to = byId[e.to], from = byId[e.from];\n if (to && to.x == null && from && from.x != null) { to.x = from.x; to.y = from.y + 110; }\n });\n }\n let autoY = topY;\n g.nodes.forEach((n) => { if (n.x == null) { n.x = baseX + 230; n.y = autoY; autoY += 90; } });\n const xs = g.nodes.map((n) => n.x), ys = g.nodes.map((n) => n.y);\n const minX = Math.min(...xs), minY = Math.min(...ys);\n const maxX = Math.max(...xs), maxY = Math.max(...ys);\n const sx = 0.42, sy = 0.62; // Mendix coords are roomy; compress\n const px = (x) => (x - minX) * sx + PADX;\n const py = (y) => (y - minY) * sy + PADY;\n const width = (maxX - minX) * sx + W + PADX * 2;\n const height = (maxY - minY) * sy + H + PADY * 2;\n const pos = {}; g.nodes.forEach((n) => { pos[n.id] = { x: px(n.x), y: py(n.y) }; });\n\n const s = svg(\"svg\", { width, height, viewBox: `0 0 ${width} ${height}` });\n const defs = svg(\"defs\");\n const marker = svg(\"marker\", { id: \"arrow\", markerWidth: 8, markerHeight: 8,\n refX: 7, refY: 3, orient: \"auto\", markerUnits: \"strokeWidth\" });\n marker.appendChild(svg(\"path\", { d: \"M0,0 L7,3 L0,6 Z\", fill: \"#6a7a94\" }));\n defs.appendChild(marker); s.appendChild(defs);\n\n g.edges.forEach((e) => {\n const a = pos[e.from], b = pos[e.to];\n if (!a || !b) return;\n const x1 = a.x + W / 2, y1 = a.y + H, x2 = b.x + W / 2, y2 = b.y;\n const my = (y1 + y2) / 2;\n const d = `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`;\n s.appendChild(svg(\"path\", { class: \"edge\", d, \"marker-end\": \"url(#arrow)\" }));\n if (e.label) s.appendChild(svg(\"text\", { class: \"edge-label\", x: (x1 + x2) / 2 + 4, y: my - 2 }, e.label));\n });\n g.nodes.forEach((n) => {\n const p = pos[n.id];\n const grp = svg(\"g\", { class: \"nodeg\" });\n const cls = \"box \" + (n.kind === \"decision\" ? \"decision\" : n.kind === \"terminal\" ? \"terminal\"\n : n.kind === \"validation\" ? \"validation\" : \"\");\n const r = n.kind === \"terminal\" ? H / 2 : 6;\n grp.appendChild(svg(\"rect\", { class: cls, x: p.x, y: p.y, width: W, height: H, rx: r, ry: r }));\n grp.appendChild(svg(\"text\", { x: p.x + W / 2, y: p.y + H / 2 + 4, \"text-anchor\": \"middle\" }, n.label));\n s.appendChild(grp);\n });\n const wrap = el(\"div\", \"diagram\");\n wrap.appendChild(s);\n return wrap;\n }\n\n // ---- domain ER diagram (selected entity + direct neighbours) -------\n const ASSOCS = Object.entries(DETAILS)\n .filter(([, v]) => v && v.from && v.to)\n .map(([qn, v]) => ({ qn, from: v.from, to: v.to, type: v.association_type, owner: v.owner }));\n\n function entityBox(qn, x, y) {\n const d = DETAILS[qn] || {};\n const attrs = Array.isArray(d.attributes) ? d.attributes : [];\n const shown = attrs.slice(0, 8);\n const name = qn.split(\".\").pop();\n const W = 190, HEAD = 26, ROW = 16;\n const h = HEAD + Math.max(shown.length, 1) * ROW + 6;\n const g = svg(\"g\", { class: \"nodeg\" });\n g.setAttribute(\"data-qn\", qn);\n g.appendChild(svg(\"rect\", { class: \"box entity-body\", x, y, width: W, height: h, rx: 6, ry: 6 }));\n g.appendChild(svg(\"rect\", { class: \"box entity-head\", x, y, width: W, height: HEAD, rx: 6, ry: 6 }));\n g.appendChild(svg(\"text\", { x: x + 9, y: y + 17, style: \"font-weight:600\" }, clip(name, 24)));\n shown.forEach((a, i) => {\n const label = (a.name || a.Name || \"?\") + (a.type ? \" : \" + a.type : \"\");\n g.appendChild(svg(\"text\", { class: \"attr\", x: x + 9, y: y + HEAD + 13 + i * ROW }, clip(label, 30)));\n });\n if (attrs.length > shown.length) {\n g.appendChild(svg(\"text\", { class: \"cap\", x: x + 9, y: y + h - 4 }, \"+\" + (attrs.length - shown.length) + \" more\"));\n }\n return { g, w: W, h };\n }\n\n function renderER(centerQn) {\n const related = ASSOCS.filter((a) => a.from === centerQn || a.to === centerQn);\n if (!related.length && !DETAILS[centerQn]) return null;\n const neighbours = [];\n related.forEach((a) => {\n const other = a.from === centerQn ? a.to : a.from;\n if (other !== centerQn && !neighbours.includes(other)) neighbours.push(other);\n });\n\n const colX = [30, 320, 610];\n const layout = {}; // qn -> {x,y}\n layout[centerQn] = { col: 1, i: 0 };\n let li = 0, ri = 0;\n neighbours.forEach((qn, idx) => {\n if (idx % 2 === 0) layout[qn] = { col: 0, i: li++ };\n else layout[qn] = { col: 2, i: ri++ };\n });\n const boxes = {};\n const wrap = el(\"div\", \"diagram\");\n const s = svg(\"svg\", {});\n const defs = svg(\"defs\");\n const m = svg(\"marker\", { id: \"erarrow\", markerWidth: 9, markerHeight: 9, refX: 8, refY: 3,\n orient: \"auto\", markerUnits: \"strokeWidth\" });\n m.appendChild(svg(\"path\", { d: \"M0,0 L8,3 L0,6 Z\", fill: \"#6a7a94\" }));\n defs.appendChild(m); s.appendChild(defs);\n\n let maxBottom = 0, maxRight = 0;\n const place = (qn) => {\n const lo = layout[qn]; if (!lo || boxes[qn]) return;\n const x = colX[lo.col];\n const y = 20 + lo.i * 130;\n const b = entityBox(qn, x, y);\n b.x = x; b.y = y; b.cx = x + b.w / 2; b.cy = y + b.h / 2;\n boxes[qn] = b;\n maxBottom = Math.max(maxBottom, y + b.h);\n maxRight = Math.max(maxRight, x + b.w);\n };\n place(centerQn); neighbours.forEach(place);\n\n related.forEach((a) => {\n const from = boxes[a.from], to = boxes[a.to];\n if (!from || !to) return;\n const x1 = from.cx, y1 = from.cy, x2 = to.cx, y2 = to.cy;\n s.appendChild(svg(\"line\", { class: \"edge\", x1, y1, x2, y2, \"marker-end\": \"url(#erarrow)\" }));\n const lbl = (a.type === \"ReferenceSet\" ? \"* \" : \"1 \") + (a.qn.split(\".\").pop());\n s.appendChild(svg(\"text\", { class: \"edge-label\", x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 3, \"text-anchor\": \"middle\" }, clip(lbl, 28)));\n });\n Object.values(boxes).forEach((b) => s.appendChild(b.g));\n\n const width = Math.max(maxRight + 30, 640), height = maxBottom + 30;\n s.setAttribute(\"width\", width); s.setAttribute(\"height\", height);\n s.setAttribute(\"viewBox\", `0 0 ${width} ${height}`);\n // clicking a neighbour navigates to it\n s.addEventListener(\"click\", (ev) => {\n const g = ev.target.closest(\"g[data-qn]\");\n if (g && g.getAttribute(\"data-qn\") !== centerQn) window.__selectByQn(g.getAttribute(\"data-qn\"));\n });\n wrap.appendChild(s);\n return wrap;\n }\n\n function buildDiagram(n, d) {\n const t = n.type;\n if ((t === \"microflow\" || t === \"nanoflow\") && d.mdl) return renderFlow(d.mdl);\n if (t === \"entity\") return renderER(n.qualifiedName);\n return null;\n }\n\n function block(title, node) {\n const s = el(\"section\", \"block\");\n s.appendChild(el(\"h3\", null, title.replace(/_/g, \" \")));\n const body = el(\"div\", \"body\");\n body.appendChild(node);\n s.appendChild(body);\n return s;\n }\n\n function renderDetail(n) {\n const qn = n.qualifiedName;\n const d = DETAILS[qn] || {};\n detail.innerHTML = \"\";\n\n const h = el(\"h2\", null, n.label || qn);\n detail.appendChild(h);\n const sub = el(\"div\", \"qn\");\n sub.textContent = (n.type || \"element\") + \" \u00B7 \" + qn;\n if (d.parse_status) {\n const p = el(\"span\", \"pill \" + (d.parse_status === \"parsed\" ? \"ok\" : \"warn\"), d.parse_status);\n sub.appendChild(p);\n }\n detail.appendChild(sub);\n\n // visual diagram first (flowchart for flows, ER for entities)\n const diagram = buildDiagram(n, d);\n if (diagram) {\n const label = (n.type === \"entity\") ? \"Domain (neighbourhood)\" : \"Flowchart\";\n detail.appendChild(block(label, diagram));\n }\n\n // scalar summary (short string/number/bool fields) as a key/value grid\n const scalars = Object.entries(d).filter(([k, v]) =>\n k !== \"parse_status\" && (typeof v !== \"object\") && !(typeof v === \"string\" && v.length > 120));\n if (scalars.length) {\n const dl = el(\"dl\", \"kv\");\n scalars.forEach(([k, v]) => {\n dl.appendChild(el(\"dt\", null, k.replace(/_/g, \" \")));\n const dd = el(\"dd\");\n dd.appendChild(renderScalar(v));\n dl.appendChild(dd);\n });\n detail.appendChild(block(\"summary\", dl));\n }\n\n // everything else, in a stable, page-friendly order\n const ORDER = [\"parameters\", \"widget_types\", \"widgets\", \"data_sources\", \"attributes\",\n \"actions\", \"microflow_calls\", \"nanoflow_calls\", \"page_links\", \"view_roles\",\n \"access_rules\", \"execute_roles\", \"variables\", \"activities\", \"calls\", \"generalization\"];\n const keys = Object.keys(d).filter((k) =>\n !scalars.some(([sk]) => sk === k) && k !== \"parse_status\" && k !== \"mdl\");\n keys.sort((a, b) => {\n const ia = ORDER.indexOf(a), ib = ORDER.indexOf(b);\n return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);\n });\n\n keys.forEach((k) => {\n const v = d[k];\n let node;\n if (Array.isArray(v)) node = renderArray(k, v);\n else if (v && typeof v === \"object\") {\n const dl = el(\"dl\", \"kv\");\n Object.entries(v).forEach(([kk, vv]) => {\n dl.appendChild(el(\"dt\", null, kk));\n const dd = el(\"dd\");\n dd.appendChild(renderScalar(vv));\n dl.appendChild(dd);\n });\n node = dl;\n } else node = renderScalar(v);\n detail.appendChild(block(k, node));\n });\n\n if (d.mdl) detail.appendChild(block(\"MDL\", el(\"pre\", null, d.mdl)));\n\n if (!Object.keys(d).length) {\n detail.appendChild(el(\"p\", \"empty\", \"This node is a container; it has no element details.\"));\n }\n }\n})();\n</script>\n</body>\n</html>\n"
Class Method Summary collapse
-
.embed(data) ⇒ Object
Escape a JSON payload so it can live inside a
Escape a JSON payload so it can live inside a
37 38 39 40 41
# File 'lib/mendix_bridge/html_viewer.rb', line 37 def load_json(path) raise ArgumentError, "missing inventory file: #{path}" unless File.file?(path) JSON.parse(File.read(path)) end
.optional_json(path) ⇒ Object
43 44 45
# File 'lib/mendix_bridge/html_viewer.rb', line 43 def optional_json(path) File.file?(path) ? JSON.parse(File.read(path)) : {} end
.render(imported_dir) ⇒ Object
Render the viewer HTML for an imported directory.
19 20 21 22 23 24 25 26 27 28
# File 'lib/mendix_bridge/html_viewer.rb', line 19 def render(imported_dir) tree = load_json(File.join(imported_dir, TREE_FILE)) details = load_json(File.join(imported_dir, DETAILS_FILE)) = optional_json(File.join(imported_dir, METADATA_FILE)) TEMPLATE .gsub("__TREE_JSON__", (tree)) .gsub("__DETAILS_JSON__", (details)) .gsub("__META_JSON__", ()) end
.write(imported_dir, output = nil) ⇒ Object
Render and write the viewer, returning the output path.
31 32 33 34 35
# File 'lib/mendix_bridge/html_viewer.rb', line 31 def write(imported_dir, output = nil) output ||= File.join(imported_dir, "inventory-viewer.html") File.write(output, render(imported_dir)) output end