@antora/content-classifier 3.1.14 → 3.2.0-alpha.10

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.
@@ -4,64 +4,95 @@ const ContentCatalog = require('./content-catalog')
4
4
  const collateAsciiDocAttributes = require('@antora/asciidoc-loader/config/collate-asciidoc-attributes')
5
5
  const logger = require('./logger')
6
6
  const summarizeFileLocation = require('./util/summarize-file-location')
7
+ const families = { attachments: true, examples: true, images: true, pages: true, partials: true }
7
8
 
8
9
  /**
9
10
  * Organizes the raw aggregate of virtual files into a {ContentCatalog}.
10
11
  *
11
12
  * @memberof content-classifier
12
13
  *
13
- * @param {Object} playbook - The configuration object for Antora.
14
+ * @param {Object} playbook - The configuration object for Antora. See ContentCatalog constructor for relevant keys.
14
15
  * @param {Object} playbook.site - Site-related configuration data.
15
16
  * @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
17
  * @param {Object} aggregate - The raw aggregate of virtual file objects to be classified.
19
18
  * @param {Object} [siteAsciiDocConfig={}] - Site-wide AsciiDoc processor configuration options.
20
- * @returns {ContentCatalog} A structured catalog of content components and virtual content files.
19
+ * @param {Function} [onComponentsRegistered] - A function (optionally async) to invoke after components are
20
+ * registered. Must return an instance of ContentCatalog. If async, this function will also return a Promise.
21
+ *
22
+ * @returns {ContentCatalog} A structured catalog of content components, versions, and virtual content files.
21
23
  */
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
24
+ function classifyContent (playbook, aggregate, siteAsciiDocConfig = {}, onComponentsRegistered = undefined) {
25
+ const siteStartPage = playbook.site.startPage
26
+ let contentCatalog = registerComponentVersions(new ContentCatalog(playbook), aggregate, siteAsciiDocConfig)
27
+ return typeof onComponentsRegistered === 'function' &&
28
+ (contentCatalog = onComponentsRegistered(contentCatalog)) instanceof Promise
29
+ ? contentCatalog.then((contentCatalogValue) => addFilesAndRegisterStartPages(contentCatalogValue, siteStartPage))
30
+ : addFilesAndRegisterStartPages(contentCatalog, siteStartPage)
31
+ }
32
+
33
+ function registerComponentVersions (contentCatalog, aggregate, siteAsciiDocConfig) {
34
+ for (const componentVersionBucket of aggregate) {
35
+ // advance files, nav, and startPage to component version to be used in later phase
36
+ const { name, version, files, nav, startPage, ...data } = Object.assign(componentVersionBucket, {
37
+ asciidoc: resolveAsciiDocConfig(siteAsciiDocConfig, componentVersionBucket),
38
+ })
39
+ Object.assign(contentCatalog.registerComponentVersion(name, version, data), { files, nav, startPage })
40
+ }
41
+ return contentCatalog
42
+ }
43
+
44
+ function addFilesAndRegisterStartPages (contentCatalog, siteStartPage) {
45
+ for (const { versions: componentVersions } of contentCatalog.getComponents()) {
46
+ for (const componentVersion of componentVersions) {
47
+ const { name: component, version, files = [], nav, startPage } = componentVersion
39
48
  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))
49
+ const rootFiles = []
50
+ for (let file, i = 0; (file = files[i]); i++) {
51
+ files[i] = undefined // free memory
52
+ const outcome = allocateSrc(file, component, version, nav)
53
+ if (outcome) {
54
+ contentCatalog.addFile(file, componentVersion)
55
+ } else if (outcome == null) {
56
+ rootFiles.push(file)
57
+ }
58
+ }
59
+ if (rootFiles.length) {
60
+ for (let file, i = 0; (file = rootFiles[i]); i++) {
61
+ if (file.src.origin.hasModulesDir) continue
62
+ allocateSrc(file, component, version, null, true) && contentCatalog.addFile(file, componentVersion)
63
+ }
64
+ }
42
65
  if (navResolved && nav.length > navResolved.size && new Set(nav).size > navResolved.size) {
43
66
  const loc = summarizeFileLocation({ path: 'antora.yml', src: { origin: nav.origin } })
44
67
  for (const filepath of nav) {
45
68
  if (navResolved.has(filepath)) continue
46
- logger.warn('Could not resolve nav entry for %s@%s defined in %s: %s', version, name, loc, filepath)
69
+ logger.warn('Could not resolve nav entry for %s@%s defined in %s: %s', version, component, loc, filepath)
47
70
  }
48
71
  }
49
- contentCatalog.registerComponentVersionStartPage(name, componentVersion, startPage)
50
- })
51
- contentCatalog.registerSiteStartPage(playbook.site.startPage)
72
+ contentCatalog.registerComponentVersionStartPage(component, componentVersion, startPage)
73
+ }
74
+ }
75
+ contentCatalog.registerSiteStartPage(siteStartPage)
52
76
  return contentCatalog
