asciidoctor-kroki
Version:
Asciidoctor extension to convert diagrams to images using Kroki
1,504 lines (1,432 loc) • 52.4 kB
JavaScript
'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