@antora/assembler
Version:
A JavaScript library that merges AsciiDoc content from multiple pages in an Antora site into assembly files and delegates to an exporter to convert those files to another format, such as PDF.
336 lines (322 loc) • 14.7 kB
JavaScript
const computeOut = require('./util/compute-out')
const fs = require('node:fs')
const { promises: fsp } = fs
const LazyReadable = require('./util/lazy-readable')
const loadConfig = require('./load-config')
const logCommand = require('./log-command')
const ospath = require('node:path')
const { posix: path } = ospath
const produceAssemblyFiles = require('./produce-assembly-files')
const PromiseQueue = require('./util/promise-queue')
const runCommand = require('@antora/run-command-helper')
const { stringify: toJSON } = JSON
const invariably = { new: () => ({}), void: () => undefined }
const PACKAGE_NAME = require('../package.json').name
const NEWLINE_RX = /(?:\r?\n)+/
async function assembleContent (playbook, contentCatalog, converter, { configSource, navigationCatalog }) {
const {
convert = converter,
getDefaultCommand,
extname: targetExtname = '',
backend: targetBackend = targetExtname.slice(1),
embedReferenceStyle = 'relative',
mediaType: targetMediaType,
loggerName = PACKAGE_NAME,
} = converter ?? {}
const assemblerConfig = await loadConfig.call(this, playbook, configSource, '-' + targetBackend)
if (assemblerConfig.enabled === false) return []
const context = isBound(this)
? this
: {
getFunctions: invariably.new,
getLogger: invariably.void,
getVariables: invariably.new,
}
const generatorFunctions = context.getFunctions()
const { loadAsciiDoc = require('@antora/asciidoc-loader') } = generatorFunctions
const { assembly: assemblyConfig, build: buildConfig } = assemblerConfig
assemblyConfig.embedReferenceStyle = embedReferenceStyle
const profile = (assemblyConfig.profile ??= targetBackend)
const intrinsicAttributes = { 'loader-assembler': '' }
buildConfig.cwd ??= process.cwd()
if (profile) {
buildConfig.dir ??= ospath.join(playbook.dir ?? process.cwd(), `build/assembler/${profile}`)
intrinsicAttributes[`assembler-profile-${profile}`] = ''
intrinsicAttributes['assembler-profile'] = profile
if (targetBackend) {
intrinsicAttributes[`assembler-backend-${targetBackend}`] = ''
intrinsicAttributes['assembler-backend'] = targetBackend
}
} else {
buildConfig.dir ??= ospath.join(playbook.dir ?? process.cwd(), 'build/assembler/_')
}
if (targetExtname) {
const targetFiletype = targetExtname.slice(1)
intrinsicAttributes[`assembler-filetype-${targetFiletype}`] = ''
intrinsicAttributes['assembler-filetype'] = targetFiletype
}
Object.assign(assemblyConfig.attributes, intrinsicAttributes)
const assemblyFiles = produceAssemblyFiles(
loadAsciiDoc,
contentCatalog,
assemblerConfig,
generateSelectAssemblyProfile(context, contentCatalog, assemblyConfig, intrinsicAttributes, navigationCatalog)
)
if (!(assemblyFiles.length && typeof convert === 'function')) return assemblyFiles
if (buildConfig.command == null && typeof getDefaultCommand === 'function') {
buildConfig.command = await getDefaultCommand(buildConfig.cwd)
}
const { publishSite: publishFiles = require('@antora/site-publisher') } = generatorFunctions
await prepareWorkspace(publishFiles, assemblyFiles, buildConfig)
const helpers = { logCommand: logCommand.bind(context, loggerName), runCommand }
const boundConvert = convert.bind(context)
return new PromiseQueue({ concurrency: buildConfig.processLimit })
.add(
assemblyFiles.map((doc) => async () => {
const relativeToOutput = embedReferenceStyle === 'output-relative'
const convertAttributes = prepareConvertAttributes(doc, targetExtname, relativeToOutput, assemblerConfig)
if (buildConfig.mkdirs) await fsp.mkdir(convertAttributes.outdir, { recursive: true, force: true })
return boundConvert(doc, convertAttributes, buildConfig, helpers).then((result) => {
const fileOrContents = resolveFileOrContents.call(context, result, convertAttributes, buildConfig, loggerName)
return coerceToExportFormat(doc, targetBackend, targetExtname, targetMediaType, fileOrContents)
})
})
)
.toPromise()
.then((files) => {
if (!buildConfig.publish) {
for (const file of files) delete file.assembler.assembled
return files
}
const qualifyExports = buildConfig.qualifyExports
return files.map((file) => {
const pages = file.assembler.assembled.pages
delete file.assembler.assembled
file = contentCatalog.addFile(Object.assign(file, { out: computeOut.call(contentCatalog, file.src) }))
const extname = file.extname
const download = file.assembler.downloadStem + extname
if (qualifyExports) {
file.pub.url = '/' + (file.out.path = path.join(file.out.dirname, (file.out.basename = download)))
} else {
file.pub.download = download
}
pages.forEach((fragment, page) => {
const assemblerMeta = (page.assembler ??= {})
const exports = (assemblerMeta.exports ??= [])
const exportEntry = { fragment, file }
const insertIdx = exports.findIndex(
({ file: candidate }) =>
!(candidate.src.component === page.src.component && candidate.src.version === page.src.version)
)
~insertIdx ? exports.splice(insertIdx, 0, exportEntry) : exports.push(exportEntry)
const extnameProp = extname.slice(1)
if (extnameProp in assemblerMeta) return
Object.defineProperty(assemblerMeta, extnameProp, {
configurable: true,
enumerable: true,
get: findFirstExportWithExtname.bind(assemblerMeta, extname),
})
})
return file
})
})
}
/**
* Generates a function that selects the active assembly profile, initializes an assembly model using
* the keys from the profile as well as any inherited shared keys, builds the navigation for the assembly, and
* returns the initialized assembly model. The assembly model is further populated after the call to this function.
*/
function generateSelectAssemblyProfile (context, contentCatalog, baseModel, intrinsicAttributes, navigationCatalog) {
const logger = context.getLogger?.(PACKAGE_NAME)
const { assemblerProfiles } = context.getVariables()
if (!assemblerProfiles) {
return (componentVersion) => {
const navigation =
navigationCatalog?.getNavigation(componentVersion.name, componentVersion.version) ?? componentVersion.navigation
return Object.assign({}, baseModel, { attributes: Object.assign({}, baseModel.attributes), navigation, logger })
}
}
const boundSendToLog = sendToLog.bind(logger)
const {
buildNavigation = require('@antora/navigation-builder'),
buildAlternateNavigation = buildNavigation.buildAlternateNavigation ??
buildAlternateNavigationShim.bind(null, buildNavigation),
} = context.getFunctions()
return (componentVersion) => {
const attributes = Object.assign({}, baseModel.attributes)
const componentVersionProfiles = assemblerProfiles.get(componentVersion.version + '@' + componentVersion.name)
const overrides = componentVersionProfiles?.get(baseModel.profile) ?? componentVersionProfiles?.get() ?? {}
const { navFiles, messages } = overrides
Object.entries(overrides.attributes ?? {}).forEach(([name, val]) => (attributes[name] = val))
const model = Object.assign({}, baseModel, overrides, { attributes, logger })
delete model.navFiles
delete model.messages
const navigationOverride = navigationCatalog?.getNavigation(componentVersion.name, componentVersion.version)
if (navigationOverride) return Object.assign(model, { navigation: navigationOverride })
messages?.forEach(boundSendToLog)
if (!navFiles) return Object.assign(model, { navigation: componentVersion.navigation })
if (!navFiles.length) return Object.assign(model, { navigation: [] })
model.navigation = buildAlternateNavigation(contentCatalog, componentVersion, navFiles, {
attributes: intrinsicAttributes,
})
return model
}
}
function buildAlternateNavigationShim (
buildNavigation,
contentCatalog,
componentVersion,
navFiles,
asciidocConfigOverrides
) {
const { asciidoc: asciidoc_, navigation: navigation_ } = componentVersion
const asciidocConfig = (componentVersion.asciidoc = Object.assign({}, asciidoc_))
if (asciidocConfigOverrides) {
const attributesOverrides =
'attributes' in asciidocConfigOverrides
? { attributes: Object.assign({}, asciidocConfig.attributes, asciidocConfigOverrides.attributes) }
: undefined
Object.assign(asciidocConfig, asciidocConfigOverrides, attributesOverrides)
}
const contentCatalogProxy = new Proxy(contentCatalog, {
get (target, property) {
const method = target[property]
if (property !== 'findBy') return method
return (criteria) => (toJSON(criteria) === '{"family":"nav"}' ? navFiles : method.call(target, criteria))
},
})
buildNavigation(contentCatalogProxy)
const navigation = componentVersion.navigation
Object.assign(componentVersion, { asciidoc: asciidoc_, navigation: navigation_ })
return navigation
}
function prepareConvertAttributes (doc, targetExtname, relativeToOutput, assemblerConfig) {
const {
asciidoc: { attributes: docAttributes } = { attributes: {} },
extname: docfilesuffix,
path: reldocfile,
src: { family, relative },
} = doc
const { cwd = process.cwd(), dir = cwd } = assemblerConfig.build
const docname = family + '$' + relative.slice(0, relative.length - docfilesuffix.length)
const docfile = ospath.join(dir, reldocfile)
const outdir = ospath.dirname(docfile)
const docdir = relativeToOutput ? dir : outdir
const outfile = docfile.slice(0, docfile.length - docfilesuffix.length) + targetExtname
const attributes = Object.assign({ revdate: `${assemblerConfig.assembly.revdate}@` }, docAttributes, {
docdir,
docfile,
docfilesuffix,
'docname@': docname,
imagesdir: '',
outdir,
outfile,
outfilesuffix: targetExtname,
toArgs (optionFlag, command) {
const padCharRef = process.platform === 'win32' && command.startsWith('bundle exec ')
const args = []
for (let [name, val] of Object.entries(this)) {
if (val) {
val = `${name}=${padCharRef && typeof val.charAt === 'function' && val.charAt() === '&' ? ' ' : ''}${val}`
} else if (val === '') {
if (name === 'asciidoctor-log-integration') {
args.push('-r', require.resolve('#asciidoctor-log-adapter'))
continue
}
val = name
} else {
val = `!${name}${val === false ? '@' : ''}`
}
args.push(optionFlag, val)
}
return args
},
})
return Object.defineProperty(attributes, 'toArgs', { enumerable: false })
}
function coerceToExportFormat (originalFile, targetBackend, targetExtname, targetMediaType, fileOrContents) {
const file =
fileOrContents == null || Buffer.isBuffer(fileOrContents) || typeof fileOrContents.pipe === 'function'
? Object.assign(originalFile, { contents: fileOrContents })
: fileOrContents
;(file.assembler ??= {}).backend = targetBackend
if (file.extname === targetExtname) return file
const sourcePath = file.path
const sourceExtname = file.extname
const relativeWithoutExtname = file.src.relative.slice(0, file.src.relative.length - sourceExtname.length)
const newPath = sourcePath.slice(0, sourcePath.length - sourceExtname.length) + targetExtname
Object.assign(file, { mediaType: (file.src.mediaType = targetMediaType), path: newPath })
file.src.basename = path.basename((file.src.relative = relativeWithoutExtname + (file.src.extname = targetExtname)))
return file
}
function findFirstExportWithExtname (extname) {
return this.exports.find((it) => it.file.extname === extname)
}
// TODO: if no workspace dir is defined, we shouldn't continue
function prepareWorkspace (publishFiles, assemblyFiles, buildConfig) {
const { dir, clean, keepSource } = buildConfig
const files = []
const outPaths = new Set()
for (const file of assemblyFiles) {
for (const asset of file.assembler.assembled.assets) {
if (outPaths.has(asset.out.path)) continue
files.push(asset)
outPaths.add(asset.out.path)
}
}
if (keepSource) {
for (const file of assemblyFiles) {
file.src.contents = file.contents
file.out = { path: file.path }
files.push(file)
}
}
return publishFiles({ output: { clean, dir } }, { getFiles: () => files })
}
function resolveFileOrContents (convertResult, convertAttributes, buildConfig, loggerName) {
let fileOrContents
if (convertResult?.status != null) {
fileOrContents = 'file' in convertResult ? convertResult.file : convertResult.contents
let logger, match
if (buildConfig.stderrSink === 'log' && convertResult.stderr.length && (logger = this.getLogger(loggerName))) {
const docfile = convertAttributes.docfile
const command = buildConfig.command
const stderr = convertResult.stderr.toString().trimEnd()
stderr.split(NEWLINE_RX).forEach((line) => {
const ctx = { command, file: { path: docfile } }
if (line.charAt() === '{' && line.charAt(line.length - 1) === '}') {
const entry = JSON.parse(line)
if (entry.name) ctx.program = entry.name
if (entry.file?.line) ctx.line = entry.file.line
logger[entry.level](ctx, entry.msg)
} else if ((match = /^([^:]+):(\d+): warning: (.+)/.exec(line))) {
const [, scriptPath, lineno, msg] = match
ctx.stack = [{ file: { path: scriptPath }, line: parseInt(lineno, 10) }]
logger.warn(ctx, msg)
} else if ((match = /^asciidoctor: ([A-Z]+): (?:[^:]+: line (\d+): )?(.+)/.exec(line))) {
// NOTE we don't care about the filename in the message since it can only be docfile
const [, level, lineno, msg] = match
if (lineno) ctx.line = parseInt(lineno, 10)
logger[level === 'WARNING' ? 'warn' : level.toLowerCase()](ctx, msg)
} else {
logger.info(ctx, line)
}
})
}
} else if (convertResult !== undefined) {
return convertResult
}
return fileOrContents === undefined
? new LazyReadable(() => fs.createReadStream(convertAttributes.outfile))
: fileOrContents
}
function isBound (obj) {
if (obj == null) return false
for (const _ in obj) return true
return false
}
function sendToLog (levelAndArgs) {
if (this) this[levelAndArgs[0]].apply(this, levelAndArgs.slice(1))
}
module.exports = assembleContent