VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
232 lines
9.1 KiB
TypeScript
232 lines
9.1 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' };
|
|
}
|
|
|
|
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 '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 };
|
|
return `(function () {
|
|
var config = ${scriptJson(config)};
|
|
var vscode = acquireVsCodeApi();
|
|
if (config.state) vscode.setState(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({}, config.state, { hash: location.hash }));
|
|
vscode.postMessage({ type: 'hash', hash: location.hash });
|
|
});
|
|
|
|
function post(message) { vscode.postMessage(message); }
|
|
|
|
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);
|
|
}
|
|
|
|
window.addEventListener('message', function (event) {
|
|
var message = event.data || {};
|
|
if (message.type === 'notice') showNotice(message.notice);
|
|
});
|
|
if (config.notice) {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
|
} else {
|
|
showNotice(config.notice);
|
|
}
|
|
}
|
|
})();`;
|
|
}
|
|
|
|
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}</style>\n`;
|
|
|
|
let page = html
|
|
// 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}"`);
|
|
|
|
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;
|
|
}
|
|
|
|
/** 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>`;
|
|
}
|