UNPKG

asciidoctor-kroki

Version:

Asciidoctor extension to convert diagrams to images using Kroki

211 lines (199 loc) 7.17 kB
import pako from 'pako' import packageJson from '../package.json' with { type: 'json' } /** @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. */ export 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. */ export 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' } }