UNPKG

specpress

Version:

Export PDF and/or DOCX files from a subset of Markdown, ASN.1 and JSON files

427 lines (394 loc) 16 kB
const { execSync } = require('child_process') const { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } = require('fs') const { join } = require('path') const { tmpdir } = require('os') const https = require('https') const { createHash } = require('crypto') const MERMAID_CDN = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js' const MERMAID_FILENAME = 'mermaid.min.js' const MERMAID_TIMESTAMP = 'mermaid.timestamp' const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours const SVG_CACHE_DIR = 'cached' /** * Returns the absolute path to the SVG cache directory for a given spec root. * The cache is placed as a sibling of the spec root folder. * * e.g. specRoot = "/repo/spec" → "/repo/cached" * * @param {string} specRoot - Absolute path to the specification root. * @returns {string} Absolute path to the cache directory. */ function svgCacheDir(specRoot) { return join(require('path').dirname(specRoot), SVG_CACHE_DIR) } /** Regex to extract mermaid fence content from markdown files. */ const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g const BROWSER_PATHS = [ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ] let browserPath = null /** * Finds the first available Chromium-based browser on the system. * Checks CHROME_BIN and CHROMIUM_BIN environment variables first, * then falls back to the hardcoded list of common installation paths. * @returns {string|null} Absolute path to the browser executable, or null. */ function findBrowser() { if (browserPath !== null) return browserPath const envBrowser = process.env.CHROME_BIN || process.env.CHROMIUM_BIN if (envBrowser && existsSync(envBrowser)) { browserPath = envBrowser return browserPath } browserPath = BROWSER_PATHS.find(p => existsSync(p)) || null return browserPath } const DEFAULT_MERMAID_CONFIG = join(__dirname, '../../css/mermaid-config.json') /** * Loads the mermaid config, falling back to css/mermaid-config.json. * @param {string} [mermaidConfig] - Explicit config JSON string. * @returns {string} Config JSON string. */ function resolveConfig(mermaidConfig) { if (mermaidConfig) return mermaidConfig try { return readFileSync(DEFAULT_MERMAID_CONFIG, 'utf8') } catch (e) { return '{}' } } /** * Renders multiple mermaid diagrams to SVG + PNG in a single headless browser * invocation. SVG is extracted from the DOM; PNG is captured via canvas toDataURL. * * @param {string[]} codes - Array of mermaid diagram source strings. * @param {string} [mermaidConfig] - Mermaid init config JSON string. * @returns {{ svg: string|null, png: Buffer|null }[]} Array of result objects. */ function renderMermaidBatch(codes, mermaidConfig) { if (!codes || codes.length === 0) return [] const browser = findBrowser() if (!browser) return codes.map(() => ({ svg: null, png: null })) const config = resolveConfig(mermaidConfig) const html = `<!DOCTYPE html> <html><head><script type="module"> import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs'; mermaid.initialize(Object.assign({ startOnLoad: false }, ${config})); const codes = ${JSON.stringify(codes)}; for (let i = 0; i < codes.length; i++) { const container = document.createElement('div'); container.id = 'mermaid-out-' + i; try { const { svg } = await mermaid.render('diagram-' + i, codes[i]); container.innerHTML = svg; const svgEl = container.querySelector('svg'); const vb = svgEl && svgEl.getAttribute('viewBox'); const parts = vb ? vb.split(' ') : []; const w = Math.round(parseFloat(parts[2]) || 604); const h = Math.round(parseFloat(parts[3]) || 400); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx2d = canvas.getContext('2d'); const img = new Image(); await new Promise(res => { img.onload = () => { ctx2d.drawImage(img, 0, 0, w, h); res(); }; img.onerror = res; img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); }); const pngDiv = document.createElement('div'); pngDiv.id = 'mermaid-png-' + i; pngDiv.textContent = canvas.toDataURL('image/png').split(',')[1]; document.body.appendChild(pngDiv); } catch(e) { container.innerHTML = '<error>' + e.message + '</error>'; } document.body.appendChild(container); } </script></head><body></body></html>` const tempHtml = join(tmpdir(), `temp_mermaid_${Date.now()}.html`) writeFileSync(tempHtml, html) try { const result = execSync( `"${browser}" --headless=new --disable-gpu --dump-dom --virtual-time-budget=30000 --no-sandbox "file:///${tempHtml.replace(/\\/g, '/')}"`, { timeout: 60000, maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] } ) const dom = result.toString('utf8') return codes.map((_, i) => { const re = new RegExp(`<div id="mermaid-out-${i}">([\\s\\S]*?)</div>(?=<div id="mermaid-|</body>)`) const m = dom.match(re) const inner = m ? m[1] : null const svgMatch = inner && inner.match(/<svg[\s\S]*?<\/svg>/) const svg = svgMatch ? svgMatch[0] : null const pngRe = new RegExp(`<div id="mermaid-png-${i}">([A-Za-z0-9+/=]+)</div>`) const pngM = dom.match(pngRe) const png = pngM ? Buffer.from(pngM[1], 'base64') : null return { svg, png } }) } catch (e) { return codes.map(() => ({ svg: null, png: null })) } finally { try { unlinkSync(tempHtml) } catch (e) {} } } /** * Downloads a file from a URL to a local path. * * @param {string} url - URL to download. * @param {string} dest - Absolute path to write the file. * @returns {Promise<void>} */ function downloadFile(url, dest) { return new Promise((resolve, reject) => { const file = require('fs').createWriteStream(dest) https.get(url, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { file.close() return downloadFile(res.headers.location, dest).then(resolve, reject) } if (res.statusCode !== 200) { file.close() return reject(new Error(`HTTP ${res.statusCode}`)) } res.pipe(file) file.on('finish', () => file.close(resolve)) }).on('error', (e) => { file.close(); reject(e) }) }) } /** * Ensures a local copy of mermaid.min.js exists in the extension's global * storage directory. Downloads from CDN on first use and re-downloads if * the cached copy is older than 24 hours. * * @param {string} storageDir - Absolute path to the extension's global storage directory. * @returns {Promise<string>} Absolute path to the cached mermaid.min.js file. */ async function ensureMermaidBundle(storageDir) { if (!existsSync(storageDir)) mkdirSync(storageDir, { recursive: true }) const bundlePath = join(storageDir, MERMAID_FILENAME) const tsPath = join(storageDir, MERMAID_TIMESTAMP) let needsDownload = !existsSync(bundlePath) if (!needsDownload && existsSync(tsPath)) { try { const ts = parseInt(readFileSync(tsPath, 'utf8'), 10) if (Date.now() - ts > CACHE_TTL_MS) needsDownload = true } catch (e) { needsDownload = true } } if (needsDownload) { try { await downloadFile(MERMAID_CDN, bundlePath) writeFileSync(tsPath, String(Date.now())) } catch (e) { if (!existsSync(bundlePath)) throw new Error('Mermaid bundle not available and download failed: ' + e.message) // Stale cache is better than no cache — keep using it } } return bundlePath } /** * Renders multiple mermaid diagrams to SVG + PNG using a hidden VS Code webview. * * @param {Object} vscode - The vscode module. * @param {string[]} codes - Array of mermaid diagram source strings. * @param {string} [mermaidConfig='{}'] - Mermaid init config JSON string. * @param {string} mermaidBundlePath - Absolute path to the cached mermaid.min.js. * @returns {Promise<{ svg: string|null, png: Buffer|null }[]>} */ function renderMermaidViaWebview(vscode, codes, mermaidConfig, mermaidBundlePath) { if (!codes || codes.length === 0) return Promise.resolve([]) const config = mermaidConfig || '{}' return new Promise((resolve) => { const previousEditor = vscode.window.activeTextEditor const localRoot = vscode.Uri.file(require('path').dirname(mermaidBundlePath)) const panel = vscode.window.createWebviewPanel( 'specpressMermaid', 'Mermaid Render', { viewColumn: vscode.ViewColumn.Two, preserveFocus: true }, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [localRoot] } ) if (previousEditor) { vscode.window.showTextDocument(previousEditor.document, previousEditor.viewColumn, false) } const mermaidUri = panel.webview.asWebviewUri(vscode.Uri.file(mermaidBundlePath)) panel.webview.html = `<!DOCTYPE html> <html><head> <script src="${mermaidUri}"></script> </head><body> <script> const vscodeApi = acquireVsCodeApi(); mermaid.initialize(Object.assign({ startOnLoad: false }, ${config})); async function run() { const codes = ${JSON.stringify(codes)}; const results = []; for (let i = 0; i < codes.length; i++) { const el = document.createElement('div'); el.id = 'mermaid-render-' + i; document.body.appendChild(el); try { const { svg } = await mermaid.render('diagram-' + i, codes[i], el); const svgEl = el.querySelector('svg'); const vb = svgEl && svgEl.getAttribute('viewBox'); const parts = vb ? vb.split(' ') : []; const w = Math.round(parseFloat(parts[2]) || 604); const h = Math.round(parseFloat(parts[3]) || 400); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx2d = canvas.getContext('2d'); const img = new Image(); await new Promise(res => { img.onload = () => { ctx2d.drawImage(img, 0, 0, w, h); res(); }; img.onerror = res; img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); }); results.push({ svg, png: canvas.toDataURL('image/png').split(',')[1] }); } catch(e) { results.push({ svg: null, png: null }); } } vscodeApi.postMessage({ type: 'mermaidResults', results }); } run(); </script> </body></html>` const timeout = setTimeout(() => { panel.dispose() resolve(codes.map(() => ({ svg: null, png: null }))) }, 30000) panel.webview.onDidReceiveMessage(msg => { if (msg.type === 'mermaidResults') { clearTimeout(timeout) panel.dispose() resolve(msg.results.map(r => r && typeof r === 'object' ? { svg: r.svg, png: r.png ? Buffer.from(r.png, 'base64') : null } : { svg: null, png: null })) } }) panel.onDidDispose(() => clearTimeout(timeout)) }) } /** * Computes a SHA-256 hash key for a mermaid diagram + config combination. * * @param {string} code - Mermaid diagram source. * @param {string} config - Mermaid config JSON string. * @returns {string} Hex hash string. */ function svgCacheKey(code, config) { return createHash('sha256').update(code + '\0' + config).digest('hex') } /** * Scans all markdown files under specRoot for mermaid fences and returns * the set of cache keys (hashes) that are currently in use. * * @param {string} specRoot - Absolute path to the specification root. * @param {string} config - Mermaid config JSON string. * @returns {Set<string>} Set of hex hash strings for all live mermaid diagrams. */ function collectMermaidHashes(specRoot, config) { const hashes = new Set() const walk = (dir) => { for (const item of require('fs').readdirSync(dir)) { const full = join(dir, item) if (require('fs').statSync(full).isDirectory()) { if (item === 'node_modules' || item === '.git') continue walk(full) } else if (/\.md$/i.test(item)) { const content = readFileSync(full, 'utf8') let m MERMAID_FENCE_RE.lastIndex = 0 while ((m = MERMAID_FENCE_RE.exec(content)) !== null) { hashes.add(svgCacheKey(m[1].replace(/\r\n/g, '\n').trim(), config)) } } } } walk(specRoot) return hashes } /** * Removes cached SVG files that are no longer referenced by any mermaid * fence in the spec root. * * @param {string} specRoot - Absolute path to the specification root. * @param {string} config - Mermaid config JSON string. */ function cleanupMermaidCache(specRoot, config) { const cacheDir = svgCacheDir(specRoot) if (!existsSync(cacheDir)) return const liveHashes = collectMermaidHashes(specRoot, config) for (const file of require('fs').readdirSync(cacheDir)) { if (!file.endsWith('.svg')) continue const hash = file.slice(0, -4) if (!liveHashes.has(hash)) { try { unlinkSync(join(cacheDir, file)) } catch (e) { /* ignore */ } } } } /** * Renders mermaid diagrams with SVG caching in the spec root's * assets/cached/ directory. Only uncached diagrams are rendered via * renderFn. After rendering, cleans up stale cached SVGs. * * @param {string[]} codes - Array of mermaid diagram source strings. * @param {string} config - Mermaid config JSON string. * @param {string} specRoot - Absolute path to the specification root. * @param {Function} renderFn - Async function `(codes) => svgs[]` for uncached diagrams. * @returns {Promise<(string|null)[]>} Array of SVG strings. */ async function renderWithCache(codes, config, specRoot, renderFn) { if (!codes || codes.length === 0) return [] const cacheDir = svgCacheDir(specRoot) if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }) const results = new Array(codes.length) const uncached = [] for (let i = 0; i < codes.length; i++) { const key = svgCacheKey(codes[i], config) const cachePath = join(cacheDir, key + '.svg') if (existsSync(cachePath)) { results[i] = { svg: readFileSync(cachePath, 'utf8'), png: null } } else { uncached.push({ idx: i, code: codes[i], key }) } } if (uncached.length > 0) { const rendered = await renderFn(uncached.map(u => u.code)) for (let j = 0; j < uncached.length; j++) { const { svg, png } = rendered[j] || { svg: null, png: null } results[uncached[j].idx] = { svg, png } if (svg) writeFileSync(join(cacheDir, uncached[j].key + '.svg'), svg) } } cleanupMermaidCache(specRoot, config) return results } /** * Renders a single mermaid diagram to SVG. Convenience wrapper around * renderMermaidBatch for backward compatibility. * * @param {string} code - Mermaid diagram source code. * @param {string} [mermaidConfig] - Mermaid init config JSON string. * @returns {string|null} SVG string, or null if rendering failed. */ function renderMermaidToSvg(code, mermaidConfig) { return renderMermaidBatch([code], mermaidConfig)[0].svg } /** * Extracts width and height from an SVG string via its viewBox attribute. * * @param {string} svg - SVG string. * @returns {{ width: number, height: number }} */ function getSvgDimensions(svg) { let width = 604, height = 400 const vb = svg.match(/viewBox="[\d.\-]+ [\d.\-]+ ([\d.]+) ([\d.]+)"/) if (vb) { width = parseFloat(vb[1]) height = parseFloat(vb[2]) } const maxWidth = 604 if (width > maxWidth) { height = height * (maxWidth / width) width = maxWidth } return { width: Math.round(width), height: Math.round(height) } } module.exports = { renderMermaidToSvg, renderMermaidBatch, renderMermaidViaWebview, renderWithCache, cleanupMermaidCache, ensureMermaidBundle, getSvgDimensions, findBrowser }