@antora/assembler 1.0.0-beta.9 → 1.0.0-rc.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.
@@ -1,164 +1,165 @@
1
1
  'use strict'
2
2
 
3
- const computeOut = require('./util/compute-out')
4
- const createAsciiDocFile = require('./util/create-asciidoc-file')
3
+ const collateAsciiDocAttributes = require('./util/collate-asciidoc-attributes')
5
4
  const filterComponentVersions = require('./filter-component-versions')
6
5
  const produceAssemblyFile = require('./produce-assembly-file')
7
- const selectMutableAttributes = require('./select-mutable-attributes')
6
+ const identifyMutableAttributes = require('./util/identify-mutable-attributes')
8
7
 
9
- const ATTR_REF_RX = /\\?\{(\w[\w-]*)\}/g
10
- const IMAGE_MACRO_RX = /^image::?(.+?)\[(.*?)\]$/
11
-
12
- function produceAssemblyFiles (loadAsciiDoc, contentCatalog, assemblerConfig, resolveAssemblyModel) {
13
- const { asciidoc: assemblerAsciiDocConfig, assembly: assemblyConfig } = assemblerConfig
14
- resolveAssemblyModel ??= (componentVersion) => ({
8
+ function produceAssemblyFiles (
9
+ loadAsciiDoc,
10
+ contentCatalog,
11
+ assemblerConfig,
12
+ selectAssemblerProfile,
13
+ selectComponentVersions
14
+ ) {
15
+ const { assembly: assemblyConfig } = assemblerConfig
16
+ selectAssemblerProfile ??= (componentVersion) => ({
17
+ attributes: assemblyConfig.attributes,
15
18
  doctype: assemblyConfig.doctype,
16
19
  insertStartPage: assemblyConfig.insertStartPage,
17
20
  rootLevel: assemblyConfig.rootLevel,
21
+ rootPageStyle: assemblyConfig.rootPageStyle,
18
22
  sectionMergeStrategy: assemblyConfig.sectionMergeStrategy,
19
23
  navigation: componentVersion.navigation,
20
24
  xmlIds: assemblyConfig.xmlIds,
21
25
  embedReferenceStyle: assemblyConfig.embedReferenceStyle,
22
26
  linkReferenceStyle: assemblyConfig.linkReferenceStyle,
23
27
  dropExplicitXrefText: assemblyConfig.dropExplicitXrefText,
28
+ revdate: assemblyConfig.revdate,
29
+ logger: assemblyConfig.logger, // for tests
24
30
  })
25
- const assemblerAsciiDocAttributes = Object.assign({}, assemblerAsciiDocConfig.attributes)
26
- const { revdate, 'source-highlighter': sourceHighlighter } = assemblerAsciiDocAttributes
27
- delete assemblerAsciiDocAttributes.revdate
28
- delete assemblerAsciiDocAttributes['source-highlighter']
29
- const publishableFiles = contentCatalog.getFiles().filter((file) => file.out)
31
+ const baseAssemblyAttributes = assemblyConfig.attributes
32
+ const publishableFiles = contentCatalog.getFiles().filter((file) => file.out && file.pub)
33
+ const reducerExtension = require('@asciidoctor/reducer') // require lazily to wait for Opal to be defined
30
34
  let siteRoot
31
35
  const configMdc = assemblerConfig.file ? { file: { path: assemblerConfig.file } } : {}
32
- return filterComponentVersions(contentCatalog.getComponents(), assemblerConfig.componentVersionFilter.names).reduce(
33
- (accum, componentVersion) => {
34
- const assemblyModel = resolveAssemblyModel(componentVersion)
35
- if (!assemblyModel.navigation) return accum
36
- const { name: componentName, version, title } = componentVersion
37
- const componentVersionAsciiDocConfig = getAsciiDocConfigWithAsciidoctorReducerExtension(componentVersion)
38
- const mergedAsciiDocAttributes = collateAsciiDocAttributes(
39
- Object.assign({ revdate }, componentVersionAsciiDocConfig.attributes),
40
- assemblerAsciiDocAttributes,
41
- { logger: assemblyModel.logger, mdc: configMdc }
42
- )
43
- const mergedAsciiDocConfig = Object.assign({}, componentVersionAsciiDocConfig, {
44
- attributes: mergedAsciiDocAttributes,
45
- })
46
- assemblyModel.outDirname = computeOut.call(contentCatalog, {
36
+ selectComponentVersions ??= filterComponentVersions.bind(null, assemblerConfig.componentVersionFilter)
37
+ return selectComponentVersions(contentCatalog.getComponents()).reduce((accum, componentVersion) => {
38
+ const assemblyModel = selectAssemblerProfile(componentVersion)
39
+ const { attributes: assemblyAttributes, logger, navigation, rootLevel } = assemblyModel
40
+ if (!navigation) return accum
41
+ const contextualLogger = logger ? { warn: logger.warn.bind(logger, configMdc) } : undefined
42
+ const { name: componentName, version, title } = componentVersion
43
+ const asciidocConfig = getAsciiDocConfigWithAsciidoctorReducerExtension(componentVersion, reducerExtension)
44
+ let sourceHighlighter
45
+ if ('source-highlighter' in assemblyAttributes) {
46
+ sourceHighlighter = assemblyAttributes['source-highlighter']
47
+ delete assemblyAttributes['source-highlighter']
48
+ }
49
+ const mergedAsciiDocAttributes = collateAsciiDocAttributes(
50
+ Object.assign({}, asciidocConfig.attributes),
51
+ assemblyAttributes,
52
+ contextualLogger
53
+ )
54
+ const mergedAsciiDocConfig = Object.assign({}, asciidocConfig, {
55
+ attributes: mergedAsciiDocAttributes,
56
+ })
57
+ assemblyModel.filetype = baseAssemblyAttributes['assembler-filetype']
58
+ const outDirname = (assemblyModel.outDirname = contentCatalog.createFile({
59
+ src: {
47
60
  component: componentName,
48
61
  version,
62
+ componentVersion,
63
+ module: 'ROOT',
49
64
  family: 'export',
50
- relative: '.index.adoc',
51
- }).dirname
52
- assemblyModel.filetype = assemblerAsciiDocAttributes['assembler-filetype']
53
- assemblyModel.siteRoot =
54
- siteRoot === undefined
55
- ? (siteRoot ??= ((val) => {
56
- if (!val) return null
57
- if (val.charAt(val.length - 1) === '/') val = val.slice(0, val.length - 1)
58
- if (!val || val.charAt() === '/') return { path: val }
59
- return { url: val, path: extractUrlPath(val) }
60
- })(mergedAsciiDocAttributes['site-url'] || mergedAsciiDocAttributes['primary-site-url']))
61
- : siteRoot
62
- if (assemblyModel.filetype === 'html') {
63
- let linkRefStyle = assemblyModel.linkReferenceStyle
64
- if (linkRefStyle === 'absolute' && siteRoot?.url == null) linkRefStyle = 'root-relative'
65
- if (linkRefStyle === 'root-relative' && siteRoot?.path == null) linkRefStyle = 'relative'
66
- assemblyModel.linkReferenceStyle = linkRefStyle
67
- } else {
68
- assemblyModel.linkReferenceStyle = 'absolute'
69
- }
70
- const auxiliaryImages = new Set()
71
- Object.entries(mergedAsciiDocAttributes).forEach(([name, val]) => {
72
- const match = name.endsWith('-image') && val.startsWith('image:') && IMAGE_MACRO_RX.exec(val)
73
- if (!(match && isResourceRef(match[1]))) return
74
- // Q should we allow image to be resolved relative to component version?
75
- const image = contentCatalog.resolveResource(match[1], undefined, 'image', ['image'])
76
- if (!image?.out) return
77
- mergedAsciiDocAttributes[name] = `image:${image.out.path}[${match[2]}]`
78
- auxiliaryImages.add(image)
79
- })
80
- const rootEntry = { content: title }
81
- const { navigation, rootLevel } = assemblyModel
82
- let startPage =
83
- 'startPage' in componentVersion
84
- ? componentVersion.startPage
85
- : contentCatalog.resolvePage('index.adoc', { component: componentName, version })
86
- if (startPage && startPage.src.component === componentName && startPage.src.version === version) {
87
- if (assemblyModel.insertStartPage) {
88
- const navtitle = startPage.asciidoc?.navtitle || rootEntry.content
89
- Object.assign(rootEntry, { navtitle, url: startPage.pub.url, urlType: 'internal' })
90
- }
91
- } else {
92
- // Q: should we always use a reference page as startPage for computing mutableAttributes?
93
- startPage = createAsciiDocFile(contentCatalog, {
94
- src: {
95
- component: componentVersion.name,
96
- version: componentVersion.version,
97
- module: 'ROOT',
98
- family: 'page',
99
- relative: '.start-page.adoc',
100
- origin: (componentVersion.origins || [])[0],
101
- },
102
- })
65
+ relative: 'index.adoc',
66
+ },
67
+ }).out.dirname)
68
+ assemblyModel.pubRoot = outDirname ? '/' + outDirname : ''
69
+ assemblyModel.siteRoot =
70
+ siteRoot === undefined
71
+ ? (siteRoot ??= ((val) => {
72
+ if (!val) return null
73
+ if (val.charAt(val.length - 1) === '/') val = val.substring(0, val.length - 1)
74
+ if (!val || val.charAt() === '/') return { path: val }
75
+ return { url: val, path: extractUrlPath(val) }
76
+ })(mergedAsciiDocAttributes['site-url'] || mergedAsciiDocAttributes['primary-site-url']))
77
+ : siteRoot
78
+ if (assemblyModel.filetype === 'html') {
79
+ let linkRefStyle = assemblyModel.linkReferenceStyle
80
+ if (linkRefStyle === 'absolute' && siteRoot?.url == null) linkRefStyle = 'root-relative'
81
+ if (linkRefStyle === 'root-relative' && siteRoot?.path == null) linkRefStyle = 'relative'
82
+ assemblyModel.linkReferenceStyle = linkRefStyle
83
+ } else if (!(assemblyModel.filetype === 'pdf' && assemblyModel.linkReferenceStyle === 'relative')) {
84
+ assemblyModel.linkReferenceStyle = 'absolute'
85
+ }
86
+ const outlineRoot = { content: title }
87
+ const startPage = resolveStartPage(contentCatalog, componentVersion, assemblyModel.insertStartPage, outlineRoot)
88
+ const mutableAttributes = identifyMutableAttributes(loadAsciiDoc, contentCatalog, startPage, mergedAsciiDocConfig)
89
+ prepareOutlines(navigation, outlineRoot, rootLevel).reduce((any, outline) => {
90
+ const assemblyFile = produceAssemblyFile(
91
+ loadAsciiDoc,
92
+ contentCatalog,
93
+ componentVersion,
94
+ outline,
95
+ publishableFiles,
96
+ mergedAsciiDocConfig,
97
+ mutableAttributes,
98
+ assemblyModel
99
+ )
100
+ if (!assemblyFile) return any
101
+ // NOTE restore source highlighter for conversion if defined in Assembler config
102
+ if (sourceHighlighter !== undefined) {
103
+ assemblyFile.asciidoc.attributes['source-highlighter'] = sourceHighlighter
104
+ } else if (assemblyModel.filetype !== 'html') {
105
+ delete assemblyFile.asciidoc.attributes['source-highlighter']
103
106
  }
104
- const mutableAttributes = selectMutableAttributes(loadAsciiDoc, contentCatalog, startPage, mergedAsciiDocConfig)
105
- delete mutableAttributes.doctype // Q: should this be in selectMutableAttributes?
106
- prepareOutlines(navigation, rootEntry, rootLevel).reduce((any, outline) => {
107
- const assemblyFile = produceAssemblyFile(
108
- loadAsciiDoc,
109
- contentCatalog,
110
- componentVersion,
111
- outline,
112
- publishableFiles,
113
- mergedAsciiDocConfig,
114
- mutableAttributes,
115
- assemblyModel
116
- )
117
- if (!assemblyFile) return any
118
- if (!any && auxiliaryImages.size) {
119
- const assembledAssets = assemblyFile.assembler.assembled.assets
120
- auxiliaryImages.forEach((asset) => assembledAssets.add(asset))
121
- }
122
- accum.push(assemblyFile)
123
- return true
124
- }, false)
125
- sourceHighlighter
126
- ? (mergedAsciiDocAttributes['source-highlighter'] = sourceHighlighter)
127
- : delete mergedAsciiDocAttributes['source-highlighter']
128
- return accum
129
- },
130
- []
131
- )
107
+ return !!accum.push(assemblyFile)
108
+ }, false)
109
+ return accum
110
+ }, [])
132
111
  }
133
112
 
134
- function getAsciiDocConfigWithAsciidoctorReducerExtension (componentVersion) {
135
- const asciidoctorReducerExtension = require('@asciidoctor/reducer') // NOTE: must be required lazily
113
+ function getAsciiDocConfigWithAsciidoctorReducerExtension (componentVersion, reducerExtension) {
136
114
  const asciidocConfig = componentVersion.asciidoc
137
- const extensions = [asciidoctorReducerExtension]
115
+ const extensions = [reducerExtension]
138
116
  const configuredExtensions = asciidocConfig.extensions || []
139
- if (!configuredExtensions.length) return Object.assign({}, asciidocConfig, { extensions, sourcemap: true })
140
- return Object.assign({}, asciidocConfig, {
141
- extensions: configuredExtensions.reduce((accum, candidate) => {
142
- if (candidate !== asciidoctorReducerExtension) accum.push(candidate)
143
- return accum
144
- }, extensions),
145
- sourcemap: true,
146
- })
117
+ return configuredExtensions.length
118
+ ? Object.assign({}, asciidocConfig, {
119
+ extensions: configuredExtensions.reduce((accum, candidate) => {
120
+ if (candidate !== reducerExtension) accum.push(candidate)
121
+ return accum
122
+ }, extensions),
123
+ sourcemap: true,
124
+ })
125
+ : Object.assign({}, asciidocConfig, { extensions, sourcemap: true })
147
126
  }
148
127
 
149
128
  function includedInNav (items, url) {
150
129
  return items.find((it) => it.url === url || includedInNav(it.items || [], url))
151
130
  }
152
131
 
153
- function isResourceRef (target) {
154
- return ~target.indexOf(':') && !(~target.indexOf('://') || (target.startsWith('data:') && ~target.indexOf(',')))
132
+ function resolveStartPage (contentCatalog, componentVersion, insertStartPage, outlineRoot) {
133
+ const { name: componentName, version } = componentVersion
134
+ const startPage =
135
+ 'startPage' in componentVersion
136
+ ? componentVersion.startPage
137
+ : contentCatalog.resolvePage('index.adoc', { component: componentName, version })
138
+ if (startPage && startPage.src.component === componentName && startPage.src.version === version) {
139
+ if (insertStartPage) {
140
+ const navtitle = startPage.asciidoc?.navtitle || outlineRoot.content
141
+ Object.assign(outlineRoot, { navtitle, url: startPage.pub.url, urlType: 'internal', roles: ['page'] })
142
+ }
143
+ return startPage
144
+ }
145
+ return contentCatalog.createFile({
146
+ src: {
147
+ component: componentName,
148
+ version,
149
+ componentVersion,
150
+ module: 'ROOT',
151
+ family: 'page',
152
+ relative: 'index.adoc',
153
+ origin: componentVersion.origins?.values().next().value,
154
+ },
155
+ })
155
156
  }
156
157
 
157
158
  function prepareOutlines (navigation, rootEntry, rootLevel) {
158
159
  const singleEntry = navigation.length === 1
159
160
  const items = navigation.reduce((accum, it) => {
160
161
  if (!it.content || (singleEntry && it.url ? it.url === rootEntry.url : it.content === rootEntry.content)) {
161
- accum.push(...it.items)
162
+ it.items && accum.push(...it.items)
162
163
  } else {
163
164
  accum.push(it)
164
165
  }
@@ -166,7 +167,7 @@ function prepareOutlines (navigation, rootEntry, rootLevel) {
166
167
  }, [])
167
168
  if (rootLevel === 0) {
168
169
  if (rootEntry.url && includedInNav(items, rootEntry.url)) {
169
- for (const p of ['url', 'urlType']) delete rootEntry[p]
170
+ for (const p of ['roles', 'url', 'urlType']) delete rootEntry[p]
170
171
  }
171
172
  return [Object.assign(rootEntry, { index: true, items })]
172
173
  }
@@ -177,39 +178,9 @@ function prepareOutlines (navigation, rootEntry, rootLevel) {
177
178
  return items
178
179
  }
179
180
 
180
- function collateAsciiDocAttributes (collated, additional, { logger, mdc }) {
181
- Object.entries(additional).forEach(([name, val]) => {
182
- if (val && val.constructor === String) {
183
- let alias
184
- val = val.replace(ATTR_REF_RX, (ref, refname) => {
185
- if (ref.charAt() === '\\') return ref.substr(1)
186
- const refval = collated[refname]
187
- if (refval == null || refval === false) {
188
- if (refname in collated && ref === val) {
189
- alias = refval
190
- } else if (collated['attribute-missing'] === 'warn') {
191
- if (logger) {
192
- logger.warn(mdc, "Skipping reference to missing attribute '%s' in value of '%s' attribute", refname, name)
193
- }
194
- }
195
- return ref
196
- }
197
- if (refval.constructor === String) return refval
198
- if (ref !== val) return refval.toString()
199
- alias = refval
200
- return ref
201
- })
202
- if (alias !== undefined) val = alias
203
- }
204
- collated[name] = val
205
- })
206
- return collated
207
- }
208
-
209
181
  function extractUrlPath (url) {
210
- if (!url) return ''
211
- const urlPath = new URL(url).pathname
212
- return urlPath === '/' ? '' : urlPath
182
+ const parsedUrl = url ? URL.parse(url) : null
183
+ return parsedUrl == null || parsedUrl.pathname === '/' ? '' : parsedUrl.pathname
213
184
  }
214
185
 
215
186
  module.exports = produceAssemblyFiles
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+
3
+ const ATTR_REF_RX = /\\?\{(\w[\w-]*)\}/g
4
+
5
+ function collateAsciiDocAttributes (collated, additional = {}, logger = undefined) {
6
+ Object.entries(additional).forEach(([name, val]) => {
7
+ if (val && val.constructor === String) {
8
+ let alias
9
+ val = val.replace(ATTR_REF_RX, (ref, refname) => {
10
+ if (ref.charAt() === '\\') return ref.substring(1)
11
+ const refval = collated[refname]
12
+ if (refval == null || refval === false) {
13
+ if (refname in collated && ref === val) {
14
+ alias = refval
15
+ } else if (collated['attribute-missing'] === 'warn') {
16
+ logger?.warn("Skipping reference to missing attribute '%s' in value of '%s' attribute", refname, name)
17
+ }
18
+ return ref
19
+ }
20
+ if (refval.constructor === String) return refval
21
+ if (ref !== val) return refval.toString()
22
+ alias = refval
23
+ return ref
24
+ })
25
+ if (alias !== undefined) val = alias
26
+ }
27
+ collated[name] = val
28
+ })
29
+ return collated
30
+ }
31
+
32
+ module.exports = collateAsciiDocAttributes
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ function createResourceKey ({ component, version, module: mod, family, relative }) {
4
+ return `${version}@${component}:${mod === 'ROOT' ? '' : mod}:${family === 'page' ? '' : family + '$'}${relative}`
5
+ }
6
+
7
+ module.exports = createResourceKey
@@ -0,0 +1,18 @@
1
+ 'use strict'
2
+
3
+ function deepClone (o) {
4
+ switch (o.constructor) {
5
+ case Object:
6
+ return Object.keys(o).reduce((accum, k) => {
7
+ const v = o[k]
8
+ accum[k] = !v || typeof v !== 'object' ? v : deepClone(v)
9
+ return accum
10
+ }, {})
11
+ case Array:
12
+ return o.map((it) => (!it || typeof it !== 'object' ? it : deepClone(it)))
13
+ default:
14
+ return o
15
+ }
16
+ }
17
+
18
+ module.exports = deepClone
@@ -0,0 +1,26 @@
1
+ 'use strict'
2
+
3
+ const HTML_RESERVED_ID_NAMES = 'content header footnotes footer footer-text premable toc toctitle'.split(' ')
4
+
5
+ function generateScopedId (componentSrc, componentVersion, separators, filetype, asXrefTarget) {
6
+ let { component, module: mod, relative } = componentSrc
7
+ let id = relative.replace(/\.adoc$/, '').replace(/[/.]/g, '-')
8
+ let { coordinate: coordinateSeparator, prefix: prefixSeparator } = separators
9
+ if (mod !== 'ROOT' && ~mod.indexOf('.')) mod = mod.replace(/[.]/g, '-')
10
+ if (component !== componentVersion.name) {
11
+ id = [component, mod === 'ROOT' ? '' : mod, id].join(coordinateSeparator)
12
+ } else if (mod !== 'ROOT') {
13
+ if (asXrefTarget && coordinateSeparator === ':' && /(?:pass|stem)$/.test(mod) && /^[a-z]+(?:,[a-z-]+)*$/.test(id)) {
14
+ prefixSeparator = ''
15
+ mod = mod.replace(/(?:pass|stem)$/, '\\$&')
16
+ }
17
+ id = mod + coordinateSeparator + id
18
+ } else if (filetype === 'html' && HTML_RESERVED_ID_NAMES.includes(id)) {
19
+ id = prefixSeparator + id
20
+ prefixSeparator = ''
21
+ }
22
+ if (prefixSeparator && !/^[\p{Alpha}_:]/u.test(id)) id = prefixSeparator + id
23
+ return id
24
+ }
25
+
26
+ module.exports = generateScopedId
@@ -0,0 +1,25 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Identifies AsciiDoc attributes that can be modified in the body of the page.
5
+ *
6
+ * This determination is made by loading a reference page with empty contents, retrieving the list of document attribute
7
+ * names, then removing any attributes that were passed via the API with some exceptions. The reason for this function
8
+ * is to be more prudent with which attributes to manage at the boundaries of an assembled page.
9
+ */
10
+ function identifyMutableAttributes (loadAsciiDoc, contentCatalog, referencePage, asciidocConfig) {
11
+ const doc = loadAsciiDoc(
12
+ new referencePage.constructor(
13
+ Object.assign({}, referencePage, { contents: Buffer.alloc(0), mediaType: 'text/asciidoc' })
14
+ ),
15
+ contentCatalog,
16
+ Object.assign({}, asciidocConfig, { extensions: [], headerOnly: true })
17
+ )
18
+ const immutableAttributeNames = doc.attribute_overrides.$keys()['$+'](['doctype'])
19
+ return Object.entries(doc.getAttributes()).reduce((accum, [name, val]) => {
20
+ if (!immutableAttributeNames.includes(name)) accum[name] = val
21
+ return accum
22
+ }, {})
23
+ }
24
+
25
+ module.exports = identifyMutableAttributes
@@ -1,14 +1,23 @@
1
1
  'use strict'
2
2
 
3
+ const fs = require('node:fs')
3
4
  const { PassThrough } = require('node:stream')
4
5
 
5
6
  // adapted from https://github.com/jpommerening/node-lazystream/blob/master/lib/lazystream.js | license: MIT
6
7
  class LazyReadable extends PassThrough {
7
- constructor (fn, options) {
8
- super(options)
8
+ constructor (fnOrPath) {
9
+ super()
10
+ const fn = typeof fnOrPath === 'function' ? fnOrPath : fs.createReadStream.bind(null, fnOrPath)
9
11
  this._read = function () {
10
12
  delete this._read // restores original method
11
- fn.call(this, options).on('error', this.emit.bind(this, 'error')).pipe(this)
13
+ const readable = fn().on('error', (err) => {
14
+ let msg = 'Failed to create stream'
15
+ if (err.syscall === 'open') msg += ` for path '${err.path}'`
16
+ this.emit('error', new Error(msg, { cause: err }))
17
+ })
18
+ readable.pipe(this)
19
+ this.fd = readable.fd
20
+ this.path = readable.path
12
21
  return this._read.apply(this, arguments)
13
22
  }
14
23
  this.emit('readable')
@@ -0,0 +1,150 @@
1
+ 'use strict'
2
+
3
+ const SPECIAL = { '.': true, '[': true, ']': true, '^': true, $: true, '}': true, ')': true, '|': true }
4
+ const QUANTIFIER_ALTERNATE = { '*': '.*?', '+': '\\+', '?': '.' }
5
+ const QUANTIFIERS = { '*': true, '+': true, '?': true, '@': true, '!': true }
6
+ const RS = '\x1e'
7
+
8
+ const BRACE_RANGE_RX = /^\{(?:([a-z])\.\.([a-z])|(0|[1-9][0-9]*)\.\.(0|[1-9][0-9]*))\}/
9
+
10
+ function filterCollection (candidates, patterns, opts = {}) {
11
+ const rxs = patterns.map(opts.compilePattern ?? compilePattern)
12
+ const test = opts.test ?? ((rx, candidate) => rx.test(candidate))
13
+ return candidates.filter((candidate) => {
14
+ let matched = false
15
+ for (const rx of rxs) {
16
+ let voteIfMatched = true
17
+ if (matched) {
18
+ if (!rx.negated) continue
19
+ voteIfMatched = false
20
+ } else if (rx.negated) {
21
+ voteIfMatched = false
22
+ }
23
+ if (test(rx, candidate)) matched = voteIfMatched
24
+ }
25
+ return matched
26
+ })
27
+ }
28
+
29
+ function compilePattern (str) {
30
+ const negated = str.charAt() === '!' ? (str = str.substring(1)).constructor === String : false
31
+ if (str === '**') return Object.assign(new RegExp(), { globstar: true, negated })
32
+ if (str === '*') return Object.assign(new RegExp(), { star: true, negated })
33
+ const buffer = { result: '', brace: undefined, group: undefined }
34
+ let target = 'result'
35
+ let nestedTarget
36
+ for (let i = 0, len = str.length; i < len; i++) {
37
+ const c = str[i]
38
+ if (target === 'brace') {
39
+ if (c === '}') {
40
+ const braceBuffer = buffer.brace
41
+ let braceResult
42
+ if (~braceBuffer.indexOf(RS)) {
43
+ braceResult = braceBuffer === RS ? '' : '(?:' + braceBuffer.replace(/\x1e/g, '|') + ')'
44
+ } else {
45
+ const m = BRACE_RANGE_RX.exec('{' + braceBuffer.replace('\\.\\.', '..') + '}')
46
+ braceResult = m[1] != null ? `[${m[1]}-${m[2]}]` : rangeRe(+m[3], +m[4])
47
+ }
48
+ buffer[(target = nestedTarget === 'group' ? 'group' : 'result')] += braceResult
49
+ buffer.brace = nestedTarget = undefined
50
+ continue
51
+ } else if (c === ',') {
52
+ buffer.brace += RS
53
+ continue
54
+ }
55
+ } else if (target === 'group') {
56
+ if (c === ')') {
57
+ const { group: groupBuffer, groupQuantifier } = buffer
58
+ let groupResult = '(?:' + groupBuffer + ')'
59
+ if (groupQuantifier === '!') {
60
+ groupResult = '(?!' + groupResult + '.*?)'
61
+ } else if (groupQuantifier !== '@') {
62
+ groupResult += groupQuantifier
63
+ }
64
+ buffer[(target = nestedTarget === 'brace' ? 'brace' : 'result')] += groupResult
65
+ buffer.group = buffer.groupQuantifier = nestedTarget = undefined
66
+ continue
67
+ }
68
+ }
69
+ if (SPECIAL[c]) {
70
+ buffer[target] += '\\' + c
71
+ } else if (c === '(' || QUANTIFIERS[c]) {
72
+ const hasQuantifier = c !== '('
73
+ const atGroupStart = hasQuantifier ? str[i + 1] === '(' : true
74
+ if (target !== 'group' && atGroupStart && isGroup(str.substring(hasQuantifier ? i + 1 : i))) {
75
+ if (target === 'brace') nestedTarget = 'brace'
76
+ buffer[(target = 'group')] = ''
77
+ buffer.groupQuantifier = hasQuantifier ? ++i > 0 && c : ''
78
+ } else if (hasQuantifier) {
79
+ buffer[target] += (QUANTIFIER_ALTERNATE[c] ?? c) + (atGroupStart ? ++i > 0 && '\\(' : '')
80
+ } else {
81
+ buffer[target] += '\\('
82
+ }
83
+ } else if (c === '{') {
84
+ if (target !== 'brace' && isBrace(str.substring(i))) {
85
+ if (target === 'group') nestedTarget = 'group'
86
+ buffer[(target = 'brace')] = ''
87
+ } else {
88
+ buffer[target] += '\\' + c
89
+ }
90
+ } else {
91
+ buffer[target] += c
92
+ }
93
+ }
94
+ return Object.assign(new RegExp('^' + cleanup(buffer, target, nestedTarget) + '$'), { negated })
95
+ }
96
+
97
+ function cleanup (buffer, target, nestedTarget) {
98
+ if (target === 'result') return buffer.result
99
+ if (target === 'brace') {
100
+ buffer.result += '\\{' + buffer.brace.replace(/\x1e/g, ',')
101
+ buffer.brace = undefined
102
+ } else if (target === 'group') {
103
+ const groupQuantifier = buffer.groupQuantifier
104
+ buffer.result += (QUANTIFIER_ALTERNATE[groupQuantifier] ?? groupQuantifier) + '\\(' + buffer.group
105
+ buffer.group = buffer.groupQuantifier = undefined
106
+ }
107
+ return nestedTarget ? cleanup(buffer, nestedTarget) : buffer.result
108
+ }
109
+
110
+ function isBrace (str) {
111
+ const endBraceIdx = str.indexOf('}')
112
+ const candidate = ~endBraceIdx ? str.substring(0, endBraceIdx + 1) : ''
113
+ if (!candidate || ~candidate.indexOf('{', 1)) return false
114
+ return ~candidate.indexOf(',') || BRACE_RANGE_RX.test(candidate)
115
+ }
116
+
117
+ function isGroup (str) {
118
+ const endBraceIdx = str.indexOf(')')
119
+ const candidate = ~endBraceIdx ? str.substring(0, endBraceIdx + 1) : ''
120
+ return candidate ? !~candidate.indexOf('(', 1) : false
121
+ }
122
+
123
+ function rangeRe (min, max) {
124
+ if (min === max) return String(min)
125
+ min > max && ([min, max] = [max, min])
126
+ if (min < 10 && max < 10) return `[${min}${min + 1 === max ? '' : '-'}${max}]`
127
+ const result = []
128
+ let minStr, minStrLen, toStr
129
+ while (min <= max) {
130
+ let to = (minStrLen = (minStr = String(min)).length) < String(max).length ? 10 ** minStrLen - 1 : max
131
+ const limit = 10 ** minStrLen
132
+ for (let i = 10; i <= limit; i *= 10) {
133
+ const candidate = min + (i - (min % i) || i) - 1
134
+ if (candidate > max) break
135
+ to = candidate
136
+ }
137
+ toStr = String(to)
138
+ let rangesForChunk = ''
139
+ for (let i = 0, len = minStr.length; i < len; i++) {
140
+ const lhs = minStr[i]
141
+ const rhs = toStr[i]
142
+ rangesForChunk += lhs === rhs ? lhs : `[${lhs}${+lhs + 1 === +rhs ? '' : '-'}${rhs}]`
143
+ }
144
+ result.push(rangesForChunk)
145
+ min = to + 1
146
+ }
147
+ return result.length > 1 ? `(?:${result.join('|')})` : result[0]
148
+ }
149
+
150
+ module.exports = { filterCollection, compilePattern }