UNPKG

asciidoctor-kroki

Version:

Asciidoctor extension to convert diagrams to images using Kroki

399 lines (384 loc) 12 kB
// @ts-check import fetch from './fetch.js' import httpClient from './http-client.js' import { KrokiClient, KrokiDiagram } from './kroki-client.js' import fs from './node-fs.js' import { preprocessPlantUML, preprocessStructurizr, preprocessVegaLite, } from './preprocess.js' const isBrowser = () => typeof window === 'object' // A value of 20 (SECURE) disallows the document from attempting to read files from the file system const SAFE_MODE_SECURE = 20 const BUILTIN_ATTRIBUTES = [ 'target', 'width', 'height', 'format', 'fallback', 'link', 'float', 'align', 'role', 'title', 'caption', 'cloaked-context', '$positional', 'subs', 'opts', ] /** * Build an auto-formatting log message describing a skipped diagram block. * * Delegates to the document's `messageWithContext` (Logger.AutoFormattingMessage) * so the message carries a structured `source_location` — recorded as-is by a * MemoryLogger — while still rendering the location inline through * `inspect()`/`toString()` when a stderr Logger formats the line. * * @param {Object} doc - Asciidoctor document. * @param {Error} err - The error that caused the block to be skipped. * @param {string} diagramType - The diagram type (e.g. "plantuml"). * @param {Object} [sourceLocation] - Reader cursor pointing at the block, if any. * @returns {Object|string} An auto-formatting log message. */ const skipDiagramMessage = (doc, err, diagramType, sourceLocation) => { const text = `Skipping ${diagramType} block: ${err.message || err}` if (typeof doc.messageWithContext === 'function') { return doc.messageWithContext(text, { source_location: sourceLocation }) } // Fallback for Asciidoctor versions without the logging mixin on Document. if (!sourceLocation) { return text } const message = { text, source_location: sourceLocation } message.inspect = message.toString = () => `${sourceLocation}: ${text}` return message } const createImageSrc = async ( doc, krokiDiagram, target, vfs, krokiClient, inline, ) => { if ( doc.isAttribute('kroki-fetch-diagram') && doc.getSafe() < SAFE_MODE_SECURE ) { return fetch.save(krokiDiagram, doc, target, vfs, krokiClient) } // The `inline` option asks the converter to embed the diagram (e.g. inline // SVG). The converter can fetch the content itself from the server URL when // `allow-uri-read` is set; otherwise it has no way to obtain it, so resolve the // diagram to a data-URI here. We still only set the image target — the // converter decides how to render it — so DocBook, PDF and other backends keep // working from the same data-URI image. if ( inline && !doc.isAttribute('allow-uri-read') && doc.getSafe() < SAFE_MODE_SECURE ) { return fetch.toDataUri(krokiDiagram, krokiClient) } return krokiDiagram.getDiagramUri(krokiClient.getServerUrl()) } /** * Get the option defined on the block or macro. * * First, check if an option is defined as an attribute. * If there is no match, check if an option is defined as a default settings in the document attributes. * * @param attrs - list of attributes * @param document - Asciidoctor document * @returns {string|undefined} - the option name or undefined */ function getOption(attrs, document) { const availableOptions = ['inline', 'interactive', 'none'] for (const option of availableOptions) { if (attrs[`${option}-option`] === '') { return option } } for (const option of availableOptions) { if (document.getAttribute('kroki-default-options') === option) { return option } } } function isNumeric(value) { return /^\d+$/.test(value) } const processKroki = async ( processor, parent, attrs, diagramType, diagramText, context, resource, ) => { const doc = parent.getDocument() // If "subs" attribute is specified, substitute accordingly. // Be careful not to specify "specialcharacters" or your diagram code won't be valid anymore! const subs = attrs.subs if (subs) { diagramText = await parent.applySubs(diagramText, parent.resolveSubs(subs)) } if (doc.getSafe() < SAFE_MODE_SECURE) { if (diagramType === 'vegalite') { diagramText = await preprocessVegaLite( diagramText, context, resource?.dir || '', ) } else if (diagramType === 'plantuml' || diagramType === 'c4plantuml') { const plantUmlIncludeFile = doc.getAttribute('kroki-plantuml-include') if (plantUmlIncludeFile) { diagramText = `!include ${plantUmlIncludeFile}\n${diagramText}` } const plantUmlIncludePaths = doc.getAttribute( 'kroki-plantuml-include-paths', ) diagramText = await preprocessPlantUML( diagramText, context, plantUmlIncludePaths, resource, ) } else if (diagramType === 'structurizr') { diagramText = await preprocessStructurizr(diagramText, context, resource) } } const blockId = attrs.id const format = attrs.format || doc.getAttribute('kroki-default-format') || 'svg' const caption = attrs.caption const title = attrs.title const role = attrs.role ? `${attrs.role} kroki-format-${format} kroki` : 'kroki' const blockAttrs = Object.assign({}, attrs) blockAttrs.role = role blockAttrs.format = format delete blockAttrs.title delete blockAttrs.caption delete blockAttrs.opts const option = getOption(attrs, doc) if (option && option !== 'none') { blockAttrs[`${option}-option`] = '' } if (blockId) { blockAttrs.id = blockId } const opts = Object.fromEntries( Object.entries(attrs).filter( ([key, _]) => !key.endsWith('-option') && !BUILTIN_ATTRIBUTES.includes(key) && !isNumeric(key), ), ) const krokiDiagram = new KrokiDiagram(diagramType, format, diagramText, opts) const krokiClient = new KrokiClient(doc, httpClient) let block if (format === 'txt' || format === 'atxt' || format === 'utxt') { const textContent = await krokiClient.getTextContent(krokiDiagram) block = processor.createBlock(parent, 'literal', textContent, blockAttrs) } else { let alt if (attrs.title) { alt = attrs.title } else if (attrs.target) { alt = attrs.target } else { alt = 'Diagram' } blockAttrs.target = await createImageSrc( doc, krokiDiagram, attrs.target, context.vfs, krokiClient, option === 'inline', ) blockAttrs.alt = alt block = processor.createImageBlock(parent, blockAttrs) } if (title) { block.title = title } block.assignCaption(caption, 'figure') return block } function diagramBlock(context) { return function () { this.onContext(['listing', 'literal']) this.positionalAttributes(['target', 'format']) this.process(async (parent, reader, attrs) => { const diagramType = this.name.toString() const role = attrs.role // Capture the cursor at the block start before read() advances it. const sourceLocation = reader?.cursor const diagramText = await reader.read() const logger = parent.getDocument().getLogger() try { context.logger = logger return await processKroki( this, parent, attrs, diagramType, diagramText, context, ) } catch (err) { const doc = parent.getDocument() logger.warn(skipDiagramMessage(doc, err, diagramType, sourceLocation)) attrs.role = role ? `${role} kroki-error` : 'kroki-error' return this.createBlock( parent, attrs['cloaked-context'], diagramText, attrs, ) } }) } } function diagramBlockMacro(name, context) { return function () { this.named(name) this.positionalAttributes(['format']) this.process(async (parent, target, attrs) => { let vfs = context.vfs target = await parent.applySubs(target, ['attributes']) if (isBrowser()) { if ( !['file://', 'https://', 'http://'].some((prefix) => target.startsWith(prefix), ) ) { // if not an absolute URL, prefix with baseDir in the browser environment const doc = parent.getDocument() const baseDir = doc.getBaseDir() const startDir = typeof baseDir !== 'undefined' ? baseDir : '.' target = startDir !== '.' ? doc.normalizeWebPath(target, startDir) : target } if (vfs === undefined || typeof vfs.read !== 'function') { vfs = { read: async (path, encoding = 'utf8') => { const response = await globalThis.fetch(path) if (!response.ok) throw new Error(`No such file: ${path}`) if (encoding === 'binary') { const buf = await response.arrayBuffer() return String.fromCharCode(...new Uint8Array(buf)) } return response.text() }, exists: (path) => path.startsWith('http://') || path.startsWith('https://'), add: (_) => {}, parse: (path) => ({ dir: path.substring(0, path.lastIndexOf('/')), path, }), } context.vfs = vfs } } else { if (vfs === undefined || typeof vfs.read !== 'function') { vfs = fs target = parent.normalizeSystemPath(target) } } context.logger = parent.getDocument().getLogger() const role = attrs.role const diagramType = name try { const diagramText = await vfs.read(target) const resource = (typeof vfs.parse === 'function' && vfs.parse(target)) || { dir: '' } return await processKroki( this, parent, attrs, diagramType, diagramText, context, resource, ) } catch (err) { const doc = parent.getDocument() doc.getLogger().warn(skipDiagramMessage(doc, err, diagramType)) attrs.role = role ? `${role} kroki-error` : 'kroki-error' return this.createBlock( parent, 'paragraph', `${err.message} - ${diagramType}::${target}[]`, attrs, ) } }) } } /** * Extension context shared across diagram processing. * * @typedef {Object} KrokiContext * @property {Partial<import('./node-fs.js').Vfs>} [vfs] - Custom virtual filesystem used to read * diagram sources and write generated images. Missing methods fall back to the Node.js * implementation (or to a `fetch`-based implementation in the browser). * @property {Object} [logger] - Asciidoctor logger; set by the extension while processing. */ export default { /** * Registers the Kroki extension — a block and a block macro for every supported * diagram type — into the given Asciidoctor extensions registry. * * @param {import('@asciidoctor/core').Registry} registry - Asciidoctor extensions registry. * @param {KrokiContext} [context={}] - Extension context (e.g. a custom virtual filesystem). * @returns {import('@asciidoctor/core').Registry} The registry, for chaining. */ register: (registry, context = {}) => { const names = [ 'actdiag', 'blockdiag', 'bpmn', 'bytefield', 'c4plantuml', 'd2', 'dbml', 'ditaa', 'erd', 'excalidraw', 'goat', 'graphviz', 'mermaid', 'nomnoml', 'nwdiag', 'packetdiag', 'pikchr', 'plantuml', 'rackdiag', 'seqdiag', 'svgbob', 'symbolator', 'tikz', 'umlet', 'vega', 'vegalite', 'wavedrom', 'structurizr', 'diagramsnet', 'wireviz', ] for (const name of names) { registry.block(name, diagramBlock(context)) registry.blockMacro(diagramBlockMacro(name, context)) } return registry }, }