UNPKG

asciidoctor-kroki

Version:

Asciidoctor extension to convert diagrams to images using Kroki

1,504 lines (1,432 loc) 52.4 kB
'use strict'; var node_crypto = require('node:crypto'); var path = require('node:path'); var fs = require('node:fs'); var url = require('node:url'); var pako = require('pako'); var JSON5 = require('json5'); const httpRequest = async ( uri, method, headers, encoding = 'utf8', body, expectedContentType, ) => { let response; try { response = await fetch(uri, { method, headers, body }); } catch (e) { throw new Error(`${method} ${uri} - error; reason: ${e.message}`) } if (response.ok) { if (expectedContentType) { const contentType = response.headers.get('content-type') || ''; if ( !contentType.toLowerCase().startsWith(expectedContentType.toLowerCase()) ) { throw new Error( `${method} ${uri} - unexpected content-type; expected: ${expectedContentType}, got: ${contentType}`, ) } } if (encoding === 'binary') { const arrayBuffer = await response.arrayBuffer(); const byteArray = new Uint8Array(arrayBuffer); let data = ''; for (let i = 0; i < byteArray.byteLength; i++) { data += String.fromCharCode(byteArray[i]); } if (data !== '') { return data } throw new Error(`${method} ${uri} - server returns an empty response`) } const data = await response.text(); if (data !== '') { return data } throw new Error(`${method} ${uri} - server returns an empty response`) } let errorBody = ''; if (encoding !== 'binary') { try { errorBody = await response.text(); } catch (_) {} } if (response.status === 414) { throw new Error( `${method} ${uri} - server returns 414 (URI Too Long). The diagram URI is too long for the server. Consider using the 'kroki-http-method' attribute set to 'post' or 'adaptive' to send the diagram source via POST.`, ) } throw new Error( `${method} ${uri} - server returns ${response.status} status code; response: ${errorBody}`, ) }; var httpClient = { get: (uri, headers, encoding = 'utf8', expectedContentType) => httpRequest(uri, 'GET', headers, encoding, undefined, expectedContentType), post: (uri, body, headers, encoding = 'utf8', expectedContentType) => httpRequest(uri, 'POST', headers, encoding, body, expectedContentType), }; /** * Descriptor for a diagram image to be persisted by {@link Vfs#add}. * * @typedef {Object} VfsImage * @property {string} relative - Directory (relative or absolute) where the file should be written. * @property {string} basename - File name including extension. * @property {string} mediaType - MIME type of the image (e.g. `image/svg+xml`, `image/png`). * @property {Buffer} contents - Raw file contents. */ /** * Virtual filesystem interface that abstracts file I/O for both Node.js and browser environments. * Pass a custom implementation to the Asciidoctor extension to redirect reads and writes * (e.g. to an in-memory store or a bundler's asset pipeline). * Any method that is omitted falls back to the Node.js implementation provided by {@link nodefs}. * * @typedef {Object} Vfs * @property {(image: VfsImage) => void} add * Persists a rendered diagram image. The Node.js default creates the target directory * recursively and writes the file synchronously. * @property {(path: string) => boolean} exists * Returns `true` when the file at the given path already exists, allowing the extension * to skip a Kroki network request for previously generated diagrams. * @property {(path: string, encoding?: BufferEncoding, resource?: {[key: string]: string}) => Promise<string>} read * Reads a file and returns its contents. Accepts local filesystem paths, `file://` URIs, * and `http://`/`https://` URLs (fetched via the built-in HTTP client). * Custom implementations may use the optional `resource` (diagram resource identity, * e.g. an Antora resource id) to resolve the path. * @property {(resourceId: string, resource?: {[key: string]: string}) => {dir: string, path: string}} parse * Parses a resource identifier into its directory and full path components. * Backslashes are normalised to forward slashes so that path.posix operations * work correctly on Windows. */ /** * Default Node.js virtual filesystem implementation backed by `node:fs`. * * @type {Vfs} */ const nodefs = { /** * Creates the target directory (recursively) and writes the image file synchronously. * * @param {VfsImage} image - Image descriptor. */ add: (image) => { fs.mkdirSync(image.relative, { recursive: true }); const filePath = path.format({ dir: image.relative, base: image.basename }); fs.writeFileSync(filePath, image.contents, 'binary'); }, /** * Returns `true` when the file at `path` exists on the local filesystem. * * @param {string} path - Local filesystem path to check. * @returns {boolean} */ exists: (path) => { return fs.existsSync(path) }, /** * Reads a file and returns its contents with the given encoding. * Supports `http://` and `https://` URLs (delegated to the HTTP client), * `file://` URIs, and plain filesystem paths. * * @param {string} path - File path, `file://` URI, or HTTP(S) URL. * @param {BufferEncoding} [encoding='utf8'] - Encoding for the returned content. * @returns {Promise<string>} File contents. */ read: async (path, encoding = 'utf8') => { if (path.startsWith('http://') || path.startsWith('https://')) { return httpClient.get(path, {}, encoding) } if (path.startsWith('file://')) { return fs.readFileSync(url.fileURLToPath(path), encoding) } return fs.readFileSync(path, encoding) }, /** * Parses a resource identifier into its directory and normalised path. * Backslashes are converted to forward slashes so that `path.posix` operations * in `preprocess.js` work correctly on Windows. * * @param {string} resourceId - File path or resource identifier to parse. * @returns {{dir: string, path: string}} Parsed path components. */ parse: (resourceId) => { // Normalize backslashes to forward slashes so that preprocess.js, // which uses path.posix throughout, can join and resolve paths correctly // on Windows (posix treats backslashes as literal characters, not separators). const normalized = resourceId.replace(/\\/g, '/'); return { dir: path.posix.dirname(normalized), path: normalized, } }, }; /** * Resolves a complete {@link Vfs} implementation by merging the provided `vfs` object * with {@link nodefs} fallbacks for any missing methods. * * @param {Partial<Vfs>|undefined} vfs - Custom VFS implementation, or `undefined` to use Node.js defaults entirely. * @returns {Vfs} A fully-resolved VFS with all four methods defined. */ function resolveVfs(vfs) { return { read: typeof vfs?.read === 'function' ? vfs.read : nodefs.read, exists: typeof vfs?.exists === 'function' ? vfs.exists : nodefs.exists, parse: typeof vfs?.parse === 'function' ? vfs.parse : nodefs.parse, add: typeof vfs?.add === 'function' ? vfs.add : nodefs.add, } } /** * Resolves the directory where generated diagram images should be written. * Uses `imagesoutdir` when set; otherwise combines the output directory with `imagesdir`. * * @param {Object} doc - Asciidoctor document. * @returns {string} Absolute or relative path to the images output directory. */ const getImagesOutputDirectory = (doc) => { const imagesOutputDir = doc.getAttribute('imagesoutdir'); if (imagesOutputDir) { return imagesOutputDir } const outputDirectory = getOutputDirectory(doc); const imagesDir = doc.getAttribute('imagesdir') || ''; return path.posix.join(outputDirectory, imagesDir) }; /** * Resolves the document output directory. * For nested documents the parent document's `to_dir` option is used, as nested * documents do not expose their own until a future Asciidoctor release. * * @param {Object} doc - Asciidoctor document. * @returns {string} Output directory path, falling back to the document base directory. */ const getOutputDirectory = (doc) => { // the nested document logic will become obsolete once https://github.com/asciidoctor/asciidoctor/commit/7edc9da023522be67b17e2a085d72e056703a438 is released const outDir = doc.getAttribute('outdir') || (doc.isNested() ? doc.getParentDocument() : doc).getOptions().to_dir; const baseDir = doc.getBaseDir(); if (outDir) { return outDir } return baseDir }; /** * Resolves the media type and encoding to use for a given diagram output format. * * @param {string} format - Diagram output format (e.g. `svg`, `png`, `txt`). * @returns {{mediaType: string, encoding: BufferEncoding}} */ const mediaTypeAndEncoding = (format) => { if (format === 'txt' || format === 'atxt' || format === 'utxt') { return { mediaType: 'text/plain; charset=utf-8', encoding: 'utf8' } } if (format === 'svg') { return { mediaType: 'image/svg+xml', encoding: 'binary' } } return { mediaType: 'image/png', encoding: 'binary' } }; /** * Fetches a rendered diagram from Kroki and returns it as a `data:` URI, * embedding the content directly without writing any file. Shared by data-URI * mode and by the `inline` option, which both need the content carried in the * node (as a standard data-URI image target) rather than referenced by URL or * file path — keeping the extension converter-agnostic. * * @param {import('./kroki-client.js').KrokiDiagram} krokiDiagram - Diagram to render. * @param {import('./kroki-client.js').KrokiClient} krokiClient - Client used to fetch the diagram. * @returns {Promise<string>} A `data:` URI embedding the rendered diagram. */ const toDataUri = async (krokiDiagram, krokiClient) => { const { mediaType, encoding } = mediaTypeAndEncoding(krokiDiagram.format); const contents = await krokiClient.getImage(krokiDiagram, encoding); return `data:${mediaType};base64,${Buffer.from(contents, encoding).toString('base64')}` }; /** * Per-document registry of file names generated from an explicit diagram name, * mapped to the diagram URI they were generated from. Used to detect when the * same name is reused for diagrams with different content within one conversion. * * @type {WeakMap<Object, Map<string, string>>} */ const generatedNamesByDocument = new WeakMap(); var fetch$1 = { toDataUri, /** * Fetches a rendered diagram from Kroki (or from the local cache) and either * saves it to the virtual filesystem or returns it as a `data:` URI. * * When an explicit name is provided, the file name is the name itself (e.g. * `foo.svg`) so that links stay stable across content changes. Anonymous * diagrams use a content-addressed name (`diag-<sha256>.svg`) so they never * collide and can be reused from disk when unchanged. * * @param {import('./kroki-client.js').KrokiDiagram} krokiDiagram - Diagram to render. * @param {Object} doc - Asciidoctor document whose attributes control output paths * and data-URI mode (`data-uri`, `kroki-data-uri`, `imagesoutdir`, `imagesdir`). * @param {string|undefined} target - Explicit base name for the output file; when omitted a content-addressed name is used. * @param {Object|undefined} vfs - Virtual filesystem implementation; resolved via {@link resolveVfs}. * @param {import('./kroki-client.js').KrokiClient} krokiClient - Client used to fetch the diagram when not cached. * @returns {Promise<string>} The diagram file name (relative to the images output directory) * or a `data:` URI when data-URI mode is active. */ save: async (krokiDiagram, doc, target, vfs, krokiClient) => { const { exists, read, add } = resolveVfs(vfs); const imagesOutputDirectory = getImagesOutputDirectory(doc); const dataUri = doc.isAttribute('data-uri') || doc.isAttribute('kroki-data-uri'); const diagramUrl = krokiDiagram.getDiagramUri(krokiClient.getServerUrl()); const format = krokiDiagram.format; const { mediaType, encoding } = mediaTypeAndEncoding(format); // In data-URI mode no file is written, so the file name is irrelevant: embed the diagram inline. if (dataUri) { return toDataUri(krokiDiagram, krokiClient) } // An explicit name is used verbatim so links stay stable; otherwise the name is // content-addressed so anonymous diagrams don't collide (see #451). const named = typeof target === 'string' && target !== ''; const diagramName = named ? `${target}.${format}` : `diag-${node_crypto.createHash('sha256').update(diagramUrl).digest('hex')}.${format}`; const filePath = path.posix.format({ dir: imagesOutputDirectory, base: diagramName, }); let contents; if (named) { // A stable file may exist from a previous build with stale content, so it cannot // be trusted: re-fetch and overwrite. Reuse the file only when the very same // diagram was already generated in this conversion, and warn on a name clash. let generated = generatedNamesByDocument.get(doc); if (!generated) { generated = new Map(); generatedNamesByDocument.set(doc, generated); } const previousUrl = generated.get(diagramName); if (previousUrl === diagramUrl && exists(filePath)) { contents = await read(filePath, encoding); } else { if (previousUrl !== undefined && previousUrl !== diagramUrl) { doc .getLogger() .warn( `kroki: the diagram file name '${diagramName}' is generated by more than one diagram with different content; the file will be overwritten. Use unique names to keep stable links.`, ); } contents = await krokiClient.getImage(krokiDiagram, encoding); } generated.set(diagramName, diagramUrl); } else { // Content-addressed name: an existing file necessarily has identical content. contents = exists(filePath) ? await read(filePath, encoding) : await krokiClient.getImage(krokiDiagram, encoding); } add({ relative: imagesOutputDirectory, basename: diagramName, mediaType, contents: Buffer.from(contents, encoding), }); return diagramName }, }; var version = "1.0.1"; var packageJson = { version: version}; /** @type {number} Default maximum URI length before switching to POST. */ const MAX_URI_DEFAULT_VALUE = 4000; /** @type {Object<string, string>} Maps diagram output format to its expected MIME type. */ const MIME_TYPES = { svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', pdf: 'application/pdf', txt: 'text/plain', atxt: 'text/plain', utxt: 'text/plain', base64: 'text/plain', }; /** @type {string} Referer header value sent with every request. */ const REFERER = `asciidoctor/kroki.js/${packageJson.version}`; /** * Encodes a byte array to a standard (RFC 4648) base64 string. * Uses `btoa`, which is a global in both Node.js (>=16) and browsers, so the * result is identical to the Node-only base64 encoding of the same bytes * without relying on the global that browser bundlers do not provide. * * @param {Uint8Array} bytes - Bytes to encode. * @returns {string} Base64-encoded representation. */ function toBase64(bytes) { let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary) } /** * Represents a Kroki diagram, holding its type, output format, source text, and rendering options. */ class KrokiDiagram { /** * @param {string} type - Diagram type (e.g. `plantuml`, `mermaid`). * @param {string} format - Output format (e.g. `svg`, `png`). * @param {string} text - Diagram source text. * @param {Object<string, string|number>} opts - Diagram-specific rendering options forwarded as query parameters and headers. */ constructor(type, format, text, opts) { this.text = text; this.type = type; this.format = format; this.opts = opts; } /** * Builds the GET URI for this diagram against the given Kroki server URL. * Options are appended as URL-encoded query parameters. * * @param {string} serverUrl - Base URL of the Kroki server (e.g. `https://kroki.io`). * @returns {string} Fully-qualified diagram URI. */ getDiagramUri(serverUrl) { const queryParams = Object.entries(this.opts) .map( ([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value.toString())}`, ) .join('&'); return `${serverUrl}/${this.type}/${this.format}/${this.encode()}${queryParams ? `?${queryParams}` : ''}` } /** * Encodes the diagram source text using zlib deflate (level 9) and base64url encoding, * as required by the Kroki GET endpoint. * * @returns {string} Base64url-encoded deflated representation of the diagram text. */ encode() { const bytes = new TextEncoder().encode(this.text); const compressed = pako.deflate(bytes, { level: 9 }); return toBase64(compressed).replace(/\+/g, '-').replace(/\//g, '_') } } /** * HTTP client that communicates with a Kroki server to fetch rendered diagrams. * Supports GET, POST, and adaptive (GET with POST fallback when the URI is too long) strategies. */ class KrokiClient { /** * @param {Object} doc - Asciidoctor document whose attributes configure the client * (`kroki-server-url`, `kroki-http-method`, `kroki-max-uri-length`). * @param {Object} httpClient - HTTP adapter exposing `get(uri, headers, encoding)` and * `post(uri, body, headers, encoding)` methods. */ constructor(doc, httpClient) { const maxUriLengthValue = parseInt( doc.getAttribute( 'kroki-max-uri-length', MAX_URI_DEFAULT_VALUE.toString(), ), 10, ); this.maxUriLength = Number.isNaN(maxUriLengthValue) ? MAX_URI_DEFAULT_VALUE : maxUriLengthValue; this.httpClient = httpClient; const method = doc .getAttribute('kroki-http-method', 'adaptive') .toLowerCase(); if (method === 'get' || method === 'post' || method === 'adaptive') { this.method = method; } else { doc .getLogger() .warn( `Invalid value '${method}' for kroki-http-method attribute. The value must be either: 'get', 'post' or 'adaptive'. Proceeding using: 'adaptive'.`, ); this.method = 'adaptive'; } this.doc = doc; } /** * Fetches the diagram from Kroki and returns its content as a UTF-8 string. * Typically used for text-based formats such as SVG. * * @param {KrokiDiagram} krokiDiagram - Diagram to render. * @returns {Promise<string>} Rendered diagram content. */ async getTextContent(krokiDiagram) { return this.getImage(krokiDiagram, 'utf8') } /** * Fetches the rendered diagram from Kroki using the configured HTTP method strategy. * * In `adaptive` mode the request is sent as GET; if the resulting URI exceeds * {@link KrokiClient#maxUriLength} the diagram source is sent via POST instead. * In `get` mode GET is always used (the server may respond with 414 for very long URIs). * In `post` mode POST is always used. * * @param {KrokiDiagram} krokiDiagram - Diagram to render. * @param {BufferEncoding|null} encoding - Encoding for the response body, or `null` for binary. * @returns {Promise<string>} Rendered diagram content. */ async getImage(krokiDiagram, encoding) { const serverUrl = this.getServerUrl(); const type = krokiDiagram.type; const format = krokiDiagram.format; const text = krokiDiagram.text; const opts = krokiDiagram.opts; const headers = { Referer: REFERER, ...Object.fromEntries( Object.entries(opts).map(([key, value]) => [ `Kroki-Diagram-Options-${key}`, value, ]), ), }; const expectedContentType = MIME_TYPES[format]; if (this.method === 'adaptive' || this.method === 'get') { const uri = krokiDiagram.getDiagramUri(serverUrl); if (uri.length > this.maxUriLength) { // The request URI is longer than the max URI length. if (this.method === 'get') { this.doc .getLogger() .warn( `The diagram URI length (${uri.length}) exceeds kroki-max-uri-length (${this.maxUriLength}). The server may reject the request with a 414 (URI Too Long). Consider using the 'kroki-http-method' attribute set to 'adaptive' or 'post'.`, ); return this.httpClient.get( uri, headers, encoding, expectedContentType, ) } return this.httpClient.post( `${serverUrl}/${type}/${format}`, text, headers, encoding, expectedContentType, ) } return this.httpClient.get(uri, headers, encoding, expectedContentType) } return this.httpClient.post( `${serverUrl}/${type}/${format}`, text, headers, encoding, expectedContentType, ) } /** * Returns the Kroki server URL, falling back to `https://kroki.io` when the * `kroki-server-url` document attribute is not set. * * @returns {string} Kroki server base URL. */ getServerUrl() { return this.doc.getAttribute('kroki-server-url') || 'https://kroki.io' } } // @ts-check // The previous line must be the first non-comment line in the file to enable TypeScript checks: // https://www.typescriptlang.org/docs/handbook/intro-to-js-ts.html#ts-check /** * @param {string} diagramText * @param {any} context * @param {string} diagramDir * @returns {Promise<string>} */ async function preprocessVegaLite( diagramText, context = {}, diagramDir = '', ) { const logger = 'logger' in context && typeof context.logger !== 'undefined' ? context.logger : console; let diagramObject; try { diagramObject = JSON5.parse(diagramText); } catch (e) { const message = `Preprocessing of Vega-Lite view specification failed, because of a parsing error: ${e} The invalid view specification was: ${diagramText} `; throw addCauseToError(new Error(message), e) } if (!diagramObject?.data?.url) { return diagramText } const { read } = resolveVfs(context.vfs); const data = diagramObject.data; const urlOrPath = data.url; try { data.values = await read( isLocalAndRelative(urlOrPath) ? path.posix.join(diagramDir, urlOrPath) : urlOrPath, ); } catch (e) { if (isRemoteUrl(urlOrPath)) { // Includes a remote file that cannot be found but might be resolved by the Kroki server (https://github.com/yuzutech/kroki/issues/60) logger.info( `Skipping preprocessing of Vega-Lite view specification, because reading the remote data file '${urlOrPath}' referenced in the diagram caused an error:\n${e}`, ); return diagramText } const message = `Preprocessing of Vega-Lite view specification failed, because reading the local data file '${urlOrPath}' referenced in the diagram caused an error:\n${e}`; throw addCauseToError(new Error(message), e) } if (!data.format) { // Extract extension from URL using snippet from // http://stackoverflow.com/questions/680929/how-to-extract-extension-from-filename-string-in-javascript // Same code as in Vega-Lite: // https://github.com/vega/vega-lite/blob/master/src/compile/data/source.ts let type = /(?:\.([^.]+))?$/.exec(data.url)[1]; if (['json', 'csv', 'tsv', 'dsv', 'topojson'].indexOf(type) < 0) { type = 'json'; } data.format = { type }; } data.url = undefined; // reconsider once #42 is fixed: // return JSON.stringify(diagramObject, undefined, 2) return JSON.stringify(diagramObject) } const plantUmlBlocksRx = /@startuml\r?\n([\s\S]*?)\r?\n@enduml/gm; const plantUmlFirstBlockRx = /@startuml\r?\n([\s\S]*?)\r?\n@enduml/m; /** * Removes all plantuml tags (@startuml/@enduml) from the diagram * It's possible to have more than one diagram in a single file in the cli version of plantuml * This does not work for the server, so recent plantuml versions remove the tags before processing the diagram * We don't want to rely on the server to handle this, so we remove the tags in here before sending the diagram to the server * * Some diagrams have special tags (ie. @startmindmap for mindmap) - these are mandatory, so we can't do much about them... * * @param diagramText * @returns {string} diagramText without any plantuml tags */ function removePlantUmlTags(diagramText) { if (diagramText) { diagramText = diagramText.replace(/^\s*@(startuml|enduml).*\n?/gm, ''); } return diagramText } /** * @param {string} diagramText * @param {any} context * @param {string} diagramIncludePaths - predefined include paths (can be null) * @param {{[key: string]: string}} resource - diagram resource identity * @returns {Promise<string>} */ async function preprocessPlantUML( diagramText, context, diagramIncludePaths = '', resource = { dir: '' }, ) { const logger = 'logger' in context ? context.logger : console; const includeOnce = []; const includeStack = []; const includePaths = diagramIncludePaths ? diagramIncludePaths.split(path.delimiter) : []; diagramText = await preprocessPlantUmlIncludes( diagramText, resource, includeOnce, includeStack, includePaths, context.vfs, logger, ); return removePlantUmlTags(diagramText) } /** * @param {string} diagramText * @param {{[key: string]: string}} resource * @param {string[]} includeOnce * @param {string[]} includeStack * @param {string[]} includePaths * @param {any} vfs * @param {any} logger * @returns {Promise<string>} */ async function preprocessPlantUmlIncludes( diagramText, resource, includeOnce, includeStack, includePaths, vfs, logger, ) { // See: http://plantuml.com/en/preprocessing // Please note that we cannot use lookbehind for compatibility reasons with Safari: https://caniuse.com/mdn-javascript_builtins_regexp_lookbehind_assertion objects are stateful when they have the global flag set (e.g. /foo/g). // const regExInclude = /^\s*!(include(?:_many|_once|url|sub)?)\s+((?:(?<=\\)[ ]|[^ ])+)(.*)/ const regExInclude = /^\s*!(include(?:_many|_once|url|sub)?)\s+([\s\S]*)/; const diagramLines = diagramText.split('\n'); let insideCommentBlock = false; const diagramProcessed = []; for (const line of diagramLines) { let result = line; // replace the !include directive unless inside a comment block if (!insideCommentBlock) { const match = regExInclude.exec(line); if (match) { const [, ...args] = match; const include = args[0].toLowerCase(); const target = parseTarget(args[1]); const urlSub = target.url.split('!'); const trailingContent = target.comment; const url = urlSub[0].replace(/\\ /g, ' ').replace(/\s+$/g, ''); const sub = urlSub[1]; const readResult = await readPlantUmlInclude( url, resource, includePaths, includeStack, vfs, logger, ); if (!readResult.skip) { if (include === 'include_once') { checkIncludeOnce(readResult.text, readResult.filePath, includeOnce); } let text = readResult.text; if (sub !== undefined && sub !== null && sub !== '') { if (include === 'includesub') { text = getPlantUmlTextFromSub(text, sub); } else { const index = parseInt(sub, 10); if (Number.isNaN(index)) { text = getPlantUmlTextFromId(text, sub); } else { text = getPlantUmlTextFromIndex(text, index); } } } else { text = getPlantUmlTextOrFirstBlock(text); } includeStack.push(readResult.filePath); const { parse } = resolveVfs(vfs); text = await preprocessPlantUmlIncludes( text, parse(readResult.filePath, resource), includeOnce, includeStack, includePaths, vfs, logger, ); includeStack.pop(); result = trailingContent !== '' ? `${text} ${trailingContent}` : text; } } } if (line.includes("/'")) { insideCommentBlock = true; } if (insideCommentBlock && line.includes("'/")) { insideCommentBlock = false; } diagramProcessed.push(result); } return diagramProcessed.join('\n') } /** * @param {string} includeFile - relative or absolute include file * @param {{[key: string]: string}} resource * @param {string[]} includePaths - array with include paths * @param {any} vfs * @returns {string} the found file or include file path */ function resolveIncludeFile(includeFile, resource, includePaths, vfs) { const { exists } = resolveVfs(vfs); if (resource.module) { // antora resource id const dirPath = path.posix.dirname(resource.relative); return path.posix.join(dirPath, includeFile) } // When the including file was itself fetched from a remote URL, resolve a // relative include against that URL so it can be fetched remotely as well, // instead of being (incorrectly) looked up on the local file system (see #398). if (typeof resource.path === 'string' && isRemoteUrl(resource.path)) { return new URL(includeFile, resource.path).toString() } let filePath = includeFile; for (const includePath of [resource.dir, ...includePaths]) { const localFilePath = path.posix.join(includePath, includeFile); if (exists(localFilePath)) { filePath = localFilePath; break } } return filePath } function parseStructurizrTarget(value, tokens) { for (const token of tokens) { const i = value.indexOf(token); if (i > 0 && value.charAt(i - 1) !== '\\') { return { url: value.slice(0, i).trim(), comment: value.slice(i).trim() } } } return { url: value, comment: '' } } function parseTarget(value) { for (let i = 0; i < value.length; i++) { const char = value.charAt(i); if (i > 2) { // # inline comment if ( char === '#' && value.charAt(i - 1) === ' ' && value.charAt(i - 2) !== '\\' ) { return { url: value.slice(0, i - 1).trim(), comment: value.slice(i) } } // /' multi-lines comment '/ if ( char === "'" && value.charAt(i - 1) === '/' && value.charAt(i - 2) !== '\\' ) { return { url: value.slice(0, i - 1).trim(), comment: value.slice(i - 1), } } } } return { url: value, comment: '' } } async function readStructurizrInclude( url, resource, includePaths, includeStack, vfs, logger, ) { const { read } = resolveVfs(vfs); let skip = false; let text = ''; let filePath = url; if (includeStack.includes(url)) { const message = `Preprocessing of Structurizr include failed, because recursive reading already included referenced file '${url}'`; throw new Error(message) } else { if (isRemoteUrl(url)) { try { text = await read(url); } catch (e) { // Includes a remote file that cannot be found but might be resolved by the Kroki server (https://github.com/yuzutech/kroki/issues/60) logger.info( `Skipping preprocessing of Structurizr include, because reading the referenced remote file '${url}' caused an error:\n${e}`, ); skip = true; } } else { filePath = resolveIncludeFile(url, resource, includePaths, vfs); if (includeStack.includes(filePath)) { const message = `Preprocessing of Structurizr include failed, because recursive reading already included referenced file '${filePath}'`; throw new Error(message) } else { try { text = await read(filePath, 'utf8', resource); } catch (e) { // Includes a local file that cannot be found but might be resolved by the Kroki server logger.info( `Skipping preprocessing of Structurizr include, because reading the referenced local file '${filePath}' caused an error:\n${e}`, ); skip = true; } } } } return { skip, text, filePath } } /** * @param {string} url * @param {{[key: string]: string}} resource * @param {string[]} includePaths * @param {string[]} includeStack * @param {any} vfs * @param {any} logger * @returns {Promise<any>} */ async function readPlantUmlInclude( url, resource, includePaths, includeStack, vfs, logger, ) { const { read } = resolveVfs(vfs); let skip = false; let text = ''; let filePath = url; if (url.startsWith('<')) { // Includes a standard library that cannot be resolved locally but might be resolved the by Kroki server logger.info( `Skipping preprocessing of PlantUML standard library include '${url}'`, ); skip = true; } else if (includeStack.includes(url)) { const message = `Preprocessing of PlantUML include failed, because recursive reading already included referenced file '${url}'`; throw new Error(message) } else { if (isRemoteUrl(url)) { try { text = await read(url); } catch (e) { // Includes a remote file that cannot be found but might be resolved by the Kroki server (https://github.com/yuzutech/kroki/issues/60) logger.info( `Skipping preprocessing of PlantUML include, because reading the referenced remote file '${url}' caused an error:\n${e}`, ); skip = true; } } else { filePath = resolveIncludeFile(url, resource, includePaths, vfs); if (includeStack.includes(filePath)) { const message = `Preprocessing of PlantUML include failed, because recursive reading already included referenced file '${filePath}'`; throw new Error(message) } else { try { text = await read(filePath, 'utf8'); } catch (e) { // Includes a local file that cannot be found but might be resolved by the Kroki server logger.info( `Skipping preprocessing of PlantUML include, because reading the referenced local file '${filePath}' caused an error:\n${e}`, ); skip = true; } } } } return { skip, text, filePath } } function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** * @param {string} text * @param {string} sub * @returns {string} */ function getPlantUmlTextFromSub(text, sub) { const regEx = new RegExp( `!startsub\\s+${escapeRegExp(sub)}(?:\\r\\n|\\n)([\\s\\S]*?)(?:\\r\\n|\\n)!endsub`, 'gm', ); return getPlantUmlTextRegEx(text, regEx) } /** * @param {string} text * @param {string} id * @returns {string} */ function getPlantUmlTextFromId(text, id) { const regEx = new RegExp( `@startuml\\(id=${escapeRegExp(id)}\\)(?:\\r\\n|\\n)([\\s\\S]*?)(?:\\r\\n|\\n)@enduml`, 'gm', ); return getPlantUmlTextRegEx(text, regEx) } /** * @param {string} text * @param {RegExp} regEx * @returns {string} */ function getPlantUmlTextRegEx(text, regEx) { let matchedStrings = ''; let match = regEx.exec(text); if (match != null) { matchedStrings += match[1]; match = regEx.exec(text); while (match != null) { matchedStrings += `\n${match[1]}`; match = regEx.exec(text); } } return matchedStrings } /** * @param {string} text * @param {number} index * @returns {string} */ function getPlantUmlTextFromIndex(text, index) { // Please note that RegExp objects are stateful when they have the global flag set (e.g. /foo/g). // They store a lastIndex from the previous match. // Using exec() multiple times will return the next occurrence. // Reset to find the first occurrence. let idx = 0; plantUmlBlocksRx.lastIndex = 0; let match = plantUmlBlocksRx.exec(text); while (match && idx < index) { // find the nth occurrence match = plantUmlBlocksRx.exec(text); idx++; } if (match) { // [0] - result matching the complete regular expression // [1] - the first capturing group return match[1] } return '' } /** * @param {string} text * @returns {string} */ function getPlantUmlTextOrFirstBlock(text) { const match = text.match(plantUmlFirstBlockRx); if (match) { return match[1] } return text } /** * @param {string} _text * @param {string} filePath * @param {string[]} includeOnce */ function checkIncludeOnce(_text, filePath, includeOnce) { if (includeOnce.includes(filePath)) { const message = `Preprocessing of PlantUML include failed, because including multiple times referenced file '${filePath}' with '!include_once' guard`; throw new Error(message) } else { includeOnce.push(filePath); } } /** * @param {string} diagramText * @param {any} context * @param {{[key: string]: string}} resource - diagram resource identity * @returns {Promise<string>} */ async function preprocessStructurizr( diagramText, context, resource = { dir: '' }, ) { const logger = 'logger' in context ? context.logger : console; const includeStack = []; return preprocessStructurizrIncludes( diagramText, resource, includeStack, context.vfs, logger, ) } /** * @param {string} diagramText * @param {{[key: string]: string}} resource * @param {string[]} includeStack * @param {any} vfs * @param {any} logger * @returns {Promise<string>} */ async function preprocessStructurizrIncludes( diagramText, resource, includeStack, vfs, logger, ) { // See: https://docs.structurizr.com/dsl/includes const regExInclude = /^\s*!include\s+([\s\S]*)/; const diagramLines = diagramText.split('\n'); let insideCommentBlock = false; const diagramProcessed = []; for (const line of diagramLines) { let result = line; // replace the !include directive unless inside a comment block if (!insideCommentBlock) { const match = regExInclude.exec(line); if (match) { const [, ...args] = match; const target = parseStructurizrTarget(args[0], [' #', ' //', '/*']); const trailingContent = target.comment; const url = target.url.replace(/\\ /g, ' ').replace(/\s+$/g, ''); const readResult = await readStructurizrInclude( url, resource, [], includeStack, vfs, logger, ); if (!readResult.skip) { let text = readResult.text; includeStack.push(readResult.filePath); const { parse } = resolveVfs(vfs); text = await preprocessStructurizrIncludes( text, parse(readResult.filePath, resource), includeStack, vfs, logger, ); includeStack.pop(); result = trailingContent !== '' ? `${text} ${trailingContent}` : text; } } } let position = 0; while ( // biome-ignore lint/suspicious/noAssignInExpressions: assignment in while condition is intentional (position = insideCommentBlock ? line.indexOf('*/', position) : line.indexOf('/*', position)) !== -1 ) { insideCommentBlock = !insideCommentBlock; position += 2; } diagramProcessed.push(result); } return diagramProcessed.join('\n') } /** * @param {Error} error * @param {any} causedBy * @returns {Error} */ function addCauseToError(error, causedBy) { if (causedBy.stack) { error.stack += `\nCaused by: ${causedBy.stack}`; } return error } /** * @param {string} string * @returns {boolean} */ function isRemoteUrl(string) { try { const url = new URL(string); return url.protocol !== 'file:' } catch (_) { return false } } /** * @param {string} string * @returns {boolean} */ function isLocalAndRelative(string) { if (string.startsWith('/')) { return false } try { // eslint-disable-next-line no-new new URL(string); // A URL can not be local AND relative: https://stackoverflow.com/questions/7857416/file-uri-scheme-and-relative-files return false } catch (_) { return true } } // @ts-check 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$1.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$1.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 = nodefs; target = parent.normalizeSystemPath(target); } } context.logger = parent.getDocument().getLogger(); const role = attrs.role; const diagramType = name; try { const diagramText = await vfs