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.

336 lines (322 loc) 14.7 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 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