UNPKG

@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
'use strict' 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