asciidoctor-kroki
Version:
Asciidoctor extension to convert diagrams to images using Kroki
634 lines (608 loc) • 19 kB
JavaScript
// @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
}
}