53
77
  }
54
78
 
55
- function allocateSrc (file, component, version, nav) {
56
- const extname = file.src.extname
79
+ function allocateSrc (file, component, version, nav, implicitRoot) {
80
+ const { extname, family, origin } = file.src
81
+ if (family && family !== 'nav') {
82
+ Object.assign(file.src, { component, version })
83
+ file.src.moduleRootPath ??= calculateRootPath(file.src.relative.split('/').length)
84
+ return true
85
+ }
57
86
  const filepath = file.path
58
- const pathSegments = filepath.split('/')
87
+ const pathSegments = implicitRoot ? ['modules', 'ROOT'].concat(filepath.split('/')) : filepath.split('/')
88
+ const inModules = pathSegments[0] === 'modules'
89
+ if (!implicitRoot && origin) origin.hasModulesDir ||= inModules
59
90
  let navInfo
60
91
  if (nav && (navInfo = getNavInfo(filepath, nav))) {
61
- if (extname !== '.adoc') return // ignore file
92
+ if (extname !== '.adoc') return false // ignore file
62
93
  file.nav = navInfo
63
94
  file.src.family = 'nav'
64
- if (pathSegments[0] === 'modules' && pathSegments.length > 2) {
95
+ if (inModules && pathSegments.length > 2) {
65
96
  file.src.module = pathSegments[1]
66
97
  // relative to modules/<module>
67
98
  file.src.relative = pathSegments.slice(2).join('/')
@@ -70,7 +101,7 @@ function allocateSrc (file, component, version, nav) {
70
101
  // relative to content source root
71
102
  file.src.relative = filepath
72
103
  }
73
- } else if (pathSegments[0] === 'modules') {
104
+ } else if (inModules) {
74
105
  let familyFolder = pathSegments[2]
75
106
  switch (familyFolder) {
76
107
  case 'pages':
@@ -84,29 +115,29 @@ function allocateSrc (file, component, version, nav) {
84
115
  // relative to modules/<module>/pages
85
116
  file.src.relative = pathSegments.slice(3).join('/')
86
117
  } else {
87
- return // ignore file
118
+ return false // ignore file
88
119
  }
89
120
  break
90
121
  case 'assets':
91
122
  switch ((familyFolder = pathSegments[3])) {
92
123
  case 'attachments':
93
124
  case 'images':
94
- if (!extname) return // ignore file
125
+ if (!extname && familyFolder === 'images') return false // ignore file
95
126
  file.src.family = familyFolder.substr(0, familyFolder.length - 1)
96
127
  // relative to modules/<module>/assets/<family>s
97
128
  file.src.relative = pathSegments.slice(4).join('/')
98
129
  break
99
130
  default:
100
- return // ignore file
131
+ return false // ignore file
101
132
  }
102
133
  break
103
- case 'attachments':
104
134
  case 'images':
105
- if (!extname) return
135
+ if (!extname) return false
106
136
  file.src.family = familyFolder.substr(0, familyFolder.length - 1)
107
137
  // relative to modules/<module>/<family>s
108
138
  file.src.relative = pathSegments.slice(3).join('/')
109
139
  break
140
+ case 'attachments':
110
141
  case 'examples':
111
142
  case 'partials':
112
143
  file.src.family = familyFolder.substr(0, familyFolder.length - 1)
@@ -114,12 +145,14 @@ function allocateSrc (file, component, version, nav) {
114
145
  file.src.relative = pathSegments.slice(3).join('/')
115
146
  break
116
147
  default:
117
- return // ignore file
148
+ return false // ignore file
118
149
  }
119
150
  file.src.module = pathSegments[1]
120
151
  file.src.moduleRootPath = calculateRootPath(pathSegments.length - 3)
152
+ } else if (origin && pathSegments[0] in families) {
153
+ return // defer file
121
154
  } else {
122
- return // ignore file
155
+ return false // ignore file
123
156
  }
124
157
  file.src.component = component
125
158
  file.src.version = version
@@ -140,13 +173,19 @@ function getNavInfo (filepath, nav) {
140
173
  if (~index) return nav.resolved.add(filepath) && { index }
141
174
  }
142
175
 
143
- function resolveAsciiDocConfig (siteAsciiDocConfig, { asciidoc, origins = [] }) {
176
+ function resolveAsciiDocConfig (siteAsciiDocConfig, { name, version, asciidoc, origins = [] }) {
144
177
  const scopedAttributes = asciidoc?.attributes
145
178
  if (scopedAttributes) {
146
- const initial = siteAsciiDocConfig.attributes
179
+ const initial = Object.assign({}, siteAsciiDocConfig.attributes)
180
+ initial['antora-component-name'] = name
181
+ initial['antora-component-version'] = version
147
182
  const mdc = { file: { path: 'antora.yml', origin: origins[origins.length - 1] } }
148
183
  const attributes = collateAsciiDocAttributes(scopedAttributes, { initial, mdc, merge: true })
149
- if (attributes !== initial) siteAsciiDocConfig = Object.assign({}, siteAsciiDocConfig, { attributes })
184
+ if (attributes !== initial) {
185
+ delete attributes['antora-component-name']
186
+ delete attributes['antora-component-version']
187
+ return Object.assign({}, siteAsciiDocConfig, { attributes })
188
+ }
150
189
  }
151
190
  return siteAsciiDocConfig
152
191
  }
@@ -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,14 @@ 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 componentVersion = { displayVersion: displayVersion || version || 'default', title: title || name, version }
63
+ const { asciidoc, displayVersion, prerelease, startPage: startPageRef, title, versionSegment, origins } = descriptor
64
+ const componentVersion = {
65
+ displayVersion: displayVersion || version || 'default',
66
+ title: title || name,
67
+ version,
68
+ origins: new Set(origins && typeof origins[Symbol.iterator] === 'function' ? origins : []),
69
+ }
70
+ if (versionSegment != null) componentVersion.versionSegment = versionSegment
64
71
  Object.defineProperty(componentVersion, 'name', { value: name, enumerable: true })
65
72
  if (prerelease) {
66
73
  componentVersion.prerelease = prerelease
@@ -128,15 +135,16 @@ class ContentCatalog {
128
135
  )
129
136
  )
130
137
  }
131
- if (startPageSpec) {
132
- this.registerComponentVersionStartPage(name, componentVersion, startPageSpec === true ? undefined : startPageSpec)
138
+ if (startPageRef) {
139
+ // @deprecated use separate call to register start page for component version
140
+ this.registerComponentVersionStartPage(name, componentVersion, startPageRef === true ? undefined : startPageRef)
133
141
  }
134
142
  return componentVersion
135
143
  }
136
144
 
137
- addFile (file) {
145
+ addFile (file, componentVersion) {
138
146
  const src = file.src
139
- let family = src.family
147
+ let { component, version, family } = src
140
148
  let filesForFamily = this[$files].get(family)
141
149
  if (!filesForFamily) this[$files].set(family, (filesForFamily = new Map()))
142
150
  const key = generateKey(src)
@@ -148,48 +156,60 @@ class ContentCatalog {
148
156
  .map((it, idx) => `${idx + 1}: ${summarizeFileLocation(it)}`)
149
157
  .join(LOG_WRAP)
150
158
  if (family === 'nav') {
151
- throw new Error(`Duplicate nav in ${src.version}@${src.component}: ${file.path}${LOG_WRAP}${details}`)
159
+ throw new Error(`Duplicate nav file: ${file.path} in ${version}@${component}${LOG_WRAP}${details}`)
152
160
  }
153
161
  throw new Error(`Duplicate ${family}: ${generateResourceSpec(src)}${LOG_WRAP}${details}`)
154
162
  }
155
- // NOTE: if the path property is not set, assume the src likely needs to be prepared
156
- // another option is to assume that if the file is not a vinyl object, the src likely needs to be prepared
163
+ // NOTE: assume that if the file is not a vinyl object, the src likely needs to be prepared
157
164
  // a vinyl object is one indication the file was created and prepared by the content aggregator
158
- //if (!src.path) prepareSrc(src)
159
- //if (!File.isVinyl(file)) file = new File(file)
165
+ // an alternate approach would be to call prepareSrc if the path property is not set
160
166
  if (!File.isVinyl(file)) {
161
167
  prepareSrc(src)
162
168
  file = new File(file)
163
169
  }
164
170
  if (family === 'alias') {
165
- src.mediaType = 'text/asciidoc'
166
171
  file.mediaType = 'text/html'
167
- // NOTE: an alias masquerades as the target file
168
- family = file.rel.src.family
169
- // QUESTION: should we preserve the mediaType property on file if already defined?
172
+ // NOTE: short circuit in case of splat alias (alias -> alias)
173
+ if (file.rel.src.family === 'alias' && file.pub?.splat) return filesForFamily.set(key, file) && file
174
+ // NOTE the effective family of an alias is a page, which redirects to the target file
175
+ family = 'page'
176
+ src.mediaType = 'text/asciidoc'
170
177
  } else if (!(file.mediaType = src.mediaType) && !('mediaType' in src)) {
171
- file.mediaType = src.mediaType = resolveMimeType(src.extname) || (family === 'page' ? 'text/asciidoc' : undefined)
178
+ // QUESTION: should we preserve the mediaType property on file if already defined?
179
+ file.mediaType = src.mediaType = src.extname
180
+ ? resolveMimeType(src.extname) || (family === 'page' ? 'text/asciidoc' : undefined)
181
+ : 'text/plain'
172
182
  }
173
183
  let publishable
174
- let versionSegment
184
+ let activeVersionSegment
175
185
  if (file.out) {
176
186
  publishable = true
177
187
  } else if ('out' in file) {
178
188
  delete file.out
179
189
  } else if (
190
+ !file.private &&
180
191
  (family === 'page' || family === 'image' || family === 'attachment') &&
181
- ('/' + src.relative).indexOf('/_') < 0
192
+ (file.private === false || ('/' + src.relative).indexOf('/_') < 0)
182
193
  ) {
183
194
  publishable = true
184
- versionSegment = computeVersionSegment.call(this, src.component, src.version)
185
- file.out = computeOut(src, family, versionSegment, this.htmlUrlExtensionStyle)
195
+ if (componentVersion == null) componentVersion = this.getComponentVersion(component, version) || { version }
196
+ activeVersionSegment =
197
+ 'activeVersionSegment' in componentVersion
198
+ ? componentVersion.activeVersionSegment
199
+ : computeVersionSegment.call(this, componentVersion)
200
+ file.out = computeOut(src, family, activeVersionSegment, this.htmlUrlExtensionStyle)
186
201
  }
187
202
  if (!file.pub && (publishable || family === 'nav')) {
188
- if (versionSegment == null) versionSegment = computeVersionSegment.call(this, src.component, src.version)
189
- file.pub = computePub(src, file.out, family, versionSegment, this.htmlUrlExtensionStyle)
203
+ if (activeVersionSegment == null) {
204
+ if (componentVersion == null) componentVersion = this.getComponentVersion(component, version) || { version }
205
+ activeVersionSegment =
206
+ 'activeVersionSegment' in componentVersion
207
+ ? componentVersion.activeVersionSegment
208
+ : computeVersionSegment.call(this, componentVersion)
209
+ }
210
+ file.pub = computePub(src, file.out, family, activeVersionSegment, this.htmlUrlExtensionStyle)
190
211
  }
191
- filesForFamily.set(key, file)
192
- return file
212
+ return filesForFamily.set(key, file) && file
193
213
  }
194
214
 
195
215
  removeFile (file) {
@@ -266,38 +286,48 @@ class ContentCatalog {
266
286
 
267
287
  // TODO add `follow` argument to control whether alias is followed
268
288
  getSiteStartPage () {
269
- return this.getById(ROOT_INDEX_PAGE_ID) || this.getById(ROOT_INDEX_ALIAS_ID)?.rel
289
+ let file
290
+ if ((file = this.getById(ROOT_INDEX_PAGE_ID))) return file
291
+ if ((file = this.getById(ROOT_INDEX_ALIAS_ID))) return file.rel
292
+ const rootComponent = this.getComponent('ROOT')
293
+ if (!rootComponent) return
294
+ const version = rootComponent.versions.find(({ activeVersionSegment }) => activeVersionSegment === '')?.version
295
+ if (!version) return
296
+ if ((file = this.getById(Object.assign({}, ROOT_INDEX_PAGE_ID, { version })))) return file
297
+ if ((file = this.getById(Object.assign({}, ROOT_INDEX_ALIAS_ID, { version })))) return file.rel
270
298
  }
271
299
 
272
300
  registerComponentVersionStartPage (name, componentVersion, startPageSpec = undefined) {
301
+ const component = name
273
302
  let version = componentVersion.version
274
303
  if (version == null) {
275
304
  // QUESTION: should we warn or throw error if component version cannot be found?
276
- if (!(componentVersion = this.getComponentVersion(name, componentVersion))) return
305
+ if (!(componentVersion = this.getComponentVersion(component, componentVersion))) return
277
306
  version = componentVersion.version
278
307
  }
308
+ const activeVersionSegment = computeVersionSegment.call(this, componentVersion)
279
309
  let startPage
280
310
  let startPageSrc
281
- const indexPageId = Object.assign({}, ROOT_INDEX_PAGE_ID, { component: name, version })
311
+ const indexPageId = Object.assign({}, ROOT_INDEX_PAGE_ID, { component, version })
282
312
  if (startPageSpec) {
283
313
  if (
284
314
  (startPage = this.resolvePage(startPageSpec, indexPageId)) &&
285
- (startPageSrc = startPage.src).component === name &&
315
+ (startPageSrc = startPage.src).component === component &&
286
316
  startPageSrc.version === version
287
317
  ) {
288
318
  if (!this.getById(indexPageId)) {
289
- const indexAliasId = Object.assign({}, ROOT_INDEX_ALIAS_ID, { component: name, version })
319
+ const indexAliasId = Object.assign({}, ROOT_INDEX_ALIAS_ID, { component, version })
290
320
  const indexAlias = this.getById(indexAliasId)
291
321
  indexAlias
292
322
  ? indexAlias.synthetic && Object.assign(indexAlias, { rel: startPage })
293
- : this.addFile({ src: indexAliasId, rel: startPage, synthetic: true })
323
+ : this.addFile({ src: indexAliasId, rel: startPage, synthetic: true }, componentVersion)
294
324
  }
295
325
  } else {
296
326
  // TODO pass componentVersion as logObject
297
327
  logger.warn(
298
328
  'Start page specified for %s@%s %s: %s',
299
329
  version,
300
- name,
330
+ component,
301
331
  startPage === false ? 'has invalid syntax' : 'not found',
302
332
  startPageSpec
303
333
  )
@@ -308,25 +338,34 @@ class ContentCatalog {
308
338
  }
309
339
  if (startPage) {
310
340
  componentVersion.url = startPage.pub.url
311
- } else {
341
+ } else if (!componentVersion.url) {
312
342
  // QUESTION: should we warn if the default start page cannot be found?
313
- const versionSegment = computeVersionSegment.call(this, name, version)
314
343
  componentVersion.url = computePub(
315
344
  (startPageSrc = prepareSrc(Object.assign({}, indexPageId, { family: 'page' }))),
316
- computeOut(startPageSrc, startPageSrc.family, versionSegment, this.htmlUrlExtensionStyle),
345
+ computeOut(startPageSrc, startPageSrc.family, activeVersionSegment, this.htmlUrlExtensionStyle),
317
346
  startPageSrc.family,
318
- versionSegment,
347
+ activeVersionSegment,
319
348
  this.htmlUrlExtensionStyle
320
349
  ).url
321
350
  }
322
-
323
- const symbolicVersionAlias = createSymbolicVersionAlias(
324
- name,
325
- version,
326
- computeVersionSegment.call(this, name, version, 'alias'),
327
- this.latestVersionUrlSegmentStrategy
328
- )
329
- if (symbolicVersionAlias) this.addFile(symbolicVersionAlias)
351
+ Object.defineProperties(componentVersion, {
352
+ activeVersionSegment:
353
+ activeVersionSegment === version
354
+ ? { configurable: true, enumerable: false, get: getVersion }
355
+ : { configurable: true, enumerable: false, value: activeVersionSegment },
356
+ files: {
357
+ configurable: true,
358
+ enumerable: false,
359
+ get: getComponentVersionFiles.bind(this, { component, version }),
360
+ },
361
+ startPage: {
362
+ configurable: true,
363
+ enumerable: false,
364
+ get: getComponentVersionStartPage.bind(this, { component, version }),
365
+ },
366
+ })
367
+ addSymbolicVersionAlias.call(this, componentVersion)
368
+ return startPage
330
369
  }
331
370
 
332
371
  registerSiteStartPage (startPageSpec) {
@@ -334,9 +373,9 @@ class ContentCatalog {
334
373
  const rel = this.resolvePage(startPageSpec)
335
374
  if (rel) {
336
375
  if (this.getById(ROOT_INDEX_PAGE_ID)) return
376
+ if (rel.pub.url === (this.htmlUrlExtensionStyle === 'default' ? '/index.html' : '/')) return
337
377
  const rootIndexAlias = this.getById(ROOT_INDEX_ALIAS_ID)
338
378
  if (rootIndexAlias) return rootIndexAlias.synthetic ? Object.assign(rootIndexAlias, { rel }) : undefined
339
- if (rel.pub.url === (this.htmlUrlExtensionStyle === 'default' ? '/index.html' : '/')) return
340
379
  const src = Object.assign({}, ROOT_INDEX_ALIAS_ID)
341
380
  return this.addFile({ src, rel, synthetic: true }, { version: src.version })
342
381
  }
@@ -357,9 +396,14 @@ class ContentCatalog {
357
396
  // QUESTION should we throw an error if alias is invalid?
358
397
  if (!src || (inferredSpec && src.relative === '.adoc')) return
359
398
  const component = this.getComponent(src.component)
399
+ let componentVersion
360
400
  if (component) {
361
401
  // NOTE version is not set when alias specifies a component, but not a version
362
- if (src.version == null) src.version = component.latest.version
402
+ if (src.version == null) {
403
+ src.version = (componentVersion = component.latest).version
404
+ } else {
405
+ componentVersion = this.getComponentVersion(component, src.version)
406
+ }
363
407
  const existingPage = this.getById(src)
364
408
  if (existingPage) {
365
409
  throw new Error(
@@ -384,12 +428,34 @@ class ContentCatalog {
384
428
  )
385
429
  }
386
430
  // NOTE the redirect producer will populate contents when the redirect facility is 'static'
387
- const alias = this.addFile({ src, rel: target })
431
+ const alias = this.addFile({ src, rel: target }, componentVersion)
388
432
  // NOTE record the first alias this target claims as the preferred one
389
433
  if (!target.rel) target.rel = alias
390
434
  return alias
391
435
  }
392
436
 
437
+ /**
438
+ * Adds a splat (directory) alias from the specified version segment in one component to the specified
439
+ * version segment in the same or different component.
440
+ *
441
+ * @returns {File} The virtual file that represents the splat alias.
442
+ */
443
+ addSplatAlias (from, to) {
444
+ if (!from.versionSegment) throw new Error('cannot map splat alias from empty version segment')
445
+ const family = 'alias'
446
+ const baseSrc = { module: 'ROOT', family, relative: '', basename: '', stem: '', extname: '' }
447
+ const basePub = { splat: true }
448
+ const { component: fromComponent = to.component, versionSegment: fromVersionSegment } = from
449
+ const fromSrc = Object.assign({ component: fromComponent, version: fromVersionSegment }, baseSrc)
450
+ const fromPub = Object.assign(computePub(fromSrc, computeOut(fromSrc, family, fromVersionSegment), family), basePub)
451
+ const { component: toComponent, version: toVersion } = to
452
+ const toVersionSegment =
453
+ to.versionSegment ?? this.getComponentVersion(toComponent, toVersion)?.activeVersionSegment ?? toVersion
454
+ const toSrc = Object.assign({ component: toComponent, version: toVersion ?? toVersionSegment }, baseSrc)
455
+ const toPub = Object.assign(computePub(toSrc, computeOut(toSrc, family, toVersionSegment), family), basePub)
456
+ return this.addFile({ pub: fromPub, src: fromSrc, rel: { pub: toPub, src: toSrc } })
457
+ }
458
+
393
459
  /**
394
460
  * Attempts to resolve a page reference within the given context to a page in the catalog.
395
461
  *
@@ -474,32 +540,28 @@ function generateResourceSpec ({ component, version, module: module_, family, re
474
540
 
475
541
  function prepareSrc (src) {
476
542
  let { basename, extname, stem } = src
477
- let update
478
543
  if (basename == null) {
479
- update = true
480
- basename = path.basename(src.relative)
544
+ basename = src.basename = path.basename(src.relative)
481
545
  }
482
546
  if (stem == null) {
483
- update = true
484
547
  if (extname == null) {
485
548
  if (~(extname = basename.lastIndexOf('.'))) {
486
- stem = basename.substr(0, extname)
487
- extname = basename.substr(extname)
549
+ src.stem = basename.substr(0, extname)
550
+ src.extname = basename.substr(extname)
488
551
  } else {
489
- stem = basename
490
- extname = ''
552
+ src.stem = basename
553
+ src.extname = ''
491
554
  }
492
555
  } else {
493
- stem = basename.substr(0, basename.length - extname.length)
556
+ src.stem = basename.substr(0, basename.length - extname.length)
494
557
  }
495
558
  } else if (extname == null) {
496
- update = true
497
- extname = basename.substr(stem.length)
559
+ src.extname = basename.substr(stem.length)
498
560
  }
499
- return update ? Object.assign(src, { basename, extname, stem }) : src
561
+ return src
500
562
  }
501
563
 
502
- function computeOut (src, family, version, htmlUrlExtensionStyle) {
564
+ function computeOut (src, family, versionSegment, htmlUrlExtensionStyle) {
503
565
  let { component, module: module_, basename, extname, relative, stem } = src
504
566
  if (component === 'ROOT') component = ''
505
567
  if (module_ === 'ROOT') module_ = ''
@@ -519,7 +581,7 @@ function computeOut (src, family, version, htmlUrlExtensionStyle) {
519
581
  familyPathSegment = '_attachments'
520
582
  }
521
583
 
522
- const modulePath = path.join(component, version, module_)
584
+ const modulePath = path.join(component, versionSegment, module_)
523
585
  const dirname = path.join(modulePath, familyPathSegment, path.dirname(relative), indexifyPathSegment)
524
586
  const path_ = path.join(dirname, basename)
525
587
  const moduleRootPath = path.relative(dirname, modulePath) || '.'
@@ -528,13 +590,13 @@ function computeOut (src, family, version, htmlUrlExtensionStyle) {
528
590
  return { dirname, basename, path: path_, moduleRootPath, rootPath }
529
591
  }
530
592
 
531
- function computePub (src, out, family, version, htmlUrlExtensionStyle) {
593
+ function computePub (src, out, family, versionSegment, htmlUrlExtensionStyle) {
532
594
  const pub = {}
533
595
  let url
534
596
  if (family === 'nav') {
535
597
  const component = src.component || 'ROOT'
536
598
  const urlSegments = component === 'ROOT' ? [] : [component]
537
- if (version) urlSegments.push(version)
599
+ if (versionSegment) urlSegments.push(versionSegment)
538
600
  const module_ = src.module || 'ROOT'
539
601
  if (module_ !== 'ROOT') urlSegments.push(module_)
540
602
  if (urlSegments.length) urlSegments.push('')
@@ -553,71 +615,61 @@ function computePub (src, out, family, version, htmlUrlExtensionStyle) {
553
615
  urlSegments[lastUrlSegmentIdx] = ''
554
616
  }
555
617
  url = '/' + urlSegments.join('/')
556
- } else {
557
- if ((url = '/' + out.path) === '/.') url = '/'
558
- if (family === 'alias' && !src.relative) pub.splat = true
559
- }
560
-
561
- pub.url = ~url.indexOf(' ') ? url.replace(SPACE_RX, '%20') : url
562
-
563
- if (out) {
564
- pub.moduleRootPath = out.moduleRootPath
565
- pub.rootPath = out.rootPath
618
+ } else if ((url = '/' + out.path) === '/.') {
619
+ url = '/'
566
620
  }
621
+ pub.url = ~url.indexOf(' ') ? url.replaceAll(' ', '%20') : url
622
+ return out ? Object.assign(pub, { moduleRootPath: out.moduleRootPath, rootPath: out.rootPath }) : pub
623
+ }
567
624
 
568
- return pub
625
+ function addSymbolicVersionAlias (componentVersion) {
626
+ const { name: component, version } = componentVersion
627
+ const originalVersionSegment = computeVersionSegment.call(this, componentVersion, 'original')
628
+ const symbolicVersionSegment = computeVersionSegment.call(this, componentVersion, 'alias')
629
+ if (symbolicVersionSegment === originalVersionSegment || symbolicVersionSegment == null) return
630
+ const originalVersionSrc = { component, version, versionSegment: originalVersionSegment }
631
+ const symbolicVersionSrc = { component, version, versionSegment: symbolicVersionSegment }
632
+ return this.latestVersionSegmentStrategy === 'redirect:to'
633
+ ? this.addSplatAlias(originalVersionSrc, symbolicVersionSrc)
634
+ : this.addSplatAlias(symbolicVersionSrc, originalVersionSrc)
569
635
  }
570
636
 
571
- function computeVersionSegment (name, version, mode) {
637
+ function computeVersionSegment (componentVersion, mode) {
638
+ const version = componentVersion.version
572
639
  // special designation for master version is @deprecated; special designation scheduled to be removed in Antora 4
573
- if (mode === 'original') return !version || version === 'master' ? '' : version
574
- const strategy = this.latestVersionUrlSegmentStrategy
575
- if (!version || version === 'master') {
576
- if (mode !== 'alias') return ''
640
+ const normalizedVersion = version && version !== 'master' ? version : ''
641
+ const { versionSegment = normalizedVersion } = componentVersion
642
+ if (mode === 'original') return versionSegment
643
+ const strategy = this.latestVersionSegmentStrategy
644
+ if (!versionSegment) {
645
+ if (!mode) return ''
577
646
  if (strategy === 'redirect:to') return
578
647
  }
579
- if (strategy === 'redirect:to' || strategy === (mode === 'alias' ? 'redirect:from' : 'replace')) {
580
- const component = this.getComponent(name)
581
- const componentVersion = component && this.getComponentVersion(component, version)
582
- if (componentVersion) {
583
- const segment =
648
+ if (strategy === 'redirect:to' || strategy === (mode ? 'redirect:from' : 'replace')) {
649
+ let component
650
+ if ((component = 'name' in componentVersion && this.getComponent(componentVersion.name))) {
651
+ const latestSegment =
584
652
  componentVersion === component.latest
585
- ? this.latestVersionUrlSegment
653
+ ? this.latestVersionSegment
586
654
  : componentVersion === component.latestPrerelease
587
- ? this.latestPrereleaseVersionUrlSegment
655
+ ? this.latestPrereleaseVersionSegment
588
656
  : undefined
589
- return segment == null ? version : segment
657
+ return latestSegment == null ? versionSegment : latestSegment
590
658
  }
591
659
  }
592
- return version
660
+ return versionSegment
593
661
  }
594
662
 
595
- function createSymbolicVersionAlias (component, version, symbolicVersionSegment, strategy) {
596
- if (symbolicVersionSegment == null || symbolicVersionSegment === version) return
597
- const family = 'alias'
598
- const baseVersionAliasSrc = { component, module: 'ROOT', family, relative: '', basename: '', stem: '', extname: '' }
599
- const symbolicVersionAliasSrc = Object.assign({}, baseVersionAliasSrc, { version: symbolicVersionSegment })
600
- const symbolicVersionAlias = {
601
- src: symbolicVersionAliasSrc,
602
- pub: computePub(
603
- symbolicVersionAliasSrc,
604
- computeOut(symbolicVersionAliasSrc, family, symbolicVersionSegment),
605
- family
606
- ),
607
- }
608
- const originalVersionAliasSrc = Object.assign({}, baseVersionAliasSrc, { version })
609
- const originalVersionSegment = computeVersionSegment(component, version, 'original')
610
- const originalVersionAlias = {
611
- src: originalVersionAliasSrc,
612
- pub: computePub(
613
- originalVersionAliasSrc,
614
- computeOut(originalVersionAliasSrc, family, originalVersionSegment),
615
- family
616
- ),
617
- }
618
- return strategy === 'redirect:to'
619
- ? Object.assign(originalVersionAlias, { out: undefined, rel: symbolicVersionAlias })
620
- : Object.assign(symbolicVersionAlias, { out: undefined, rel: originalVersionAlias })
663
+ function getComponentVersionFiles (componentVersionId) {
664
+ return this.findBy(componentVersionId)
665
+ }
666
+
667
+ function getComponentVersionStartPage (componentVersionId) {
668
+ return this.resolvePage('index.adoc', componentVersionId)
669
+ }
670
+
671
+ function getVersion () {
672
+ return this.version
621
673
  }
622
674
 
623
675
  module.exports = ContentCatalog
@@ -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_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antora/content-classifier",
3
- "version": "3.1.14",
3
+ "version": "3.2.0-alpha.10",
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)",
@@ -12,7 +12,8 @@
12
12
  "homepage": "https://antora.org",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://gitlab.com/antora/antora.git"
15
+ "url": "git+https://gitlab.com/antora/antora.git",
16
+ "directory": "packages/content-classifier"
16
17
  },
17
18
  "bugs": {
18
19
  "url": "https://gitlab.com/antora/antora/issues"
@@ -30,13 +31,13 @@
30
31
  "#constants": "./lib/constants.js"
31
32
  },
32
33
  "dependencies": {
33
- "@antora/asciidoc-loader": "3.1.14",
34
- "@antora/logger": "3.1.14",
34
+ "@antora/asciidoc-loader": "3.2.0-alpha.10",
35
+ "@antora/logger": "3.2.0-alpha.10",
35
36
  "mime-types": "~2.1",
36
37
  "vinyl": "~3.0"
37
38
  },
38
39
  "engines": {
39
- "node": ">=16.0.0"
40
+ "node": ">=18.0.0"
40
41
  },
41
42
  "files": [
42
43
  "lib/"
@@ -51,7 +52,7 @@
51
52
  ],
52
53
  "scripts": {
53
54
  "test": "_mocha",
54
- "prepublishOnly": "npx -y downdoc --prepublish",
55
- "postpublish": "npx -y downdoc --postpublish"
55
+ "prepublishOnly": "npx -y downdoc@latest --prepublish",
56
+ "postpublish": "npx -y downdoc@latest --postpublish"
56
57
  }
57
58
  }