@antora/assembler 1.0.0-alpha.1

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.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # Antora Assembler
2
+
3
+ A library for writing Antora extensions that combine multiple pages into one.
4
+
5
+ Assembler works by constructing aggregate AsciiDoc documents from the pages based on the navigation tree per component version.
6
+ It then invokes the specified callback to convert those documents to an output format.
7
+ If a catalog is specified, Assembler pushes the converted files back into the catalog to be published as attachments alongside the other files in the site.
8
+
9
+ [Antora](https://antora.org) is a modular static site generator designed for creating documentation sites from AsciiDoc documents.
10
+ The Assembler extends the feature set of Antora by providing the foundation for page aggregation.
11
+
12
+ ## Copyright and License
13
+
14
+ Copyright (C) 2022-present by OpenDevise Inc. and the individual contributors of this project.
15
+
16
+ Use of this software is granted under the terms of the [Mozilla Public License Version 2.0](https://www.mozilla.org/en-US/MPL/2.0/) (MPL-2.0).
@@ -0,0 +1,230 @@
1
+ 'use strict'
2
+
3
+ const Opal = global.Opal
4
+ const Asciidoctor = Opal.Asciidoctor
5
+ const $Reducer = function $Reducer () {}
6
+
7
+ const DocumentExt = (() => {
8
+ const parentScope = Opal.module(Asciidoctor, 'Reducer', $Reducer)
9
+ const scope = Opal.module(parentScope, 'DocumentExt', function $DocumentExt () {})
10
+
11
+ Opal.defn(scope, '$save_attributes', function saveAttributes () {
12
+ this.attributes_defined_in_header = this.attributes_modified.$to_a().reduce((accum, name) => {
13
+ accum[name] = this.getAttribute(name)
14
+ return accum
15
+ }, {})
16
+ return Opal.send(this, Opal.find_super_dispatcher(this, 'save_attributes', saveAttributes), [])
17
+ })
18
+
19
+ return scope
20
+ })()
21
+
22
+ const ConditionalDirectiveTracker = (() => {
23
+ const parentScope = Opal.module(Asciidoctor, 'Reducer', $Reducer)
24
+ const scope = Opal.module(parentScope, 'ConditionalDirectiveTracker', function $ConditionalDirectiveTracker () {})
25
+
26
+ Opal.defn(
27
+ scope,
28
+ '$preprocess_conditional_directive',
29
+ function preprocessConditionalDirective (keyword, target, delimiter, text) {
30
+ const skipActive = this.skipping
31
+ const depth = this.conditional_stack.length
32
+ let directiveLineno = this.lineno
33
+ const result = Opal.send(
34
+ this,
35
+ Opal.find_super_dispatcher(this, 'preprocess_conditional_directive', preprocessConditionalDirective),
36
+ [keyword, target, delimiter, text]
37
+ )
38
+ if (this.skipping && skipActive) return result
39
+ const currIncReplacement = this.includeReplacements.$current()
40
+ const drop = currIncReplacement.drop || (currIncReplacement.drop = [])
41
+ directiveLineno -= currIncReplacement.offset || 0
42
+ const depthChange = this.conditional_stack.length - depth
43
+ if (depthChange < 0) {
44
+ if (skipActive) {
45
+ for (let n = drop.pop(); n <= directiveLineno; n++) drop.push(n)
46
+ } else {
47
+ drop.push(directiveLineno)
48
+ }
49
+ } else if (depthChange > 0 || directiveLineno === this.lineno) {
50
+ drop.push(directiveLineno)
51
+ } else {
52
+ drop.push([directiveLineno, text])
53
+ }
54
+ return result
55
+ }
56
+ )
57
+
58
+ return scope
59
+ })()
60
+
61
+ const CurrentPosition = (() => {
62
+ const parentScope = Opal.module(Opal.Asciidoctor, 'Reducer', $Reducer)
63
+ const scope = Opal.module(parentScope, 'CurrentPosition', function $CurrentPosition () {})
64
+
65
+ Opal.defn(Opal.get_singleton_class(scope), '$extended', function $extended (instance) {
66
+ instance.$to_end()
67
+ })
68
+
69
+ Opal.defn(scope, '$current', function $current () {
70
+ return this[this.pointer]
71
+ })
72
+
73
+ Opal.defn(scope, '$to_end', function $toEnd () {
74
+ this.pointer = this.length - 1
75
+ })
76
+
77
+ Opal.defn(scope, '$up', function $up () {
78
+ this.pointer = this.$current().into
79
+ })
80
+
81
+ return scope
82
+ })()
83
+
84
+ const IncludeDirectiveTracker = (() => {
85
+ const parentScope = Opal.module(Opal.Asciidoctor, 'Reducer', $Reducer)
86
+ const scope = Opal.module(parentScope, 'IncludeDirectiveTracker', function $IncludeDirectiveTracker () {})
87
+
88
+ Opal.defn(Opal.get_singleton_class(scope), '$extended', function $extended (instance) {
89
+ instance.includeReplacements = [{}].$extend(CurrentPosition)
90
+ instance.$$reducer = {}
91
+ })
92
+
93
+ Opal.defn(scope, '$preprocess_include_directive', function preprocessIncludeDirective (target, attrlist) {
94
+ this.$$reducer.includeDirectiveLine = `include::${target}[${attrlist}]`
95
+ this.$$reducer.includePushed = false
96
+ const directiveLineno = this.lineno // we're currently on the include line, which is 1-based
97
+ const result = Opal.send(
98
+ this,
99
+ Opal.find_super_dispatcher(this, 'preprocess_include_directive', preprocessIncludeDirective),
100
+ [target, attrlist]
101
+ )
102
+ if (!this.$$reducer.includePushed) {
103
+ let ln = this.peekLine(true)
104
+ let unresolved
105
+ if (
106
+ ln &&
107
+ ln.charAt(ln.length - 1) === ']' &&
108
+ !(unresolved = ln.startsWith('Unresolved directive in ')) &&
109
+ directiveLineno === this.lineno &&
110
+ (unresolved = ln.startsWith('link:'))
111
+ ) {
112
+ ln = `${ln.slice(0, ln.length - 1)}role=include]`
113
+ }
114
+ pushIncludeReplacement.call(this, directiveLineno, unresolved ? [ln] : [], 0, unresolved)
115
+ }
116
+ this.$$reducer = {}
117
+ return result
118
+ })
119
+
120
+ Opal.defn(scope, '$push_include', function pushInclude (data, file, path, lineno, attrs) {
121
+ this.$$reducer.includePushed = true
122
+ const directiveLineno = this.lineno - 1 // we're below the include line, which is 1-based
123
+ const prevIncDepth = this.include_stack.length
124
+ const result = Opal.send(this, Opal.find_super_dispatcher(this, 'push_include', pushInclude), [
125
+ data,
126
+ file,
127
+ path,
128
+ lineno,
129
+ attrs,
130
+ ])
131
+ pushIncludeReplacement.call(
132
+ this,
133
+ directiveLineno,
134
+ this.include_stack.length > prevIncDepth ? this.$lines() : [],
135
+ lineno > 1 ? lineno - 1 : 0
136
+ )
137
+ return result
138
+ })
139
+
140
+ Opal.defn(scope, '$pop_include', function popInclude () {
141
+ if (!this.$$reducer.includePushed) this.includeReplacements.$up()
142
+ return Opal.send(this, Opal.find_super_dispatcher(this, 'pop_include', popInclude), [])
143
+ })
144
+
145
+ function pushIncludeReplacement (lineno, lines, offset, unresolved) {
146
+ const incReplacements = this.includeReplacements
147
+ const into = incReplacements.pointer
148
+ const line = this.$$reducer.includeDirectiveLine
149
+ incReplacements.push({ into, lineno: lineno - (incReplacements.$current().offset || 0), line, lines, offset })
150
+ if (!unresolved && lines.length) incReplacements.$to_end()
151
+ }
152
+
153
+ return scope
154
+ })()
155
+
156
+ function preprocessor () {
157
+ this.process((doc, reader) =>
158
+ doc.getOptions().preserve_conditionals
159
+ ? reader.$extend(IncludeDirectiveTracker)
160
+ : reader.$extend(ConditionalDirectiveTracker, IncludeDirectiveTracker)
161
+ )
162
+ }
163
+
164
+ function treeProcessor () {
165
+ this.process((doc) => {
166
+ const incReplacements = doc.reader.includeReplacements
167
+ if (incReplacements.length > 1 || (incReplacements[0].drop || []).length) {
168
+ const sourceLines = doc.getSourceLines()
169
+ incReplacements[0].lines = sourceLines.slice()
170
+ incReplacements
171
+ .slice()
172
+ .reverse()
173
+ .forEach(({ into, lineno, lines, line, drop }) => {
174
+ let targetLines, idx
175
+ if (into != null) {
176
+ targetLines = incReplacements[into].lines
177
+ // adds assurance that we're replacing the correct line
178
+ if (targetLines[(idx = lineno - 1)] !== line) return
179
+ }
180
+ if ((drop || []).length) {
181
+ drop
182
+ .slice()
183
+ .reverse()
184
+ .forEach((dropIt) => {
185
+ Array.isArray(dropIt) ? (lines[dropIt[0] - 1] = dropIt[1]) : lines.splice(dropIt - 1, 1)
186
+ })
187
+ }
188
+ if (targetLines) targetLines[idx] = lines
189
+ })
190
+ const reducedSourceLines = flattenDeep(incReplacements[0].lines)
191
+ if (doc.getSourcemap()) {
192
+ const logger = Asciidoctor.LoggerManager.getLogger()
193
+ const opts = Object.assign(doc.getOptions(), { logger: undefined, parse: false, reduced: true })
194
+ if (opts.extension_registry) {
195
+ opts.extension_registry = Asciidoctor.Extensions.Registry.$new(opts.extension_registry.groups)
196
+ }
197
+ const includes = doc.getCatalog().includes
198
+ doc = Asciidoctor.load(reducedSourceLines, opts)
199
+ doc.catalog.$send('[]=', 'includes', includes)
200
+ doc.parse()
201
+ Asciidoctor.LoggerManager.setLogger(logger)
202
+ } else {
203
+ while (reducedSourceLines[reducedSourceLines.length - 1] === '') reducedSourceLines.pop()
204
+ sourceLines.splice(0, sourceLines.length, ...reducedSourceLines)
205
+ }
206
+ }
207
+ return doc
208
+ })
209
+ }
210
+
211
+ function flattenDeep (array, accum = []) {
212
+ const len = array.length
213
+ for (let i = 0, it; i < len; i++) Array.isArray((it = array[i])) ? flattenDeep(it, accum) : accum.push(it)
214
+ return accum
215
+ }
216
+
217
+ function toProc (fn) {
218
+ return Object.defineProperty(fn, '$$arity', { value: fn.length })
219
+ }
220
+
221
+ module.exports.register = (registry) => {
222
+ const extGroup = toProc(function () {
223
+ const doc = this.document
224
+ doc.$extend(DocumentExt)
225
+ if (doc.getOptions().reduced) return
226
+ this.preprocessor(preprocessor)
227
+ this.treeProcessor(treeProcessor)
228
+ })
229
+ registry.groups.$send('[]=', 'reducer', extGroup)
230
+ }
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ const loadConfig = require('./load-config')
4
+ const produceAggregateDocuments = require('./produce-aggregate-documents')
5
+ const PromiseQueue = require('./util/promise-queue')
6
+
7
+ async function assembleContent (playbook, contentCatalog, converter, { siteCatalog, configSource }) {
8
+ // Q: could we get ContentCatalog#getComponentVersionStartPage() in Antora core?
9
+ if (typeof contentCatalog.getComponentVersionStartPage !== 'function') {
10
+ contentCatalog.getComponentVersionStartPage = function (component, version) {
11
+ return this.resolvePage('index.adoc', { component, version })
12
+ }
13
+ }
14
+ const assemblerConfig = await loadConfig(playbook, configSource)
15
+ if (!assemblerConfig) return [] // TODO consider removing and doing this another way
16
+ const generatorFunctions = this ? this.getFunctions() : {}
17
+ const { loadAsciiDoc = require('@antora/asciidoc-loader') } = generatorFunctions
18
+ const aggregateDocuments = produceAggregateDocuments(loadAsciiDoc, contentCatalog, assemblerConfig)
19
+ if (!converter) return aggregateDocuments
20
+ const { publishSite: publishFiles = require('@antora/site-publisher') } = generatorFunctions
21
+ const buildConfig = assemblerConfig.build
22
+ await prepareWorkspace(publishFiles, aggregateDocuments, contentCatalog, buildConfig)
23
+ // TODO: pass more information to converter so it doesn't have to compute internal stuff
24
+ // Q: don't we need to pass in the combined/resolved AsciiDoc attributes per file or component version?
25
+ return new PromiseQueue({ concurrency: buildConfig.processLimit })
26
+ .add(aggregateDocuments.map((doc) => () => converter.call(this, doc, buildConfig)))
27
+ .toPromise()
28
+ .then((files) => {
29
+ if (buildConfig.publish && siteCatalog) siteCatalog.addFiles(files)
30
+ return files
31
+ })
32
+ }
33
+
34
+ // TODO: if no workspace dir is defined; we shouldn't continue
35
+ function prepareWorkspace (publishFiles, aggregateDocuments, contentCatalog, buildConfig) {
36
+ const { dir, clean, keepAggregateSource } = buildConfig
37
+ const files = contentCatalog.findBy({ family: 'image' }).filter(({ out }) => out?.assembled)
38
+ if (keepAggregateSource) {
39
+ files.push(...aggregateDocuments.map((file) => Object.assign(file, { out: { path: file.path } })))
40
+ }
41
+ // TODO: site publisher should accept a single catalog
42
+ return publishFiles({ output: { clean, dir } }, [{ getFiles: () => files }])
43
+ }
44
+
45
+ module.exports = assembleContent
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+
3
+ const { compile: bracesToGroup } = require('braces')
4
+ const { makeRe: makePicomatchRx } = require('picomatch')
5
+
6
+ const PICOMATCH_OPTS = {
7
+ bash: true,
8
+ expandRange: (begin, end, step, opts) => bracesToGroup(opts ? `{${begin}..${end}..${step}}` : `{${begin}..${end}}`),
9
+ fastpaths: false,
10
+ nobracket: true,
11
+ noglobstar: true,
12
+ nonegate: true,
13
+ noquantifiers: true,
14
+ regex: false,
15
+ strictSlashes: true,
16
+ }
17
+
18
+ const VERSION_SEPARATOR_RX = /@(?!\()/
19
+
20
+ function compilePatterns (patterns) {
21
+ if (patterns[0].charAt() === '!') patterns = ['**', ...patterns]
22
+ return patterns.map((pattern) => {
23
+ const negated = pattern.charAt() === '!'
24
+ if (negated) pattern = pattern.substr(1)
25
+ let version
26
+ const separatorIdx = pattern.search(VERSION_SEPARATOR_RX)
27
+ if (~separatorIdx) {
28
+ pattern = (version = true) && `${pattern.substr(0, separatorIdx)}%${pattern.substr(separatorIdx + 1) || '*'}`
29
+ }
30
+ return Object.assign(makePicomatchRx(pattern, PICOMATCH_OPTS), {
31
+ globstar: pattern === '**',
32
+ negated,
33
+ star: pattern === '*',
34
+ version,
35
+ })
36
+ })
37
+ }
38
+
39
+ function filterComponentVersions (components, patterns) {
40
+ if (!patterns.length) return []
41
+ const rxs = compilePatterns(patterns)
42
+ return components.reduce((accum, { latest, versions }) => {
43
+ accum.push(
44
+ ...versions.filter((version) => {
45
+ let matched
46
+ for (const rx of rxs) {
47
+ let voteIfMatched
48
+ if (matched) {
49
+ if (rx.negated) voteIfMatched = false
50
+ } else if (!rx.negated) {
51
+ voteIfMatched = true
52
+ }
53
+ if (voteIfMatched == null) continue
54
+ if (rx.globstar) {
55
+ matched = voteIfMatched
56
+ } else if (rx.star) {
57
+ if (version === latest) matched = voteIfMatched
58
+ } else if (rx.version) {
59
+ if (rx.test(`${version.version}%${version.name}`)) matched = voteIfMatched
60
+ } else if (version === latest && rx.test(version.name)) {
61
+ matched = voteIfMatched
62
+ }
63
+ }
64
+ return matched
65
+ })
66
+ )
67
+ return accum
68
+ }, [])
69
+ }
70
+
71
+ module.exports = filterComponentVersions
package/lib/index.js ADDED
@@ -0,0 +1,6 @@
1
+ 'use strict'
2
+
3
+ const assembleContent = require('./assemble-content')
4
+ const runCommand = require('./util/run-command')
5
+
6
+ module.exports = { assembleContent, runCommand }
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ const camelCaseKeys = require('camelcase-keys')
4
+ const expandPath = require('@antora/expand-path-helper')
5
+ const { promises: fsp } = require('fs')
6
+ const os = require('os')
7
+ const yaml = require('js-yaml')
8
+
9
+ const CAMEL_CASE_KEYS_OPTS = { deep: true, stopPaths: ['asciidoc'] }
10
+
11
+ function loadConfig (playbook, configSource = './antora-assembler.yml') {
12
+ return (
13
+ configSource.constructor === Object
14
+ ? Promise.resolve(configSource)
15
+ : fsp
16
+ .access((configSource = expandPath(configSource, { dot: playbook.dir })))
17
+ .then(
18
+ () => true,
19
+ () => false
20
+ )
21
+ .then((exists) =>
22
+ exists
23
+ ? fsp.readFile(configSource).then((data) => camelCaseKeys(yaml.load(data), CAMEL_CASE_KEYS_OPTS))
24
+ : {}
25
+ )
26
+ ).then((config) => {
27
+ if (config.enabled === false) return undefined
28
+ let asciidocAttrs
29
+ if (!config.asciidoc) {
30
+ config.asciidoc = { attributes: (asciidocAttrs = { doctype: 'book' }) }
31
+ } else if (!(asciidocAttrs = config.asciidoc.attributes)) {
32
+ config.asciidoc.attributes = asciidocAttrs = { doctype: 'book' }
33
+ }
34
+ Object.assign(asciidocAttrs, { revdate: new Date().toISOString().split('T')[0], 'page-partial': null })
35
+ if (config.componentVersions == null) {
36
+ config.componentVersions = ['**']
37
+ } else if (typeof config.componentVersions === 'string') {
38
+ config.componentVersions = config.componentVersions.split(', ')
39
+ }
40
+ if (!('rootLevel' in config)) config.rootLevel = 0
41
+ if (!('insertStartPage' in config)) config.insertStartPage = true
42
+ if (['discrete', 'fuse', 'enclose'].indexOf(config.sectionMergeStrategy) < 0) {
43
+ config.sectionMergeStrategy = 'discrete'
44
+ }
45
+ const build = config.build || (config.build = {})
46
+ if (build.dir === '$' + '{playbook.output.dir}') {
47
+ //build.dir = playbook.output.dir
48
+ throw new Error('Not implemented')
49
+ } else {
50
+ build.dir = expandPath(build.dir || './build/assembler', { dot: playbook.dir })
51
+ }
52
+ build.cwd = playbook.dir // use playbook.dir the purpose of finding and loading require scripts
53
+ if (!('clean' in build) && 'output' in playbook) build.clean = playbook.output.clean
54
+ if (!('publish' in build)) build.publish = true
55
+ if (!build.processLimit) {
56
+ build.processLimit = 'processLimit' in build ? Infinity : Math.round(os.cpus().length * 0.5)
57
+ }
58
+ return config
59
+ })
60
+ }
61
+
62
+ module.exports = loadConfig
@@ -0,0 +1,520 @@
1
+ 'use strict'
2
+
3
+ const File = require('vinyl')
4
+ const { posix: path } = require('path')
5
+
6
+ function produceAggregateDocument (
7
+ loadAsciiDoc,
8
+ contentCatalog,
9
+ componentVersion,
10
+ outline,
11
+ pages,
12
+ asciidocConfig,
13
+ mutableAttributes,
14
+ sectionMergeStrategy = 'discrete'
15
+ ) {
16
+ const pagesInOutline = selectPagesInOutline(outline, pages)
17
+ const navtitle = outline.content
18
+ const stem = generateStem(componentVersion, navtitle)
19
+ const header = buildAsciiDocHeader(componentVersion, navtitle)
20
+ const body = aggregateAsciiDoc(
21
+ loadAsciiDoc,
22
+ contentCatalog,
23
+ header,
24
+ componentVersion,
25
+ outline,
26
+ pagesInOutline,
27
+ asciidocConfig,
28
+ mutableAttributes,
29
+ sectionMergeStrategy
30
+ )
31
+ const relativeSrcPath = `${stem}.adoc`
32
+ return new File({
33
+ asciidoc: asciidocConfig,
34
+ contents: Buffer.from([...header, ...body].join('\n') + '\n'),
35
+ mediaType: 'text/asciidoc',
36
+ path: relativeSrcPath,
37
+ src: {
38
+ component: componentVersion.name,
39
+ version: componentVersion.version,
40
+ basename: path.basename(relativeSrcPath),
41
+ stem,
42
+ extname: '.adoc',
43
+ },
44
+ })
45
+ }
46
+
47
+ function buildAsciiDocHeader (componentVersion, navtitle) {
48
+ const doctitle = navtitle === componentVersion.title ? navtitle : `${componentVersion.title}: ${navtitle}`
49
+ const version = componentVersion.version && componentVersion.version !== 'master' ? componentVersion.version : ''
50
+ return [
51
+ `= ${doctitle}`,
52
+ ...(version ? [`v${version}`] : []),
53
+ ':doctype: book', // for debugging only; set via CLI
54
+ // Q: should we pass these via the CLI so they cannot be modified?
55
+ `:page-component-name: ${componentVersion.name}`,
56
+ `:page-component-version:${version ? ' ' + version : ''}`,
57
+ ':page-version: {page-component-version}',
58
+ `:page-component-display-version: ${componentVersion.displayVersion}`,
59
+ `:page-component-title: ${componentVersion.title}`,
60
+ ]
61
+ }
62
+
63
+ function selectPagesInOutline (outlineEntry, pages) {
64
+ const page = outlineEntry.urlType === 'internal' ? pages.find((it) => it.pub.url === outlineEntry.url) : undefined
65
+ return (outlineEntry.items || []).reduce(
66
+ (accum, item) => new Map([...accum, ...selectPagesInOutline(item, pages)]),
67
+ new Map(
68
+ page && [
69
+ [`${page.src.module === 'ROOT' ? '' : page.src.module + ':'}${page.src.relative}`, page],
70
+ [page.pub.url, page],
71
+ ]
72
+ )
73
+ )
74
+ }
75
+
76
+ function aggregateAsciiDoc (
77
+ loadAsciiDoc,
78
+ contentCatalog,
79
+ header,
80
+ componentVersion,
81
+ outlineEntry,
82
+ pagesInOutline,
83
+ asciidocConfig,
84
+ mutableAttributes,
85
+ sectionMergeStrategy,
86
+ level = 0
87
+ ) {
88
+ const buffer = []
89
+ // TODO: we could try to be smart about it and make sure the page with fragment is included at least once
90
+ if (outlineEntry.hash) return buffer
91
+ const { content: navtitle, items, unresolved, urlType, url } = outlineEntry
92
+ // FIXME: ideally, resource ID would be stored in navigation so we can look up the page more efficiently
93
+ let page = urlType === 'internal' && !unresolved ? pagesInOutline.get(url) : undefined
94
+ if (page) {
95
+ let contents = page.src.contents
96
+ // NOTE: blank lines at top and bottom of document create mismatch when using line numbers to navigate source lines
97
+ // IMPORTANT: this must not leave behind lines the parser will drop!
98
+ // IDEA: another option is to capture initial lineno of reader and use as offset (but preseves those blank lines)
99
+ contents = Buffer.from(
100
+ contents
101
+ .toString()
102
+ .replace(/^(?:[ \t]*\r\n?|[ \t]*\n)+/, '')
103
+ .trimRight()
104
+ )
105
+ page = new page.constructor(Object.assign({}, page, { contents, mediaType: 'text/asciidoc' }))
106
+ const { module: module_, relative, origin } = page.src
107
+ const doc = loadAsciiDoc(page, contentCatalog, asciidocConfig)
108
+ const ids = doc.getCatalog().ids
109
+ // NOTE: in Antora, docname is relative src path from module without file extension
110
+ const docname = doc.getAttribute('docname')
111
+ //const idprefix = `${module_ === 'ROOT' ? '' : module_ + ':'}${docname}//`
112
+ const idprefix = `${module_ === 'ROOT' ? '' : module_ + ':'}${docname.replace(/[/]/g, '::')}:::`
113
+ //const idprefix = `${module_ === 'ROOT' ? '' : module_ + ':'}${docname.replace(/[/]/g, ':-:')}:--:`
114
+ buffer.push('')
115
+ buffer.push(`:docname: ${docname}`)
116
+ buffer.push(`:page-module: ${module_}`)
117
+ buffer.push(`:page-relative-src-path: ${relative}`)
118
+ //buffer.push(`:page-origin-type: ${origin.type}`)
119
+ buffer.push(`:page-origin-url: ${origin.url}`)
120
+ buffer.push(`:page-origin-start-path:${origin.startPath && ' '}${origin.startPath}`)
121
+ buffer.push(`:page-origin-refname: ${origin.branch || origin.tag}`)
122
+ buffer.push(`:page-origin-reftype: ${origin.branch ? 'branch' : 'tag'}`)
123
+ buffer.push(`:page-origin-refhash: ${origin.worktree ? '(worktree)' : origin.refhash}`)
124
+ let enclosed
125
+ // NOTE: if level is 0, doctitle has already been added and we're in the document header
126
+ if (level) {
127
+ if (level === 1 && navtitle === componentVersion.title) {
128
+ level--
129
+ } else {
130
+ let hlevel = level + 1
131
+ if (hlevel > 6) {
132
+ hlevel = 6
133
+ buffer.push(`[discrete#${idprefix}]`)
134
+ } else {
135
+ buffer.push(`[#${idprefix}]`)
136
+ }
137
+ buffer.push(`${'='.repeat(hlevel)} ${navtitle}`)
138
+ }
139
+ } else {
140
+ header.unshift(`[#${idprefix}]`)
141
+ }
142
+ if (sectionMergeStrategy === 'enclose' && items && doc.hasSections()) {
143
+ enclosed = true
144
+ // TODO: make overview section title configurable
145
+ //let overviewTitle = doc.getDocumentTitle()
146
+ //if (overviewTitle === navtitle) overviewTitle = doc.getAttribute('overview-title', 'Overview')
147
+ const overviewTitle = doc.getAttribute('overview-title', 'Overview')
148
+ buffer.push('')
149
+ // NOTE: try to toggle sectids; otherwise, fallback to globally unique synthetic ID
150
+ let toggleSectids, syntheticId
151
+ if (doc.isAttribute('sectids')) {
152
+ if (doc.isAttributeLocked('sectids')) {
153
+ syntheticId = `__object-id-${getObjectId(outlineEntry)}`
154
+ } else {
155
+ buffer.push(':!sectids:')
156
+ toggleSectids = true
157
+ }
158
+ }
159
+ let hlevel = level + 2
160
+ if (hlevel > 6) {
161
+ hlevel = 6
162
+ buffer.push(syntheticId ? `[discrete#${syntheticId}]` : '[discrete]')
163
+ } else if (syntheticId) {
164
+ buffer.push(`[#${syntheticId}]`)
165
+ }
166
+ buffer.push(`${'='.repeat(hlevel)} ${overviewTitle}`)
167
+ if (toggleSectids) buffer.push(':sectids:')
168
+ }
169
+ const siteUrl = doc.getAttribute('site-url')
170
+ const lines = doc.getSourceLines()
171
+ const ignoreLines = []
172
+ // TODO: think more about when multipart is allowed; perhaps configurable
173
+ if (doc.hasSections()) fixSectionLevels(doc.getSections(), level === 0)
174
+ // TODO: update / simplify findBy calls when upgrading to Asciidoctor 2
175
+ const allBlocks = doc
176
+ .findBy((it) => it.getContext() !== 'document')
177
+ .reduce((accum, block) => {
178
+ accum.push(block)
179
+ if (block.getContext() === 'table') {
180
+ const rows = block.rows
181
+ ;[...rows.body, ...rows.foot].forEach((row) => {
182
+ row.forEach((cell) => {
183
+ if (cell.style !== 'asciidoc') return
184
+ accum.push(...cell.inner_document.findBy((it) => it.getContext() !== 'document'))
185
+ })
186
+ })
187
+ }
188
+ return accum
189
+ }, [])
190
+ allBlocks.forEach((block) => {
191
+ const contentModel = block.content_model
192
+ if (
193
+ ((contentModel === 'verbatim' && block.getContext() !== 'table_cell') ||
194
+ contentModel === 'simple' ||
195
+ contentModel === 'pass') &&
196
+ !block.hasSubstitution('macros')
197
+ ) {
198
+ const lineno = block.getLineNumber()
199
+ const idx = typeof lineno === 'number' ? lineno - 1 : undefined
200
+ const startLine = lines[idx]
201
+ // NOTE: one case this happens if when sourcemap isn't enabled when reducing
202
+ if (startLine == null) {
203
+ console.log(`null startLine for ${block.getContext()} at ${lineno} in ${relative}`)
204
+ return
205
+ }
206
+ const char0 = startLine.charAt()
207
+ // FIXME: needs to be more robust; move logic to helper
208
+ const delimited =
209
+ startLine.length > 3 &&
210
+ startLine === char0.repeat(startLine.length) &&
211
+ (char0 === '-' || char0 === '.' || char0 === '+')
212
+ // QUESTION: exclude block attribute lines too? what about attribute entries?
213
+ for (let i = idx; i < block.lines.length + (delimited ? idx + 2 : idx); i++) ignoreLines.push(i)
214
+ }
215
+ })
216
+ for (let idx = 0, len = lines.length; idx < len; idx++) {
217
+ if (~ignoreLines.indexOf(idx)) continue
218
+ let line = lines[idx]
219
+ if (~line.indexOf('<<')) {
220
+ line = line.replace(/(?<![\\+])<<#?([\p{Alpha}0-9_/.:{][^>,]*?)(?:|, *([^>]+?))?>>/gu, (m, refid, text) => {
221
+ // support natural xref; note this logic will change when upgrading to Asciidoctor 2
222
+ if (!ids['$key?'](refid) && (~refid.indexOf(' ') || refid.toLowerCase() !== refid)) {
223
+ if (!(refid = doc.getCatalog().ids.$key(refid).$to_s())) {
224
+ return m
225
+ }
226
+ }
227
+ return `<<${idprefix}${refid}${text ? ',' + text : ''}>>`
228
+ })
229
+ }
230
+ // NOTE: the next check takes care of inline and block anchors
231
+ if (~line.indexOf('[[')) {
232
+ line = line.replace(/\[\[([\p{Alpha}_:][\p{Alpha}0-9_\-:.]*)(|, *.+?)\]\]/gu, `[[${idprefix}$1$2]]`)
233
+ }
234
+ if (~line.indexOf('xref:')) {
235
+ // Q: should we allow : as first character of target?
236
+ line = line.replace(/xref:([\p{Alpha}#/.{].*?)\[(|.*?[^\\])\]/gu, (m, target, text) => {
237
+ let pagePart, fragment, targetPage
238
+ const hashIdx = target.indexOf('#')
239
+ if (~hashIdx) {
240
+ pagePart = target.substr(0, hashIdx)
241
+ fragment = target.substr(hashIdx + 1)
242
+ // TODO: for now, assume .adoc; in the future, consider other file extensions
243
+ if (!(pagePart && pagePart.endsWith('.adoc'))) pagePart += '.adoc'
244
+ } else if (target.endsWith('.adoc')) {
245
+ pagePart = target
246
+ fragment = ''
247
+ } else {
248
+ fragment = target
249
+ }
250
+ if (!pagePart) return `<<${idprefix}${fragment}${text ? ',' + text.replace(/\\]/g, ']') : ''}>>`
251
+ if (~pagePart.indexOf('@') || /:.*:/.test(pagePart)) {
252
+ // TODO: handle unresolved page better
253
+ return siteUrl && (targetPage = contentCatalog.resolvePage(pagePart, page.src))
254
+ ? `${siteUrl}${targetPage.pub.url}${fragment && '#' + fragment}[${text}]`
255
+ : m
256
+ } else if (pagePart.indexOf(':') < 0) {
257
+ if (module_ !== 'ROOT') pagePart = `${module_}:${pagePart}`
258
+ } else if (pagePart.startsWith('ROOT:')) {
259
+ pagePart = pagePart.substr(5)
260
+ }
261
+ if (!(targetPage = pagesInOutline.get(pagePart))) {
262
+ // TODO: handle unresolved page better
263
+ return siteUrl && (targetPage = contentCatalog.resolvePage(target, page.src))
264
+ ? `${siteUrl}${targetPage.pub.url}${fragment && '#' + fragment}[${text}]`
265
+ : m
266
+ }
267
+ pagePart = pagePart.replace(/[/]/g, '::').replace(/\.adoc$/, '')
268
+ const refid = `${pagePart}:::${fragment}`
269
+ return `<<${refid}${text && text !== targetPage.title ? ',' + text.replace(/\\]/g, ']') : ''}>>`
270
+ })
271
+ }
272
+ if (~line.indexOf('link:{attachmentsdir}/')) {
273
+ line = line.replace(/(?<![\\+])link:\{attachmentsdir\}\/([^\s[]+)\[(|.*?[^\\])\]/g, (m, relative, text) => {
274
+ const attachment =
275
+ siteUrl &&
276
+ contentCatalog.getById({
277
+ component: componentVersion.name,
278
+ version: componentVersion.version,
279
+ module: module_,
280
+ family: 'attachment',
281
+ relative,
282
+ })
283
+ return attachment ? `${siteUrl}${attachment.pub.url}[${text}]` : m
284
+ })
285
+ }
286
+ if (~line.indexOf('image:') && !line.startsWith('image::')) {
287
+ line = line.replace(/(?<![\\+])image:([^:\s[](?:[^[]*[^\s[])?)\[([^\]]*)\]/g, (m, target, attrlist) => {
288
+ if (isResourceSpec(target)) {
289
+ const image = contentCatalog.resolveResource(target, page.src, 'image', ['image'])
290
+ // TODO: handle (or report) unresolved image better
291
+ if (image) {
292
+ image.out.assembled = true
293
+ return `image:${image.pub.url.substr(1)}[${attrlist}]`
294
+ }
295
+ }
296
+ return m
297
+ })
298
+ }
299
+ lines[idx] = line
300
+ }
301
+ // NOTE: need to do this last since it modifies the line numbers
302
+ // we could remap the line numbers to make them resilient
303
+ // or we could mark which lines to remove and filter them after
304
+ ;[...allBlocks].reverse().forEach((block) => {
305
+ const lineno = block.getLineNumber()
306
+ // NOTE: lineno is not defined for preamble
307
+ if (typeof lineno !== 'number') return
308
+ const context = block.getContext()
309
+ const idx = lineno - 1
310
+ if (context === 'section') {
311
+ if (block.getSectionName() === 'header') {
312
+ lines.splice(idx, 1)
313
+ return
314
+ }
315
+ let blockStyle = sectionMergeStrategy === 'discrete' ? 'discrete' : undefined
316
+ // FIXME: quick fix; needs more thorough review
317
+ const leveloffset = Number(doc.getAttribute('leveloffset') || 0)
318
+ lines[idx] = lines[idx].replace(/^=+( .+)/, (_, rest) => {
319
+ let targetMarkerLength = block.level + (1 - leveloffset) + level + (enclosed ? 1 : 0)
320
+ if (targetMarkerLength > 6) {
321
+ targetMarkerLength = 6
322
+ blockStyle = 'discrete'
323
+ }
324
+ return '='.repeat(targetMarkerLength) + rest
325
+ })
326
+ // NOTE: ID will be undefined if sectids are turned off
327
+ if (block.getId()) rewriteStyleAttribute(block, lines, idx, idprefix, blockStyle)
328
+ } else {
329
+ if (context === 'image') {
330
+ const atImageMacro = (lines[idx] || '').startsWith('image::')
331
+ // NOTE: the following logic is needed only if parser is messing up line number of image block
332
+ //let atImageMacro
333
+ //// NOTE: account for line number tracking error in parser when image has block anchor
334
+ //if (block.getId()) {
335
+ // const originalIdx = idx
336
+ // let line
337
+ // while ((line = lines[idx]) != null && !(atImageMacro = line.startsWith('image::'))) idx++
338
+ // if (!atImageMacro) {
339
+ // idx = originalIdx
340
+ // if ((lines[idx - 1] || '').startsWith('image::')) {
341
+ // atImageMacro = true
342
+ // idx--
343
+ // }
344
+ // }
345
+ //} else if ((lines[idx] || '').startsWith('image::')) {
346
+ // atImageMacro = true
347
+ //}
348
+ const target = block.getAttribute('target')
349
+ if (atImageMacro && isResourceSpec(target)) {
350
+ const image = contentCatalog.resolveResource(target, page.src, 'image', ['image'])
351
+ // FIXME: handle (or report) case when image is not resolved
352
+ if (image) {
353
+ image.out.assembled = true
354
+ const line = lines[idx]
355
+ lines[idx] = `image::${image.pub.url.substr(1)}${line.substr(line.indexOf('['))}`
356
+ }
357
+ }
358
+ }
359
+ if (block.getId()) rewriteStyleAttribute(block, lines, idx, idprefix)
360
+ }
361
+ })
362
+ buffer.push(...lines)
363
+ const attributeEntries = Object.entries(doc.attributes_defined_in_header || {})
364
+ if (attributeEntries.length) {
365
+ const resolvedAttributeEntries = attributeEntries.reduce(
366
+ (accum, [name, val]) => {
367
+ // Q: couldn't we just check if attribute is locked?
368
+ if (name in mutableAttributes) {
369
+ const initialVal = mutableAttributes[name]
370
+ if (initialVal == null) {
371
+ if (val != null) accum.push(`:!${name}:`)
372
+ } else if (val !== initialVal) {
373
+ accum.push(`:${name}:${initialVal ? ' ' + initialVal : ''}`)
374
+ }
375
+ } else if (val != null && !doc.isAttributeLocked(name)) {
376
+ accum.push(`:!${name}:`)
377
+ }
378
+ return accum
379
+ },
380
+ ['']
381
+ )
382
+ if (resolvedAttributeEntries.length > 1) buffer.push(...resolvedAttributeEntries)
383
+ }
384
+ } else {
385
+ if (level) {
386
+ if (level === 1 && navtitle === componentVersion.title) {
387
+ level--
388
+ } else {
389
+ buffer.push('')
390
+ // NOTE: try to toggle sectids; otherwise, fallback to globally unique synthetic ID
391
+ let toggleSectids, syntheticId
392
+ if (!('sectids' in asciidocConfig.attributes)) {
393
+ buffer.push(':!sectids:')
394
+ toggleSectids = true
395
+ } else if (typeof asciidocConfig.attributes.sectids === 'string') {
396
+ if ('sectids' in mutableAttributes) {
397
+ buffer.push(':!sectids:')
398
+ toggleSectids = true
399
+ } else {
400
+ syntheticId = `__object-id-${global.Opal.hash(outlineEntry).$object_id()}`
401
+ }
402
+ }
403
+ // Q: should we unset docname, page-module, etc?
404
+ const sectionTitle = urlType === 'external' ? `${url}[${navtitle.replace(/\]/, '\\]')}]` : navtitle
405
+ let hlevel = level + 1
406
+ if (hlevel > 6) {
407
+ hlevel = 6
408
+ buffer.push(syntheticId ? `[discrete#${syntheticId}]` : '[discrete]')
409
+ } else if (syntheticId) {
410
+ buffer.push(`[#${syntheticId}]`)
411
+ }
412
+ buffer.push(`${'='.repeat(hlevel)} ${sectionTitle}`)
413
+ if (toggleSectids) buffer.push(':sectids:')
414
+ }
415
+ }
416
+ }
417
+
418
+ const nextLevel = level + 1
419
+ if (items) {
420
+ items.forEach((item) => {
421
+ buffer.push(
422
+ ...aggregateAsciiDoc(
423
+ loadAsciiDoc,
424
+ contentCatalog,
425
+ header,
426
+ componentVersion,
427
+ item,
428
+ pagesInOutline,
429
+ asciidocConfig,
430
+ mutableAttributes,
431
+ sectionMergeStrategy,
432
+ nextLevel
433
+ )
434
+ )
435
+ })
436
+ }
437
+ return buffer
438
+ }
439
+
440
+ function generateStem (componentVersion, title) {
441
+ const { name, version } = componentVersion
442
+ const segments = [name]
443
+ if (version && version !== 'master') segments.push(version)
444
+ segments.push(
445
+ title
446
+ .toLowerCase()
447
+ .replace(/&.+?;|[^ \p{Alpha}0-9_\-.]/gu, '')
448
+ .replace(/[ _.]/g, '-')
449
+ .replace(/--+/g, '-')
450
+ )
451
+ return path.join(...segments)
452
+ }
453
+
454
+ function fixSectionLevels (sections, multipart) {
455
+ sections.forEach((sect) => {
456
+ const targetLevel = sect.getParent().getLevel() + 1
457
+ if (multipart ? sect.getLevel() > targetLevel : sect.getLevel() !== targetLevel) sect.level = targetLevel
458
+ if (sect.hasSections()) fixSectionLevels(sect.getSections(), multipart)
459
+ })
460
+ }
461
+
462
+ function rewriteStyleAttribute (block, lines, idx, idprefix, replacementStyle = '') {
463
+ let prevLine = lines[idx - 1]
464
+ const char0 = prevLine?.charAt()
465
+ if (char0) {
466
+ if (
467
+ (char0 === '.' && /^\.\.?[^ \t.]/.test(prevLine)) ||
468
+ (char0 === '[' &&
469
+ prevLine.charAt(1) === '[' &&
470
+ /^\[\[(?:|[\p{Alpha}_:][\p{Alpha}0-9_\-:.]*(?:, *.+)?)\]\]$/u.test(prevLine))
471
+ ) {
472
+ return rewriteStyleAttribute(block, lines, idx - 1, idprefix, replacementStyle)
473
+ }
474
+ }
475
+ let cellSpec
476
+ if (
477
+ char0 &&
478
+ (char0 === '[' || (block.getDocument().isNested() && (cellSpec = prevLine.match(/^([^[|]*)\| *(\[.+)/)))) &&
479
+ prevLine.charAt(prevLine.length - 1) === ']'
480
+ ) {
481
+ if (cellSpec) {
482
+ prevLine = cellSpec[2]
483
+ cellSpec = cellSpec[1]
484
+ }
485
+ let rawStyle
486
+ const commaIdx = prevLine.indexOf(',')
487
+ if (~commaIdx) {
488
+ rawStyle = prevLine.substr(1, commaIdx - 1)
489
+ if (~rawStyle.indexOf('=')) rawStyle = undefined
490
+ } else if (!~prevLine.indexOf('=')) {
491
+ rawStyle = prevLine.substr(1, prevLine.length - 2)
492
+ }
493
+ if (rawStyle) {
494
+ if (~rawStyle.indexOf('#')) {
495
+ prevLine = prevLine.replace(/#[^.%,\]]+/, `#${idprefix}${block.getId()}`)
496
+ if (replacementStyle) prevLine = prevLine.replace(/^[^#.%,\]]+/, `[${replacementStyle}`)
497
+ } else {
498
+ prevLine = `[${
499
+ replacementStyle ? rawStyle.replace(/^[^.%]*]/, replacementStyle) : rawStyle
500
+ }#${idprefix}${block.getId()}${prevLine.substr(rawStyle.length + 1)}`
501
+ }
502
+ } else {
503
+ prevLine = `[${replacementStyle}#${idprefix}${block.getId()}${rawStyle == null ? ',' : ''}${prevLine.substr(1)}`
504
+ }
505
+ if (cellSpec) prevLine = `${cellSpec}|${prevLine}`
506
+ lines[idx - 1] = prevLine
507
+ } else {
508
+ lines.splice(idx, 0, `[${replacementStyle}#${idprefix}${block.getId()}]`)
509
+ }
510
+ }
511
+
512
+ function isResourceSpec (str) {
513
+ return !(~str.indexOf(':') && (~str.indexOf('://') || (str.startsWith('data:') && ~str.indexOf(','))))
514
+ }
515
+
516
+ function getObjectId (obj) {
517
+ return global.Opal.uid()
518
+ }
519
+
520
+ module.exports = produceAggregateDocument
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ const filterComponentVersions = require('./filter-component-versions')
4
+ const produceAggregateDocument = require('./produce-aggregate-document')
5
+ const selectMutableAttributes = require('./select-mutable-attributes')
6
+
7
+ function produceAggregateDocuments (loadAsciiDoc, contentCatalog, assemblerConfig) {
8
+ const { insertStartPage, rootLevel, sectionMergeStrategy, asciidoc: assemblerAsciiDocConfig } = assemblerConfig
9
+ return filterComponentVersions(contentCatalog.getComponents(), assemblerConfig.componentVersions).reduce(
10
+ (accum, componentVersion) => {
11
+ const { name: componentName, version, title, navigation } = componentVersion
12
+ if (!navigation) return accum
13
+ const componentVersionAsciiDocConfig = getAsciiDocConfigWithAsciidoctorReducerExtension(componentVersion)
14
+ const mergedAsciiDocConfig = Object.assign({}, componentVersionAsciiDocConfig, {
15
+ attributes: Object.assign({}, componentVersionAsciiDocConfig.attributes, assemblerAsciiDocConfig.attributes),
16
+ })
17
+ const rootEntry = { content: title }
18
+ const startPage = contentCatalog.getComponentVersionStartPage(componentName, version)
19
+ let startPageUrl
20
+ if (startPage && insertStartPage) {
21
+ if (includedInNav(navigation, (startPageUrl = startPage.pub.url))) {
22
+ startPageUrl = undefined
23
+ } else {
24
+ Object.assign(rootEntry, { url: startPageUrl, urlType: 'internal' })
25
+ }
26
+ }
27
+ // Q: should we use an artificial page instead or as fallback?
28
+ const mutableAttributes = startPage
29
+ ? selectMutableAttributes(loadAsciiDoc, contentCatalog, startPage, mergedAsciiDocConfig)
30
+ : {}
31
+ return accum.concat(
32
+ prepareOutlines(navigation, rootEntry, rootLevel).map((outline) =>
33
+ produceAggregateDocument(
34
+ loadAsciiDoc,
35
+ contentCatalog,
36
+ componentVersion,
37
+ outline,
38
+ contentCatalog.getPages((page) => page.out),
39
+ mergedAsciiDocConfig,
40
+ mutableAttributes,
41
+ sectionMergeStrategy
42
+ )
43
+ )
44
+ )
45
+ },
46
+ []
47
+ )
48
+ }
49
+
50
+ function getAsciiDocConfigWithAsciidoctorReducerExtension (componentVersion) {
51
+ const asciidoctorReducerExtension = require('./asciidoctor/reducer-extension') // NOTE: must be required lazily
52
+ const asciidocConfig = componentVersion.asciidoc
53
+ const extensions = asciidocConfig.extensions || []
54
+ if (extensions.length) {
55
+ return Object.assign({}, asciidocConfig, {
56
+ extensions: extensions.reduce(
57
+ (accum, candidate) => {
58
+ if (candidate !== asciidoctorReducerExtension) accum.push(candidate)
59
+ return accum
60
+ },
61
+ [asciidoctorReducerExtension]
62
+ ),
63
+ })
64
+ }
65
+ return Object.assign({}, asciidocConfig, { extensions: [asciidoctorReducerExtension], sourcemap: true })
66
+ }
67
+
68
+ function includedInNav (items, url) {
69
+ return items.find((it) => it.url === url || includedInNav(it.items || [], url))
70
+ }
71
+
72
+ function prepareOutlines (navigation, rootEntry, rootLevel) {
73
+ if (rootLevel === 0 || navigation.length === 1) {
74
+ const navBranch =
75
+ navigation.length === 1
76
+ ? navigation[0]
77
+ : { items: navigation.reduce((navTree, it) => navTree.concat(it.content ? it : it.items), []) }
78
+ return [Object.assign(rootEntry, navBranch)]
79
+ }
80
+ return navigation.reduce((navTree, it) => navTree.concat(it.content ? it : it.items), [rootEntry])
81
+ }
82
+
83
+ module.exports = produceAggregateDocuments
@@ -0,0 +1,26 @@
1
+ 'use strict'
2
+
3
+ function selectMutableAttributes (loadAsciiDoc, contentCatalog, referencePage, asciidocConfig) {
4
+ const doc = loadAsciiDoc(
5
+ new referencePage.constructor(
6
+ Object.assign({}, referencePage, { contents: Buffer.alloc(0), mediaType: 'text/asciidoc' })
7
+ ),
8
+ contentCatalog,
9
+ asciidocConfig
10
+ )
11
+ const additionalMutableNames = [
12
+ 'page-component-name',
13
+ 'page-component-version',
14
+ 'page-version',
15
+ 'page-component-display-version',
16
+ 'page-component-title',
17
+ ]
18
+ // we could consider using an Asciidoctor extension here to grab attributes passed via the API instead
19
+ const immutableAttributeNames = doc.attribute_overrides.$keys()['$-'](additionalMutableNames)
20
+ return Object.entries(doc.getAttributes()).reduce((accum, [name, val]) => {
21
+ if (!immutableAttributeNames.includes(name)) accum[name] = val
22
+ return accum
23
+ }, {})
24
+ }
25
+
26
+ module.exports = selectMutableAttributes
@@ -0,0 +1,19 @@
1
+ 'use strict'
2
+
3
+ const { PassThrough } = require('stream')
4
+
5
+ // adapted from https://github.com/jpommerening/node-lazystream/blob/master/lib/lazystream.js | license: MIT
6
+ class LazyReadable extends PassThrough {
7
+ constructor (fn, options) {
8
+ super(options)
9
+ const _read = this._read
10
+ this._read = function () {
11
+ this._read = _read.bind(this)
12
+ fn.call(this, options).on('error', this.emit.bind(this, 'error')).pipe(this)
13
+ return this._read.apply(this, arguments)
14
+ }
15
+ this.emit('readable')
16
+ }
17
+ }
18
+
19
+ module.exports = LazyReadable
@@ -0,0 +1,57 @@
1
+ 'use strict'
2
+
3
+ class PromiseQueue {
4
+ #scheduler
5
+ #started
6
+ #pending
7
+ #rejection
8
+ #startTask
9
+ #trapRejection
10
+
11
+ constructor ({ concurrency = Infinity } = {}) {
12
+ this.concurrency = concurrency
13
+ this.#scheduler = Promise.resolve()
14
+ this.#started = []
15
+ this.#pending = []
16
+ this.#startTask = (task) => {
17
+ if (this.#rejection) return
18
+ if (this.concurrency === Infinity) {
19
+ this.#started.push(task().catch(this.#trapRejection))
20
+ } else {
21
+ let current
22
+ this.#pending.push(
23
+ (current = task()
24
+ .catch(this.#trapRejection)
25
+ .finally(() => this.#pending.splice(this.#pending.indexOf(current), 1)))
26
+ )
27
+ this.#started.push(current)
28
+ }
29
+ }
30
+ this.#trapRejection = (err) => (this.#rejection = err || new Error()) && undefined
31
+ }
32
+
33
+ add (tasks) {
34
+ if (!Array.isArray(tasks)) tasks = [tasks]
35
+ for (const task of tasks) {
36
+ if (this.#pending.length < this.concurrency) {
37
+ this.#startTask(task)
38
+ } else {
39
+ this.#scheduler = this.#scheduler.then(
40
+ () => this.#pending.length && Promise.race(this.#pending).then(() => this.#startTask(task))
41
+ )
42
+ }
43
+ }
44
+ return this
45
+ }
46
+
47
+ async toPromise () {
48
+ // what about promiseAll(), all(), or promise()?
49
+ await this.#scheduler
50
+ return Promise.all(this.#started).then((returnValues) => {
51
+ if (this.#rejection) throw this.#rejection
52
+ return returnValues
53
+ })
54
+ }
55
+ }
56
+
57
+ module.exports = PromiseQueue
@@ -0,0 +1,56 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const LazyReadable = require('./lazy-readable')
5
+ const { spawn } = require('child_process')
6
+
7
+ const IS_WIN = process.platform === 'win32'
8
+ const DBL_QUOTE_RX = /"/g
9
+
10
+ const shellEscape = IS_WIN
11
+ ? (val) => {
12
+ if (val.charAt() === '-') {
13
+ return val
14
+ } else if (~val.indexOf('"')) {
15
+ return ~val.indexOf(' ') ? `"${val.replace(DBL_QUOTE_RX, '"""')}"` : val.replace(DBL_QUOTE_RX, '""')
16
+ } else if (~val.indexOf(' ')) {
17
+ return `"${val}"`
18
+ }
19
+ return val
20
+ }
21
+ : (val) => val
22
+
23
+ async function runCommand (cmd, argv = [], opts = {}) {
24
+ if (!cmd) throw new TypeError('Command not specified')
25
+ const cmdv = cmd.split(' ').map(shellEscape)
26
+ const { input, output, implicitStdin, ...spawnOpts } = opts
27
+ if (input) input instanceof Buffer ? implicitStdin || argv.push('-') : argv.push(input)
28
+ if (IS_WIN) Object.assign(spawnOpts, { shell: true, windowsHide: true })
29
+ return new Promise((resolve, reject) => {
30
+ const stdout = []
31
+ const stderr = []
32
+ const ps = spawn(cmdv[0], [...cmdv.slice(1), ...argv.map(shellEscape)], spawnOpts)
33
+ ps.on('close', (code) => {
34
+ if (code === 0) {
35
+ if (stderr.length) process.stderr.write(stderr.join(''))
36
+ resolve(output ? new LazyReadable(() => fs.createReadStream(output)) : Buffer.from(stdout.join('')))
37
+ } else {
38
+ let msg = `Command failed: ${ps.spawnargs.join(' ')}`
39
+ if (stderr.length) msg += '\n' + stderr.join('')
40
+ reject(new Error(msg))
41
+ }
42
+ })
43
+ ps.on('error', (err) => reject(err.code === 'ENOENT' ? new Error(`Command not found: ${cmdv.join(' ')}`) : err))
44
+ ps.stdout.on('data', (data) => (output ? process.stdout.write(data) : stdout.push(data)))
45
+ ps.stderr.on('data', (data) => stderr.push(data))
46
+ try {
47
+ input instanceof Buffer ? ps.stdin.end(input) : ps.stdin.end()
48
+ } catch (err) {
49
+ reject(err)
50
+ } finally {
51
+ ps.stdin.end()
52
+ }
53
+ })
54
+ }
55
+
56
+ module.exports = runCommand
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@antora/assembler",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "An extension library for Antora that assembles content from multiple pages into a single AsciiDoc file to converted and publish.",
5
+ "license": "MPL-2.0",
6
+ "author": "OpenDevise Inc. (https://opendevise.com)",
7
+ "contributors": [
8
+ "Dan Allen <dan@opendevise.com>",
9
+ "Sarah White <sarah@opendevise.com>"
10
+ ],
11
+ "homepage": "https://antora.org",
12
+ "repository": "gitlab:antora/antora-assembler",
13
+ "bugs": {
14
+ "url": "https://gitlab.com/antora/antora-assembler/issues"
15
+ },
16
+ "scripts": {
17
+ "test": "_mocha test",
18
+ "prepublishOnly": "node $npm_config_local_prefix/npm/prepublishOnly.js",
19
+ "postpublish": "node $npm_config_local_prefix/npm/postpublish.js"
20
+ },
21
+ "main": "lib/index.js",
22
+ "exports": {
23
+ ".": "./lib/index.js",
24
+ "./asciidoctor/reducer-extension": "./lib/asciidoctor/reducer-extension.js",
25
+ "./filter-component-versions": "./lib/filter-component-versions.js",
26
+ "./load-config": "./lib/load-config.js",
27
+ "./produce-aggregate-document": "./lib/produce-aggregate-document.js",
28
+ "./select-mutable-attributes": "./lib/select-mutable-attributes.js"
29
+ },
30
+ "dependencies": {
31
+ "@antora/expand-path-helper": "~2.0",
32
+ "braces": "~3.0",
33
+ "camelcase-keys": "~7.0",
34
+ "picomatch": "~2.3",
35
+ "vinyl": "~2.2",
36
+ "js-yaml": "~4.1"
37
+ },
38
+ "devDependencies": {
39
+ "@antora/asciidoc-loader": "3.0.1",
40
+ "@antora/site-publisher": "3.0.1"
41
+ },
42
+ "engines": {
43
+ "node": ">=16.0.0"
44
+ },
45
+ "files": [
46
+ "lib/"
47
+ ],
48
+ "keywords": [
49
+ "antora",
50
+ "antora-extension",
51
+ "asciidoc",
52
+ "documentation"
53
+ ],
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }