@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.
235 lines (223 loc) • 10.1 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
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 targetBackend = converter == null ? undefined : (converter.backend ?? converter.extname?.slice(1))
const { assembly: assemblyConfig, build: buildConfig } = assemblerConfig
const { profile = targetBackend } = assemblyConfig
const intrinsicAttributes = { 'loader-assembler': '' }
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')
}
Object.assign(assemblerConfig.asciidoc.attributes, intrinsicAttributes)
const assemblyFiles = produceAssemblyFiles(
loadAsciiDoc,
contentCatalog,
assemblerConfig,
createResolveAssemblyModel(context, contentCatalog, assemblyConfig, intrinsicAttributes, navigationCatalog)
)
const { convert = converter, extname: targetExtname, mediaType: targetMediaType } = converter ?? {}
if (!(convert && assemblyFiles.length)) return assemblyFiles
const { publishSite: publishFiles = require('@antora/site-publisher') } = generatorFunctions
await prepareWorkspace(publishFiles, assemblyFiles, contentCatalog, buildConfig)
const boundConvert = convert.bind(context)
return new PromiseQueue({ concurrency: buildConfig.processLimit })
.add(
assemblyFiles.map((doc) => async () => {
const convertAttributes = prepareConvertAttributes(doc, targetExtname, buildConfig)
if (buildConfig.mkdirs) await fsp.mkdir(convertAttributes.outdir, { recursive: true, force: true })
return boundConvert(doc, convertAttributes, buildConfig).then(
(fileOrContents = new LazyReadable(() => fs.createReadStream(convertAttributes.outfile))) => {
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, shared, intrinsicAttributes, navigationCatalog) {
const { assemblerProfiles } = context.getVariables()
if (!assemblerProfiles) {
return (componentVersion) => {
const navigation =
navigationCatalog?.getNavigation(componentVersion.name, componentVersion.version) ?? componentVersion.navigation
return Object.assign({}, shared, { navigation })
}
}
const boundSendToLog = sendToLog.bind(context.getLogger ? context.getLogger(PACKAGE_NAME) : undefined)
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({}, shared, 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, 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 docdir = dir
const imagesdir = ''
const outfile = docfile.slice(0, docfile.length - docfilesuffix.length) + targetExtname
const attributes = Object.assign({}, docAttributes, {
docdir,
docfile,
docfilesuffix,
'docname@': docname,
imagesdir,
outdir: ospath.dirname(docfile),
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('-a', 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, contentCatalog, 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 isBound (obj) {
if (obj == null) return false
for (const p in obj) return true
return false
}
function sendToLog (levelAndArgs) {
if (this) this[levelAndArgs[0]].apply(this, levelAndArgs.slice(1))
}
module.exports = assembleContent