@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.
288 lines (275 loc) • 12.3 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 ospath = require('node:path')
const { posix: path } = ospath
const produceAssemblyFiles = require('./produce-assembly-files')
const PromiseQueue = require('./util/promise-queue')
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 assemblerConfig = await loadConfig(playbook, configSource)
if (!assemblerConfig) return [] // TODO consider removing and doing this another way
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 {
convert = converter,
getDefaultCommand,
extname: targetExtname = '',
backend: targetBackend = targetExtname.slice(1),
embedReferenceStyle = 'relative',
mediaType: targetMediaType,
loggerName = PACKAGE_NAME,
} = converter ?? {}
const { assembly: assemblyConfig, build: buildConfig } = assemblerConfig
assemblyConfig.embedReferenceStyle = embedReferenceStyle
const { profile = targetBackend } = assemblyConfig
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(assemblerConfig.asciidoc.attributes, intrinsicAttributes)
const assemblyFiles = produceAssemblyFiles(
loadAsciiDoc,
contentCatalog,
assemblerConfig,
createResolveAssemblyModel(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 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, buildConfig)
if (buildConfig.mkdirs) await fsp.mkdir(convertAttributes.outdir, { recursive: true, force: true })
return boundConvert(doc, convertAttributes, buildConfig).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 ??= {})
;(assemblerMeta.exports ??= []).push({ fragment, file })
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
})
})
}
function createResolveAssemblyModel (context, contentCatalog, common, 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({ logger }, common, { navigation })
}
}
const boundSendToLog = sendToLog.bind(logger)
const { buildNavigation = require('@antora/navigation-builder') } = context.getFunctions()
return (componentVersion) => {
const componentVersionProfiles = assemblerProfiles.get(componentVersion.version + '@' + componentVersion.name)
const overrides =
componentVersionProfiles?.get(intrinsicAttributes['assembler-profile']) ?? componentVersionProfiles?.get() ?? {}
const { navFiles, messages } = overrides
const model = Object.assign({ logger }, common, overrides)
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)
model.navigation = componentVersion.navigation
if (!navFiles) return model
if (!navFiles.length) return Object.assign(model, { navigation: [] })
const navigation_ = model.navigation
const asciidoc_ = componentVersion.asciidoc
componentVersion.asciidoc = Object.assign({}, asciidoc_, {
attributes: Object.assign({}, asciidoc_.attributes, intrinsicAttributes),
})
buildNavigation(
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))
},
})
)
model.navigation = componentVersion.navigation
Object.assign(componentVersion, { asciidoc: asciidoc_, navigation: navigation_ })
return model
}
}
function prepareConvertAttributes (doc, targetExtname, relativeToOutput, buildConfig) {
const {
asciidoc: { attributes: docAttributes } = { attributes: {} },
extname: docfilesuffix,
path: reldocfile,
src: { family, relative },
} = doc
const { cwd = process.cwd(), dir = cwd } = buildConfig
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 imagesdir = ''
const outfile = docfile.slice(0, docfile.length - docfilesuffix.length) + targetExtname
const attributes = Object.assign({}, 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 === '') {
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) files.push(...assemblyFiles.map((file) => Object.assign(file, { out: { path: file.path } })))
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 {
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