@antora/content-aggregator 3.1.5 → 3.1.6

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.
@@ -5,7 +5,6 @@ const { createHash } = require('crypto')
5
5
  const createGitHttpPlugin = require('./git-plugin-http')
6
6
  const decodeUint8Array = require('./decode-uint8-array')
7
7
  const deepClone = require('./deep-clone')
8
- const deepFlatten = require('./deep-flatten')
9
8
  const EventEmitter = require('events')
10
9
  const expandPath = require('@antora/expand-path-helper')
11
10
  const File = require('./file')
@@ -93,83 +92,71 @@ function aggregateContent (playbook) {
93
92
  return ensureCacheDir(requestedCacheDir, startDir).then((cacheDir) => {
94
93
  const gitConfig = Object.assign({ ensureGitSuffix: true }, playbook.git)
95
94
  const gitPlugins = loadGitPlugins(gitConfig, playbook.network || {}, startDir)
96
- const fetchConcurrency = Math.max(gitConfig.fetchConcurrency || Infinity, 1)
95
+ const concurrency = {
96
+ fetch: Math.max(gitConfig.fetchConcurrency || Infinity, 1),
97
+ read: Math.max(gitConfig.readConcurrency || Infinity, 1),
98
+ }
97
99
  const sourcesByUrl = sources.reduce((accum, source) => {
98
100
  return accum.set(source.url, [...(accum.get(source.url) || []), Object.assign({}, sourceDefaults, source)])
99
101
  }, new Map())
100
102
  const progress = !quiet && createProgress(sourcesByUrl.keys(), process.stdout)
101
103
  const refPatternCache = Object.assign(new Map(), { braces: new Map() })
102
104
  const loadOpts = { cacheDir, fetch, gitPlugins, progress, startDir, refPatternCache }
103
- return collectFiles(sourcesByUrl, loadOpts, fetchConcurrency).then(buildAggregate, (err) => {
105
+ return collectFiles(sourcesByUrl, loadOpts, concurrency).then(buildAggregate, (err) => {
104
106
  progress && progress.terminate()
105
107
  throw err
106
108
  })
107
109
  })
108
110
  }
109
111
 
110
- async function collectFiles (sourcesByUrl, loadOpts, concurrency) {
111
- const tasks = [...sourcesByUrl.entries()].map(([url, sources]) => [
112
- () => loadRepository(url, Object.assign({ fetchTags: tagsSpecified(sources) }, loadOpts)),
113
- ({ repo, authStatus }) =>
114
- Promise.all(
115
- sources.map((source) => {
116
- // NOTE if repository is managed (has a url property), we can assume the remote name is origin
117
- // TODO if the repo has no remotes, then remoteName should be undefined
118
- const remoteName = repo.url ? 'origin' : source.remote || 'origin'
119
- return collectFilesFromSource(source, repo, remoteName, authStatus)
120
- })
121
- ),
122
- ])
123
- let rejection, started
124
- const startedContinuations = []
125
- const recordRejection = (err) => {
126
- rejection = err
127
- }
128
- const runTask = (primary, continuation, idx) =>
129
- primary().then((value) => {
130
- if (!rejection) startedContinuations[idx] = continuation(value).catch(recordRejection)
131
- }, recordRejection)
132
- if (tasks.length > concurrency) {
133
- started = []
134
- const pending = []
135
- for (const [primary, continuation] of tasks) {
136
- const current = runTask(primary, continuation, started.length).finally(() =>
137
- pending.splice(pending.indexOf(current), 1)
138
- )
139
- started.push(current)
140
- if (pending.push(current) < concurrency) continue
141
- await Promise.race(pending)
142
- if (rejection) break
112
+ async function collectFiles (sourcesByUrl, loadOpts, concurrency, fetchedUrls) {
113
+ const loadTasks = [...sourcesByUrl.entries()].map(([url, sources]) => {
114
+ const loadOptsForUrl = Object.assign({}, loadOpts)
115
+ if (loadOpts.fetch && fetchedUrls && fetchedUrls.length && fetchedUrls.includes(url)) loadOptsForUrl.fetch = false
116
+ if (tagsSpecified(sources)) loadOptsForUrl.fetchTags = true
117
+ return loadRepository.bind(null, url, loadOptsForUrl, { url, sources })
118
+ })
119
+ return gracefulPromiseAllWithLimit(loadTasks, concurrency.fetch).then(([results, rejections]) => {
120
+ if (rejections.length) {
121
+ if (concurrency.fetch > 1 && rejections.every(({ recoverable }) => recoverable)) {
122
+ if (loadOpts.progress) loadOpts.progress.terminate() // reset cursor position and allow it be reused
123
+ const msg0 = 'An unexpected error occurred while concurrently fetching content sources.'
124
+ const msg1 = 'Retrying with git.fetch_concurrency value of 1.'
125
+ logger.warn(msg0 + ' ' + msg1)
126
+ const fulfilledUrls = results.map((it) => it && it.repo.url && it.url).filter((it) => it)
127
+ return collectFiles(sourcesByUrl, loadOpts, Object.assign(concurrency, { fetch: 1 }), fulfilledUrls)
128
+ }
129
+ throw rejections[0]
143
130
  }
144
- } else {
145
- started = tasks.map(([primary, continuation], idx) => runTask(primary, continuation, idx))
146
- }
147
- return Promise.all(started).then(() =>
148
- Promise.all(startedContinuations).then((result) => {
149
- if (rejection) throw rejection
150
- return result
151
- })
152
- )
131
+ return Promise.all(
132
+ results.map(({ repo, authStatus, sources }) =>
133
+ selectStartPathsForRepository(repo, authStatus, sources).then((startPaths) =>
134
+ collectFilesFromStartPaths.bind(null, startPaths, repo, authStatus)
135
+ )
136
+ )
137
+ ).then((collectTasks) => promiseAllWithLimit(collectTasks, concurrency.read))
138
+ })
153
139
  }
154
140
 
155
141
  function buildAggregate (componentVersionBuckets) {
156
- return [
157
- ...deepFlatten(componentVersionBuckets)
158
- .reduce((accum, batch) => {
159
- const key = batch.version + '@' + batch.name
160
- const entry = accum.get(key)
161
- if (!entry) return accum.set(key, batch)
142
+ const entries = Object.assign(new Map(), { accum: [] })
143
+ for (const batchesForOrigin of componentVersionBuckets) {
144
+ for (const batch of batchesForOrigin) {
145
+ let key, entry
146
+ if ((entry = entries.get((key = batch.version + '@' + batch.name)))) {
162
147
  const { files, origins } = batch
163
148
  ;(batch.files = entry.files).push(...files)
164
149
  ;(batch.origins = entry.origins).push(origins[0])
165
150
  Object.assign(entry, batch)
166
- return accum
167
- }, new Map())
168
- .values(),
169
- ]
151
+ } else {
152
+ entries.set(key, batch).accum.push(batch)
153
+ }
154
+ }
155
+ }
156
+ return entries.accum
170
157
  }
171
158
 
172
- async function loadRepository (url, opts) {
159
+ async function loadRepository (url, opts, result = {}) {
173
160
  let authStatus, dir, repo
174
161
  const cache = { [REF_PATTERN_CACHE_KEY]: opts.refPatternCache }
175
162
  if (~url.indexOf(':') && GIT_URI_DETECTOR_RX.test(url)) {
@@ -179,6 +166,7 @@ async function loadRepository (url, opts) {
179
166
  dir = ospath.join(cacheDir, generateCloneFolderName(displayUrl))
180
167
  // NOTE the presence of the url property on the repo object implies the repository is remote
181
168
  repo = { cache, dir, fs, gitdir: dir, noCheckout: true, url }
169
+ const { credentialManager } = gitPlugins
182
170
  const validStateFile = ospath.join(dir, VALID_STATE_FILENAME)
183
171
  try {
184
172
  await fsp.access(validStateFile)
@@ -188,8 +176,7 @@ async function loadRepository (url, opts) {
188
176
  await git
189
177
  .fetch(fetchOpts)
190
178
  .then(() => {
191
- const credentialManager = gitPlugins.credentialManager
192
- authStatus = credentials ? 'auth-embedded' : credentialManager.status({ url }) ? 'auth-required' : undefined
179
+ authStatus = identifyAuthStatus(credentialManager, credentials, url)
193
180
  return git.setConfig(Object.assign({ path: 'remote.origin.private', value: authStatus }, repo))
194
181
  })
195
182
  .catch((fetchErr) => {
@@ -210,14 +197,13 @@ async function loadRepository (url, opts) {
210
197
  .clone(fetchOpts)
211
198
  .then(() => git.resolveRef(Object.assign({ ref: 'HEAD', depth: 1 }, repo)))
212
199
  .then(() => {
213
- const credentialManager = gitPlugins.credentialManager
214
- authStatus = credentials ? 'auth-embedded' : credentialManager.status({ url }) ? 'auth-required' : undefined
200
+ authStatus = identifyAuthStatus(credentialManager, credentials, url)
215
201
  return git.setConfig(Object.assign({ path: 'remote.origin.private', value: authStatus }, repo))
216
202
  })
217
203
  .catch((cloneErr) => {
218
- // FIXME triggering the error handler here causes assertion problems in the test suite
219
- //if (fetchOpts.onProgress) fetchOpts.onProgress.finish(cloneErr)
220
- throw transformGitCloneError(cloneErr, displayUrl)
204
+ if (fetchOpts.onProgress) fetchOpts.onProgress.finish(cloneErr)
205
+ const authRequested = credentialManager.status({ url }) === 'requested'
206
+ throw transformGitCloneError(cloneErr, displayUrl, authRequested)
221
207
  })
222
208
  .then(() => fsp.writeFile(validStateFile, '').catch(invariably.void))
223
209
  .then(() => fetchOpts.onProgress && fetchOpts.onProgress.finish())
@@ -234,7 +220,7 @@ async function loadRepository (url, opts) {
234
220
  } else {
235
221
  throw new Error(`Local content source does not exist: ${dir}${url !== dir ? ' (url: ' + url + ')' : ''}`)
236
222
  }
237
- return { repo, authStatus }
223
+ return Object.assign(result, { repo, authStatus })
238
224
  }
239
225
 
240
226
  function extractCredentials (url) {
@@ -256,19 +242,33 @@ function extractCredentials (url) {
256
242
  }
257
243
  }
258
244
 
259
- async function collectFilesFromSource (source, repo, remoteName, authStatus) {
260
- const originUrl = repo.url || (await resolveRemoteUrl(repo, remoteName))
261
- return selectReferences(source, repo, remoteName).then((refs) => {
262
- if (!refs.length) {
263
- const { url, branches, tags, startPath, startPaths } = source
245
+ async function selectStartPathsForRepository (repo, authStatus, sources) {
246
+ const startPaths = []
247
+ const originUrls = {}
248
+ for (const source of sources) {
249
+ const { version, editUrl } = source
250
+ // NOTE if repository is managed (has a url property), we can assume the remote name is origin
251
+ // TODO if the repo has no remotes, then remoteName should be undefined
252
+ const remoteName = repo.url ? 'origin' : source.remote || 'origin'
253
+ const originUrl = repo.url || (originUrls[remoteName] ||= await resolveRemoteUrl(repo, remoteName))
254
+ const refs = await selectReferences(source, repo, remoteName)
255
+ if (refs.length) {
256
+ for (const ref of refs) {
257
+ for (const startPath of await selectStartPaths(source, repo, remoteName, ref)) {
258
+ startPaths.push({ startPath, ref, originUrl, editUrl, version })
259
+ }
260
+ }
261
+ } else {
262
+ const { url, branches, tags } = source
264
263
  const startPathInfo =
265
- 'startPaths' in source ? { 'start paths': startPaths || undefined } : { 'start path': startPath || undefined }
264
+ 'startPaths' in source
265
+ ? { 'start paths': source.startPaths || undefined }
266
+ : { 'start path': source.startPath || undefined }
266
267
  const sourceInfo = yaml.dump({ url, branches, tags, ...startPathInfo }, { flowLevel: 1 }).trimRight()
267
268
  logger.info(`No matching references found for content source entry (${sourceInfo.replace(NEWLINE_RX, ' | ')})`)
268
- return []
269
269
  }
270
- return Promise.all(refs.map((it) => collectFilesFromReference(source, repo, remoteName, authStatus, it, originUrl)))
271
- })
270
+ }
271
+ return startPaths
272
272
  }
273
273
 
274
274
  // QUESTION should we resolve HEAD to a ref eagerly to avoid having to do a match on it?
@@ -397,10 +397,9 @@ function getCurrentBranchName (repo, remote) {
397
397
  return refPromise.then((ref) => (ref.startsWith('refs/') ? ref.replace(SHORTEN_REF_RX, '') : undefined))
398
398
  }
399
399
 
400
- async function collectFilesFromReference (source, repo, remoteName, authStatus, ref, originUrl) {
400
+ async function selectStartPaths (source, repo, remoteName, ref) {
401
401
  const url = repo.url
402
402
  const displayUrl = url || repo.dir
403
- const { version, editUrl } = source
404
403
  const worktreePath = ref.head
405
404
  if (!worktreePath) {
406
405
  ref.oid = await git.resolveRef(
@@ -420,17 +419,22 @@ async function collectFilesFromReference (source, repo, remoteName, authStatus,
420
419
  const flag = worktreePath ? ' <worktree>' : ref.remote && worktreePath === false ? ` <remotes/${ref.remote}>` : ''
421
420
  throw new Error(`no start paths found in ${where} (${ref.type}: ${ref.shortname}${flag})`)
422
421
  }
423
- return Promise.all(
424
- startPaths.map((startPath) =>
425
- collectFilesFromStartPath(startPath, repo, authStatus, ref, worktreePath, originUrl, editUrl, version)
426
- )
427
- )
422
+ return startPaths
423
+ }
424
+ return [cleanStartPath(coerceToString(source.startPath))]
425
+ }
426
+
427
+ async function collectFilesFromStartPaths (startPaths, repo, authStatus) {
428
+ const buckets = []
429
+ for (const { startPath, ref, originUrl, editUrl, version } of startPaths) {
430
+ buckets.push(await collectFilesFromStartPath(startPath, repo, authStatus, ref, originUrl, editUrl, version))
428
431
  }
429
- const startPath = cleanStartPath(coerceToString(source.startPath))
430
- return collectFilesFromStartPath(startPath, repo, authStatus, ref, worktreePath, originUrl, editUrl, version)
432
+ repo.cache = undefined
433
+ return buckets
431
434
  }
432
435
 
433
- function collectFilesFromStartPath (startPath, repo, authStatus, ref, worktreePath, originUrl, editUrl, version) {
436
+ function collectFilesFromStartPath (startPath, repo, authStatus, ref, originUrl, editUrl, version) {
437
+ const worktreePath = ref.head
434
438
  const origin = computeOrigin(originUrl, authStatus, repo.gitdir, ref, startPath, worktreePath, editUrl)
435
439
  return (worktreePath ? readFilesFromWorktree(origin) : readFilesFromGitTree(repo, ref.oid, startPath))
436
440
  .then((files) =>
@@ -510,26 +514,25 @@ function srcFs (cwd, origin) {
510
514
  }
511
515
 
512
516
  function readFilesFromGitTree (repo, oid, startPath) {
513
- return git
514
- .readTree(Object.assign({ oid }, repo))
515
- .then((root) =>
516
- getGitTreeAtStartPath(repo, oid, startPath).then((start) =>
517
- srcGitTree(repo, Object.assign(root, { dirname: '' }), start)
518
- )
519
- )
517
+ return git.readTree(Object.assign({ oid }, repo)).then((root) => {
518
+ Object.assign(root, { dirname: '' })
519
+ return startPath
520
+ ? getGitTreeAtStartPath(repo, oid, startPath).then((start) => {
521
+ Object.assign(start, { dirname: startPath })
522
+ return srcGitTree(repo, root, start)
523
+ })
524
+ : srcGitTree(repo, root)
525
+ })
520
526
  }
521
527
 
522
528
  function getGitTreeAtStartPath (repo, oid, startPath) {
523
- return git.readTree(Object.assign({ oid, filepath: startPath }, repo)).then(
524
- (result) => Object.assign(result, { dirname: startPath }),
525
- (err) => {
526
- const m = err instanceof ObjectTypeError && err.data.expected === 'tree' ? 'is not a directory' : 'does not exist'
527
- throw new Error(`the start path '${startPath}' ${m}`)
528
- }
529
- )
529
+ return git.readTree(Object.assign({ oid, filepath: startPath }, repo)).catch((err) => {
530
+ const m = err instanceof ObjectTypeError && err.data.expected === 'tree' ? 'is not a directory' : 'does not exist'
531
+ throw new Error(`the start path '${startPath}' ${m}`)
532
+ })
530
533
  }
531
534
 
532
- function srcGitTree (repo, root, start) {
535
+ function srcGitTree (repo, root, start = root) {
533
536
  return new Promise((resolve, reject) => {
534
537
  const files = []
535
538
  createGitTreeWalker(repo, root, filterGitEntry, gitEntryToFile)
@@ -816,7 +819,6 @@ function onGitProgress ({ phase, loaded, total }) {
816
819
 
817
820
  function onGitComplete (err) {
818
821
  if (err) {
819
- // TODO could use progressBar.interrupt() to replace bar with message instead
820
822
  this.chars.incomplete = '?'
821
823
  this.update(0)
822
824
  // NOTE force progress bar to update regardless of throttle setting
@@ -846,6 +848,10 @@ function resolveCredentials (credentialsFromUrlHolder, url, auth) {
846
848
  )
847
849
  }
848
850
 
851
+ function identifyAuthStatus (credentialManager, credentials, url) {
852
+ return credentials ? 'auth-embedded' : credentialManager.status({ url }) ? 'auth-required' : undefined
853
+ }
854
+
849
855
  /**
850
856
  * Generates a safe, unique folder name for a git URL.
851
857
  *
@@ -957,8 +963,8 @@ function ensureCacheDir (preferredCacheDir, startDir) {
957
963
  )
958
964
  }
959
965
 
960
- function transformGitCloneError (err, displayUrl) {
961
- let wrappedMsg, trimMessage
966
+ function transformGitCloneError (err, displayUrl, authRequested) {
967
+ let wrappedMsg, recoverable, trimMessage
962
968
  if (HTTP_ERROR_CODE_RX.test(err.code)) {
963
969
  switch (err.data.statusCode) {
964
970
  case 401:
@@ -967,11 +973,13 @@ function transformGitCloneError (err, displayUrl) {
967
973
  : 'Content repository not found or requires credentials'
968
974
  break
969
975
  case 404:
970
- wrappedMsg = 'Content repository not found'
976
+ wrappedMsg = authRequested
977
+ ? 'Content repository not found or credentials were rejected'
978
+ : 'Content repository not found'
971
979
  break
972
980
  default:
973
981
  wrappedMsg = err.message
974
- trimMessage = true
982
+ recoverable = trimMessage = true
975
983
  }
976
984
  } else if (err instanceof UrlParseError || err instanceof UnknownTransportError) {
977
985
  wrappedMsg = 'Content source uses an unsupported transport protocol'
@@ -979,14 +987,14 @@ function transformGitCloneError (err, displayUrl) {
979
987
  wrappedMsg = `Content repository host could not be resolved: ${err.hostname}`
980
988
  } else {
981
989
  wrappedMsg = `${err.name}: ${err.message}`
982
- trimMessage = true
990
+ recoverable = trimMessage = true
983
991
  }
984
992
  if (trimMessage) {
985
- wrappedMsg = ~(wrappedMsg = wrappedMsg.trimRight()).indexOf('. ') ? wrappedMsg : wrappedMsg.replace(/\.$/, '')
993
+ wrappedMsg = ~(wrappedMsg = wrappedMsg.trimEnd()).indexOf('. ') ? wrappedMsg : wrappedMsg.replace(/\.$/, '')
986
994
  }
987
995
  const errWrapper = new Error(`${wrappedMsg} (url: ${displayUrl})`)
988
996
  errWrapper.stack += `\nCaused by: ${err.stack || 'unknown'}`
989
- return errWrapper
997
+ return recoverable ? Object.assign(errWrapper, { recoverable }) : errWrapper
990
998
  }
991
999
 
992
1000
  function splitRefPatterns (str) {
@@ -1034,7 +1042,7 @@ function findWorktrees (repo, patterns) {
1034
1042
  .then((branch = worktreeName) =>
1035
1043
  fsp
1036
1044
  .readFile(ospath.join(gitdir, 'gitdir'), 'utf8')
1037
- .then((contents) => ({ branch, dir: ospath.dirname(contents.trimRight()) }))
1045
+ .then((contents) => ({ branch, dir: ospath.dirname(contents.trimEnd()) }))
1038
1046
  )
1039
1047
  })
1040
1048
  ).then((entries) => entries.reduce((accum, it) => accum.set(it.branch, it.dir), new Map()))
@@ -1048,4 +1056,37 @@ function findWorktrees (repo, patterns) {
1048
1056
  )
1049
1057
  }
1050
1058
 
1059
+ async function gracefulPromiseAllWithLimit (tasks, limit = Infinity) {
1060
+ const rejections = []
1061
+ const recordRejection = (err) => rejections.push(err) && undefined
1062
+ const started = []
1063
+ if (tasks.length <= limit) {
1064
+ for (const task of tasks) started.push(task().catch(recordRejection))
1065
+ } else {
1066
+ const pending = []
1067
+ for (const task of tasks) {
1068
+ const current = task()
1069
+ .catch(recordRejection)
1070
+ .finally(() => pending.splice(pending.indexOf(current), 1))
1071
+ started.push(current)
1072
+ if (pending.push(current) < limit) continue
1073
+ await Promise.race(pending)
1074
+ if (rejections.length) break
1075
+ }
1076
+ }
1077
+ return Promise.all(started).then((results) => [results, rejections])
1078
+ }
1079
+
1080
+ async function promiseAllWithLimit (tasks, limit = Infinity) {
1081
+ if (tasks.length <= limit) return Promise.all(tasks.map((task) => task()))
1082
+ const started = []
1083
+ const pending = []
1084
+ for (const task of tasks) {
1085
+ const current = task().finally(() => pending.splice(pending.indexOf(current), 1))
1086
+ started.push(current)
1087
+ if (pending.push(current) >= limit) await Promise.race(pending)
1088
+ }
1089
+ return Promise.all(started)
1090
+ }
1091
+
1051
1092
  module.exports = aggregateContent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antora/content-aggregator",
3
- "version": "3.1.5",
3
+ "version": "3.1.6",
4
4
  "description": "Fetches and aggregates content from distributed sources for use in an Antora documentation pipeline.",
5
5
  "license": "MPL-2.0",
6
6
  "author": "OpenDevise Inc. (https://opendevise.com)",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@antora/expand-path-helper": "~2.0",
32
- "@antora/logger": "3.1.5",
32
+ "@antora/logger": "3.1.6",
33
33
  "@antora/user-require-helper": "~2.0",
34
34
  "braces": "~3.0",
35
35
  "cache-directory": "~2.0",