| #!/usr/bin/env node |
| 'use strict'; |
| |
| const fs = require('fs'); |
| const fsp = fs.promises; |
| const path = require('path'); |
| const http = require('http'); |
| const crypto = require('crypto'); |
| const { pathToFileURL } = require('url'); |
| |
| const PROJECT_ROOT = path.resolve(__dirname, '..'); |
| const DEFAULT_MANIFEST_PATH = path.join(PROJECT_ROOT, 'docs', 'screenshot-manifest.json'); |
| const DEFAULT_STATE_FILENAME = '.screenshot-state.json'; |
| |
| function printUsage() { |
| console.log( |
| [ |
| 'Usage: node scripts/gendoc.js [options]', |
| '', |
| 'Options:', |
| ' --manifest <path> Path to screenshot manifest JSON (default: docs/screenshot-manifest.json)', |
| ' --base-url <url> Capture from an existing Vulnogram URL instead of local standalone server', |
| ' --port <number> Port for local standalone server (default: 0 = random)', |
| ' --only <ids> Comma-separated screenshot IDs to capture', |
| ' --force Rewrite screenshot files even if hash did not change', |
| ' --no-capture Regenerate markdown/state without taking new screenshots', |
| ' --help Show this help text' |
| ].join('\n') |
| ); |
| } |
| |
| function parseArgs(argv) { |
| const options = { |
| manifestPath: DEFAULT_MANIFEST_PATH, |
| baseUrl: null, |
| port: 0, |
| only: null, |
| force: false, |
| capture: true |
| }; |
| |
| for (let i = 0; i < argv.length; i += 1) { |
| const arg = argv[i]; |
| if (arg === '--help' || arg === '-h') { |
| printUsage(); |
| process.exit(0); |
| } else if (arg === '--manifest') { |
| i += 1; |
| options.manifestPath = argv[i]; |
| } else if (arg.startsWith('--manifest=')) { |
| options.manifestPath = arg.split('=').slice(1).join('='); |
| } else if (arg === '--base-url') { |
| i += 1; |
| options.baseUrl = argv[i]; |
| } else if (arg.startsWith('--base-url=')) { |
| options.baseUrl = arg.split('=').slice(1).join('='); |
| } else if (arg === '--port') { |
| i += 1; |
| options.port = Number(argv[i] || 0); |
| } else if (arg.startsWith('--port=')) { |
| options.port = Number(arg.split('=').slice(1).join('') || 0); |
| } else if (arg === '--only') { |
| i += 1; |
| options.only = String(argv[i] || '') |
| .split(',') |
| .map((item) => item.trim()) |
| .filter(Boolean); |
| } else if (arg.startsWith('--only=')) { |
| options.only = arg |
| .split('=') |
| .slice(1) |
| .join('=') |
| .split(',') |
| .map((item) => item.trim()) |
| .filter(Boolean); |
| } else if (arg === '--force') { |
| options.force = true; |
| } else if (arg === '--no-capture') { |
| options.capture = false; |
| } else { |
| throw new Error('Unknown option: ' + arg); |
| } |
| } |
| |
| if (!Number.isFinite(options.port) || options.port < 0 || options.port > 65535) { |
| throw new Error('Invalid port: ' + options.port); |
| } |
| return options; |
| } |
| |
| function normalizeManifest(manifest) { |
| if (!manifest || typeof manifest !== 'object') { |
| throw new Error('Manifest JSON must be an object'); |
| } |
| if (!Array.isArray(manifest.sections) || manifest.sections.length === 0) { |
| throw new Error('Manifest is missing non-empty "sections" array'); |
| } |
| if (!Array.isArray(manifest.screenshots) || manifest.screenshots.length === 0) { |
| throw new Error('Manifest is missing non-empty "screenshots" array'); |
| } |
| |
| const sectionIds = new Set(); |
| for (const section of manifest.sections) { |
| if (!section || typeof section !== 'object' || !section.id || !section.title) { |
| throw new Error('Each section must include "id" and "title"'); |
| } |
| sectionIds.add(section.id); |
| } |
| |
| const screenshotIds = new Set(); |
| for (const shot of manifest.screenshots) { |
| if (!shot || typeof shot !== 'object') { |
| throw new Error('Each screenshot entry must be an object'); |
| } |
| if (!shot.id || !shot.file || !shot.section || !shot.alt || !Array.isArray(shot.steps)) { |
| throw new Error('Screenshot entries require "id", "file", "section", "alt", and "steps"'); |
| } |
| if (!sectionIds.has(shot.section)) { |
| throw new Error('Screenshot "' + shot.id + '" references unknown section "' + shot.section + '"'); |
| } |
| if (screenshotIds.has(shot.id)) { |
| throw new Error('Duplicate screenshot id in manifest: ' + shot.id); |
| } |
| screenshotIds.add(shot.id); |
| } |
| |
| return manifest; |
| } |
| |
| function normalizeHeroGraphic(hero) { |
| if (!hero) { |
| return null; |
| } |
| if (typeof hero !== 'object') { |
| throw new Error('Manifest "hero" must be an object'); |
| } |
| if (!hero.src || !hero.alt) { |
| throw new Error('Manifest hero requires "src" and "alt"'); |
| } |
| return { |
| src: String(hero.src), |
| alt: String(hero.alt), |
| href: hero.href ? String(hero.href) : '', |
| caption: hero.caption ? String(hero.caption) : '' |
| }; |
| } |
| |
| async function readJson(filePath) { |
| const raw = await fsp.readFile(filePath, 'utf8'); |
| return JSON.parse(raw); |
| } |
| |
| async function readJsonIfExists(filePath, fallback) { |
| try { |
| return await readJson(filePath); |
| } catch (error) { |
| if (error && error.code === 'ENOENT') { |
| return fallback; |
| } |
| throw error; |
| } |
| } |
| |
| async function writeJson(filePath, data) { |
| await fsp.mkdir(path.dirname(filePath), { recursive: true }); |
| await fsp.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8'); |
| } |
| |
| async function writeTextIfChanged(filePath, content) { |
| let current = null; |
| try { |
| current = await fsp.readFile(filePath, 'utf8'); |
| } catch (error) { |
| if (!(error && error.code === 'ENOENT')) { |
| throw error; |
| } |
| } |
| if (current === content) { |
| return false; |
| } |
| await fsp.mkdir(path.dirname(filePath), { recursive: true }); |
| await fsp.writeFile(filePath, content, 'utf8'); |
| return true; |
| } |
| |
| async function fileHash(filePath) { |
| const buffer = await fsp.readFile(filePath); |
| return crypto.createHash('sha256').update(buffer).digest('hex'); |
| } |
| |
| function hashBuffer(buffer) { |
| return crypto.createHash('sha256').update(buffer).digest('hex'); |
| } |
| |
| function readPngDimensions(buffer) { |
| if (!Buffer.isBuffer(buffer) || buffer.length < 24) { |
| throw new Error('Invalid PNG buffer'); |
| } |
| const signature = '89504e470d0a1a0a'; |
| if (buffer.subarray(0, 8).toString('hex') !== signature) { |
| throw new Error('Screenshot buffer is not PNG'); |
| } |
| const width = buffer.readUInt32BE(16); |
| const height = buffer.readUInt32BE(20); |
| if (!width || !height) { |
| throw new Error('Screenshot PNG has invalid dimensions'); |
| } |
| return { width, height }; |
| } |
| |
| function clampNumber(value, fallback, min, max) { |
| const parsed = Number(value); |
| if (!Number.isFinite(parsed)) { |
| return fallback; |
| } |
| if (typeof min === 'number' && parsed < min) { |
| return min; |
| } |
| if (typeof max === 'number' && parsed > max) { |
| return max; |
| } |
| return parsed; |
| } |
| |
| function getScreenshotFrameStyle(shot) { |
| const input = shot && shot.frameStyle && typeof shot.frameStyle === 'object' ? shot.frameStyle : {}; |
| return { |
| enabled: input.enabled !== false, |
| radius: clampNumber(input.radius, 16, 0, 100), |
| padding: clampNumber(input.padding, 24, 0, 200), |
| borderWidth: clampNumber(input.borderWidth, 1, 0, 20), |
| borderColor: String(input.borderColor || 'rgba(15, 23, 42, 0.12)'), |
| shadow: String(input.shadow || '0 18px 42px rgba(15, 23, 42, 0.26)'), |
| shadowPadding: clampNumber(input.shadowPadding, 28, 0, 200) |
| }; |
| } |
| |
| async function addScreenshotFrame(browser, screenshotBuffer, shot) { |
| const frameStyle = getScreenshotFrameStyle(shot); |
| if (!frameStyle.enabled) { |
| return screenshotBuffer; |
| } |
| |
| const dimensions = readPngDimensions(screenshotBuffer); |
| const totalPadding = frameStyle.padding + frameStyle.shadowPadding; |
| const viewportWidth = Math.min( |
| 8192, |
| Math.max(300, dimensions.width + totalPadding * 2 + frameStyle.borderWidth * 2) |
| ); |
| const viewportHeight = Math.min( |
| 8192, |
| Math.max(220, dimensions.height + totalPadding * 2 + frameStyle.borderWidth * 2) |
| ); |
| |
| const renderPage = await browser.newPage({ |
| viewport: { width: viewportWidth, height: viewportHeight } |
| }); |
| try { |
| const html = |
| '<!doctype html><html><head><meta charset="utf-8">' + |
| '<style>' + |
| 'html,body{margin:0;padding:0;background:transparent;}' + |
| '#gendoc-shot-shell{display:inline-block;padding:' + |
| totalPadding + |
| 'px;background:transparent;box-sizing:border-box;}' + |
| '#gendoc-shot-frame{display:inline-block;line-height:0;background:#fff;overflow:hidden;border:' + |
| frameStyle.borderWidth + |
| 'px solid ' + |
| frameStyle.borderColor + |
| ';border-radius:' + |
| frameStyle.radius + |
| 'px;box-shadow:' + |
| frameStyle.shadow + |
| ';}' + |
| '#gendoc-shot-frame>img{display:block;width:' + |
| dimensions.width + |
| 'px;height:' + |
| dimensions.height + |
| 'px;}' + |
| '</style></head><body>' + |
| '<div id="gendoc-shot-shell">' + |
| '<div id="gendoc-shot-frame">' + |
| '<img alt="" src="data:image/png;base64,' + |
| screenshotBuffer.toString('base64') + |
| '">' + |
| '</div>' + |
| '</div>' + |
| '</body></html>'; |
| |
| await renderPage.setContent(html, { waitUntil: 'load' }); |
| await renderPage.evaluate(async () => { |
| const image = document.querySelector('#gendoc-shot-frame > img'); |
| if (!image) { |
| throw new Error('Framed screenshot image was not rendered'); |
| } |
| if (image.complete) { |
| return; |
| } |
| await new Promise((resolve, reject) => { |
| image.onload = () => resolve(); |
| image.onerror = () => reject(new Error('Failed to decode screenshot image')); |
| }); |
| }); |
| return await renderPage.locator('#gendoc-shot-shell').screenshot({ |
| animations: 'disabled', |
| type: 'png' |
| }); |
| } finally { |
| await renderPage.close(); |
| } |
| } |
| |
| function toUtcTimestamp(date = new Date()) { |
| return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); |
| } |
| |
| function asPublicDocPath(relativePath) { |
| return relativePath.split(path.sep).join('/'); |
| } |
| |
| function makeMimeType(filePath) { |
| const ext = path.extname(filePath).toLowerCase(); |
| const map = { |
| '.html': 'text/html; charset=utf-8', |
| '.js': 'application/javascript; charset=utf-8', |
| '.json': 'application/json; charset=utf-8', |
| '.css': 'text/css; charset=utf-8', |
| '.svg': 'image/svg+xml', |
| '.png': 'image/png', |
| '.jpg': 'image/jpeg', |
| '.jpeg': 'image/jpeg', |
| '.gif': 'image/gif', |
| '.ico': 'image/x-icon', |
| '.woff': 'font/woff', |
| '.woff2': 'font/woff2', |
| '.ttf': 'font/ttf', |
| '.map': 'application/json; charset=utf-8' |
| }; |
| return map[ext] || 'application/octet-stream'; |
| } |
| |
| function startStaticServer(rootDir, port) { |
| const absoluteRoot = path.resolve(rootDir); |
| return new Promise((resolve, reject) => { |
| const server = http.createServer(async (req, res) => { |
| try { |
| const parsed = new URL(req.url || '/', 'http://127.0.0.1'); |
| let requestPath = decodeURIComponent(parsed.pathname || '/'); |
| if (requestPath === '/') { |
| requestPath = '/index.html'; |
| } |
| |
| const relativePath = path.normalize(requestPath).replace(/^[/\\]+/, ''); |
| let filePath = path.resolve(absoluteRoot, relativePath); |
| const rel = path.relative(absoluteRoot, filePath); |
| if (rel.startsWith('..') || path.isAbsolute(rel)) { |
| res.writeHead(403); |
| res.end('Forbidden'); |
| return; |
| } |
| |
| let stat; |
| try { |
| stat = await fsp.stat(filePath); |
| } catch (error) { |
| if (error && error.code === 'ENOENT') { |
| res.writeHead(404); |
| res.end('Not Found'); |
| return; |
| } |
| throw error; |
| } |
| |
| if (stat.isDirectory()) { |
| filePath = path.join(filePath, 'index.html'); |
| } |
| |
| const data = await fsp.readFile(filePath); |
| res.writeHead(200, { |
| 'Content-Type': makeMimeType(filePath), |
| 'Content-Length': data.length |
| }); |
| res.end(data); |
| } catch (error) { |
| res.writeHead(500); |
| res.end('Internal Server Error'); |
| } |
| }); |
| |
| server.on('error', reject); |
| server.listen(port, '127.0.0.1', () => { |
| resolve(server); |
| }); |
| }); |
| } |
| |
| function closeServer(server) { |
| return new Promise((resolve, reject) => { |
| server.close((error) => { |
| if (error) { |
| reject(error); |
| } else { |
| resolve(); |
| } |
| }); |
| }); |
| } |
| |
| async function loadPlaywrightModule() { |
| const moduleNames = ['playwright', 'playwright-core']; |
| for (const moduleName of moduleNames) { |
| try { |
| const mod = await import(moduleName); |
| return mod.default || mod; |
| } catch (error) { |
| continue; |
| } |
| } |
| return null; |
| } |
| |
| async function applyAction(page, action, context) { |
| const name = action.action; |
| if (!name) { |
| throw new Error('Action entry is missing "action"'); |
| } |
| |
| switch (name) { |
| case 'setViewport': { |
| const width = Number(action.width || 1600); |
| const height = Number(action.height || 1000); |
| await page.setViewportSize({ width, height }); |
| break; |
| } |
| case 'goto': { |
| let targetUrl = context.baseUrl; |
| if (action.url) { |
| targetUrl = String(action.url); |
| } else if (action.path) { |
| targetUrl = new URL(String(action.path), context.baseUrl).toString(); |
| } |
| await page.goto(targetUrl, { waitUntil: action.waitUntil || 'networkidle' }); |
| break; |
| } |
| case 'setStyle': { |
| const selector = String(action.selector || '').trim(); |
| const property = String(action.property || '').trim(); |
| if (!selector || !property) { |
| throw new Error('setStyle action requires "selector" and "property"'); |
| } |
| const value = String(action.value || ''); |
| const important = Boolean(action.important); |
| await page.evaluate( |
| ({ targetSelector, cssProperty, cssValue, useImportant }) => { |
| const element = document.querySelector(targetSelector); |
| if (!element) { |
| throw new Error('Style target selector not found: ' + targetSelector); |
| } |
| element.style.setProperty(cssProperty, cssValue, useImportant ? 'important' : ''); |
| }, |
| { |
| targetSelector: selector, |
| cssProperty: property, |
| cssValue: value, |
| useImportant: important |
| } |
| ); |
| break; |
| } |
| case 'setAttribute': { |
| const selector = String(action.selector || '').trim(); |
| const attribute = String(action.attribute || '').trim(); |
| if (!selector || !attribute) { |
| throw new Error('setAttribute action requires "selector" and "attribute"'); |
| } |
| if (!Object.prototype.hasOwnProperty.call(action, 'value')) { |
| throw new Error('setAttribute action requires "value"'); |
| } |
| const value = String(action.value); |
| await page.evaluate( |
| ({ targetSelector, attributeName, attributeValue }) => { |
| const element = document.querySelector(targetSelector); |
| if (!element) { |
| throw new Error('Attribute target selector not found: ' + targetSelector); |
| } |
| element.setAttribute(attributeName, attributeValue); |
| }, |
| { |
| targetSelector: selector, |
| attributeName: attribute, |
| attributeValue: value |
| } |
| ); |
| break; |
| } |
| case 'markLinkByText': { |
| const text = String(action.text || '').trim(); |
| if (!text) { |
| throw new Error('markLinkByText action requires "text"'); |
| } |
| const exact = Boolean(action.exact); |
| const includeTitle = action.includeTitle !== false; |
| const attributeName = String(action.attribute || 'data-gendoc-link-target').trim(); |
| const attributeValue = String( |
| Object.prototype.hasOwnProperty.call(action, 'value') ? action.value : '1' |
| ); |
| await page.evaluate( |
| ({ targetText, useExact, matchTitle, markerAttr, markerValue }) => { |
| const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim(); |
| const expected = normalize(targetText); |
| if (!expected) { |
| throw new Error('markLinkByText action received empty text'); |
| } |
| const links = Array.from(document.querySelectorAll('a')); |
| for (const link of links) { |
| link.removeAttribute(markerAttr); |
| } |
| const matches = (value) => (useExact ? value === expected : value.includes(expected)); |
| const isVisible = (element) => { |
| const style = window.getComputedStyle(element); |
| if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { |
| return false; |
| } |
| return element.getClientRects().length > 0; |
| }; |
| |
| let found = null; |
| for (const link of links) { |
| if (!isVisible(link)) { |
| continue; |
| } |
| const label = normalize(link.textContent); |
| const title = normalize(link.getAttribute('title')); |
| if (matches(label) || (matchTitle && matches(title))) { |
| found = link; |
| break; |
| } |
| } |
| |
| if (!found) { |
| throw new Error('No visible link found for text: ' + expected); |
| } |
| found.setAttribute(markerAttr, markerValue); |
| }, |
| { |
| targetText: text, |
| useExact: exact, |
| matchTitle: includeTitle, |
| markerAttr: attributeName, |
| markerValue: attributeValue |
| } |
| ); |
| break; |
| } |
| case 'seedDraftsCache': { |
| const entries = Array.isArray(action.entries) ? action.entries : []; |
| const clearExisting = action.clearExisting !== false; |
| await page.evaluate( |
| async ({ sampleEntries, shouldClear }) => { |
| function buildSampleDoc(entry, id) { |
| const title = String(entry.title || ('Sample draft ' + id)); |
| const description = String(entry.description || title); |
| const score = Number(entry.score); |
| const hasScore = !isNaN(score); |
| const roundedScore = hasScore ? Number(score.toFixed(1)) : null; |
| const severity = !hasScore |
| ? null |
| : roundedScore >= 9 |
| ? 'CRITICAL' |
| : roundedScore >= 7 |
| ? 'HIGH' |
| : roundedScore >= 4 |
| ? 'MEDIUM' |
| : 'LOW'; |
| const publicDate = entry.publicDate |
| ? String(entry.publicDate) |
| : new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); |
| |
| const cna = { |
| title: title, |
| descriptions: [ |
| { |
| lang: 'en', |
| value: description |
| } |
| ], |
| affected: [ |
| { |
| vendor: 'Example Vendor', |
| product: 'Example Product', |
| versions: [ |
| { |
| status: 'affected', |
| version: '1.0' |
| } |
| ] |
| } |
| ], |
| references: [ |
| { |
| url: 'https://example.com/advisories/' + encodeURIComponent(id) |
| } |
| ], |
| datePublic: publicDate |
| }; |
| |
| if (hasScore) { |
| cna.metrics = [ |
| { |
| cvssV3_1: { |
| version: '3.1', |
| vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', |
| baseScore: roundedScore, |
| baseSeverity: severity |
| } |
| } |
| ]; |
| } |
| |
| return { |
| dataType: 'CVE_RECORD', |
| dataVersion: '5.1', |
| cveMetadata: { |
| cveId: id, |
| state: 'PUBLISHED', |
| assigningCna: 'example-cna' |
| }, |
| containers: { |
| cna: cna |
| } |
| }; |
| } |
| |
| const start = Date.now(); |
| while (!(window.draftsCache && typeof window.draftsCache.save === 'function')) { |
| if (Date.now() - start > 5000) { |
| throw new Error('window.draftsCache is unavailable'); |
| } |
| await new Promise((resolve) => setTimeout(resolve, 50)); |
| } |
| |
| const cache = window.draftsCache; |
| if (typeof cache.cancelSave === 'function') { |
| cache.cancelSave(); |
| } |
| |
| if ( |
| shouldClear && |
| typeof cache.getAll === 'function' && |
| typeof cache.remove === 'function' |
| ) { |
| const existing = await cache.getAll(); |
| for (const entry of existing || []) { |
| if (entry && entry.id) { |
| await cache.remove(entry.id); |
| } |
| } |
| } |
| |
| for (const entry of sampleEntries) { |
| if (!entry || !entry.id) { |
| continue; |
| } |
| const id = String(entry.id).trim(); |
| if (!id) { |
| continue; |
| } |
| const errorCount = Number(entry.errorCount); |
| const doc = |
| entry.doc && typeof entry.doc === 'object' |
| ? entry.doc |
| : buildSampleDoc(entry, id); |
| await cache.save(id, doc, isNaN(errorCount) ? 0 : errorCount); |
| } |
| }, |
| { |
| sampleEntries: entries, |
| shouldClear: clearExisting |
| } |
| ); |
| break; |
| } |
| case 'openDraftPublishDialog': { |
| await page.evaluate(async () => { |
| if (typeof window.cveOpenDraftPublishDialog === 'function') { |
| await Promise.resolve(window.cveOpenDraftPublishDialog()); |
| return; |
| } |
| const trigger = document.getElementById('draftPublishOpenBtn'); |
| if (!trigger) { |
| throw new Error('Draft publish dialog trigger is unavailable'); |
| } |
| trigger.click(); |
| }); |
| break; |
| } |
| case 'loadCveDocument': { |
| const cveId = String(action.cveId || '').trim(); |
| if (!cveId) { |
| throw new Error('loadCveDocument action requires "cveId"'); |
| } |
| await page.evaluate(async (docId) => { |
| if (typeof window.cveLoad !== 'function') { |
| throw new Error('window.cveLoad is unavailable'); |
| } |
| await Promise.resolve(window.cveLoad(docId)); |
| }, cveId); |
| break; |
| } |
| case 'addArrowOverlay': { |
| const selector = String(action.selector || '').trim(); |
| if (!selector) { |
| throw new Error('addArrowOverlay action requires "selector"'); |
| } |
| const color = String(action.color || '#d62828'); |
| const outlineColor = String(action.outlineColor || '#ffffff'); |
| const width = Math.max(4, Number(action.width || 14)); |
| const outlineWidth = Math.max(width + 2, Number(action.outlineWidth || width + 8)); |
| const startOffsetX = Number(action.startOffsetX || -320); |
| const startOffsetY = Number(action.startOffsetY || 200); |
| const endOffsetX = Number(action.endOffsetX || 0); |
| const endOffsetY = Number(action.endOffsetY || 0); |
| const curve = Number(action.curve || 110); |
| |
| await page.evaluate( |
| ({ |
| targetSelector, |
| strokeColor, |
| strokeOutlineColor, |
| strokeWidth, |
| strokeOutlineWidth, |
| offsetX, |
| offsetY, |
| targetOffsetX, |
| targetOffsetY, |
| curveY |
| }) => { |
| const target = document.querySelector(targetSelector); |
| if (!target) { |
| throw new Error('Arrow target selector not found: ' + targetSelector); |
| } |
| |
| const rect = target.getBoundingClientRect(); |
| const endX = rect.left + rect.width / 2 + targetOffsetX; |
| const endY = rect.top + rect.height / 2 + targetOffsetY; |
| const startX = endX + offsetX; |
| const startY = endY + offsetY; |
| |
| const c1X = startX + (endX - startX) * 0.35; |
| const c1Y = startY - curveY; |
| const c2X = startX + (endX - startX) * 0.75; |
| const c2Y = endY + curveY * 0.2; |
| const pathD = ['M', startX, startY, 'C', c1X, c1Y, c2X, c2Y, endX, endY].join(' '); |
| |
| const svgNS = 'http://www.w3.org/2000/svg'; |
| const overlayId = '__gendoc_arrow_overlay__'; |
| const outlineMarkerId = '__gendoc_arrow_head_outline__'; |
| const colorMarkerId = '__gendoc_arrow_head_color__'; |
| |
| let overlay = document.getElementById(overlayId); |
| if (!overlay) { |
| overlay = document.createElementNS(svgNS, 'svg'); |
| overlay.setAttribute('id', overlayId); |
| overlay.style.position = 'fixed'; |
| overlay.style.left = '0'; |
| overlay.style.top = '0'; |
| overlay.style.width = '100vw'; |
| overlay.style.height = '100vh'; |
| overlay.style.pointerEvents = 'none'; |
| overlay.style.zIndex = '2147483647'; |
| overlay.style.overflow = 'visible'; |
| if (document.body) { |
| document.body.appendChild(overlay); |
| } else { |
| throw new Error('Document body is unavailable for arrow overlay'); |
| } |
| } |
| |
| const viewportWidth = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1); |
| const viewportHeight = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1); |
| overlay.setAttribute('viewBox', '0 0 ' + viewportWidth + ' ' + viewportHeight); |
| overlay.setAttribute('width', String(viewportWidth)); |
| overlay.setAttribute('height', String(viewportHeight)); |
| overlay.replaceChildren(); |
| |
| const defs = document.createElementNS(svgNS, 'defs'); |
| const markerOutline = document.createElementNS(svgNS, 'marker'); |
| const markerColor = document.createElementNS(svgNS, 'marker'); |
| const outlineHeadSize = Math.max(strokeOutlineWidth * 2.2, 24); |
| const colorHeadSize = Math.max(strokeWidth * 2.2, 20); |
| |
| markerOutline.setAttribute('id', outlineMarkerId); |
| markerOutline.setAttribute('viewBox', '0 0 ' + outlineHeadSize + ' ' + outlineHeadSize); |
| markerOutline.setAttribute('refX', String(outlineHeadSize - 2)); |
| markerOutline.setAttribute('refY', String(outlineHeadSize / 2)); |
| markerOutline.setAttribute('markerWidth', String(outlineHeadSize)); |
| markerOutline.setAttribute('markerHeight', String(outlineHeadSize)); |
| markerOutline.setAttribute('orient', 'auto'); |
| markerOutline.setAttribute('markerUnits', 'userSpaceOnUse'); |
| const outlineHead = document.createElementNS(svgNS, 'path'); |
| outlineHead.setAttribute( |
| 'd', |
| 'M 0 0 L ' + outlineHeadSize + ' ' + outlineHeadSize / 2 + ' L 0 ' + outlineHeadSize + ' z' |
| ); |
| outlineHead.setAttribute('fill', strokeOutlineColor); |
| markerOutline.appendChild(outlineHead); |
| |
| markerColor.setAttribute('id', colorMarkerId); |
| markerColor.setAttribute('viewBox', '0 0 ' + colorHeadSize + ' ' + colorHeadSize); |
| markerColor.setAttribute('refX', String(colorHeadSize - 2)); |
| markerColor.setAttribute('refY', String(colorHeadSize / 2)); |
| markerColor.setAttribute('markerWidth', String(colorHeadSize)); |
| markerColor.setAttribute('markerHeight', String(colorHeadSize)); |
| markerColor.setAttribute('orient', 'auto'); |
| markerColor.setAttribute('markerUnits', 'userSpaceOnUse'); |
| const colorHead = document.createElementNS(svgNS, 'path'); |
| colorHead.setAttribute( |
| 'd', |
| 'M 0 0 L ' + colorHeadSize + ' ' + colorHeadSize / 2 + ' L 0 ' + colorHeadSize + ' z' |
| ); |
| colorHead.setAttribute('fill', strokeColor); |
| markerColor.appendChild(colorHead); |
| |
| defs.appendChild(markerOutline); |
| defs.appendChild(markerColor); |
| overlay.appendChild(defs); |
| |
| const outlinePath = document.createElementNS(svgNS, 'path'); |
| outlinePath.setAttribute('d', pathD); |
| outlinePath.setAttribute('fill', 'none'); |
| outlinePath.setAttribute('stroke', strokeOutlineColor); |
| outlinePath.setAttribute('stroke-width', String(strokeOutlineWidth)); |
| outlinePath.setAttribute('stroke-linecap', 'round'); |
| outlinePath.setAttribute('stroke-linejoin', 'round'); |
| outlinePath.setAttribute('marker-end', 'url(#' + outlineMarkerId + ')'); |
| overlay.appendChild(outlinePath); |
| |
| const colorPath = document.createElementNS(svgNS, 'path'); |
| colorPath.setAttribute('d', pathD); |
| colorPath.setAttribute('fill', 'none'); |
| colorPath.setAttribute('stroke', strokeColor); |
| colorPath.setAttribute('stroke-width', String(strokeWidth)); |
| colorPath.setAttribute('stroke-linecap', 'round'); |
| colorPath.setAttribute('stroke-linejoin', 'round'); |
| colorPath.setAttribute('marker-end', 'url(#' + colorMarkerId + ')'); |
| overlay.appendChild(colorPath); |
| }, |
| { |
| targetSelector: selector, |
| strokeColor: color, |
| strokeOutlineColor: outlineColor, |
| strokeWidth: width, |
| strokeOutlineWidth: outlineWidth, |
| offsetX: startOffsetX, |
| offsetY: startOffsetY, |
| targetOffsetX: endOffsetX, |
| targetOffsetY: endOffsetY, |
| curveY: curve |
| } |
| ); |
| break; |
| } |
| case 'addCircleOverlay': { |
| const selector = String(action.selector || '').trim(); |
| if (!selector) { |
| throw new Error('addCircleOverlay action requires "selector"'); |
| } |
| const color = String(action.color || '#d62828'); |
| const outlineColor = String(action.outlineColor || '#ffffff'); |
| const width = Math.max(2, Number(action.width || 8)); |
| const outlineWidth = Math.max(width + 2, Number(action.outlineWidth || width + 6)); |
| const paddingX = Number(action.paddingX || 24); |
| const paddingY = Number(action.paddingY || 16); |
| const centerOffsetX = Number(action.centerOffsetX || 0); |
| const centerOffsetY = Number(action.centerOffsetY || 0); |
| const containerSelector = String(action.containerSelector || '').trim(); |
| |
| await page.evaluate( |
| ({ |
| targetSelector, |
| strokeColor, |
| strokeOutlineColor, |
| strokeWidth, |
| strokeOutlineWidth, |
| padX, |
| padY, |
| offsetX, |
| offsetY, |
| overlayContainerSelector |
| }) => { |
| const target = document.querySelector(targetSelector); |
| if (!target) { |
| throw new Error('Circle target selector not found: ' + targetSelector); |
| } |
| |
| const rect = target.getBoundingClientRect(); |
| const cx = rect.left + rect.width / 2 + offsetX; |
| const cy = rect.top + rect.height / 2 + offsetY; |
| const rx = Math.max(10, rect.width / 2 + padX); |
| const ry = Math.max(10, rect.height / 2 + padY); |
| |
| const svgNS = 'http://www.w3.org/2000/svg'; |
| const overlayId = '__gendoc_circle_overlay__'; |
| let overlayHost = document.body; |
| if (overlayContainerSelector) { |
| overlayHost = document.querySelector(overlayContainerSelector); |
| if (!overlayHost) { |
| throw new Error('Circle overlay container not found: ' + overlayContainerSelector); |
| } |
| } |
| |
| let overlay = document.getElementById(overlayId); |
| if (!overlay) { |
| overlay = document.createElementNS(svgNS, 'svg'); |
| overlay.setAttribute('id', overlayId); |
| overlay.style.position = 'fixed'; |
| overlay.style.left = '0'; |
| overlay.style.top = '0'; |
| overlay.style.width = '100vw'; |
| overlay.style.height = '100vh'; |
| overlay.style.pointerEvents = 'none'; |
| overlay.style.zIndex = '2147483647'; |
| overlay.style.overflow = 'visible'; |
| overlayHost.appendChild(overlay); |
| } else if (overlay.parentElement !== overlayHost) { |
| overlay.remove(); |
| overlayHost.appendChild(overlay); |
| } |
| |
| const viewportWidth = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1); |
| const viewportHeight = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1); |
| overlay.setAttribute('viewBox', '0 0 ' + viewportWidth + ' ' + viewportHeight); |
| overlay.setAttribute('width', String(viewportWidth)); |
| overlay.setAttribute('height', String(viewportHeight)); |
| overlay.replaceChildren(); |
| |
| const outlineEllipse = document.createElementNS(svgNS, 'ellipse'); |
| outlineEllipse.setAttribute('cx', String(cx)); |
| outlineEllipse.setAttribute('cy', String(cy)); |
| outlineEllipse.setAttribute('rx', String(rx)); |
| outlineEllipse.setAttribute('ry', String(ry)); |
| outlineEllipse.setAttribute('fill', 'none'); |
| outlineEllipse.setAttribute('stroke', strokeOutlineColor); |
| outlineEllipse.setAttribute('stroke-width', String(strokeOutlineWidth)); |
| overlay.appendChild(outlineEllipse); |
| |
| const colorEllipse = document.createElementNS(svgNS, 'ellipse'); |
| colorEllipse.setAttribute('cx', String(cx)); |
| colorEllipse.setAttribute('cy', String(cy)); |
| colorEllipse.setAttribute('rx', String(rx)); |
| colorEllipse.setAttribute('ry', String(ry)); |
| colorEllipse.setAttribute('fill', 'none'); |
| colorEllipse.setAttribute('stroke', strokeColor); |
| colorEllipse.setAttribute('stroke-width', String(strokeWidth)); |
| overlay.appendChild(colorEllipse); |
| }, |
| { |
| targetSelector: selector, |
| strokeColor: color, |
| strokeOutlineColor: outlineColor, |
| strokeWidth: width, |
| strokeOutlineWidth: outlineWidth, |
| padX: paddingX, |
| padY: paddingY, |
| offsetX: centerOffsetX, |
| offsetY: centerOffsetY, |
| overlayContainerSelector: containerSelector |
| } |
| ); |
| break; |
| } |
| case 'showBrowserFrame': { |
| const heightPx = Math.max(48, Number(action.height || 64)); |
| const radiusPx = Math.max(8, Number(action.radius || 14)); |
| const displayUrl = action.url ? String(action.url) : ''; |
| await page.evaluate( |
| ({ frameHeight, frameRadius, urlText }) => { |
| const frameId = '__gendoc_browser_frame__'; |
| const styleId = '__gendoc_browser_frame_style__'; |
| const doc = document; |
| const body = doc.body; |
| const head = doc.head || doc.documentElement; |
| if (!body || !head) { |
| return; |
| } |
| |
| const existingFrame = doc.getElementById(frameId); |
| if (existingFrame) { |
| const urlNode = existingFrame.querySelector('[data-gendoc-url]'); |
| if (urlNode) { |
| urlNode.textContent = urlText || window.location.href; |
| } |
| return; |
| } |
| |
| if (!doc.getElementById(styleId)) { |
| const style = doc.createElement('style'); |
| style.id = styleId; |
| style.textContent = |
| 'body[data-gendoc-browser-frame="1"]{' + |
| 'margin:0 !important;' + |
| 'padding-top:calc(' + |
| frameHeight + |
| 'px + 12px) !important;' + |
| 'background:#e9edf4 !important;' + |
| 'box-sizing:border-box;' + |
| '}' + |
| '#' + |
| frameId + |
| '{position:fixed;top:10px;left:12px;right:12px;height:' + |
| frameHeight + |
| 'px;background:#f8fafc;border:1px solid #c7d2e3;border-radius:' + |
| frameRadius + |
| 'px;display:flex;align-items:center;gap:10px;padding:0 12px;z-index:2147483647;box-shadow:0 4px 12px rgba(16,24,40,0.08);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}' + |
| '#' + |
| frameId + |
| ' .gendoc-dot{width:11px;height:11px;border-radius:50%;display:inline-block;}' + |
| '#' + |
| frameId + |
| ' .gendoc-url{flex:1;min-width:0;padding:10px 14px;border-radius:999px;background:#eef2f8;border:1px solid #d0dae8;color:#1f2937;font-size:15px;line-height:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}'; |
| head.appendChild(style); |
| } |
| |
| const frame = doc.createElement('div'); |
| frame.id = frameId; |
| frame.innerHTML = |
| '<span class="gendoc-dot" style="background:#ff5f57"></span>' + |
| '<span class="gendoc-dot" style="background:#ffbd2e"></span>' + |
| '<span class="gendoc-dot" style="background:#28c840"></span>' + |
| '<div class="gendoc-url" data-gendoc-url></div>'; |
| |
| body.setAttribute('data-gendoc-browser-frame', '1'); |
| body.prepend(frame); |
| |
| const urlNode = frame.querySelector('[data-gendoc-url]'); |
| if (urlNode) { |
| urlNode.textContent = urlText || window.location.href; |
| } |
| }, |
| { frameHeight: heightPx, frameRadius: radiusPx, urlText: displayUrl } |
| ); |
| break; |
| } |
| case 'waitForSelector': { |
| await page.waitForSelector(String(action.selector), { |
| state: action.state || 'visible', |
| timeout: Number(action.timeout || 30000) |
| }); |
| break; |
| } |
| case 'click': { |
| await page.click(String(action.selector), { |
| timeout: Number(action.timeout || 30000) |
| }); |
| break; |
| } |
| case 'waitForTimeout': { |
| await page.waitForTimeout(Number(action.ms || 200)); |
| break; |
| } |
| case 'selectTab': { |
| const tabId = String(action.tabId || ''); |
| if (!tabId) { |
| throw new Error('selectTab action requires "tabId"'); |
| } |
| await page.click('label[for="' + tabId + '"]'); |
| if (action.waitForSelector) { |
| await page.waitForSelector(String(action.waitForSelector), { |
| state: 'visible', |
| timeout: Number(action.timeout || 30000) |
| }); |
| } |
| break; |
| } |
| case 'showPortalLogin': { |
| await page.evaluate(() => { |
| var dialog = document.getElementById('cvePortalDialog'); |
| if (typeof window.showPortalLogin === 'function') { |
| window.showPortalLogin(); |
| } else { |
| var nav = document.getElementById('cvePortalNav'); |
| if (nav) { |
| nav.click(); |
| } |
| } |
| if (dialog && !dialog.open && typeof dialog.showModal === 'function') { |
| dialog.showModal(); |
| } |
| }); |
| break; |
| } |
| case 'renderPortalMock': { |
| const variant = String(action.variant || 'member'); |
| await page.evaluate((kind) => { |
| var dialog = document.getElementById('cvePortalDialog'); |
| var port = document.getElementById('port'); |
| if (!dialog || !port) { |
| throw new Error('Portal dialog container is missing'); |
| } |
| if (!dialog.open && typeof dialog.showModal === 'function') { |
| dialog.showModal(); |
| } |
| if (typeof window.cveRender !== 'function') { |
| port.innerHTML = '<div class="pad">Portal template render function is unavailable.</div>'; |
| return; |
| } |
| |
| var isAdmin = kind === 'admin'; |
| var userInfo = { |
| username: isAdmin ? 'admin.user' : 'cna.user', |
| name: { |
| first: isAdmin ? 'Portal' : 'CNA', |
| last: isAdmin ? 'Admin' : 'User' |
| }, |
| active: true, |
| authority: { |
| active_roles: isAdmin ? ['ADMIN'] : ['USER'] |
| } |
| }; |
| var orgInfo = { |
| name: 'Example CNA', |
| short_name: 'example-cna', |
| authority: { |
| active_roles: ['CNA'] |
| } |
| }; |
| |
| port.innerHTML = window.cveRender({ |
| ctemplate: 'portal', |
| portalType: 'test', |
| userInfo: userInfo, |
| org: orgInfo |
| }); |
| |
| if (isAdmin) { |
| var data = [ |
| { |
| username: 'getid', |
| org_UUID: 'ba34e592-f0ae-45a4-bc65-de9154191442', |
| time: { |
| created: '2022-05-06T02:15:30.328Z', |
| modified: '2024-10-22T18:49:26.224Z' |
| }, |
| UUID: 'cd8598c5-4d8c-4fbc-9196-787ec1ad187b', |
| active: true, |
| authority: { |
| active_roles: [] |
| }, |
| name: { |
| first: 'Get', |
| last: 'Ident', |
| middle: '', |
| suffix: '' |
| } |
| }, |
| { |
| username: 'alice.redwood', |
| org_UUID: 'ba34e592-f0ae-45a4-bc65-de9154191442', |
| time: { |
| created: '2023-01-10T09:20:11.100Z', |
| modified: '2025-01-14T11:03:00.500Z' |
| }, |
| UUID: '63ed3ae8-68f7-4ead-b8f4-4c22fa730001', |
| active: true, |
| authority: { |
| active_roles: ['ADMIN'] |
| }, |
| name: { |
| first: 'Alice', |
| last: 'Redwood', |
| middle: '', |
| suffix: '' |
| } |
| }, |
| { |
| username: 'bruno.kent', |
| org_UUID: 'ba34e592-f0ae-45a4-bc65-de9154191442', |
| time: { |
| created: '2023-02-18T13:42:19.000Z', |
| modified: '2024-09-12T08:17:44.000Z' |
| }, |
| UUID: '63ed3ae8-68f7-4ead-b8f4-4c22fa730002', |
| active: true, |
| authority: { |
| active_roles: ['USER'] |
| }, |
| name: { |
| first: 'Bruno', |
| last: 'Kent', |
| middle: '', |
| suffix: '' |
| } |
| }, |
| { |
| username: 'cora.fields', |
| org_UUID: 'ba34e592-f0ae-45a4-bc65-de9154191442', |
| time: { |
| created: '2023-03-22T16:00:00.000Z', |
| modified: '2025-02-03T21:12:05.000Z' |
| }, |
| UUID: '63ed3ae8-68f7-4ead-b8f4-4c22fa730003', |
| active: true, |
| authority: { |
| active_roles: ['USER'] |
| }, |
| name: { |
| first: 'Cora', |
| last: 'Fields', |
| middle: 'M', |
| suffix: '' |
| } |
| }, |
| { |
| username: 'diego.lake', |
| org_UUID: 'ba34e592-f0ae-45a4-bc65-de9154191442', |
| time: { |
| created: '2023-05-01T07:33:28.000Z', |
| modified: '2024-11-29T10:02:31.000Z' |
| }, |
| UUID: '63ed3ae8-68f7-4ead-b8f4-4c22fa730004', |
| active: false, |
| authority: { |
| active_roles: ['USER'] |
| }, |
| name: { |
| first: 'Diego', |
| last: 'Lake', |
| middle: '', |
| suffix: '' |
| } |
| }, |
| { |
| username: 'eva.north', |
| org_UUID: 'ba34e592-f0ae-45a4-bc65-de9154191442', |
| time: { |
| created: '2023-07-12T12:44:53.000Z', |
| modified: '2025-01-19T19:45:17.000Z' |
| }, |
| UUID: '63ed3ae8-68f7-4ead-b8f4-4c22fa730005', |
| active: true, |
| authority: { |
| active_roles: [] |
| }, |
| name: { |
| first: 'Eva', |
| last: 'North', |
| middle: '', |
| suffix: 'Jr.' |
| } |
| }, |
| { |
| username: 'farah.hill', |
| org_UUID: 'ba34e592-f0ae-45a4-bc65-de9154191442', |
| time: { |
| created: '2023-09-03T05:27:40.000Z', |
| modified: '2024-12-08T14:08:09.000Z' |
| }, |
| UUID: '63ed3ae8-68f7-4ead-b8f4-4c22fa730006', |
| active: true, |
| authority: { |
| active_roles: ['USER'] |
| }, |
| name: { |
| first: 'Farah', |
| last: 'Hill', |
| middle: '', |
| suffix: '' |
| } |
| } |
| ]; |
| |
| var userListPopup = document.getElementById('userListPopup'); |
| if (userListPopup) { |
| userListPopup.setAttribute('ontoggle', ''); |
| } |
| |
| var userList = document.getElementById('userlist'); |
| if (userList) { |
| userList.innerHTML = window.cveRender({ |
| ctemplate: 'listUsers', |
| users: data |
| }); |
| } |
| } |
| }, variant); |
| break; |
| } |
| case 'renderCveListMock': { |
| const count = Math.max(1, Math.min(200, Number(action.count || 10))); |
| const year = Math.max(2000, Math.min(9999, Number(action.year || 2025))); |
| const startSequence = Math.max(1, Number(action.startSequence || 1000)); |
| const requester = String(action.requester || 'cna.user'); |
| const defaultState = String(action.state || 'RESERVED').toUpperCase(); |
| const entries = Array.isArray(action.entries) ? action.entries : []; |
| await page.evaluate( |
| ({ sampleEntries, sampleCount, idYear, sequenceStart, defaultRequester, fallbackState }) => { |
| function padSequence(value) { |
| return String(value).padStart(4, '0'); |
| } |
| |
| function makeTime(index) { |
| const base = Date.UTC(idYear, 0, 1, 10, 0, 0); |
| const created = new Date(base + index * 24 * 60 * 60 * 1000).toISOString(); |
| const modified = new Date(base + index * 24 * 60 * 60 * 1000 + 4 * 60 * 60 * 1000).toISOString(); |
| return { created, modified }; |
| } |
| |
| function normalizeState(value) { |
| const next = String(value || fallbackState || 'RESERVED').toUpperCase(); |
| if (next === 'RESERVED' || next === 'PUBLISHED' || next === 'REJECTED') { |
| return next; |
| } |
| return 'RESERVED'; |
| } |
| |
| function normalizeEntry(entry, index) { |
| const suffix = padSequence(sequenceStart + index); |
| const cveId = entry && entry.cve_id ? String(entry.cve_id) : 'CVE-' + idYear + '-' + suffix; |
| const state = normalizeState(entry && entry.state); |
| const requestedBy = |
| entry && entry.requested_by && entry.requested_by.user |
| ? String(entry.requested_by.user) |
| : defaultRequester; |
| const time = |
| entry && |
| entry.time && |
| entry.time.created && |
| entry.time.modified && |
| !isNaN(Date.parse(entry.time.created)) && |
| !isNaN(Date.parse(entry.time.modified)) |
| ? { |
| created: String(entry.time.created), |
| modified: String(entry.time.modified) |
| } |
| : makeTime(index); |
| return { |
| cve_id: cveId, |
| state: state, |
| requested_by: { |
| user: requestedBy |
| }, |
| time: time |
| }; |
| } |
| |
| const list = []; |
| if (sampleEntries.length > 0) { |
| for (let i = 0; i < sampleEntries.length; i += 1) { |
| list.push(normalizeEntry(sampleEntries[i], i)); |
| } |
| } else { |
| for (let i = 0; i < sampleCount; i += 1) { |
| list.push(normalizeEntry(null, i)); |
| } |
| } |
| |
| const cveList = document.getElementById('cveList'); |
| if (!cveList) { |
| throw new Error('#cveList container is unavailable'); |
| } |
| if (typeof window.cveRender !== 'function') { |
| throw new Error('window.cveRender is unavailable'); |
| } |
| |
| cveList.innerHTML = window.cveRender({ |
| ctemplate: 'listIds', |
| cveIds: list, |
| editable: true, |
| inlineLoad: true, |
| docPathBase: '/cve5/' |
| }); |
| }, |
| { |
| sampleEntries: entries, |
| sampleCount: count, |
| idYear: year, |
| sequenceStart: startSequence, |
| defaultRequester: requester, |
| fallbackState: defaultState |
| } |
| ); |
| break; |
| } |
| case 'openUserList': { |
| await page.evaluate(() => { |
| var summary = document.querySelector('#userListPopup > summary'); |
| if (summary) { |
| summary.click(); |
| } |
| }); |
| break; |
| } |
| case 'scrollToSelector': { |
| const selector = String(action.selector || '').trim(); |
| if (!selector) { |
| throw new Error('scrollToSelector action requires "selector"'); |
| } |
| const block = String(action.block || 'center'); |
| const inline = String(action.inline || 'nearest'); |
| await page.evaluate( |
| ({ targetSelector, targetBlock, targetInline }) => { |
| const element = document.querySelector(targetSelector); |
| if (!element) { |
| throw new Error('Scroll target selector not found: ' + targetSelector); |
| } |
| element.scrollIntoView({ |
| behavior: 'auto', |
| block: targetBlock, |
| inline: targetInline |
| }); |
| }, |
| { |
| targetSelector: selector, |
| targetBlock: block, |
| targetInline: inline |
| } |
| ); |
| if (action.ms) { |
| await page.waitForTimeout(Number(action.ms)); |
| } |
| break; |
| } |
| default: |
| throw new Error('Unsupported action: ' + name); |
| } |
| } |
| |
| async function captureScreenshot(page, shot, screenshotsDir, context) { |
| const outputPath = path.join(screenshotsDir, shot.file); |
| |
| for (const step of shot.steps) { |
| await applyAction(page, step, context); |
| } |
| |
| let screenshotBuffer; |
| if (shot.target) { |
| screenshotBuffer = await page.locator(shot.target).first().screenshot({ |
| animations: 'disabled', |
| type: 'png' |
| }); |
| } else { |
| screenshotBuffer = await page.screenshot({ |
| fullPage: Boolean(shot.fullPage), |
| animations: 'disabled', |
| type: 'png' |
| }); |
| } |
| const framedBuffer = await addScreenshotFrame(context.browser, screenshotBuffer, shot); |
| |
| const nextHash = hashBuffer(framedBuffer); |
| let previousHash = null; |
| try { |
| previousHash = await fileHash(outputPath); |
| } catch (error) { |
| if (!(error && error.code === 'ENOENT')) { |
| throw error; |
| } |
| } |
| |
| const changed = context.force || nextHash !== previousHash; |
| if (changed) { |
| await fsp.mkdir(path.dirname(outputPath), { recursive: true }); |
| await fsp.writeFile(outputPath, framedBuffer); |
| } |
| |
| return { |
| id: shot.id, |
| file: shot.file, |
| hash: nextHash, |
| changed, |
| exists: true |
| }; |
| } |
| |
| async function getExistingScreenshotStatus(shot, screenshotsDir) { |
| const outputPath = path.join(screenshotsDir, shot.file); |
| try { |
| const hash = await fileHash(outputPath); |
| return { |
| id: shot.id, |
| file: shot.file, |
| hash, |
| changed: false, |
| exists: true |
| }; |
| } catch (error) { |
| if (error && error.code === 'ENOENT') { |
| return { |
| id: shot.id, |
| file: shot.file, |
| hash: null, |
| changed: false, |
| exists: false |
| }; |
| } |
| throw error; |
| } |
| } |
| |
| function buildMarkdown(manifest, screenshotStatuses, generatedAt) { |
| const uiBadgeRules = [ |
| { label: 'CVE CNA Portal', icon: 'org' }, |
| { label: 'CVE Portal', icon: 'org' }, |
| { label: 'CNA Portal', icon: 'org' }, |
| { label: 'Reserve One CVE', icon: 'magic' }, |
| { label: 'Publish Selected', icon: 'export' }, |
| { label: 'Publish CVE', icon: 'export' }, |
| { label: 'Drafts Manager', icon: 'edit' }, |
| { label: 'Drafts', icon: 'edit' }, |
| { label: 'Users', icon: 'cog' }, |
| { label: 'Reject this CVE ID', icon: 'del' }, |
| { label: 'Reject this ID', icon: 'no' }, |
| { label: 'Manage', icon: 'export' } |
| ]; |
| |
| function escapeHtmlAttribute(value) { |
| return String(value) |
| .replace(/&/g, '&') |
| .replace(/"/g, '"') |
| .replace(/</g, '<') |
| .replace(/>/g, '>'); |
| } |
| |
| function escapeRegex(value) { |
| return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| } |
| |
| function renderUiBadge(icon, label) { |
| return '<b class="lbl bor vgi-' + icon + '">' + label + '</b>'; |
| } |
| |
| function applyUiBadges(markdownText) { |
| let output = String(markdownText || ''); |
| for (const rule of uiBadgeRules) { |
| const pattern = new RegExp('\\*\\*' + escapeRegex(rule.label) + '\\*\\*', 'g'); |
| output = output.replace(pattern, renderUiBadge(rule.icon, rule.label)); |
| } |
| return output; |
| } |
| |
| const title = manifest.title || 'Using <span class="vgi-logo">Vulnogram</a> with CVE Services'; |
| const sourcePdfTitle = manifest.sourcePdfTitle || 'Using Vulnogram with CVE Services'; |
| const sourcePdfDate = manifest.sourcePdfDate || 'unknown'; |
| const sourcePdf = manifest.sourcePdf; |
| const markdownStylesheet = |
| manifest.markdownStylesheet || 'https://vulnogram.org/css/min.css'; |
| const markdownIconStylesheet = |
| manifest.markdownIconStylesheet || 'https://vulnogram.org/css/vg-icons.css'; |
| const hero = normalizeHeroGraphic(manifest.hero); |
| const screenshotsDir = asPublicDocPath(manifest.screenshotsDir || 'screenshots'); |
| const sections = manifest.sections || []; |
| |
| const lines = []; |
| lines.push('# ' + title); |
| lines.push(''); |
| lines.push('<link rel="stylesheet" href="' + escapeHtmlAttribute(markdownStylesheet) + '" />'); |
| lines.push('<link rel="stylesheet" href="' + escapeHtmlAttribute(markdownIconStylesheet) + '" />'); |
| lines.push( |
| '<style>body {background-color:var(--wht);} .doc-hero {margin:1rem 0 2rem;} .doc-hero img {display:block;width:100%;max-width:1200px;height:auto;border-radius:18px;box-shadow:0 12px 32px rgba(15,23,42,0.18);} .doc-hero a {display:inline-block;}</style>' |
| ); |
| lines.push(''); |
| if (hero) { |
| let heroImage = |
| '<img src="' + |
| escapeHtmlAttribute(hero.src) + |
| '" alt="' + |
| escapeHtmlAttribute(hero.alt) + |
| '"/>'; |
| if (hero.href) { |
| heroImage = |
| '<a href="' + |
| escapeHtmlAttribute(hero.href) + |
| '">' + |
| heroImage + |
| '</a>'; |
| } |
| lines.push('<p class="doc-hero">' + heroImage + '</p>'); |
| if (hero.caption) { |
| lines.push('*' + applyUiBadges(hero.caption) + '*'); |
| } |
| lines.push(''); |
| } |
| for (const section of sections) { |
| lines.push('## ' + section.title); |
| lines.push(''); |
| if (section.text) { |
| lines.push(applyUiBadges(section.text)); |
| lines.push(''); |
| } |
| |
| const sectionShots = manifest.screenshots.filter((shot) => shot.section === section.id); |
| let lastSubsection = null; |
| for (const shot of sectionShots) { |
| const subsection = shot.subsection ? String(shot.subsection).trim() : ''; |
| if (subsection && subsection !== lastSubsection) { |
| lines.push('### ' + subsection); |
| lines.push(''); |
| lastSubsection = subsection; |
| } |
| const status = screenshotStatuses.get(shot.id); |
| const imagePath = './' + asPublicDocPath(path.join(screenshotsDir, shot.file)); |
| lines.push( |
| '<img src="' + |
| escapeHtmlAttribute(imagePath) + |
| '" alt="' + |
| escapeHtmlAttribute(shot.alt) + |
| '"/>' |
| ); |
| if (shot.caption) { |
| lines.push('*' + applyUiBadges(shot.caption) + '*'); |
| } |
| if (!status || !status.exists) { |
| lines.push('_Screenshot missing. Run `node scripts/gendoc.js` to generate this file._'); |
| } |
| lines.push(''); |
| } |
| } |
| return lines.join('\n'); |
| } |
| |
| function selectScreenshots(manifest, onlyIds) { |
| if (!onlyIds || onlyIds.length === 0) { |
| return manifest.screenshots.slice(); |
| } |
| const known = new Set(manifest.screenshots.map((shot) => shot.id)); |
| for (const id of onlyIds) { |
| if (!known.has(id)) { |
| throw new Error('Unknown screenshot ID in --only: ' + id); |
| } |
| } |
| return manifest.screenshots.filter((shot) => onlyIds.includes(shot.id)); |
| } |
| |
| function mergeStatuses(manifest, updates, existingStatuses) { |
| const merged = new Map(); |
| const updateMap = new Map(); |
| for (const item of updates) { |
| updateMap.set(item.id, item); |
| } |
| for (const shot of manifest.screenshots) { |
| if (updateMap.has(shot.id)) { |
| merged.set(shot.id, updateMap.get(shot.id)); |
| } else if (existingStatuses.has(shot.id)) { |
| merged.set(shot.id, existingStatuses.get(shot.id)); |
| } |
| } |
| return merged; |
| } |
| |
| function buildState(manifest, statuses, priorState, generatedAt) { |
| const state = { |
| generatedAt, |
| sourcePdf: manifest.sourcePdf, |
| screenshots: {} |
| }; |
| |
| for (const shot of manifest.screenshots) { |
| const status = statuses.get(shot.id); |
| const previous = priorState && priorState.screenshots ? priorState.screenshots[shot.id] : null; |
| state.screenshots[shot.id] = { |
| file: shot.file, |
| hash: status ? status.hash : previous ? previous.hash : null, |
| exists: status ? status.exists : previous ? previous.exists : false, |
| updatedAt: status && status.changed ? generatedAt : previous ? previous.updatedAt : generatedAt |
| }; |
| } |
| return state; |
| } |
| |
| function summarizeStatuses(statuses) { |
| let updated = 0; |
| let unchanged = 0; |
| let missing = 0; |
| for (const status of statuses.values()) { |
| if (!status.exists) { |
| missing += 1; |
| } else if (status.changed) { |
| updated += 1; |
| } else { |
| unchanged += 1; |
| } |
| } |
| return { updated, unchanged, missing }; |
| } |
| |
| async function main() { |
| const options = parseArgs(process.argv.slice(2)); |
| const manifestPath = path.resolve(PROJECT_ROOT, options.manifestPath); |
| const manifest = normalizeManifest(await readJson(manifestPath)); |
| |
| const docsDir = path.dirname(manifestPath); |
| const screenshotsDir = path.resolve(docsDir, manifest.screenshotsDir || 'screenshots'); |
| const markdownPath = path.resolve(docsDir, manifest.outputMarkdown || 'using-vulnogram-cve-services.md'); |
| const statePath = path.resolve(docsDir, DEFAULT_STATE_FILENAME); |
| |
| await fsp.mkdir(screenshotsDir, { recursive: true }); |
| |
| const priorState = await readJsonIfExists(statePath, { screenshots: {} }); |
| const selectedShots = selectScreenshots(manifest, options.only); |
| |
| const existingStatuses = new Map(); |
| for (const shot of manifest.screenshots) { |
| existingStatuses.set(shot.id, await getExistingScreenshotStatus(shot, screenshotsDir)); |
| } |
| |
| let baseUrl = options.baseUrl || 'http://127.0.0.1:3555/'; |
| let server = null; |
| let browser = null; |
| let capturedStatuses = []; |
| |
| try { |
| if (options.capture) { |
| const playwright = await loadPlaywrightModule(); |
| if (!playwright || !playwright.chromium) { |
| throw new Error( |
| 'Playwright is not installed. Install it with `npm install --save-dev playwright` and `npx playwright install chromium`, or rerun with --no-capture.' |
| ); |
| } |
| |
| if (!options.baseUrl) { |
| const standaloneRoot = path.join(PROJECT_ROOT, 'standalone'); |
| try { |
| server = await startStaticServer(standaloneRoot, options.port); |
| const address = server.address(); |
| const port = typeof address === 'object' && address ? address.port : options.port; |
| baseUrl = 'http://127.0.0.1:' + port + '/'; |
| } catch (error) { |
| const code = error && error.code ? error.code : ''; |
| if (code === 'EPERM' || code === 'EACCES') { |
| baseUrl = pathToFileURL(path.join(standaloneRoot, 'index.html')).toString(); |
| console.warn('Local server unavailable; falling back to file URL capture:', baseUrl); |
| } else { |
| throw error; |
| } |
| } |
| } |
| |
| browser = await playwright.chromium.launch({ headless: true }); |
| for (const shot of selectedShots) { |
| const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); |
| page.setDefaultTimeout(30000); |
| const status = await captureScreenshot(page, shot, screenshotsDir, { |
| baseUrl, |
| force: options.force, |
| browser |
| }); |
| capturedStatuses.push(status); |
| await page.close(); |
| } |
| } |
| } finally { |
| if (browser) { |
| await browser.close(); |
| } |
| if (server) { |
| await closeServer(server); |
| } |
| } |
| |
| let mergedStatuses = existingStatuses; |
| if (capturedStatuses.length > 0) { |
| mergedStatuses = mergeStatuses(manifest, capturedStatuses, existingStatuses); |
| } |
| |
| if (!options.capture) { |
| mergedStatuses = existingStatuses; |
| } |
| |
| const generatedAt = toUtcTimestamp(new Date()); |
| const markdown = buildMarkdown(manifest, mergedStatuses, generatedAt); |
| const markdownChanged = await writeTextIfChanged(markdownPath, markdown); |
| |
| const nextState = buildState(manifest, mergedStatuses, priorState, generatedAt); |
| await writeJson(statePath, nextState); |
| |
| const summary = summarizeStatuses(mergedStatuses); |
| console.log('Documentation generated at:', markdownPath); |
| console.log('Screenshots directory:', screenshotsDir); |
| console.log( |
| 'Screenshot summary:', |
| summary.updated + ' updated,', |
| summary.unchanged + ' unchanged,', |
| summary.missing + ' missing' |
| ); |
| console.log('Markdown', markdownChanged ? 'updated.' : 'unchanged.'); |
| } |
| |
| main().catch((error) => { |
| console.error('gendoc failed:', error && error.message ? error.message : error); |
| process.exit(1); |
| }); |