UNPKG

asciidoctor-kroki

Version:

Asciidoctor extension to convert diagrams to images using Kroki

170 lines (158 loc) 7.25 kB
import { createHash } from 'node:crypto' import { posix as path } from 'node:path' import { resolveVfs } from './node-fs.js' /** * 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.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() export default { 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-${createHash('sha256').update(diagramUrl).digest('hex')}.${format}` const filePath = path.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 }, }