@antora/content-classifier 3.1.9 → 3.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,50 +10,66 @@ const summarizeFileLocation = require('./util/summarize-file-location')
10
10
  *
11
11
  * @memberof content-classifier
12
12
  *
13
- * @param {Object} playbook - The configuration object for Antora.
13
+ * @param {Object} playbook - The configuration object for Antora. See ContentCatalog constructor for relevant keys.
14
14
  * @param {Object} playbook.site - Site-related configuration data.
15
15
  * @param {String} playbook.site.startPage - The start page for the site; redirects from base URL.
16
- * @param {Object} playbook.urls - URL settings for the site.
17
- * @param {String} playbook.urls.htmlExtensionStyle - The style to use when computing page URLs.
18
16
  * @param {Object} aggregate - The raw aggregate of virtual file objects to be classified.
19
17
  * @param {Object} [siteAsciiDocConfig={}] - Site-wide AsciiDoc processor configuration options.
20
- * @returns {ContentCatalog} A structured catalog of content components and virtual content files.
18
+ * @param {Function} [onComponentsRegistered] - A function (optionally async) to invoke after components are
19
+ * registered. Must return an instance of ContentCatalog. If async, this function will also return a Promise.
20
+ *
21
+ * @returns {ContentCatalog} A structured catalog of content components, versions, and virtual content files.
21
22
  */
