UNPKG

@antora/content-classifier

Version:

Organizes aggregated content into a virtual file catalog for use in an Antora documentation pipeline.

624 lines (584 loc) 25.9 kB
'use strict' const File = require('./file') const invariably = { void: () => undefined } const logger = require('./logger') const { lookup: resolveMimeType } = require('./mime-types-with-asciidoc') const parseResourceId = require('./util/parse-resource-id') const { posix: path } = require('path') const resolveResource = require('./util/resolve-resource') const summarizeFileLocation = require('./util/summarize-file-location') const versionCompare = require('./util/version-compare-desc') const { ROOT_INDEX_ALIAS_ID, ROOT_INDEX_PAGE_ID } = require('./constants') const SPACE_RX = / /g const LOG_WRAP = '\n ' const $components = Symbol('components') const $files = Symbol('files') class ContentCatalog { constructor (playbook = {}) { this[$components] = new Map() this[$files] = new Map() const urls = playbook.urls || {} this.htmlUrlExtensionStyle = urls.htmlExtensionStyle || 'default' this.urlRedirectFacility = urls.redirectFacility || 'static' this.latestVersionUrlSegment = urls.latestVersionSegment this.latestPrereleaseVersionUrlSegment = urls.latestPrereleaseVersionSegment if (this.latestVersionUrlSegment == null && this.latestPrereleaseVersionUrlSegment == null) { this.latestVersionUrlSegmentStrategy = undefined } else { this.latestVersionUrlSegmentStrategy = urls.latestVersionSegmentStrategy || 'replace' if (this.latestVersionUrlSegmentStrategy === 'redirect:from') { if (!this.latestVersionUrlSegment) this.latestVersionUrlSegment = undefined if (!this.latestPrereleaseVersionUrlSegment) { this.latestPrereleaseVersionUrlSegment = undefined if (!this.latestVersionUrlSegment) this.latestVersionUrlSegmentStrategy = undefined } } } } /** * Registers a new component version with the content catalog. Also registers the component if it does not yet exist. * * @param {String} name - The name of the component to which this component version belongs. * @param {String} version - The version of the component to register. * @param {Object} [descriptor={}] - The configuration data for the component version. * @param {Object} [descriptor.asciidoc=undefined] - The AsciiDoc configuration for this component version. * @param {String} [descriptor.displayVersion=version] - The display version for this component version. * @param {Boolean|String} [descriptor.prerelease=undefined] - The prerelease flag for this version. If the value * is a String, it implies true and is appended to the display version, separated if necessary by a space. * @param {Boolean|String} [descriptor.startPage=undefined] - The page specifier for the start page. The start page * is only registered if this property is truthy. A String value will be used to resolve a start page within this * component version. A true value is a special case to tell this method to register the default start page and is * intended for testing. * @param {String} [descriptor.title=name] - The title for this component version. * * @returns {Object} The constructed component version object. */ registerComponentVersion (name, version, descriptor = {}) { const { asciidoc, displayVersion, prerelease, startPage: startPageSpec, title } = descriptor const componentVersion = { displayVersion: displayVersion || version || 'default', title: title || name, version } Object.defineProperty(componentVersion, 'name', { value: name, enumerable: true }) if (prerelease) { componentVersion.prerelease = prerelease if (!displayVersion && prerelease.constructor === String) { if (version) { const ch0 = prerelease.charAt() componentVersion.displayVersion = `${version}${ch0 === '-' || ch0 === '.' ? '' : ' '}${prerelease}` } else { componentVersion.displayVersion = prerelease } } } // NOTE if no AsciiDoc attributes are defined in the component descriptor, asciidoc is the siteAsciiDocConfig object if (asciidoc) componentVersion.asciidoc = asciidoc const component = this[$components].get(name) if (component) { const componentVersions = component.versions if (componentVersions.find(({ version: candidate }) => candidate === version)) { throw new Error(`Duplicate version detected for component ${name}: ${version}`) } let lastVerdict const insertIdx = version ? componentVersions.findIndex(({ version: candidateVersion, prerelease: candidatePrerelease }) => { return (lastVerdict = versionCompare(candidateVersion, version)) > 1 ? !!prerelease === !!candidatePrerelease : lastVerdict > 0 || (lastVerdict < -1 && prerelease && !candidatePrerelease) }) : prerelease ? -1 : ~(~componentVersions.findIndex(({ prerelease: candidatePrerelease }) => !candidatePrerelease) || -1) if (~insertIdx) { componentVersions.splice(insertIdx, 0, componentVersion) } else if (lastVerdict === -1 || !prerelease) { componentVersions.push(componentVersion) } else { componentVersions.unshift(componentVersion) } if ((component.latest = componentVersions.find((candidate) => !candidate.prerelease))) { if (componentVersions[0] !== component.latest) component.latestPrerelease = componentVersions[0] } else { component.latest = componentVersions[0] } } else { this[$components].set( name, Object.defineProperties( { name, latest: componentVersion, versions: [componentVersion] }, { asciidoc: { get () { return this.latest.asciidoc }, }, title: { get () { return this.latest.title }, }, url: { get () { return this.latest.url }, }, } ) ) } if (startPageSpec) { this.registerComponentVersionStartPage(name, componentVersion, startPageSpec === true ? undefined : startPageSpec) } return componentVersion } addFile (file) { const src = file.src let family = src.family let filesForFamily = this[$files].get(family) if (!filesForFamily) this[$files].set(family, (filesForFamily = new Map())) const key = generateKey(src) if (filesForFamily.has(key)) { if (family === 'alias') { throw new Error(`Duplicate alias: ${generateResourceSpec(src)}`) } const details = [filesForFamily.get(key), file] .map((it, idx) => `${idx + 1}: ${summarizeFileLocation(it)}`) .join(LOG_WRAP) if (family === 'nav') { throw new Error(`Duplicate nav in ${src.version}@${src.component}: ${file.path}${LOG_WRAP}${details}`) } throw new Error(`Duplicate ${family}: ${generateResourceSpec(src)}${LOG_WRAP}${details}`) } // NOTE: if the path property is not set, assume the src likely needs to be prepared // another option is to assume that if the file is not a vinyl object, the src likely needs to be prepared // a vinyl object is one indication the file was created and prepared by the content aggregator //if (!src.path) prepareSrc(src) //if (!File.isVinyl(file)) file = new File(file) if (!File.isVinyl(file)) { prepareSrc(src) file = new File(file) } if (family === 'alias') { src.mediaType = 'text/asciidoc' file.mediaType = 'text/html' // NOTE: an alias masquerades as the target file family = file.rel.src.family // QUESTION: should we preserve the mediaType property on file if already defined? } else if (!(file.mediaType = src.mediaType) && !('mediaType' in src)) { file.mediaType = src.mediaType = resolveMimeType(src.extname) || (family === 'page' ? 'text/asciidoc' : undefined) } let publishable let versionSegment if (file.out) { publishable = true } else if ('out' in file) { delete file.out } else if ( (family === 'page' || family === 'image' || family === 'attachment') && ('/' + src.relative).indexOf('/_') < 0 ) { publishable = true versionSegment = computeVersionSegment.call(this, src.component, src.version) file.out = computeOut(src, family, versionSegment, this.htmlUrlExtensionStyle) } if (!file.pub && (publishable || family === 'nav')) { if (versionSegment == null) versionSegment = computeVersionSegment.call(this, src.component, src.version) file.pub = computePub(src, file.out, family, versionSegment, this.htmlUrlExtensionStyle) } filesForFamily.set(key, file) return file } removeFile (file) { const src = file.src const filesForFamily = this[$files].get(src.family) return filesForFamily ? filesForFamily.delete(generateKey(src)) : false } findBy (criteria) { const criteriaEntries = Object.entries(criteria) const family = criteria.family if (criteriaEntries.length === 1 && family) { const filesForFamily = this[$files].get(family) return filesForFamily ? [...filesForFamily.values()] : [] } const accum = [] for (const filesForFamily of this[$files].values()) { for (const candidate of filesForFamily.values()) { const candidateSrc = candidate.src if (criteriaEntries.every(([key, val]) => candidateSrc[key] === val)) accum.push(candidate) } } return accum } getById (id) { return (this[$files].get(id.family) || { get: invariably.void }).get(generateKey(id)) } getByPath ({ component, version, path: path_ }) { for (const filesForFamily of this[$files].values()) { for (const it of filesForFamily.values()) { if (it.path === path_ && it.src.component === component && it.src.version === version) return it } } } getComponent (name) { return this[$components].get(name) } getComponentVersion (component, version) { return (component.versions || this.getComponent(component)?.versions || []).find( ({ version: candidate }) => candidate === version ) } getComponents () { return [...this[$components].values()] } getComponentsSortedBy (property) { return this.getComponents().sort((a, b) => a[property].localeCompare(b[property])) } getFiles () { const accum = [] for (const filesForFamily of this[$files].values()) { for (const file of filesForFamily.values()) accum.push(file) } return accum } getPages (filter) { const candidates = this[$files].get('page') if (!candidates) return [] if (filter) { const accum = [] for (const candidate of candidates.values()) filter(candidate) && accum.push(candidate) return accum } return [...candidates.values()] } // TODO add `follow` argument to control whether alias is followed getSiteStartPage () { return this.getById(ROOT_INDEX_PAGE_ID) || this.getById(ROOT_INDEX_ALIAS_ID)?.rel } registerComponentVersionStartPage (name, componentVersion, startPageSpec = undefined) { let version = componentVersion.version if (version == null) { // QUESTION: should we warn or throw error if component version cannot be found? if (!(componentVersion = this.getComponentVersion(name, componentVersion))) return version = componentVersion.version } let startPage let startPageSrc const indexPageId = Object.assign({}, ROOT_INDEX_PAGE_ID, { component: name, version }) if (startPageSpec) { if ( (startPage = this.resolvePage(startPageSpec, indexPageId)) && (startPageSrc = startPage.src).component === name && startPageSrc.version === version ) { if (!this.getById(indexPageId)) { const indexAliasId = Object.assign({}, ROOT_INDEX_ALIAS_ID, { component: name, version }) const indexAlias = this.getById(indexAliasId) indexAlias ? indexAlias.synthetic && Object.assign(indexAlias, { rel: startPage }) : this.addFile({ src: indexAliasId, rel: startPage, synthetic: true }) } } else { // TODO pass componentVersion as logObject logger.warn( 'Start page specified for %s@%s %s: %s', version, name, startPage === false ? 'has invalid syntax' : 'not found', startPageSpec ) startPage = this.getById(indexPageId) } } else { startPage = this.getById(indexPageId) } if (startPage) { componentVersion.url = startPage.pub.url } else { // QUESTION: should we warn if the default start page cannot be found? const versionSegment = computeVersionSegment.call(this, name, version) componentVersion.url = computePub( (startPageSrc = prepareSrc(Object.assign({}, indexPageId, { family: 'page' }))), computeOut(startPageSrc, startPageSrc.family, versionSegment, this.htmlUrlExtensionStyle), startPageSrc.family, versionSegment, this.htmlUrlExtensionStyle ).url } const symbolicVersionAlias = createSymbolicVersionAlias( name, version, computeVersionSegment.call(this, name, version, 'alias'), this.latestVersionUrlSegmentStrategy ) if (symbolicVersionAlias) this.addFile(symbolicVersionAlias) } registerSiteStartPage (startPageSpec) { if (!startPageSpec) return const rel = this.resolvePage(startPageSpec) if (rel) { if (this.getById(ROOT_INDEX_PAGE_ID)) return const rootIndexAlias = this.getById(ROOT_INDEX_ALIAS_ID) if (rootIndexAlias) return rootIndexAlias.synthetic ? Object.assign(rootIndexAlias, { rel }) : undefined if (rel.pub.url === (this.htmlUrlExtensionStyle === 'default' ? '/index.html' : '/')) return const src = Object.assign({}, ROOT_INDEX_ALIAS_ID) return this.addFile({ src, rel, synthetic: true }, { version: src.version }) } if (rel === false) { logger.warn('Start page specified for site has invalid syntax: %s', startPageSpec) } else if (startPageSpec.lastIndexOf(':') > startPageSpec.indexOf(':')) { logger.warn('Start page specified for site not found: %s', startPageSpec) } else { logger.warn('Missing component name in start page for site: %s', startPageSpec) } } // QUESTION should this be addPageAlias? registerPageAlias (spec, target) { // adding .adoc file extension to page alias if missing is @deprecated; scheduled to be removed in Antora 4 const inferredSpec = spec.endsWith('.adoc') ? undefined : spec + '.adoc' const src = parseResourceId(inferredSpec || spec, target.src, 'page', ['page']) // QUESTION should we throw an error if alias is invalid? if (!src || (inferredSpec && src.relative === '.adoc')) return const component = this.getComponent(src.component) if (component) { // NOTE version is not set when alias specifies a component, but not a version if (src.version == null) src.version = component.latest.version const existingPage = this.getById(src) if (existingPage) { throw new Error( existingPage === target ? `Page cannot define alias that references itself: ${generateResourceSpec(src)}` + ` (specified as: ${spec})${LOG_WRAP}source: ${summarizeFileLocation(existingPage)}` : `Page alias cannot reference an existing page: ${generateResourceSpec(src)} (specified as: ${spec})` + `${LOG_WRAP}source: ${summarizeFileLocation(target)}` + `${LOG_WRAP}existing page: ${summarizeFileLocation(existingPage)}` ) } } else if (src.version == null) { // QUESTION should we skip registering alias in this case? src.version = '' } src.family = 'alias' const existingAlias = this.getById(src) if (existingAlias) { throw new Error( `Duplicate alias: ${generateResourceSpec(src)} (specified as: ${spec})` + `${LOG_WRAP}source: ${summarizeFileLocation(target)}` ) } // NOTE the redirect producer will populate contents when the redirect facility is 'static' const alias = this.addFile({ src, rel: target }) // NOTE record the first alias this target claims as the preferred one if (!target.rel) target.rel = alias return alias } /** * Attempts to resolve a page reference within the given context to a page in the catalog. * * Parses the specified page reference (i.e., page ID spec) into a partial page ID, expands it * using the provided context, then attempts to locate a file in the page family with that page ID * in this catalog. The family segment is optional. * * If a component is specified, but no version, the latest version of the component stored in the * catalog is used. If a page cannot be resolved, the search is attempted again for an "alias". If * neither a page or alias can be resolved, the function returns undefined. If the syntax of the * reference is invalid, this function throws an error. * * @param {String} spec - The contextual page reference (e.g., version@component:module:topic/page.adoc). * @param {Object} [context={}] - The context to use to qualify the page reference. * * @returns {File} The virtual file to which the contextual page reference resolves, or undefined * if the file cannot be resolved. */ resolvePage (spec, context = {}) { return this.resolveResource(spec, context, 'page', ['page']) } /** * Attempts to resolve a resource reference within the given context to a file in the catalog. * * Parses the specified resource reference (i.e., resource ID spec) into a partial resource ID, * expands it using the provided context, then attempts to locate a file with that resource ID in * this catalog. * * If a component is specified, but no version, the latest version of the component stored in the * catalog is used. If a defaultFamily is not specified, the family must be specified either by * the reference or the context. If permittedFamilies are stated, the family must resolve to a * family in this list. If a file cannot be resolved, the function returns undefined. If the * syntax of the reference is invalid, this function throws an error. * * @param {String} spec - The contextual resource reference (e.g., version@component:module:image$topic/image.png). * @param {Object} [context={}] - The context to use to qualify the resource reference. * @param {String} [defaultFamily=undefined] - The default family to use if one is not provided. * @param {Array<String>} [permittedFamilies=undefined] - A list of families that are permitted. * * @returns {File} The virtual file to which the contextual resource reference resolves, or * undefined if the file cannot be resolved. */ resolveResource (spec, context = {}, defaultFamily = undefined, permittedFamilies = undefined) { return resolveResource(spec, this, context, defaultFamily, permittedFamilies) } exportToModel () { return [ this.findBy, { name: 'getAll', bind: (to) => this.getAll.bind(to) }, this.getById, this.getComponent, this.getComponentVersion, this.getComponents, this.getComponentsSortedBy, this.getFiles, this.getPages, this.getSiteStartPage, this.resolvePage, this.resolveResource, ].reduce((proxy, method) => (proxy[method.name] = method.bind(this)) && proxy, new (class ContentCatalogProxy {})()) } } /** * @deprecated superceded by getFiles(); scheduled to be removed in Antora 4 */ ContentCatalog.prototype.getAll = ContentCatalog.prototype.getFiles function generateKey ({ component, version, module: module_, relative }) { return `${version}@${component}:${module_}:${relative}` } function generateResourceSpec ({ component, version, module: module_, family, relative }, shorthand = true) { return ( `${version}@${component}:${shorthand && module_ === 'ROOT' ? '' : module_}:` + (family === 'page' || family === 'alias' ? '' : `${family}$`) + relative ) } function prepareSrc (src) { let { basename, extname, stem } = src let update if (basename == null) { update = true basename = path.basename(src.relative) } if (stem == null) { update = true if (extname == null) { if (~(extname = basename.lastIndexOf('.'))) { stem = basename.substr(0, extname) extname = basename.substr(extname) } else { stem = basename extname = '' } } else { stem = basename.substr(0, basename.length - extname.length) } } else if (extname == null) { update = true extname = basename.substr(stem.length) } return update ? Object.assign(src, { basename, extname, stem }) : src } function computeOut (src, family, version, htmlUrlExtensionStyle) { let { component, module: module_, basename, extname, relative, stem } = src if (component === 'ROOT') component = '' if (module_ === 'ROOT') module_ = '' let indexifyPathSegment = '' let familyPathSegment = '' if (family === 'page') { if (stem !== 'index' && htmlUrlExtensionStyle === 'indexify') { basename = 'index.html' indexifyPathSegment = stem } else if (extname === '.adoc') { basename = stem + '.html' } } else if (family === 'image') { familyPathSegment = '_images' } else if (family === 'attachment') { familyPathSegment = '_attachments' } const modulePath = path.join(component, version, module_) const dirname = path.join(modulePath, familyPathSegment, path.dirname(relative), indexifyPathSegment) const path_ = path.join(dirname, basename) const moduleRootPath = path.relative(dirname, modulePath) || '.' const rootPath = path.relative(dirname, '') || '.' return { dirname, basename, path: path_, moduleRootPath, rootPath } } function computePub (src, out, family, version, htmlUrlExtensionStyle) { const pub = {} let url if (family === 'nav') { const component = src.component || 'ROOT' const urlSegments = component === 'ROOT' ? [] : [component] if (version) urlSegments.push(version) const module_ = src.module || 'ROOT' if (module_ !== 'ROOT') urlSegments.push(module_) if (urlSegments.length) urlSegments.push('') // an artificial URL used for resolving page references in navigation model url = '/' + urlSegments.join('/') pub.moduleRootPath = '.' } else if (family === 'page') { const urlSegments = out.path.split('/') const lastUrlSegmentIdx = urlSegments.length - 1 if (htmlUrlExtensionStyle === 'drop') { // drop just the .html extension or, if the filename is index.html, the whole segment const lastUrlSegment = urlSegments[lastUrlSegmentIdx] urlSegments[lastUrlSegmentIdx] = lastUrlSegment === 'index.html' ? '' : lastUrlSegment.substr(0, lastUrlSegment.length - 5) } else if (htmlUrlExtensionStyle === 'indexify') { urlSegments[lastUrlSegmentIdx] = '' } url = '/' + urlSegments.join('/') } else { if ((url = '/' + out.path) === '/.') url = '/' if (family === 'alias' && !src.relative) pub.splat = true } pub.url = ~url.indexOf(' ') ? url.replace(SPACE_RX, '%20') : url if (out) { pub.moduleRootPath = out.moduleRootPath pub.rootPath = out.rootPath } return pub } function computeVersionSegment (name, version, mode) { // special designation for master version is @deprecated; special designation scheduled to be removed in Antora 4 if (mode === 'original') return !version || version === 'master' ? '' : version const strategy = this.latestVersionUrlSegmentStrategy if (!version || version === 'master') { if (mode !== 'alias') return '' if (strategy === 'redirect:to') return } if (strategy === 'redirect:to' || strategy === (mode === 'alias' ? 'redirect:from' : 'replace')) { const component = this.getComponent(name) const componentVersion = component && this.getComponentVersion(component, version) if (componentVersion) { const segment = componentVersion === component.latest ? this.latestVersionUrlSegment : componentVersion === component.latestPrerelease ? this.latestPrereleaseVersionUrlSegment : undefined return segment == null ? version : segment } } return version } function createSymbolicVersionAlias (component, version, symbolicVersionSegment, strategy) { if (symbolicVersionSegment == null || symbolicVersionSegment === version) return const family = 'alias' const baseVersionAliasSrc = { component, module: 'ROOT', family, relative: '', basename: '', stem: '', extname: '' } const symbolicVersionAliasSrc = Object.assign({}, baseVersionAliasSrc, { version: symbolicVersionSegment }) const symbolicVersionAlias = { src: symbolicVersionAliasSrc, pub: computePub( symbolicVersionAliasSrc, computeOut(symbolicVersionAliasSrc, family, symbolicVersionSegment), family ), } const originalVersionAliasSrc = Object.assign({}, baseVersionAliasSrc, { version }) const originalVersionSegment = computeVersionSegment(component, version, 'original') const originalVersionAlias = { src: originalVersionAliasSrc, pub: computePub( originalVersionAliasSrc, computeOut(originalVersionAliasSrc, family, originalVersionSegment), family ), } return strategy === 'redirect:to' ? Object.assign(originalVersionAlias, { out: undefined, rel: symbolicVersionAlias }) : Object.assign(symbolicVersionAlias, { out: undefined, rel: originalVersionAlias }) } module.exports = ContentCatalog