- Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram) - union graph of every element and connection, registry and inline, built from the model index - query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared - side panel per element and connection with diagrams, registry line and decisions one click away - legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js - command Archify | Open Model Graph; integration check; unit tests for graph and queries
1005 lines
59 KiB
TypeScript
1005 lines
59 KiB
TypeScript
export type ViewerTheme = 'dark' | 'light' | undefined;
|
|
|
|
export interface PageOptions {
|
|
/** webview.cspSource */
|
|
cspSource: string;
|
|
nonce: string;
|
|
/** Forced diagram theme; undefined keeps the diagram's own choice. */
|
|
theme: ViewerTheme;
|
|
/** Location hash to restore (Archify keeps focus/route state there). */
|
|
hash?: string;
|
|
/** Opaque state handed to vscode.setState for panel restore. */
|
|
state?: unknown;
|
|
/** Banner shown on top of the diagram. */
|
|
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
|
/** The 3D viewer script (a webview URI), added to Archify pages. */
|
|
viewer3d?: { scriptUri: string; defaultView: '2d' | '3d' };
|
|
/** Node id → diagram path (relative to the model root); double-click opens it. */
|
|
links?: Record<string, string>;
|
|
/** The model page: metadata above the diagram, the diagram list and registry below. */
|
|
model?: ModelPanel;
|
|
/** Node id → registry description, shown as tooltips in the Elements panel. */
|
|
descriptions?: Record<string, string>;
|
|
/** Registry page: the elements of one .archify-registry file. */
|
|
registry?: RegistryPanel;
|
|
/** Decision page: one .archify-decision file. */
|
|
decision?: DecisionPanel;
|
|
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */
|
|
decisions?: DiagramDecision[];
|
|
/** Pages inside a model get a switch to the model's Graph page; `left` names the page they are on. */
|
|
graphSwitch?: { left: 'Model' | 'Diagram'; query?: string };
|
|
/** The Graph page: the model as one graph, the bundled script that draws it, and the query to start with. */
|
|
graph?: { graph: ModelGraph; query?: string; scriptUri: string };
|
|
}
|
|
|
|
import type { ModelGraph } from '../model/graph';
|
|
import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel';
|
|
import type { DiagramDecision } from '../model/prepare';
|
|
|
|
export function makeNonce(): string {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let nonce = '';
|
|
for (let i = 0; i < 32; i += 1) nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
return nonce;
|
|
}
|
|
|
|
export function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/** JSON that is safe to embed inside an inline <script>. */
|
|
function scriptJson(value: unknown): string {
|
|
return JSON.stringify(value ?? null)
|
|
.replace(/</g, '\\u003c')
|
|
.replace(/>/g, '\\u003e')
|
|
.replace(/\u2028/g, '\\u2028')
|
|
.replace(/\u2029/g, '\\u2029');
|
|
}
|
|
|
|
export function contentSecurityPolicy(cspSource: string, nonce: string): string {
|
|
return [
|
|
"default-src 'none'",
|
|
`img-src ${cspSource} https: data: blob:`,
|
|
`media-src ${cspSource} data: blob:`,
|
|
`font-src ${cspSource} data:`,
|
|
`style-src ${cspSource} 'unsafe-inline'`,
|
|
`script-src ${cspSource} 'nonce-${nonce}'`,
|
|
'connect-src data: blob:',
|
|
'worker-src blob:',
|
|
].join('; ');
|
|
}
|
|
|
|
/**
|
|
* Runs before any page script: pins the theme, restores the hash, and
|
|
* routes links, window.open and export downloads to the extension, since a
|
|
* webview cannot navigate or download on its own.
|
|
*/
|
|
function bridgeScript(options: PageOptions): string {
|
|
const config = {
|
|
theme: options.theme,
|
|
hash: options.hash,
|
|
state: options.state,
|
|
notice: options.notice,
|
|
defaultView: options.viewer3d?.defaultView,
|
|
links: options.links ?? {},
|
|
model: options.model,
|
|
registry: options.registry,
|
|
decision: options.decision,
|
|
decisions: options.decisions || [],
|
|
descriptions: options.descriptions || {},
|
|
graphSwitch: options.graphSwitch,
|
|
graph: options.graph ? { graph: options.graph.graph, query: options.graph.query } : undefined,
|
|
};
|
|
return `(function () {
|
|
var config = ${scriptJson(config)};
|
|
var vscode = acquireVsCodeApi();
|
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state));
|
|
if (config.theme) {
|
|
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
|
}
|
|
if (config.hash && !location.hash) {
|
|
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
|
}
|
|
window.addEventListener('hashchange', function () {
|
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state, { hash: location.hash }));
|
|
vscode.postMessage({ type: 'hash', hash: location.hash });
|
|
});
|
|
|
|
function post(message) { vscode.postMessage(message); }
|
|
|
|
// For the 3D viewer, which cannot call acquireVsCodeApi a second time.
|
|
window.__archifyHost = {
|
|
defaultView: config.defaultView || '2d',
|
|
post: post,
|
|
getState: function () { return vscode.getState() || {}; },
|
|
setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); },
|
|
graph: config.graph
|
|
};
|
|
|
|
function saveBlobUrl(url, filename) {
|
|
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
|
var reader = new FileReader();
|
|
reader.onload = function () {
|
|
var data = String(reader.result);
|
|
post({ type: 'save', filename: filename || 'archify-export', base64: data.slice(data.indexOf(',') + 1) });
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
}).catch(function (error) {
|
|
post({ type: 'error', message: 'Export failed: ' + (error && error.message || error) });
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function (event) {
|
|
var target = event.target;
|
|
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
|
if (!anchor) return;
|
|
var href = anchor.getAttribute('href') || '';
|
|
if (anchor.hasAttribute('download') && /^(blob|data):/i.test(anchor.href)) {
|
|
event.preventDefault();
|
|
saveBlobUrl(anchor.href, anchor.getAttribute('download'));
|
|
return;
|
|
}
|
|
if (href.charAt(0) === '#') return;
|
|
event.preventDefault();
|
|
if (anchor.classList.contains('semantic-passport-source')) {
|
|
var pathNode = anchor.querySelector('small');
|
|
var match = /#L(\\d+)(?:-L(\\d+))?$/.exec(anchor.href);
|
|
post({
|
|
type: 'openSource',
|
|
path: pathNode ? pathNode.textContent : '',
|
|
line: match ? Number(match[1]) : undefined,
|
|
endLine: match && match[2] ? Number(match[2]) : undefined,
|
|
href: anchor.href
|
|
});
|
|
return;
|
|
}
|
|
if (/^(https?|mailto):/i.test(href)) post({ type: 'openExternal', href: href });
|
|
else post({ type: 'openRelative', href: href });
|
|
}, true);
|
|
|
|
var nativeOpen = window.open;
|
|
window.open = function (url) {
|
|
if (typeof url === 'string' && /^(https?|mailto):/i.test(url)) {
|
|
post({ type: 'openExternal', href: url });
|
|
return null;
|
|
}
|
|
return nativeOpen ? nativeOpen.apply(window, arguments) : null;
|
|
};
|
|
|
|
function showNotice(notice) {
|
|
var existing = document.getElementById('archify-vscode-notice');
|
|
if (existing) existing.remove();
|
|
if (!notice || !notice.text) return;
|
|
var bar = document.createElement('div');
|
|
bar.id = 'archify-vscode-notice';
|
|
bar.setAttribute('role', notice.kind === 'error' ? 'alert' : 'status');
|
|
bar.className = 'archify-vscode-notice archify-vscode-notice-' + notice.kind;
|
|
var text = document.createElement('span');
|
|
text.textContent = notice.text;
|
|
var close = document.createElement('button');
|
|
close.type = 'button';
|
|
close.textContent = '\\u00d7';
|
|
close.title = 'Dismiss';
|
|
close.addEventListener('click', function () { bar.remove(); });
|
|
bar.appendChild(text);
|
|
bar.appendChild(close);
|
|
(document.body || document.documentElement).appendChild(bar);
|
|
}
|
|
|
|
// A node that links to another diagram opens it on double-click.
|
|
document.addEventListener('dblclick', function (event) {
|
|
var target = event.target;
|
|
var node = target && target.closest ? target.closest('[data-node-id]') : null;
|
|
if (!node) return;
|
|
var id = node.getAttribute('data-node-id');
|
|
if (id && config.links[id]) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
post({ type: 'openDiagram', path: config.links[id] });
|
|
}
|
|
}, true);
|
|
|
|
// Collapsible panels of the model page; open/closed is kept in the webview state.
|
|
function panelStates() { return (vscode.getState() || {}).modelPanels || {}; }
|
|
function panel(key, className, title, count, defaultOpen) {
|
|
// count: a number or a short text such as "3 files".
|
|
var details = el('details', 'archify-model-panel ' + className);
|
|
var saved = panelStates()[key];
|
|
details.open = saved === undefined ? defaultOpen : saved;
|
|
var summary = el('summary', 'archify-model-summary');
|
|
summary.appendChild(el('span', 'archify-model-chevron'));
|
|
summary.appendChild(el('span', 'archify-model-summary-title', title));
|
|
if (count !== undefined) summary.appendChild(el('span', 'archify-model-count', String(count)));
|
|
details.appendChild(summary);
|
|
details.addEventListener('toggle', function () {
|
|
var states = panelStates();
|
|
states[key] = details.open;
|
|
vscode.setState(Object.assign({}, vscode.getState() || {}, { modelPanels: states }));
|
|
});
|
|
return details;
|
|
}
|
|
|
|
// One registry element as an Archify card. Clicking the card opens the
|
|
// usage modal; the chips open the diagram directly.
|
|
function elementCard(element) {
|
|
var card = el('div', 'card archify-model-card');
|
|
card.setAttribute('role', 'button');
|
|
card.tabIndex = 0;
|
|
card.title = 'Show where ' + (element.label || element.id) + ' is used';
|
|
var header = el('div', 'card-header');
|
|
header.appendChild(el('div', 'card-dot ' + dotForType(element.type)));
|
|
header.appendChild(el('h3', '', element.label || element.id));
|
|
card.appendChild(header);
|
|
var list = el('ul');
|
|
list.appendChild(el('li', 'archify-model-card-id', element.id + (element.type ? ' · ' + element.type : '') + (element.sublabel ? ' · ' + element.sublabel : '')));
|
|
if (element.description) list.appendChild(el('li', '', element.description.trim()));
|
|
var used = el('li', 'archify-model-card-used');
|
|
if (element.usedIn.length) {
|
|
element.usedIn.forEach(function (use) {
|
|
var link = el('button', 'archify-model-used-link', use.path.replace(/\.archify$/, ''));
|
|
link.type = 'button';
|
|
link.title = 'Open ' + use.path;
|
|
link.addEventListener('click', function (event) { event.stopPropagation(); post({ type: 'openDiagram', path: use.path }); });
|
|
used.appendChild(link);
|
|
});
|
|
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
|
|
list.appendChild(used);
|
|
if (element.decisions && element.decisions.length) {
|
|
var decided = el('li', 'archify-model-card-used');
|
|
element.decisions.forEach(function (row) { decided.appendChild(decisionChip(row)); });
|
|
list.appendChild(decided);
|
|
}
|
|
card.appendChild(list);
|
|
card.addEventListener('click', function () { openElementModal(element); });
|
|
card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } });
|
|
return card;
|
|
}
|
|
|
|
// A decision as a chip (id, title, status) that opens its page.
|
|
function decisionChip(row) {
|
|
var chip = el('button', 'archify-model-used-link archify-decision-chip is-' + row.status, '');
|
|
chip.type = 'button';
|
|
chip.appendChild(el('span', 'archify-decision-dot'));
|
|
chip.appendChild(document.createTextNode(row.id + (row.title ? ' · ' + row.title : '')));
|
|
chip.title = 'Decision ' + row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')';
|
|
chip.addEventListener('click', function (event) { event.stopPropagation(); if (row.file) post({ type: 'openDecision', file: row.file }); });
|
|
return chip;
|
|
}
|
|
function statusBadge(row) {
|
|
var badge = el('span', 'archify-decision-status is-' + row.status, row.status + (row.supersededBy ? ' by ' + row.supersededBy : ''));
|
|
return badge;
|
|
}
|
|
// A decision as a row in a list: id, title, status, date; click opens it.
|
|
function decisionRowItem(row) {
|
|
var item = el('li', 'archify-model-item archify-decision-item is-' + row.status);
|
|
var button = el('button', 'archify-model-open archify-decision-open');
|
|
button.type = 'button';
|
|
button.title = row.file || row.id;
|
|
var line = el('span', 'archify-decision-line');
|
|
line.appendChild(el('span', 'archify-decision-id', row.id));
|
|
line.appendChild(el('span', 'archify-model-item-title', row.title || ''));
|
|
line.appendChild(statusBadge(row));
|
|
if (row.date) line.appendChild(el('span', 'archify-model-item-meta', row.date));
|
|
button.appendChild(line);
|
|
button.addEventListener('click', function () { if (row.file) post({ type: 'openDecision', file: row.file }); });
|
|
item.appendChild(button);
|
|
return item;
|
|
}
|
|
|
|
// Modal: one element, where it is defined and the diagrams that use it.
|
|
function openElementModal(element) {
|
|
closeElementModal();
|
|
var backdrop = el('div', 'archify-modal-backdrop');
|
|
backdrop.id = 'archify-element-modal';
|
|
var modal = el('div', 'archify-modal archify-model');
|
|
modal.setAttribute('role', 'dialog');
|
|
modal.setAttribute('aria-modal', 'true');
|
|
var head = el('div', 'archify-modal-head');
|
|
head.appendChild(el('span', 'card-dot ' + dotForType(element.type)));
|
|
var titles = el('div', 'archify-modal-titles');
|
|
titles.appendChild(el('h2', 'archify-modal-title', element.label || element.id));
|
|
titles.appendChild(el('div', 'archify-model-card-id', element.id + (element.type ? ' · ' + element.type : '') + (element.sublabel ? ' · ' + element.sublabel : '')));
|
|
head.appendChild(titles);
|
|
var close = el('button', 'archify-modal-close', '\u00d7');
|
|
close.type = 'button';
|
|
close.title = 'Close (Esc)';
|
|
close.addEventListener('click', closeElementModal);
|
|
head.appendChild(close);
|
|
modal.appendChild(head);
|
|
if (element.description) modal.appendChild(el('p', 'archify-modal-description', element.description.trim()));
|
|
var defined = el('p', 'archify-modal-defined');
|
|
defined.appendChild(document.createTextNode('Defined in '));
|
|
var source = el('button', 'archify-model-used-link', element.file + ':' + (element.line + 1));
|
|
source.type = 'button';
|
|
source.title = 'Open the registry at this line';
|
|
source.addEventListener('click', function () { post({ type: 'openRegistry', file: element.file, line: element.line }); });
|
|
defined.appendChild(source);
|
|
if (element.diagram) {
|
|
defined.appendChild(document.createTextNode(' · explained by '));
|
|
var explained = el('button', 'archify-model-used-link', element.diagram.replace(/\.archify$/, ''));
|
|
explained.type = 'button';
|
|
explained.addEventListener('click', function () { post({ type: 'openDiagram', path: element.diagram }); });
|
|
defined.appendChild(explained);
|
|
}
|
|
modal.appendChild(defined);
|
|
modal.appendChild(el('h3', 'archify-model-title', element.usedIn.length ? 'Used in ' + element.usedIn.length + (element.usedIn.length === 1 ? ' diagram' : ' diagrams') : 'Not used in any diagram yet'));
|
|
if (element.usedIn.length) {
|
|
var list = el('ul', 'archify-model-list');
|
|
element.usedIn.forEach(function (use) {
|
|
var item = el('li', 'archify-model-item');
|
|
var button = el('button', 'archify-model-open');
|
|
button.type = 'button';
|
|
button.title = use.path;
|
|
button.appendChild(el('span', 'archify-model-item-title', use.title || use.path.split('/').pop()));
|
|
button.appendChild(el('span', 'archify-model-item-meta', (use.kind || 'diagram') + ' · ' + use.path));
|
|
button.addEventListener('click', function () { post({ type: 'openDiagram', path: use.path }); });
|
|
item.appendChild(button);
|
|
list.appendChild(item);
|
|
});
|
|
modal.appendChild(list);
|
|
}
|
|
if (element.decisions && element.decisions.length) {
|
|
modal.appendChild(el('h3', 'archify-model-title', 'Decisions · ' + element.decisions.length));
|
|
var decisions = el('ul', 'archify-model-list archify-decision-list');
|
|
element.decisions.forEach(function (row) { decisions.appendChild(decisionRowItem(row)); });
|
|
modal.appendChild(decisions);
|
|
}
|
|
backdrop.appendChild(modal);
|
|
backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); });
|
|
document.body.appendChild(backdrop);
|
|
close.focus();
|
|
}
|
|
function closeElementModal() {
|
|
var existing = document.getElementById('archify-element-modal');
|
|
if (existing) existing.remove();
|
|
}
|
|
document.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Escape' && document.getElementById('archify-element-modal')) { event.stopPropagation(); closeElementModal(); }
|
|
}, true);
|
|
|
|
// The registry page: one .archify-registry file, its elements as cards.
|
|
function renderRegistry(registry) {
|
|
if (!registry) return;
|
|
var head = el('section', 'archify-model archify-model-head');
|
|
head.appendChild(el('div', 'archify-model-kicker', 'Registry' + (registry.model && registry.model.id ? ' · ' + registry.model.id : '')));
|
|
head.appendChild(el('h1', 'archify-model-name', registry.file));
|
|
if (registry.model && registry.model.name) head.appendChild(el('p', 'archify-model-description', 'Shared elements of ' + registry.model.name + '. Click an element to see where it is used.'));
|
|
if (!registry.inModel) head.appendChild(el('p', 'archify-model-problem', 'This registry is not inside a model (no main.archify in a folder above it), so nothing can refer to its elements yet.'));
|
|
registry.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
|
document.body.appendChild(head);
|
|
var body = el('section', 'archify-model archify-model-body');
|
|
var elementsPanel = panel('elements', 'archify-model-registry', 'Elements', registry.elements.length, true);
|
|
body.appendChild(elementsPanel);
|
|
if (!registry.elements.length) elementsPanel.appendChild(el('p', 'archify-model-empty', 'No elements yet. Add them under "elements:" with a type, a label and a description.'));
|
|
var cards = el('div', 'cards archify-model-cards');
|
|
registry.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
|
elementsPanel.appendChild(cards);
|
|
document.body.appendChild(body);
|
|
}
|
|
|
|
// The decision page: one .archify-decision file.
|
|
function renderDecision(page) {
|
|
if (!page) return;
|
|
var d = page.decision;
|
|
var head = el('section', 'archify-model archify-model-head');
|
|
head.appendChild(el('div', 'archify-model-kicker', 'Decision' + (page.model && page.model.id ? ' · ' + page.model.id : '') + (d ? ' · ' + d.id : '')));
|
|
head.appendChild(el('h1', 'archify-model-name', d && d.title ? d.title : page.file));
|
|
if (d) {
|
|
var meta = el('p', 'archify-decision-meta');
|
|
meta.appendChild(statusBadge(d));
|
|
if (d.date) meta.appendChild(el('span', 'archify-model-item-meta', d.date));
|
|
meta.appendChild(el('span', 'archify-model-item-meta', page.file));
|
|
head.appendChild(meta);
|
|
}
|
|
if (!page.inModel) head.appendChild(el('p', 'archify-model-problem', 'This decision has lost its model: there is no main.archify in a folder above it, so its elements and diagrams cannot be resolved.'));
|
|
page.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
|
document.body.appendChild(head);
|
|
var body = el('section', 'archify-model archify-model-body');
|
|
if (d) {
|
|
[['Context', d.context], ['Decision', d.decision], ['Consequences', d.consequences]].forEach(function (pair) {
|
|
if (!pair[1]) return;
|
|
var section = el('section', 'archify-decision-section');
|
|
section.appendChild(el('h2', 'archify-model-title', pair[0]));
|
|
section.appendChild(el('p', 'archify-decision-text', pair[1].trim()));
|
|
body.appendChild(section);
|
|
});
|
|
if (d.supersedes.length || page.supersededBy.length) {
|
|
var rel = el('section', 'archify-decision-section');
|
|
rel.appendChild(el('h2', 'archify-model-title', 'Related decisions'));
|
|
var relList = el('ul', 'archify-model-list archify-decision-list');
|
|
d.supersedes.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'supersedes'), item.firstChild.firstChild); relList.appendChild(item); });
|
|
page.supersededBy.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'superseded by'), item.firstChild.firstChild); relList.appendChild(item); });
|
|
rel.appendChild(relList);
|
|
body.appendChild(rel);
|
|
}
|
|
}
|
|
if (page.elements.length) {
|
|
var elementsPanel = panel('decision:elements', 'archify-model-registry', 'Elements', page.elements.length, true);
|
|
var cards = el('div', 'cards archify-model-cards');
|
|
page.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
|
elementsPanel.appendChild(cards);
|
|
body.appendChild(elementsPanel);
|
|
}
|
|
var diagramRows = [];
|
|
page.diagrams.forEach(function (use) { diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: 'named by the decision' }); });
|
|
page.referencedBy.forEach(function (use) {
|
|
var how = use.attachedTo.kind === 'node' ? 'on ' + use.attachedTo.id : use.attachedTo.kind === 'edge' ? 'on ' + use.attachedTo.from + ' \u2192 ' + use.attachedTo.to : 'on the diagram';
|
|
var existing = diagramRows.filter(function (r) { return r.path === use.path; })[0];
|
|
if (existing) existing.how += ', ' + how; else diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: how });
|
|
});
|
|
if (diagramRows.length) {
|
|
var diagramsPanel = panel('decision:diagrams', 'archify-model-diagrams', 'Diagrams', diagramRows.length, true);
|
|
var list = el('ul', 'archify-model-list');
|
|
diagramRows.forEach(function (row) {
|
|
var item = el('li', 'archify-model-item');
|
|
var button = el('button', 'archify-model-open');
|
|
button.type = 'button';
|
|
button.title = row.path;
|
|
button.appendChild(el('span', 'archify-model-item-title', row.title || row.path.split('/').pop()));
|
|
button.appendChild(el('span', 'archify-model-item-meta', (row.kind || 'diagram') + ' \u00b7 ' + row.path + ' \u00b7 ' + row.how));
|
|
button.addEventListener('click', function () { if (page.inModel) post({ type: 'openDiagram', path: row.path }); });
|
|
item.appendChild(button);
|
|
list.appendChild(item);
|
|
});
|
|
diagramsPanel.appendChild(list);
|
|
body.appendChild(diagramsPanel);
|
|
}
|
|
document.body.appendChild(body);
|
|
}
|
|
|
|
// Decisions rail panel on a diagram page: the decisions this diagram points
|
|
// at or that name it. Hover lights up what the decision is attached to.
|
|
function renderDecisionsRail() {
|
|
var rows = config.decisions || [];
|
|
var outline = document.getElementById('node-outline');
|
|
var svg = document.querySelector('.diagram-container > svg');
|
|
if (!rows.length || !outline || !svg || document.getElementById('archify-decisions')) return;
|
|
var nav = el('nav', 'node-outline no-print archify-connectors archify-decisions');
|
|
nav.id = 'archify-decisions';
|
|
var head = el('div', 'node-outline-head');
|
|
head.appendChild(el('strong', '', 'Decisions'));
|
|
head.appendChild(el('span', 'node-outline-count', String(rows.length)));
|
|
nav.appendChild(head);
|
|
var list = el('ol', 'node-outline-list');
|
|
var group = el('li', 'node-outline-group');
|
|
var items = el('ul');
|
|
function targets(row) {
|
|
var found = [];
|
|
row.attachedTo.forEach(function (a) {
|
|
if (a.kind === 'node') Array.prototype.push.apply(found, svg.querySelectorAll('[data-node-id="' + a.id + '"]'));
|
|
if (a.kind === 'edge') Array.prototype.push.apply(found, svg.querySelectorAll('[data-edge-from="' + a.from + '"][data-edge-to="' + a.to + '"]'));
|
|
});
|
|
return found;
|
|
}
|
|
function mark(row, on) { targets(row).forEach(function (t) { if (on) t.setAttribute('data-archify-hit', ''); else t.removeAttribute('data-archify-hit'); }); }
|
|
rows.forEach(function (row) {
|
|
var item = el('li');
|
|
var button = el('button', 'node-outline-item archify-decision-rail-item is-' + row.status);
|
|
button.type = 'button';
|
|
button.style.setProperty('--outline-tone', 'var(--text-muted)');
|
|
var where = row.attachedTo.map(function (a) { return a.kind === 'node' ? a.id : a.kind === 'edge' ? a.from + ' \u2192 ' + a.to : 'diagram'; }).join(', ');
|
|
button.title = row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')' + (where ? ' \u00b7 ' + where : '');
|
|
var dot = el('span', 'node-outline-dot');
|
|
dot.setAttribute('aria-hidden', 'true');
|
|
button.appendChild(dot);
|
|
button.appendChild(el('strong', '', row.id + (row.title ? ' \u00b7 ' + row.title : '')));
|
|
button.appendChild(el('small', '', row.status + (where ? ' \u00b7 ' + where : '')));
|
|
button.addEventListener('pointerenter', function () { mark(row, true); });
|
|
button.addEventListener('pointerleave', function () { mark(row, false); });
|
|
button.addEventListener('focus', function () { mark(row, true); });
|
|
button.addEventListener('blur', function () { mark(row, false); });
|
|
button.addEventListener('click', function () { post({ type: 'openDecision', file: row.file }); });
|
|
item.appendChild(button);
|
|
items.appendChild(item);
|
|
});
|
|
group.appendChild(items);
|
|
list.appendChild(group);
|
|
nav.appendChild(list);
|
|
var connectors = document.getElementById('archify-connectors');
|
|
(connectors || outline).insertAdjacentElement('afterend', nav);
|
|
var archify = window.Archify;
|
|
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
|
|
}
|
|
|
|
// Elements: registry descriptions as tooltips on Archify's node index rows.
|
|
function describeElements() {
|
|
var items = document.querySelectorAll('#node-outline .node-outline-item[data-outline-node]');
|
|
Array.prototype.forEach.call(items, function (item) {
|
|
var text = config.descriptions[item.getAttribute('data-outline-node')];
|
|
if (text) item.title = text;
|
|
});
|
|
}
|
|
|
|
// Connectors: a rail panel next to Archify's Elements index, read from the
|
|
// SVG's edges. Hover highlights the connector, click keeps it highlighted.
|
|
function renderConnectors() {
|
|
var svg = document.querySelector('.diagram-container > svg');
|
|
var outline = document.getElementById('node-outline');
|
|
if (!svg || !outline || document.getElementById('archify-connectors')) return;
|
|
var labels = {};
|
|
Array.prototype.forEach.call(svg.querySelectorAll('[data-node-id][data-node-label]'), function (node) {
|
|
labels[node.getAttribute('data-node-id')] = node.getAttribute('data-node-label');
|
|
});
|
|
var edges = [];
|
|
var byKey = {};
|
|
Array.prototype.forEach.call(svg.querySelectorAll('[data-edge-key]'), function (fragment) {
|
|
var key = fragment.getAttribute('data-edge-key');
|
|
if (!byKey[key]) {
|
|
byKey[key] = { key: key, from: fragment.getAttribute('data-edge-from') || '', to: fragment.getAttribute('data-edge-to') || '', label: fragment.getAttribute('data-edge-label') || '', fragments: [] };
|
|
edges.push(byKey[key]);
|
|
}
|
|
byKey[key].fragments.push(fragment);
|
|
});
|
|
if (!edges.length) return;
|
|
var nav = el('nav', 'node-outline no-print archify-connectors');
|
|
nav.id = 'archify-connectors';
|
|
nav.setAttribute('aria-labelledby', 'archify-connectors-title');
|
|
var head = el('div', 'node-outline-head');
|
|
var title = el('strong', '', 'Connectors');
|
|
title.id = 'archify-connectors-title';
|
|
head.appendChild(title);
|
|
head.appendChild(el('span', 'node-outline-count', String(edges.length)));
|
|
nav.appendChild(head);
|
|
var list = el('ol', 'node-outline-list');
|
|
var group = el('li', 'node-outline-group');
|
|
var items = el('ul');
|
|
var selected = null;
|
|
function mark(edge, attr, on) {
|
|
edge.fragments.forEach(function (fragment) { if (on) fragment.setAttribute(attr, ''); else fragment.removeAttribute(attr); });
|
|
}
|
|
function select(edge, button) {
|
|
if (selected) { mark(selected.edge, 'data-archify-selected', false); selected.button.removeAttribute('aria-current'); }
|
|
if (selected && selected.edge === edge) { selected = null; return; }
|
|
selected = { edge: edge, button: button };
|
|
mark(edge, 'data-archify-selected', true);
|
|
button.setAttribute('aria-current', 'true');
|
|
var first = edge.fragments[0];
|
|
if (first && typeof first.scrollIntoView === 'function') first.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
|
|
}
|
|
edges.forEach(function (edge) {
|
|
var item = el('li');
|
|
var button = el('button', 'node-outline-item');
|
|
button.type = 'button';
|
|
button.style.setProperty('--outline-tone', 'var(--text-muted)');
|
|
button.title = (labels[edge.from] || edge.from) + ' \u2192 ' + (labels[edge.to] || edge.to) + (edge.label ? ' \u00b7 ' + edge.label : '');
|
|
var dot = el('span', 'node-outline-dot');
|
|
dot.setAttribute('aria-hidden', 'true');
|
|
button.appendChild(dot);
|
|
button.appendChild(el('strong', '', (labels[edge.from] || edge.from) + ' \u2192 ' + (labels[edge.to] || edge.to)));
|
|
if (edge.label) button.appendChild(el('small', '', edge.label));
|
|
button.addEventListener('pointerenter', function () { mark(edge, 'data-archify-hit', true); });
|
|
button.addEventListener('pointerleave', function () { mark(edge, 'data-archify-hit', false); });
|
|
button.addEventListener('focus', function () { mark(edge, 'data-archify-hit', true); });
|
|
button.addEventListener('blur', function () { mark(edge, 'data-archify-hit', false); });
|
|
button.addEventListener('click', function () { select(edge, button); });
|
|
item.appendChild(button);
|
|
items.appendChild(item);
|
|
});
|
|
group.appendChild(items);
|
|
list.appendChild(group);
|
|
nav.appendChild(list);
|
|
outline.insertAdjacentElement('afterend', nav);
|
|
document.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Escape' && selected) select(selected.edge, selected.button);
|
|
});
|
|
var archify = window.Archify;
|
|
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
|
|
}
|
|
|
|
var DOT_BY_TYPE = { frontend: 'cyan', backend: 'emerald', database: 'violet', cloud: 'amber', security: 'rose', messagebus: 'orange', external: 'slate' };
|
|
function dotForType(type) { return DOT_BY_TYPE[type || ''] || 'slate'; }
|
|
|
|
function el(tag, className, text) {
|
|
var node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
// The model page: metadata first, the diagram (if any) where Archify put
|
|
// it, then the diagrams of the model and the registry.
|
|
function renderModel(model) {
|
|
if (!model) return;
|
|
var anchor = document.querySelector('.container');
|
|
var head = el('section', 'archify-model archify-model-head');
|
|
head.appendChild(el('div', 'archify-model-kicker', 'Model' + (model.meta.id ? ' · ' + model.meta.id : '')));
|
|
head.appendChild(el('h1', 'archify-model-name', model.meta.name || model.meta.id || 'Untitled model'));
|
|
if (model.meta.description) head.appendChild(el('p', 'archify-model-description', model.meta.description.trim()));
|
|
if (model.nested.length) head.appendChild(el('p', 'archify-model-problem', 'Nested main.archify files are not supported and were ignored: ' + model.nested.join(', ')));
|
|
model.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
|
|
|
var body = el('section', 'archify-model archify-model-body');
|
|
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
|
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
|
body.appendChild(diagramsPanel);
|
|
if (!count) diagramsPanel.appendChild(el('p', 'archify-model-empty', 'No diagrams yet. Add .archify files next to main.archify or in subfolders.'));
|
|
model.groups.forEach(function (group) {
|
|
// Each folder is its own collapsible header; diagrams next to main.archify are listed directly.
|
|
var holder = diagramsPanel;
|
|
if (group.group) {
|
|
holder = panel('folder:' + group.group, 'archify-model-folder', group.group, group.diagrams.length, true);
|
|
diagramsPanel.appendChild(holder);
|
|
}
|
|
var list = el('ul', 'archify-model-list');
|
|
group.diagrams.forEach(function (diagram) {
|
|
var item = el('li', 'archify-model-item' + (diagram.parseError ? ' is-broken' : ''));
|
|
var button = el('button', 'archify-model-open');
|
|
button.type = 'button';
|
|
button.title = diagram.path;
|
|
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
|
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
|
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
|
item.appendChild(button);
|
|
list.appendChild(item);
|
|
});
|
|
holder.appendChild(list);
|
|
});
|
|
// Registry: one collapsible header per registry file, its elements as cards inside.
|
|
if (model.registry.length) {
|
|
var files = [];
|
|
var byFile = {};
|
|
model.registry.forEach(function (element) {
|
|
if (!byFile[element.file]) { byFile[element.file] = []; files.push(element.file); }
|
|
byFile[element.file].push(element);
|
|
});
|
|
files.sort();
|
|
var registryPanel = panel('registry', 'archify-model-registry', 'Registries', files.length + (files.length === 1 ? ' file' : ' files'), true);
|
|
body.appendChild(registryPanel);
|
|
files.forEach(function (file) {
|
|
var elements = byFile[file].slice().sort(function (a, b) { return a.line - b.line; });
|
|
var filePanel = panel('registry:' + file, 'archify-model-folder archify-model-registry-file', file.replace(/\.archify-registry$/, ''), elements.length + (elements.length === 1 ? ' element' : ' elements'), false);
|
|
var open = el('button', 'archify-model-used-link archify-model-open-file', 'Open');
|
|
open.type = 'button';
|
|
open.title = 'Open ' + file;
|
|
open.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'openRegistry', file: file, line: 0 }); });
|
|
filePanel.firstChild.appendChild(open);
|
|
var cards = el('div', 'cards archify-model-cards');
|
|
elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
|
filePanel.appendChild(cards);
|
|
registryPanel.appendChild(filePanel);
|
|
});
|
|
}
|
|
// Decisions: one list, live ones first, superseded and rejected dimmed and last.
|
|
if (model.decisions && model.decisions.length) {
|
|
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true);
|
|
body.appendChild(decisionsPanel);
|
|
var decisionList = el('ul', 'archify-model-list archify-decision-list');
|
|
model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); });
|
|
decisionsPanel.appendChild(decisionList);
|
|
}
|
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
|
}
|
|
|
|
// Model | Graph (or Diagram | Graph): Export's twin in Archify's toolbar,
|
|
// or at the top of the model page. Graph opens the model's Graph page.
|
|
function renderModeSwitch() {
|
|
var sw = config.graphSwitch;
|
|
if (!sw || document.getElementById('archify-mode-switch')) return;
|
|
var button = el('button', 'archify-mode-switch');
|
|
button.id = 'archify-mode-switch';
|
|
button.type = 'button';
|
|
button.title = 'Open the model as one graph, with a query box';
|
|
button.setAttribute('aria-label', sw.left + ' view; switch to the model graph');
|
|
button.appendChild(el('span', 'archify-mode-seg on', sw.left));
|
|
button.appendChild(el('span', 'archify-mode-seg', 'Graph'));
|
|
button.addEventListener('click', function (event) {
|
|
event.stopPropagation();
|
|
post({ type: 'showGraph', query: sw.query });
|
|
});
|
|
var exportWrap = document.querySelector('.toolbar .export-wrap');
|
|
var head = document.querySelector('.archify-model-head');
|
|
if (exportWrap && exportWrap.parentNode) exportWrap.parentNode.insertBefore(button, exportWrap);
|
|
else if (head) {
|
|
var row = el('div', 'archify-model-switch-row');
|
|
row.appendChild(button);
|
|
head.insertBefore(row, head.firstChild);
|
|
} else document.body.appendChild(button);
|
|
}
|
|
|
|
window.addEventListener('message', function (event) {
|
|
var message = event.data || {};
|
|
if (message.type === 'notice') showNotice(message.notice);
|
|
});
|
|
function onReady(fn) {
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
|
else fn();
|
|
}
|
|
if (config.model) onReady(function () { renderModel(config.model); });
|
|
if (config.registry) onReady(function () { renderRegistry(config.registry); });
|
|
if (config.decision) onReady(function () { renderDecision(config.decision); });
|
|
onReady(renderConnectors);
|
|
onReady(renderDecisionsRail);
|
|
onReady(describeElements);
|
|
onReady(renderModeSwitch);
|
|
if (config.notice) {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
|
} else {
|
|
showNotice(config.notice);
|
|
}
|
|
}
|
|
})();`;
|
|
}
|
|
|
|
const MODEL_STYLE = `
|
|
.archify-model { --m-text: var(--text, var(--vscode-foreground, CanvasText)); --m-muted: var(--text-muted, var(--vscode-descriptionForeground, GrayText)); --m-panel: var(--panel, var(--vscode-editorWidget-background, Canvas)); --m-border: var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); --m-accent: var(--frontend-stroke, var(--vscode-textLink-foreground));
|
|
max-width: 1400px; margin: 0 auto; padding: 1.25rem 2rem; color: var(--m-text); font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
|
.archify-model-head { padding-top: 1.5rem; }
|
|
.archify-model-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--m-accent); }
|
|
.archify-model-name { margin: 0.25rem 0 0.5rem; font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; }
|
|
.archify-model-description { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.55; color: var(--m-muted); white-space: pre-line; }
|
|
.archify-model-problem { margin: 0.75rem 0 0; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid var(--vscode-inputValidation-errorBorder, #be1100); color: var(--m-text); font-size: 0.8125rem; }
|
|
/* Collapsible panels: Diagrams and Registry, and one per folder inside Diagrams. */
|
|
.archify-model-panel { margin: 1rem 0 0; border: 1px solid var(--m-border); border-radius: 0.75rem; background: var(--m-panel); }
|
|
.archify-model-panel[open] { padding-bottom: 0.85rem; }
|
|
.archify-model-summary { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 0.9rem; cursor: pointer; list-style: none; user-select: none; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.02em; color: var(--m-text); }
|
|
.archify-model-summary::-webkit-details-marker { display: none; }
|
|
.archify-model-summary:hover { color: var(--m-accent); }
|
|
.archify-model-summary:focus-visible { outline: 2px solid var(--m-accent); outline-offset: -2px; border-radius: 0.75rem; }
|
|
.archify-model-chevron { flex: none; width: 0.5rem; height: 0.5rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform 0.15s; opacity: 0.7; margin-left: 0.1rem; }
|
|
.archify-model-panel[open] > .archify-model-summary > .archify-model-chevron { transform: rotate(45deg); }
|
|
.archify-model-count { margin-left: auto; font-size: 0.75rem; font-weight: 500; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; padding: 0.05rem 0.5rem; border: 1px solid var(--m-border); border-radius: 999px; }
|
|
.archify-model-panel > .archify-model-list, .archify-model-panel > .archify-model-cards, .archify-model-panel > .archify-model-empty { margin: 0 0.9rem; }
|
|
.archify-model-folder { margin: 0.35rem 0.9rem 0; background: transparent; border-radius: 0.6rem; }
|
|
.archify-model-folder[open] { padding-bottom: 0.7rem; }
|
|
.archify-model-folder > .archify-model-summary { padding: 0.55rem 0.75rem; font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
|
|
.archify-model-folder > .archify-model-summary:hover { color: var(--m-accent); }
|
|
.archify-model-folder > .archify-model-list { margin: 0 0.75rem; }
|
|
.archify-model-registry-file > .archify-model-summary { text-transform: none; letter-spacing: 0; font-size: 0.8125rem; }
|
|
.archify-model-registry-file > .archify-model-cards { margin: 0.25rem 0.75rem 0; }
|
|
.archify-model-open-file { margin-left: 0.5rem; text-transform: none; letter-spacing: 0; font-family: inherit; }
|
|
.archify-model-open-file:hover { color: var(--m-accent); }
|
|
.archify-model-diagrams > .archify-model-list { margin-top: 0.25rem; }
|
|
.archify-model-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; }
|
|
.archify-model-open { display: flex; flex-direction: column; gap: 0.2rem; width: 100%; padding: 0.7rem 0.85rem; text-align: left; cursor: pointer; font: inherit; color: var(--m-text); background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.6rem; transition: border-color 0.15s, transform 0.15s; }
|
|
.archify-model-open:hover { border-color: var(--m-accent); transform: translateY(-1px); }
|
|
.archify-model-open:focus-visible { outline: 2px solid var(--m-accent); outline-offset: 2px; }
|
|
.archify-model-item.is-broken .archify-model-open { border-style: dashed; }
|
|
.archify-model-item-title { font-weight: 600; font-size: 0.9375rem; }
|
|
.archify-model-item-meta { font-size: 0.75rem; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
|
|
.archify-model-empty { color: var(--m-muted); }
|
|
/* Element modal: where one element is defined and used. */
|
|
.archify-modal-backdrop { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-start; justify-content: center; padding: 6vh 1rem; background: rgba(0, 0, 0, 0.45); overflow: auto; }
|
|
.archify-modal { width: min(44rem, 100%); max-height: 88vh; overflow: auto; padding: 1.25rem 1.5rem 1.5rem; border-radius: 0.9rem; border: 1px solid var(--m-border); background: var(--m-panel); color: var(--m-text); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); }
|
|
.archify-modal-head { display: flex; align-items: flex-start; gap: 0.75rem; }
|
|
.archify-modal-head .card-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; margin-top: 0.45rem; }
|
|
.archify-modal-titles { flex: 1 1 auto; min-width: 0; }
|
|
.archify-modal-title { margin: 0; font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
|
|
.archify-modal-close { flex: none; font: inherit; font-size: 1.25rem; line-height: 1; width: 2rem; height: 2rem; border-radius: 0.5rem; border: 1px solid var(--m-border); background: transparent; color: var(--m-muted); cursor: pointer; }
|
|
.archify-modal-close:hover { color: var(--m-text); border-color: var(--m-accent); }
|
|
.archify-modal-description { margin: 0.9rem 0 0; font-size: 0.9375rem; line-height: 1.55; color: var(--m-text); white-space: pre-line; }
|
|
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
|
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
|
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
|
/* Decisions: rows, chips and status badges. */
|
|
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
|
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
|
.archify-decision-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; }
|
|
.archify-decision-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.8125rem; color: var(--m-muted); }
|
|
.archify-decision-status { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.05rem 0.45rem; border-radius: 999px; border: 1px solid var(--m-border); color: var(--m-muted); }
|
|
.archify-decision-status.is-accepted { color: var(--m-backend); border-color: var(--m-backend); }
|
|
.archify-decision-status.is-proposed { color: var(--m-cloud); border-color: var(--m-cloud); }
|
|
.archify-decision-status.is-rejected { color: var(--m-security); border-color: var(--m-security); }
|
|
.archify-decision-item.is-superseded .archify-model-open, .archify-decision-item.is-rejected .archify-model-open { opacity: 0.6; }
|
|
.archify-decision-chip { display: inline-flex; align-items: center; gap: 0.35rem; font-family: inherit; }
|
|
.archify-decision-dot { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--m-muted); }
|
|
.archify-decision-chip.is-accepted .archify-decision-dot { background: var(--m-backend); }
|
|
.archify-decision-chip.is-proposed .archify-decision-dot { background: var(--m-cloud); }
|
|
.archify-decision-chip.is-rejected .archify-decision-dot { background: var(--m-security); }
|
|
.archify-decision-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin: 0.4rem 0 0; }
|
|
.archify-decision-section { margin-top: 0.5rem; }
|
|
.archify-decision-text { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.6; color: var(--m-text); white-space: pre-line; }
|
|
/* Registry cards: Archify's own .card look, with fallbacks for the page without a diagram. */
|
|
.archify-model { --m-frontend: var(--frontend-stroke, #22d3ee); --m-backend: var(--backend-stroke, #34d399); --m-database: var(--database-stroke, #a78bfa); --m-cloud: var(--cloud-stroke, #fbbf24); --m-security: var(--security-stroke, #fb7185); --m-messagebus: var(--messagebus-stroke, #fb923c); --m-external: var(--external-stroke, #94a3b8); }
|
|
.archify-model-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; margin-top: 0.25rem; }
|
|
.archify-model-card { background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.75rem; padding: 1.125rem 1.25rem; }
|
|
.archify-model-card .card-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; }
|
|
.archify-model-card .card-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; }
|
|
.archify-model-card .card-dot.cyan { background: var(--m-frontend); } .archify-model-card .card-dot.emerald { background: var(--m-backend); } .archify-model-card .card-dot.violet { background: var(--m-database); } .archify-model-card .card-dot.amber { background: var(--m-cloud); } .archify-model-card .card-dot.rose { background: var(--m-security); } .archify-model-card .card-dot.orange { background: var(--m-messagebus); } .archify-model-card .card-dot.slate { background: var(--m-external); }
|
|
.archify-model-card h3 { margin: 0; font-size: 0.9375rem; font-weight: 600; line-height: 1.4; color: var(--m-text); }
|
|
.archify-model-card ul { list-style: none; margin: 0; padding: 0; color: var(--m-muted); font-size: 0.8125rem; line-height: 1.6; }
|
|
.archify-model-card li { margin-bottom: 0.3rem; }
|
|
.archify-model-card li.archify-model-card-id, .archify-model-card li.archify-model-card-used { padding-left: 0; }
|
|
.archify-model-card li.archify-model-card-id::before, .archify-model-card li.archify-model-card-used::before { display: none; }
|
|
.archify-model-card-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.75rem; }
|
|
.archify-model-card-used { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.5rem; }
|
|
.archify-model-used-link { font: inherit; font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--m-border); background: transparent; color: var(--m-text); cursor: pointer; font-family: ui-monospace, Menlo, monospace; }
|
|
.archify-model-used-link:hover { border-color: var(--m-accent); }
|
|
`;
|
|
|
|
/* The Model | Graph switch: in Archify's toolbar it is the 2D | 3D switch's twin; on the model page it sits top right. */
|
|
const SWITCH_STYLE = `
|
|
.archify-mode-switch { display: inline-flex; align-items: center; padding: 0 0.25rem; gap: 0; border: 0; background: transparent; color: var(--text-muted, var(--vscode-descriptionForeground)); font: inherit; cursor: pointer; position: relative; }
|
|
.toolbar .archify-mode-switch::before { content: ""; position: absolute; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); backdrop-filter: blur(12px); }
|
|
.toolbar .archify-mode-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
|
.archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; }
|
|
.archify-mode-seg.on { color: var(--bg, var(--vscode-editor-background)); background: var(--text, var(--vscode-foreground)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
|
.archify-mode-switch:hover .archify-mode-seg:not(.on) { color: var(--text, var(--vscode-foreground)); }
|
|
.archify-mode-switch:focus-visible { outline: 2px solid var(--frontend-stroke, var(--vscode-focusBorder)); outline-offset: 2px; border-radius: 0.75rem; }
|
|
.archify-model-switch-row { float: right; margin: 1.5rem 0 0 1rem; }
|
|
.archify-model-switch-row .archify-mode-switch { border: 1px solid var(--m-border); border-radius: 0.75rem; background: var(--m-panel); padding: 0.1rem 0.25rem; }
|
|
`;
|
|
|
|
/* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */
|
|
const CONNECTOR_STYLE = `
|
|
.diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); }
|
|
.diagram-container svg [data-edge-key][data-archify-selected] text { font-weight: 700; }
|
|
.archify-connectors .node-outline-item strong { font-weight: 600; }
|
|
.diagram-container svg [data-node-id][data-archify-hit] { filter: drop-shadow(0 0 6px var(--frontend-stroke)); }
|
|
/* Bottom rail with a Decisions panel: three columns. */
|
|
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions) { grid-template-areas: "cards cards cards" "index connectors decisions"; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions):has(> #node-outline[hidden]) { grid-template-areas: "cards cards" "connectors decisions"; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions { display: block; grid-area: decisions; padding: 0 1.25rem 0.75rem; border: 1px solid var(--panel-border); border-radius: 0.75rem; background: var(--panel); }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
|
|
.archify-decision-rail-item.is-superseded, .archify-decision-rail-item.is-rejected { opacity: 0.6; }
|
|
/* Bottom rail: Elements and Connectors side by side; Connectors alone when Archify hides its index. */
|
|
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors) { grid-template-areas: "cards cards" "index connectors"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 1rem; }
|
|
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors):has(> #node-outline[hidden]) { grid-template-areas: "cards" "connectors"; grid-template-columns: minmax(0, 1fr); }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors { display: block; grid-area: connectors; padding: 0 1.25rem 0.75rem; border: 1px solid var(--panel-border); border-radius: 0.75rem; background: var(--panel); }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
|
|
`;
|
|
|
|
const NOTICE_STYLE = `
|
|
.archify-vscode-notice {
|
|
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
|
display: flex; gap: 12px; align-items: flex-start; justify-content: space-between;
|
|
padding: 8px 12px; border-radius: 6px; font: 12px/1.45 var(--vscode-font-family, system-ui, sans-serif);
|
|
color: var(--vscode-editorWidget-foreground, #ddd); background: var(--vscode-editorWidget-background, #252526);
|
|
border: 1px solid var(--vscode-editorWidget-border, #454545); box-shadow: 0 4px 16px rgba(0,0,0,.35);
|
|
white-space: pre-wrap;
|
|
}
|
|
.archify-vscode-notice-warning { border-left: 4px solid var(--vscode-editorWarning-foreground, #cca700); }
|
|
.archify-vscode-notice-error { border-left: 4px solid var(--vscode-editorError-foreground, #f14c4c); }
|
|
.archify-vscode-notice-info { border-left: 4px solid var(--vscode-editorInfo-foreground, #3794ff); }
|
|
.archify-vscode-notice button {
|
|
background: none; border: 0; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
|
|
}`;
|
|
|
|
/** Prepares Archify HTML for a VS Code webview. */
|
|
export function prepareDiagramHtml(html: string, options: PageOptions): string {
|
|
const head =
|
|
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
|
|
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
|
|
`<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${SWITCH_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
|
|
|
|
let page = html
|
|
// Archify calls its node rail "Node index"; next to the registry it reads better as Elements.
|
|
.replace(/(id="node-outline-title">)Node index(<)/, '$1Elements$2')
|
|
.replace(/("viewer\.outline\.title":")Node index(")/, '$1Elements$2')
|
|
// Drop any CSP the file brings along; ours is the one that applies.
|
|
.replace(/<meta\s+http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '')
|
|
// Allow the page's own inline scripts to run under the nonce policy.
|
|
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
|
|
|
if (options.viewer3d) {
|
|
const tag = `<script nonce="${options.nonce}" defer src="${escapeHtml(options.viewer3d.scriptUri)}"></script>\n`;
|
|
page = /<\/head>/i.test(page) ? page.replace(/<\/head>/i, `${tag}</head>`) : `${tag}${page}`;
|
|
}
|
|
|
|
const headMatch = /<head\b[^>]*>/i.exec(page);
|
|
if (headMatch) {
|
|
const at = headMatch.index + headMatch[0].length;
|
|
page = `${page.slice(0, at)}\n${head}${page.slice(at)}`;
|
|
} else {
|
|
page = `<!DOCTYPE html><html><head>${head}</head><body>${page}</body></html>`;
|
|
}
|
|
return page;
|
|
}
|
|
|
|
/** The model page for a main.archify without a diagram of its own. */
|
|
export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, model: ModelPanel): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {}, graphSwitch: { left: 'Model' } });
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
|
<script nonce="${options.nonce}">${bridge}</script>
|
|
<style>
|
|
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
|
${MODEL_STYLE}
|
|
${SWITCH_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** The Graph page of a model: the bundled script builds everything from the host's graph data. */
|
|
export function graphPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, graph: NonNullable<PageOptions['graph']>): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, graph });
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
|
<script nonce="${options.nonce}">${bridge}</script>
|
|
<script nonce="${options.nonce}" defer src="${escapeHtml(graph.scriptUri)}"></script>
|
|
<style>
|
|
html, body { margin: 0; height: 100%; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
|
${NOTICE_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** The registry page for one .archify-registry file. */
|
|
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, registry: RegistryPanel): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, registry, links: {} });
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
|
<script nonce="${options.nonce}">${bridge}</script>
|
|
<style>
|
|
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
|
${MODEL_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** The decision page for one .archify-decision file. */
|
|
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, decision: DecisionPanel): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {} });
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
|
<script nonce="${options.nonce}">${bridge}</script>
|
|
<style>
|
|
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
|
${MODEL_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** A small VS Code-styled page for loading and error states. */
|
|
export function messagePage(
|
|
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
|
title: string,
|
|
body: string,
|
|
detail?: string,
|
|
): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
|
<script nonce="${options.nonce}">(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();</script>
|
|
<style>
|
|
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
|
h2 { font-weight: 600; font-size: 15px; margin: 0 0 8px; }
|
|
p { margin: 0 0 12px; color: var(--vscode-descriptionForeground); }
|
|
pre { font-family: var(--vscode-editor-font-family); font-size: 12px; white-space: pre-wrap; word-break: break-word;
|
|
background: var(--vscode-textCodeBlock-background); padding: 12px 14px; border-radius: 4px;
|
|
border-left: 3px solid var(--vscode-editorError-foreground); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>${escapeHtml(title)}</h2>
|
|
<p>${escapeHtml(body)}</p>
|
|
${detail ? `<pre>${escapeHtml(detail)}</pre>` : ''}
|
|
</body>
|
|
</html>`;
|
|
}
|