UNPKG

asciidoctor-kroki

Version:

Asciidoctor extension to convert diagrams to images using Kroki

634 lines (608 loc) 19 kB
// @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 import { delimiter, posix as path } from 'node:path' import JSON5 from 'json5' import { resolveVfs } from './node-fs.js' /** * @param {string} diagramText * @param {any} context * @param {string} diagramDir * @returns {Promise<string>} */ export 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.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>} */ export 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(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.dirname(resource.relative) return path.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.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>} */ export 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 } }