asciidoctor-kroki
Version:
Asciidoctor extension to convert diagrams to images using Kroki
284 lines (268 loc) • 9.08 kB
JavaScript
/* global Opal */
// @ts-check
const { KrokiDiagram, KrokiClient } = require('./kroki-client.js')
function UnsupportedFormatError (message) {
this.name = 'UnsupportedFormatError'
this.message = message
this.stack = (new Error()).stack
}
// eslint-disable-next-line new-parens
UnsupportedFormatError.prototype = new Error
function InvalidConfigurationError (message) {
this.name = 'InvalidConfigurationError'
this.message = message
this.stack = (new Error()).stack
}
// eslint-disable-next-line new-parens
InvalidConfigurationError.prototype = new Error
const isBrowser = () => {
return typeof window === 'object' && typeof window.XMLHttpRequest === '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'
]
const wrapError = (err, message) => {
const errWrapper = new Error(message)
errWrapper.stack += `\nCaused by: ${err.stack || 'unknown'}`
const result = {
err: {
package: 'asciidoctor-kroki',
message,
stack: errWrapper.stack
}
}
result.$inspect = function () {
return JSON.stringify(this.err)
}
return result
}
const createImageSrc = (doc, krokiDiagram, target, vfs, krokiClient) => {
const shouldFetch = doc.isAttribute('kroki-fetch-diagram')
let imageUrl
if (shouldFetch && doc.getSafe() < SAFE_MODE_SECURE) {
imageUrl = require('./fetch.js').save(krokiDiagram, doc, target, vfs, krokiClient)
} else {
imageUrl = krokiDiagram.getDiagramUri(krokiClient.getServerUrl())
}
return imageUrl
}
/**
* 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
}
}
}
const processKroki = (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 = parent.applySubstitutions(diagramText, parent.$resolve_subs(subs))
}
if (doc.getSafe() < SAFE_MODE_SECURE) {
if (diagramType === 'vegalite') {
diagramText = require('./preprocess.js').preprocessVegaLite(diagramText, context, (resource && 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 = require('./preprocess.js').preprocessPlantUML(diagramText, context, plantUmlIncludePaths, resource)
}
}
const blockId = attrs.id
const format = attrs.format || doc.getAttribute('kroki-default-format') || 'svg'
const caption = attrs.caption
const title = attrs.title
let role = attrs.role
if (role) {
if (format) {
role = `${role} kroki-format-${format} kroki`
} else {
role = `${role} kroki`
}
} else {
role = '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)))
const krokiDiagram = new KrokiDiagram(diagramType, format, diagramText, opts)
const httpClient = isBrowser() ? require('./http/browser-http.js') : require('./http/node-http.js')
const krokiClient = new KrokiClient(doc, httpClient)
let block
if (format === 'txt' || format === 'atxt' || format === 'utxt') {
const textContent = 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 = createImageSrc(doc, krokiDiagram, attrs.target, context.vfs, krokiClient)
blockAttrs.alt = alt
block = processor.createImageBlock(parent, blockAttrs)
}
if (title) {
block['$title='](title)
}
block.$assign_caption(caption, 'figure')
return block
}
function diagramBlock (context) {
return function () {
const self = this
self.onContext(['listing', 'literal'])
self.positionalAttributes(['target', 'format'])
self.process((parent, reader, attrs) => {
const diagramType = this.name.toString()
const role = attrs.role
const diagramText = reader.$read()
try {
return processKroki(this, parent, attrs, diagramType, diagramText, context)
} catch (err) {
const errorMessage = wrapError(err, `Skipping ${diagramType} block.`)
parent.getDocument().getLogger().warn(errorMessage)
attrs.role = role ? `${role} kroki-error` : 'kroki-error'
return this.createBlock(parent, attrs['cloaked-context'], diagramText, attrs)
}
})
}
}
function diagramBlockMacro (name, context) {
return function () {
const self = this
self.named(name)
self.positionalAttributes(['format'])
self.process((parent, target, attrs) => {
let vfs = context.vfs
target = parent.applySubstitutions(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
}
} else {
if (vfs === undefined || typeof vfs.read !== 'function') {
vfs = require('./node-fs.js')
target = parent.normalizeSystemPath(target)
}
}
const role = attrs.role
const diagramType = name
try {
const diagramText = vfs.read(target)
const resource = (typeof vfs.parse === 'function' && vfs.parse(target)) || { dir: '' }
return processKroki(this, parent, attrs, diagramType, diagramText, context, resource)
} catch (err) {
const errorMessage = wrapError(err, `Skipping ${diagramType} block.`)
parent.getDocument().getLogger().warn(errorMessage)
attrs.role = role ? `${role} kroki-error` : 'kroki-error'
return this.createBlock(parent, 'paragraph', `${err.message} - ${diagramType}::${target}[]`, attrs)
}
})
}
}
module.exports.register = function register (registry, context = {}) {
// patch context in case of Antora
if (typeof context.contentCatalog !== 'undefined' && typeof context.contentCatalog.addFile === 'function' && typeof context.file !== 'undefined') {
context.vfs = require('./antora-adapter.js')(context.file, context.contentCatalog, context.vfs)
}
context.logger = Opal.Asciidoctor.LoggerManager.getLogger()
const names = [
'actdiag',
'blockdiag',
'bpmn',
'bytefield',
'c4plantuml',
'd2',
'dbml',
'ditaa',
'erd',
'excalidraw',
'graphviz',
'mermaid',
'nomnoml',
'nwdiag',
'packetdiag',
'pikchr',
'plantuml',
'rackdiag',
'seqdiag',
'svgbob',
'symbolator',
'umlet',
'vega',
'vegalite',
'wavedrom',
'structurizr',
'diagramsnet',
'wireviz'
]
if (typeof registry.register === 'function') {
registry.register(function () {
for (const name of names) {
this.block(name, diagramBlock(context))
this.blockMacro(diagramBlockMacro(name, context))
}
})
} else if (typeof registry.block === 'function') {
for (const name of names) {
registry.block(name, diagramBlock(context))
registry.blockMacro(diagramBlockMacro(name, context))
}
}
return registry
}