22
- function classifyContent (playbook, aggregate, siteAsciiDocConfig = {}) {
23
- const contentCatalog = new ContentCatalog(playbook)
24
- aggregate
25
- .reduce((accum, componentVersionData) => {
26
- // drop files since they aren't needed to register component version
27
- // drop startPage to defer registration of start page
28
- const { name, version, files, startPage, ...descriptor } = Object.assign({}, componentVersionData, {
29
- asciidoc: resolveAsciiDocConfig(siteAsciiDocConfig, componentVersionData),
30
- })
31
- return new Map(accum).set(
32
- contentCatalog.registerComponentVersion(name, version, descriptor),
33
- componentVersionData
34
- )
35
- }, new Map())
36
- .forEach((componentVersionData, componentVersion) => {
37
- const { name, version } = componentVersion
38
- const { files, nav, startPage } = componentVersionData
23
+ function classifyContent (playbook, aggregate, siteAsciiDocConfig = {}, onComponentsRegistered = undefined) {
24
+ const siteStartPage = playbook.site.startPage
25
+ let contentCatalog = registerComponentVersions(new ContentCatalog(playbook), aggregate, siteAsciiDocConfig)
26
+ return typeof onComponentsRegistered === 'function' &&
27
+ (contentCatalog = onComponentsRegistered(contentCatalog)) instanceof Promise
28
+ ? contentCatalog.then((contentCatalogValue) => addFilesAndRegisterStartPages(contentCatalogValue, siteStartPage))
29
+ : addFilesAndRegisterStartPages(contentCatalog, siteStartPage)
30
+ }
31
+
32
+ function registerComponentVersions (contentCatalog, aggregate, siteAsciiDocConfig) {
33
+ for (const componentVersionBucket of aggregate) {
34
+ // advance files, nav, and startPage to component version to be used in later phase
35
+ const { name, version, files, nav, startPage, ...data } = Object.assign(componentVersionBucket, {
36
+ asciidoc: resolveAsciiDocConfig(siteAsciiDocConfig, componentVersionBucket),
37
+ })
38
+ Object.assign(contentCatalog.registerComponentVersion(name, version, data), { files, nav, startPage })
39
+ }
40
+ return contentCatalog
41
+ }
42
+
43
+ function addFilesAndRegisterStartPages (contentCatalog, siteStartPage) {
44
+ for (const { versions: componentVersions } of contentCatalog.getComponents()) {
45
+ for (const componentVersion of componentVersions) {
46
+ const { name: component, version, files = [], nav, startPage } = componentVersion
39
47
  const navResolved = nav && (nav.resolved = new Set())
40
- componentVersionData.files = undefined // clean up memory
41
- files.forEach((file) => allocateSrc(file, name, version, nav) && contentCatalog.addFile(file))
48
+ for (let file, i = 0, len = files.length; i < len; i++) {
49
+ allocateSrc((file = files[i]), component, version, nav) && contentCatalog.addFile(file, componentVersion)
50
+ files[i] = undefined // free memory
51
+ }
42
52
  if (navResolved && nav.length > navResolved.size && new Set(nav).size > navResolved.size) {
43
53
  const loc = summarizeFileLocation({ path: 'antora.yml', src: { origin: nav.origin } })
44
54
  for (const filepath of nav) {
45
55
  if (navResolved.has(filepath)) continue
46
- logger.warn('Could not resolve nav entry for %s@%s defined in %s: %s', version, name, loc, filepath)
56
+ logger.warn('Could not resolve nav entry for %s@%s defined in %s: %s', version, component, loc, filepath)
47
57
  }
48
58
  }
49
- contentCatalog.registerComponentVersionStartPage(name, componentVersion, startPage)
50
- })
51
- contentCatalog.registerSiteStartPage(playbook.site.startPage)
59
+ contentCatalog.registerComponentVersionStartPage(component, componentVersion, startPage)
60
+ }
61
+ }
62
+ contentCatalog.registerSiteStartPage(siteStartPage)
52
63
  return contentCatalog
53
64
  }
54
65
 
55
66
  function allocateSrc (file, component, version, nav) {
56
- const extname = file.src.extname
67
+ const { extname, family } = file.src
68
+ if (family && family !== 'nav') {
69
+ Object.assign(file.src, { component, version })
70
+ file.src.moduleRootPath ??= calculateRootPath(file.src.relative.split('/').length)
71
+ return true
72
+ }
57
73
  const filepath = file.path
58
74
  const pathSegments = filepath.split('/')
59
75
  let navInfo
@@ -140,13 +156,19 @@ function getNavInfo (filepath, nav) {
140
156
  if (~index) return nav.resolved.add(filepath) && { index }
141
157
  }
142
158
 
143
- function resolveAsciiDocConfig (siteAsciiDocConfig, { asciidoc, origins = [] }) {
144
- const scopedAttributes = (asciidoc || {}).attributes
159
+ function resolveAsciiDocConfig (siteAsciiDocConfig, { name, version, asciidoc, origins = [] }) {
160
+ const scopedAttributes = asciidoc?.attributes
145
161
  if (scopedAttributes) {
146
- const initial = siteAsciiDocConfig.attributes
162
+ const initial = Object.assign({}, siteAsciiDocConfig.attributes)
163
+ initial['antora-component-name'] = name
164
+ initial['antora-component-version'] = version
147
165
  const mdc = { file: { path: 'antora.yml', origin: origins[origins.length - 1] } }
148
166
  const attributes = collateAsciiDocAttributes(scopedAttributes, { initial, mdc, merge: true })
149
- if (attributes !== initial) siteAsciiDocConfig = Object.assign({}, siteAsciiDocConfig, { attributes })
167
+ if (attributes !== initial) {
168
+ delete attributes['antora-component-name']
169
+ delete attributes['antora-component-version']
170
+ return Object.assign({}, siteAsciiDocConfig, { attributes })
171
+ }
150
172
  }
151
173
  return siteAsciiDocConfig
152
174
  }
@@ -5,13 +5,12 @@ const invariably = { void: () => undefined }
5
5
  const logger = require('./logger')
6
6
  const { lookup: resolveMimeType } = require('./mime-types-with-asciidoc')
7
7
  const parseResourceId = require('./util/parse-resource-id')
8
- const { posix: path } = require('path')
8
+ const { posix: path } = require('node:path')
9
9
  const resolveResource = require('./util/resolve-resource')
10
10
  const summarizeFileLocation = require('./util/summarize-file-location')
11
11
  const versionCompare = require('./util/version-compare-desc')
12
12
 
13
13
  const { ROOT_INDEX_ALIAS_ID, ROOT_INDEX_PAGE_ID } = require('./constants')
14
- const SPACE_RX = / /g
15
14
  const LOG_WRAP = '\n '
16
15
 
17
16
  const $components = Symbol('components')
@@ -24,17 +23,17 @@ class ContentCatalog {
24
23
  const urls = playbook.urls || {}
25
24
  this.htmlUrlExtensionStyle = urls.htmlExtensionStyle || 'default'
26
25
  this.urlRedirectFacility = urls.redirectFacility || 'static'
27
- this.latestVersionUrlSegment = urls.latestVersionSegment
28
- this.latestPrereleaseVersionUrlSegment = urls.latestPrereleaseVersionSegment
29
- if (this.latestVersionUrlSegment == null && this.latestPrereleaseVersionUrlSegment == null) {
30
- this.latestVersionUrlSegmentStrategy = undefined
26
+ this.latestVersionSegment = urls.latestVersionSegment
27
+ this.latestPrereleaseVersionSegment = urls.latestPrereleaseVersionSegment
28
+ if (this.latestVersionSegment == null && this.latestPrereleaseVersionSegment == null) {
29
+ this.latestVersionSegmentStrategy = undefined
31
30
  } else {
32
- this.latestVersionUrlSegmentStrategy = urls.latestVersionSegmentStrategy || 'replace'
33
- if (this.latestVersionUrlSegmentStrategy === 'redirect:from') {
34
- if (!this.latestVersionUrlSegment) this.latestVersionUrlSegment = undefined
35
- if (!this.latestPrereleaseVersionUrlSegment) {
36
- this.latestPrereleaseVersionUrlSegment = undefined
37
- if (!this.latestVersionUrlSegment) this.latestVersionUrlSegmentStrategy = undefined
31
+ this.latestVersionSegmentStrategy = urls.latestVersionSegmentStrategy || 'replace'
32
+ if (this.latestVersionSegmentStrategy === 'redirect:from') {
33
+ if (!this.latestVersionSegment) this.latestVersionSegment = undefined
34
+ if (!this.latestPrereleaseVersionSegment) {
35
+ this.latestPrereleaseVersionSegment = undefined
36
+ if (!this.latestVersionSegment) this.latestVersionSegmentStrategy = undefined
38
37
  }
39
38
  }
40
39
  }
@@ -43,6 +42,8 @@ class ContentCatalog {
43
42
  /**
44
43
  * Registers a new component version with the content catalog. Also registers the component if it does not yet exist.
45
44
  *
45
+ * Must be followed by a call to registerComponentVersionStartPage to finalize object.
46
+ *
46
47
  * @param {String} name - The name of the component to which this component version belongs.
47
48
  * @param {String} version - The version of the component to register.
48
49
  * @param {Object} [descriptor={}] - The configuration data for the component version.
@@ -59,8 +60,9 @@ class ContentCatalog {
59
60
  * @returns {Object} The constructed component version object.
60
61
  */
61
62
  registerComponentVersion (name, version, descriptor = {}) {
62
- const { asciidoc, displayVersion, prerelease, startPage: startPageSpec, title } = descriptor
63
+ const { asciidoc, displayVersion, prerelease, startPage: startPageSpec, title, versionSegment } = descriptor
63
64
  const componentVersion = { displayVersion: displayVersion || version || 'default', title: title || name, version }
65
+ if (versionSegment != null) componentVersion.versionSegment = versionSegment
64
66
  Object.defineProperty(componentVersion, 'name', { value: name, enumerable: true })
65
67
  if (prerelease) {
66
68
  componentVersion.prerelease = prerelease
@@ -84,10 +86,10 @@ class ContentCatalog {
84
86
  let lastVerdict
85
87
  const insertIdx = version
86
88
  ? componentVersions.findIndex(({ version: candidateVersion, prerelease: candidatePrerelease }) => {
87
- return (lastVerdict = versionCompare(candidateVersion, version)) > 1
88
- ? !!prerelease === !!candidatePrerelease
89
- : lastVerdict > 0 || (lastVerdict < -1 && prerelease && !candidatePrerelease)
90
- })
89
+ return (lastVerdict = versionCompare(candidateVersion, version)) > 1
90
+ ? !!prerelease === !!candidatePrerelease
91
+ : lastVerdict > 0 || (lastVerdict < -1 && prerelease && !candidatePrerelease)
92
+ })
91
93
  : prerelease
92
94
  ? -1
93
95
  : ~(~componentVersions.findIndex(({ prerelease: candidatePrerelease }) => !candidatePrerelease) || -1)
@@ -129,51 +131,50 @@ class ContentCatalog {
129
131
  )
130
132
  }
131
133
  if (startPageSpec) {
134
+ // @deprecated use separate call to register start page for component version
132
135
  this.registerComponentVersionStartPage(name, componentVersion, startPageSpec === true ? undefined : startPageSpec)
133
136
  }
134
137
  return componentVersion
135
138
  }
136
139
 
137
- addFile (file) {
140
+ addFile (file, componentVersion) {
138
141
  const src = file.src
139
- let family = src.family
142
+ let { component, version, family } = src
140
143
  let filesForFamily = this[$files].get(family)
141
144
  if (!filesForFamily) this[$files].set(family, (filesForFamily = new Map()))
142
145
  const key = generateKey(src)
143
146
  if (filesForFamily.has(key)) {
144
147
  if (family === 'alias') {
145
148
  throw new Error(`Duplicate alias: ${generateResourceSpec(src)}`)
146
- } else {
147
- const details = [filesForFamily.get(key), file]
148
- .map((it, idx) => `${idx + 1}: ${summarizeFileLocation(it)}`)
149
- .join(LOG_WRAP)
150
- if (family === 'nav') {
151
- throw new Error(`Duplicate nav in ${src.version}@${src.component}: ${file.path}${LOG_WRAP}${details}`)
152
- } else {
153
- throw new Error(`Duplicate ${family}: ${generateResourceSpec(src)}${LOG_WRAP}${details}`)
154
- }
155
149
  }
150
+ const details = [filesForFamily.get(key), file]
151
+ .map((it, idx) => `${idx + 1}: ${summarizeFileLocation(it)}`)
152
+ .join(LOG_WRAP)
153
+ if (family === 'nav') {
154
+ throw new Error(`Duplicate nav file: ${file.path} in ${version}@${component}${LOG_WRAP}${details}`)
155
+ }
156
+ throw new Error(`Duplicate ${family}: ${generateResourceSpec(src)}${LOG_WRAP}${details}`)
156
157
  }
157
- // NOTE: if the path property is not set, assume the src likely needs to be prepared
158
- // another option is to assume that if the file is not a vinyl object, the src likely needs to be prepared
158
+ // NOTE: assume that if the file is not a vinyl object, the src likely needs to be prepared
159
159
  // a vinyl object is one indication the file was created and prepared by the content aggregator
160
- //if (!src.path) prepareSrc(src)
161
- //if (!File.isVinyl(file)) file = new File(file)
160
+ // an alternate approach would be to call prepareSrc if the path property is not set
162
161
  if (!File.isVinyl(file)) {
163
162
  prepareSrc(src)
164
163
  file = new File(file)
165
164
  }
166
165
  if (family === 'alias') {
167
- src.mediaType = 'text/asciidoc'
168
166
  file.mediaType = 'text/html'
169
167
  // NOTE: an alias masquerades as the target file
170
168
  family = file.rel.src.family
171
- // QUESTION: should we preserve the mediaType property on file if already defined?
169
+ // NOTE: short circuit in case of splat alias (alias -> alias)
170
+ if (family === 'alias' && file.pub?.splat) return filesForFamily.set(key, file) && file
171
+ src.mediaType = 'text/asciidoc'
172
172
  } else if (!(file.mediaType = src.mediaType) && !('mediaType' in src)) {
173
+ // QUESTION: should we preserve the mediaType property on file if already defined?
173
174
  file.mediaType = src.mediaType = resolveMimeType(src.extname) || (family === 'page' ? 'text/asciidoc' : undefined)
174
175
  }
175
176
  let publishable
176
- let versionSegment
177
+ let activeVersionSegment
177
178
  if (file.out) {
178
179
  publishable = true
179
180
  } else if ('out' in file) {
@@ -183,15 +184,24 @@ class ContentCatalog {
183
184
  ('/' + src.relative).indexOf('/_') < 0
184
185
  ) {
185
186
  publishable = true
186
- versionSegment = computeVersionSegment.call(this, src.component, src.version)
187
- file.out = computeOut(src, family, versionSegment, this.htmlUrlExtensionStyle)
187
+ if (componentVersion == null) componentVersion = this.getComponentVersion(component, version) || { version }
188
+ activeVersionSegment =
189
+ 'activeVersionSegment' in componentVersion
190
+ ? componentVersion.activeVersionSegment
191
+ : computeVersionSegment.call(this, componentVersion)
192
+ file.out = computeOut(src, family, activeVersionSegment, this.htmlUrlExtensionStyle)
188
193
  }
189
194
  if (!file.pub && (publishable || family === 'nav')) {
190
- if (versionSegment == null) versionSegment = computeVersionSegment.call(this, src.component, src.version)
191
- file.pub = computePub(src, file.out, family, versionSegment, this.htmlUrlExtensionStyle)
195
+ if (activeVersionSegment == null) {
196
+ if (componentVersion == null) componentVersion = this.getComponentVersion(component, version) || { version }
197
+ activeVersionSegment =
198
+ 'activeVersionSegment' in componentVersion
199
+ ? componentVersion.activeVersionSegment
200
+ : computeVersionSegment.call(this, componentVersion)
201
+ }
202
+ file.pub = computePub(src, file.out, family, activeVersionSegment, this.htmlUrlExtensionStyle)
192
203
  }
193
- filesForFamily.set(key, file)
194
- return file
204
+ return filesForFamily.set(key, file) && file
195
205
  }
196
206
 
197
207
  removeFile (file) {
@@ -234,7 +244,7 @@ class ContentCatalog {
234
244
  }
235
245
 
236
246
  getComponentVersion (component, version) {
237
- return (component.versions || (this.getComponent(component) || {}).versions || []).find(
247
+ return (component.versions || this.getComponent(component)?.versions || []).find(
238
248
  ({ version: candidate }) => candidate === version
239
249
  )
240
250
  }
@@ -262,45 +272,54 @@ class ContentCatalog {
262
272
  const accum = []
263
273
  for (const candidate of candidates.values()) filter(candidate) && accum.push(candidate)
264
274
  return accum
265
- } else {
266
- return [...candidates.values()]
267
275
  }
276
+ return [...candidates.values()]
268
277
  }
269
278
 
270
279
  // TODO add `follow` argument to control whether alias is followed
271
280
  getSiteStartPage () {
272
- return this.getById(ROOT_INDEX_PAGE_ID) || (this.getById(ROOT_INDEX_ALIAS_ID) || {}).rel
281
+ let file
282
+ if ((file = this.getById(ROOT_INDEX_PAGE_ID))) return file
283
+ if ((file = this.getById(ROOT_INDEX_ALIAS_ID))) return file.rel
284
+ const rootComponent = this.getComponent('ROOT')
285
+ if (!rootComponent) return
286
+ const version = rootComponent.versions.find(({ activeVersionSegment }) => activeVersionSegment === '')?.version
287
+ if (!version) return
288
+ if ((file = this.getById(Object.assign({}, ROOT_INDEX_PAGE_ID, { version })))) return file
289
+ if ((file = this.getById(Object.assign({}, ROOT_INDEX_ALIAS_ID, { version })))) return file.rel
273
290
  }
274
291
 
275
292
  registerComponentVersionStartPage (name, componentVersion, startPageSpec = undefined) {
293
+ const component = name
276
294
  let version = componentVersion.version
277
295
  if (version == null) {
278
296
  // QUESTION: should we warn or throw error if component version cannot be found?
279
- if (!(componentVersion = this.getComponentVersion(name, componentVersion))) return
297
+ if (!(componentVersion = this.getComponentVersion(component, componentVersion))) return
280
298
  version = componentVersion.version
281
299
  }
300
+ const activeVersionSegment = computeVersionSegment.call(this, componentVersion)
282
301
  let startPage
283
302
  let startPageSrc
284
- const indexPageId = Object.assign({}, ROOT_INDEX_PAGE_ID, { component: name, version })
303
+ const indexPageId = Object.assign({}, ROOT_INDEX_PAGE_ID, { component, version })
285
304
  if (startPageSpec) {
286
305
  if (
287
306
  (startPage = this.resolvePage(startPageSpec, indexPageId)) &&
288
- (startPageSrc = startPage.src).component === name &&
307
+ (startPageSrc = startPage.src).component === component &&
289
308
  startPageSrc.version === version
290
309
  ) {
291
310
  if (!this.getById(indexPageId)) {
292
- const indexAliasId = Object.assign({}, ROOT_INDEX_ALIAS_ID, { component: name, version })
311
+ const indexAliasId = Object.assign({}, ROOT_INDEX_ALIAS_ID, { component, version })
293
312
  const indexAlias = this.getById(indexAliasId)
294
313
  indexAlias
295
314
  ? indexAlias.synthetic && Object.assign(indexAlias, { rel: startPage })
296
- : this.addFile({ src: indexAliasId, rel: startPage, synthetic: true })
315
+ : this.addFile({ src: indexAliasId, rel: startPage, synthetic: true }, componentVersion)
297
316
  }
298
317
  } else {
299
318
  // TODO pass componentVersion as logObject
300
319
  logger.warn(
301
320
  'Start page specified for %s@%s %s: %s',
302
321
  version,
303
- name,
322
+ component,
304
323
  startPage === false ? 'has invalid syntax' : 'not found',
305
324
  startPageSpec
306
325
  )
@@ -311,25 +330,34 @@ class ContentCatalog {
311
330
  }
312
331
  if (startPage) {
313
332
  componentVersion.url = startPage.pub.url
314
- } else {
333
+ } else if (!componentVersion.url) {
315
334
  // QUESTION: should we warn if the default start page cannot be found?
316
- const versionSegment = computeVersionSegment.call(this, name, version)
317
335
  componentVersion.url = computePub(
318
336
  (startPageSrc = prepareSrc(Object.assign({}, indexPageId, { family: 'page' }))),
319
- computeOut(startPageSrc, startPageSrc.family, versionSegment, this.htmlUrlExtensionStyle),
337
+ computeOut(startPageSrc, startPageSrc.family, activeVersionSegment, this.htmlUrlExtensionStyle),
320
338
  startPageSrc.family,
321
- versionSegment,
339
+ activeVersionSegment,
322
340
  this.htmlUrlExtensionStyle
323
341
  ).url
324
342
  }
325
-
326
- const symbolicVersionAlias = createSymbolicVersionAlias(
327
- name,
328
- version,
329
- computeVersionSegment.call(this, name, version, 'alias'),
330
- this.latestVersionUrlSegmentStrategy
331
- )
332
- if (symbolicVersionAlias) this.addFile(symbolicVersionAlias)
343
+ Object.defineProperties(componentVersion, {
344
+ activeVersionSegment:
345
+ activeVersionSegment === version
346
+ ? { configurable: true, enumerable: false, get: getVersion }
347
+ : { configurable: true, enumerable: false, value: activeVersionSegment },
348
+ files: {
349
+ configurable: true,
350
+ enumerable: false,
351
+ get: getComponentVersionFiles.bind(this, { component, version }),
352
+ },
353
+ startPage: {
354
+ configurable: true,
355
+ enumerable: false,
356
+ get: getComponentVersionStartPage.bind(this, { component, version }),
357
+ },
358
+ })
359
+ addSymbolicVersionAlias.call(this, componentVersion)
360
+ return startPage
333
361
  }
334
362
 
335
363
  registerSiteStartPage (startPageSpec) {
@@ -337,12 +365,13 @@ class ContentCatalog {
337
365
  const rel = this.resolvePage(startPageSpec)
338
366
  if (rel) {
339
367
  if (this.getById(ROOT_INDEX_PAGE_ID)) return
368
+ if (rel.pub.url === (this.htmlUrlExtensionStyle === 'default' ? '/index.html' : '/')) return
340
369
  const rootIndexAlias = this.getById(ROOT_INDEX_ALIAS_ID)
341
370
  if (rootIndexAlias) return rootIndexAlias.synthetic ? Object.assign(rootIndexAlias, { rel }) : undefined
342
- if (rel.pub.url === (this.htmlUrlExtensionStyle === 'default' ? '/index.html' : '/')) return
343
371
  const src = Object.assign({}, ROOT_INDEX_ALIAS_ID)
344
372
  return this.addFile({ src, rel, synthetic: true }, { version: src.version })
345
- } else if (rel === false) {
373
+ }
374
+ if (rel === false) {
346
375
  logger.warn('Start page specified for site has invalid syntax: %s', startPageSpec)
347
376
  } else if (startPageSpec.lastIndexOf(':') > startPageSpec.indexOf(':')) {
348
377
  logger.warn('Start page specified for site not found: %s', startPageSpec)
@@ -359,18 +388,23 @@ class ContentCatalog {
359
388
  // QUESTION should we throw an error if alias is invalid?
360
389
  if (!src || (inferredSpec && src.relative === '.adoc')) return
361
390
  const component = this.getComponent(src.component)
391
+ let componentVersion
362
392
  if (component) {
363
393
  // NOTE version is not set when alias specifies a component, but not a version
364
- if (src.version == null) src.version = component.latest.version
394
+ if (src.version == null) {
395
+ src.version = (componentVersion = component.latest).version
396
+ } else {
397
+ componentVersion = this.getComponentVersion(component, src.version)
398
+ }
365
399
  const existingPage = this.getById(src)
366
400
  if (existingPage) {
367
401
  throw new Error(
368
402
  existingPage === target
369
403
  ? `Page cannot define alias that references itself: ${generateResourceSpec(src)}` +
370
- ` (specified as: ${spec})${LOG_WRAP}source: ${summarizeFileLocation(existingPage)}`
404
+ ` (specified as: ${spec})${LOG_WRAP}source: ${summarizeFileLocation(existingPage)}`
371
405
  : `Page alias cannot reference an existing page: ${generateResourceSpec(src)} (specified as: ${spec})` +
372
- `${LOG_WRAP}source: ${summarizeFileLocation(target)}` +
373
- `${LOG_WRAP}existing page: ${summarizeFileLocation(existingPage)}`
406
+ `${LOG_WRAP}source: ${summarizeFileLocation(target)}` +
407
+ `${LOG_WRAP}existing page: ${summarizeFileLocation(existingPage)}`
374
408
  )
375
409
  }
376
410
  } else if (src.version == null) {
@@ -386,33 +420,77 @@ class ContentCatalog {
386
420
  )
387
421
  }
388
422
  // NOTE the redirect producer will populate contents when the redirect facility is 'static'
389
- const alias = this.addFile({ src, rel: target })
423
+ const alias = this.addFile({ src, rel: target }, componentVersion)
390
424
  // NOTE record the first alias this target claims as the preferred one
391
425
  if (!target.rel) target.rel = alias
392
426
  return alias
393
427
  }
394
428
 
395
429
  /**
396
- * Attempts to resolve a string contextual page ID spec to a file in the catalog.
430
+ * Adds a splat (directory) alias from the specified version segment in one component to the specified
431
+ * version segment in the same or different component.
397
432
  *
398
- * Parses the specified contextual page ID spec into a page ID object, then attempts to lookup a
399
- * file with this page ID in the catalog. If a component is specified, but not a version, the
400
- * latest version of the component stored in the catalog is used. If a page cannot be resolved,
401
- * the search is attempted again for an "alias". If neither a page or alias can be resolved, the
402
- * function returns undefined. If the spec does not match the page ID syntax, this function throws
403
- * an error.
433
+ * @returns {File} The virtual file that represents the splat alias.
434
+ */
435
+ addSplatAlias (from, to) {
436
+ if (!from.versionSegment) throw new Error('cannot map splat alias from empty version segment')
437
+ const family = 'alias'
438
+ const baseSrc = { module: 'ROOT', family, relative: '', basename: '', stem: '', extname: '' }
439
+ const basePub = { splat: true }
440
+ const { component: fromComponent = to.component, versionSegment: fromVersionSegment } = from
441
+ const fromSrc = Object.assign({ component: fromComponent, version: fromVersionSegment }, baseSrc)
442
+ const fromPub = Object.assign(computePub(fromSrc, computeOut(fromSrc, family, fromVersionSegment), family), basePub)
443
+ const { component: toComponent, version: toVersion } = to
444
+ const toVersionSegment =
445
+ to.versionSegment ?? this.getComponentVersion(toComponent, toVersion)?.activeVersionSegment ?? toVersion
446
+ const toSrc = Object.assign({ component: toComponent, version: toVersion ?? toVersionSegment }, baseSrc)
447
+ const toPub = Object.assign(computePub(toSrc, computeOut(toSrc, family, toVersionSegment), family), basePub)
448
+ return this.addFile({ pub: fromPub, src: fromSrc, rel: { pub: toPub, src: toSrc } })
449
+ }
450
+
451
+ /**
452
+ * Attempts to resolve a page reference within the given context to a page in the catalog.
453
+ *
454
+ * Parses the specified page reference (i.e., page ID spec) into a partial page ID, expands it
455
+ * using the provided context, then attempts to locate a file in the page family with that page ID
456
+ * in this catalog. The family segment is optional.
404
457
  *
405
- * @param {String} spec - The contextual page ID spec (e.g., version@component:module:topic/page.adoc).
406
- * @param {ContentCatalog} catalog - The content catalog in which to resolve the page file.
407
- * @param {Object} [ctx={}] - The context to use to qualified the contextual page ID.
458
+ * If a component is specified, but no version, the latest version of the component stored in the
459
+ * catalog is used. If a page cannot be resolved, the search is attempted again for an "alias". If
460
+ * neither a page or alias can be resolved, the function returns undefined. If the syntax of the
461
+ * reference is invalid, this function throws an error.
408
462
  *
409
- * @returns {File} The virtual file to which the contextual page ID spec refers, or undefined if the
410
- * file cannot be resolved.
463
+ * @param {String} spec - The contextual page reference (e.g., version@component:module:topic/page.adoc).
464
+ * @param {Object} [context={}] - The context to use to qualify the page reference.
465
+ *
466
+ * @returns {File} The virtual file to which the contextual page reference resolves, or undefined
467
+ * if the file cannot be resolved.
411
468
  */
412
469
  resolvePage (spec, context = {}) {
413
470
  return this.resolveResource(spec, context, 'page', ['page'])
414
471
  }
415
472
 
473
+ /**
474
+ * Attempts to resolve a resource reference within the given context to a file in the catalog.
475
+ *
476
+ * Parses the specified resource reference (i.e., resource ID spec) into a partial resource ID,
477
+ * expands it using the provided context, then attempts to locate a file with that resource ID in
478
+ * this catalog.
479
+ *
480
+ * If a component is specified, but no version, the latest version of the component stored in the
481
+ * catalog is used. If a defaultFamily is not specified, the family must be specified either by
482
+ * the reference or the context. If permittedFamilies are stated, the family must resolve to a
483
+ * family in this list. If a file cannot be resolved, the function returns undefined. If the
484
+ * syntax of the reference is invalid, this function throws an error.
485
+ *
486
+ * @param {String} spec - The contextual resource reference (e.g., version@component:module:image$topic/image.png).
487
+ * @param {Object} [context={}] - The context to use to qualify the resource reference.
488
+ * @param {String} [defaultFamily=undefined] - The default family to use if one is not provided.
489
+ * @param {Array<String>} [permittedFamilies=undefined] - A list of families that are permitted.
490
+ *
491
+ * @returns {File} The virtual file to which the contextual resource reference resolves, or
492
+ * undefined if the file cannot be resolved.
493
+ */
416
494
  resolveResource (spec, context = {}, defaultFamily = undefined, permittedFamilies = undefined) {
417
495
  return resolveResource(spec, this, context, defaultFamily, permittedFamilies)
418
496
  }
@@ -454,32 +532,28 @@ function generateResourceSpec ({ component, version, module: module_, family, re
454
532
 
455
533
  function prepareSrc (src) {
456
534
  let { basename, extname, stem } = src
457
- let update
458
535
  if (basename == null) {
459
- update = true
460
- basename = path.basename(src.relative)
536
+ basename = src.basename = path.basename(src.relative)
461
537
  }
462
538
  if (stem == null) {
463
- update = true
464
539
  if (extname == null) {
465
540
  if (~(extname = basename.lastIndexOf('.'))) {
466
- stem = basename.substr(0, extname)
467
- extname = basename.substr(extname)
541
+ src.stem = basename.substr(0, extname)
542
+ src.extname = basename.substr(extname)
468
543
  } else {
469
- stem = basename
470
- extname = ''
544
+ src.stem = basename
545
+ src.extname = ''
471
546
  }
472
547
  } else {
473
- stem = basename.substr(0, basename.length - extname.length)
548
+ src.stem = basename.substr(0, basename.length - extname.length)
474
549
  }
475
550
  } else if (extname == null) {
476
- update = true
477
- extname = basename.substr(stem.length)
551
+ src.extname = basename.substr(stem.length)
478
552
  }
479
- return update ? Object.assign(src, { basename, extname, stem }) : src
553
+ return src
480
554
  }
481
555
 
482
- function computeOut (src, family, version, htmlUrlExtensionStyle) {
556
+ function computeOut (src, family, versionSegment, htmlUrlExtensionStyle) {
483
557
  let { component, module: module_, basename, extname, relative, stem } = src
484
558
  if (component === 'ROOT') component = ''
485
559
  if (module_ === 'ROOT') module_ = ''
@@ -499,7 +573,7 @@ function computeOut (src, family, version, htmlUrlExtensionStyle) {
499
573
  familyPathSegment = '_attachments'
500
574
  }
501
575
 
502
- const modulePath = path.join(component, version, module_)
576
+ const modulePath = path.join(component, versionSegment, module_)
503
577
  const dirname = path.join(modulePath, familyPathSegment, path.dirname(relative), indexifyPathSegment)
504
578
  const path_ = path.join(dirname, basename)
505
579
  const moduleRootPath = path.relative(dirname, modulePath) || '.'
@@ -508,13 +582,13 @@ function computeOut (src, family, version, htmlUrlExtensionStyle) {
508
582
  return { dirname, basename, path: path_, moduleRootPath, rootPath }
509
583
  }
510
584
 
511
- function computePub (src, out, family, version, htmlUrlExtensionStyle) {
585
+ function computePub (src, out, family, versionSegment, htmlUrlExtensionStyle) {
512
586
  const pub = {}
513
587
  let url
514
588
  if (family === 'nav') {
515
589
  const component = src.component || 'ROOT'
516
590
  const urlSegments = component === 'ROOT' ? [] : [component]
517
- if (version) urlSegments.push(version)
591
+ if (versionSegment) urlSegments.push(versionSegment)
518
592
  const module_ = src.module || 'ROOT'
519
593
  if (module_ !== 'ROOT') urlSegments.push(module_)
520
594
  if (urlSegments.length) urlSegments.push('')
@@ -533,71 +607,61 @@ function computePub (src, out, family, version, htmlUrlExtensionStyle) {
533
607
  urlSegments[lastUrlSegmentIdx] = ''
534
608
  }
535
609
  url = '/' + urlSegments.join('/')
536
- } else {
537
- if ((url = '/' + out.path) === '/.') url = '/'
538
- if (family === 'alias' && !src.relative) pub.splat = true
539
- }
540
-
541
- pub.url = ~url.indexOf(' ') ? url.replace(SPACE_RX, '%20') : url
542
-
543
- if (out) {
544
- pub.moduleRootPath = out.moduleRootPath
545
- pub.rootPath = out.rootPath
610
+ } else if ((url = '/' + out.path) === '/.') {
611
+ url = '/'
546
612
  }
613
+ pub.url = ~url.indexOf(' ') ? url.replaceAll(' ', '%20') : url
614
+ return out ? Object.assign(pub, { moduleRootPath: out.moduleRootPath, rootPath: out.rootPath }) : pub
615
+ }
547
616
 
548
- return pub
617
+ function addSymbolicVersionAlias (componentVersion) {
618
+ const { name: component, version } = componentVersion
619
+ const originalVersionSegment = computeVersionSegment.call(this, componentVersion, 'original')
620
+ const symbolicVersionSegment = computeVersionSegment.call(this, componentVersion, 'alias')
621
+ if (symbolicVersionSegment === originalVersionSegment || symbolicVersionSegment == null) return
622
+ const originalVersionSrc = { component, version, versionSegment: originalVersionSegment }
623
+ const symbolicVersionSrc = { component, version, versionSegment: symbolicVersionSegment }
624
+ return this.latestVersionSegmentStrategy === 'redirect:to'
625
+ ? this.addSplatAlias(originalVersionSrc, symbolicVersionSrc)
626
+ : this.addSplatAlias(symbolicVersionSrc, originalVersionSrc)
549
627
  }
550
628
 
551
- function computeVersionSegment (name, version, mode) {
629
+ function computeVersionSegment (componentVersion, mode) {
630
+ const version = componentVersion.version
552
631
  // special designation for master version is @deprecated; special designation scheduled to be removed in Antora 4
553
- if (mode === 'original') return !version || version === 'master' ? '' : version
554
- const strategy = this.latestVersionUrlSegmentStrategy
555
- if (!version || version === 'master') {
556
- if (mode !== 'alias') return ''
632
+ const normalizedVersion = version && version !== 'master' ? version : ''
633
+ const { versionSegment = normalizedVersion } = componentVersion
634
+ if (mode === 'original') return versionSegment
635
+ const strategy = this.latestVersionSegmentStrategy
636
+ if (!versionSegment) {
637
+ if (!mode) return ''
557
638
  if (strategy === 'redirect:to') return
558
639
  }
559
- if (strategy === 'redirect:to' || strategy === (mode === 'alias' ? 'redirect:from' : 'replace')) {
560
- const component = this.getComponent(name)
561
- const componentVersion = component && this.getComponentVersion(component, version)
562
- if (componentVersion) {
563
- const segment =
640
+ if (strategy === 'redirect:to' || strategy === (mode ? 'redirect:from' : 'replace')) {
641
+ let component
642
+ if ((component = 'name' in componentVersion && this.getComponent(componentVersion.name))) {
643
+ const latestSegment =
564
644
  componentVersion === component.latest
565
- ? this.latestVersionUrlSegment
645
+ ? this.latestVersionSegment
566
646
  : componentVersion === component.latestPrerelease
567
- ? this.latestPrereleaseVersionUrlSegment
647
+ ? this.latestPrereleaseVersionSegment
568
648
  : undefined
569
- return segment == null ? version : segment
649
+ return latestSegment == null ? versionSegment : latestSegment
570
650
  }
571
651
  }
572
- return version
652
+ return versionSegment
573
653
  }
574
654
 
575
- function createSymbolicVersionAlias (component, version, symbolicVersionSegment, strategy) {
576
- if (symbolicVersionSegment == null || symbolicVersionSegment === version) return
577
- const family = 'alias'
578
- const baseVersionAliasSrc = { component, module: 'ROOT', family, relative: '', basename: '', stem: '', extname: '' }
579
- const symbolicVersionAliasSrc = Object.assign({}, baseVersionAliasSrc, { version: symbolicVersionSegment })
580
- const symbolicVersionAlias = {
581
- src: symbolicVersionAliasSrc,
582
- pub: computePub(
583
- symbolicVersionAliasSrc,
584
- computeOut(symbolicVersionAliasSrc, family, symbolicVersionSegment),
585
- family
586
- ),
587
- }
588
- const originalVersionAliasSrc = Object.assign({}, baseVersionAliasSrc, { version })
589
- const originalVersionSegment = computeVersionSegment(component, version, 'original')
590
- const originalVersionAlias = {
591
- src: originalVersionAliasSrc,
592
- pub: computePub(
593
- originalVersionAliasSrc,
594
- computeOut(originalVersionAliasSrc, family, originalVersionSegment),
595
- family
596
- ),
597
- }
598
- return strategy === 'redirect:to'
599
- ? Object.assign(originalVersionAlias, { out: undefined, rel: symbolicVersionAlias })
600
- : Object.assign(symbolicVersionAlias, { out: undefined, rel: originalVersionAlias })
655
+ function getComponentVersionFiles (componentVersionId) {
656
+ return this.findBy(componentVersionId)
657
+ }
658
+
659
+ function getComponentVersionStartPage (componentVersionId) {
660
+ return this.resolvePage('index.adoc', componentVersionId)
661
+ }
662
+
663
+ function getVersion () {
664
+ return this.version
601
665
  }
602
666
 
603
667
  module.exports = ContentCatalog
@@ -26,7 +26,6 @@ const parseResourceId = require('./parse-resource-id')
26
26
  */
27
27
  function resolveResource (spec, catalog, ctx = {}, defaultFamily = undefined, permittedFamilies = undefined) {
28
28
  const id = parseResourceId(spec, ctx, defaultFamily, permittedFamilies)
29
-
30
29
  if (!id || !id.family) return false
31
30
  if (id.version == null) {
32
31
  const component = catalog.getComponent(id.component)
@@ -34,10 +33,9 @@ function resolveResource (spec, catalog, ctx = {}, defaultFamily = undefined, pe
34
33
  id.version = component.latest.version
35
34
  }
36
35
  if (!id.module) id.module = 'ROOT'
37
-
38
36
  return (
39
37
  catalog.getById(id) ||
40
- (id.family === 'page' ? (catalog.getById(Object.assign({}, id, { family: 'alias' })) || {}).rel : undefined)
38
+ (id.family === 'page' ? catalog.getById(Object.assign({}, id, { family: 'alias' }))?.rel : undefined)
41
39
  )
42
40
  }
43
41
 
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { posix: path } = require('path')
3
+ const { posix: path } = require('node:path')
4
4
 
5
5
  function summarizeFileLocation ({ path: path_, src: { abspath, origin } }) {
6
6
  if (!origin) return abspath || path_
@@ -24,11 +24,8 @@ function versionCompareDesc (a, b) {
24
24
  if (a && b) {
25
25
  const semverA = resolveSemver(a)
26
26
  const semverB = resolveSemver(b)
27
- if (semverA) {
28
- return semverB ? -semverCompare(semverA, semverB) : 1
29
- } else {
30
- return semverB ? -1 : -2 * a.localeCompare(b, 'en', { numeric: true })
31
- }
27
+ if (semverA) return semverB ? -semverCompare(semverA, semverB) : 1
28
+ return semverB ? -1 : -2 * a.localeCompare(b, 'en', { numeric: true })
32
29
  }
33
30
  return a ? 1 : -1
34
31
  }
@@ -64,23 +61,18 @@ function semverCompare (a, b) {
64
61
  const numsA = a.split('.')
65
62
  const numsB = b.split('.')
66
63
  for (let i = 0; i < 3; i++) {
67
- const numA = Number(numsA[i] || 0)
68
- const numB = Number(numsB[i] || 0)
69
- if (numA > numB) {
70
- return 1
71
- } else if (numB > numA) {
72
- return -1
73
- } else if (isNaN(numA)) {
74
- if (!isNaN(numB)) return -1
75
- } else if (isNaN(numB)) {
64
+ const numA = Number(numsA[i] ?? 0)
65
+ const numB = Number(numsB[i] ?? 0)
66
+ if (numA > numB) return 1
67
+ if (numB > numA) return -1
68
+ if (Number.isNaN(numA)) {
69
+ if (!Number.isNaN(numB)) return -1
70
+ } else if (Number.isNaN(numB)) {
76
71
  return 1
77
72
  }
78
73
  }
79
- if (preA == null) {
80
- return preB == null ? 0 : 1
81
- } else {
82
- return preB == null ? -1 : preA.localeCompare(preB, 'en', { numeric: true })
83
- }
74
+ if (preA == null) return preB == null ? 0 : 1
75
+ return preB == null ? -1 : preA.localeCompare(preB, 'en', { numeric: true })
84
76
  }
85
77
 
86
78
  module.exports = versionCompareDesc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antora/content-classifier",
3
- "version": "3.1.9",
3
+ "version": "3.1.11",
4
4
  "description": "Organizes aggregated content into a virtual file catalog for use in an Antora documentation pipeline.",
5
5
  "license": "MPL-2.0",
6
6
  "author": "OpenDevise Inc. (https://opendevise.com)",
@@ -10,7 +10,11 @@
10
10
  "Hubert SABLONNIÈRE <hubert.sablonniere@gmail.com>"
11
11
  ],
12
12
  "homepage": "https://antora.org",
13
- "repository": "gitlab:antora/antora",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://gitlab.com/antora/antora.git",
16
+ "directory": "packages/content-classifier"
17
+ },
14
18
  "bugs": {
15
19
  "url": "https://gitlab.com/antora/antora/issues"
16
20
  },
@@ -27,13 +31,13 @@
27
31
  "#constants": "./lib/constants.js"
28
32
  },
29
33
  "dependencies": {
30
- "@antora/asciidoc-loader": "3.1.9",
31
- "@antora/logger": "3.1.9",
34
+ "@antora/asciidoc-loader": "3.1.11",
35
+ "@antora/logger": "3.1.11",
32
36
  "mime-types": "~2.1",
33
37
  "vinyl": "~3.0"
34
38
  },
35
39
  "engines": {
36
- "node": ">=16.0.0"
40
+ "node": ">=18.0.0"
37
41
  },
38
42
  "files": [
39
43
  "lib/"
@@ -48,7 +52,7 @@
48
52
  ],
49
53
  "scripts": {
50
54
  "test": "_mocha",
51
- "prepublishOnly": "npx -y downdoc --prepublish",
52
- "postpublish": "npx -y downdoc --postpublish"
55
+ "prepublishOnly": "npx -y downdoc@latest --prepublish",
56
+ "postpublish": "npx -y downdoc@latest --postpublish"
53
57
  }
54
58
  }