@baiyibai-antora/adf-extension 1.0.0-beta.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.adoc ADDED
@@ -0,0 +1,5 @@
1
+ = @baiyibai-antora/adf-extension
2
+
3
+ Antora extension that converts every publishable page of an Antora site to Confluence-native ADF JSON during a normal site build, plus a `manifest.json` publish contract for `@baiyibai-antora/confluence-publisher`.
4
+
5
+ Full documentation lives in the repository https://gitlab.com/baiyibai-antora/confluora/-/blob/main/README.adoc[README].
@@ -0,0 +1,303 @@
1
+ 'use strict'
2
+
3
+ const { createHash } = require('node:crypto')
4
+ const fsp = require('node:fs/promises')
5
+ const ospath = require('node:path')
6
+ const { posix: path } = require('node:path')
7
+ const { convert } = require('@baiyibai-antora/asciidoc-adf-converter')
8
+ const extractNav = require('./nav')
9
+
10
+ const pageKey = (src) => [src.component, src.version, src.module, src.relative].join(':')
11
+ const md5 = (buf) => createHash('md5').update(buf).digest('hex')
12
+ const sha1 = (str) => createHash('sha1').update(str).digest('hex')
13
+
14
+ // Confluence page titles are plain text; doctitles and nav link texts can
15
+ // carry inline markup, entities, and (when metadata was extracted without
16
+ // extensions loaded) an unconsumed leading macro escape.
17
+ const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'", '&nbsp;': ' ', '&hellip;': '…' }
18
+ const plainText = (html) =>
19
+ html &&
20
+ html
21
+ .replace(/<[^>]+>/g, '')
22
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
23
+ .replace(/&#(\d+);/g, (_, num) => String.fromCodePoint(Number(num)))
24
+ .replace(/&[a-z]+;/gi, (ref) => ENTITIES[ref] || ref)
25
+ .replace(/[​-‍]/g, '') // zero-width chars from AsciiDoc word joiners
26
+ .replace(/^\\(?=[a-z])/i, '')
27
+ .trim()
28
+
29
+ // HTML-pipeline extensions (e.g. list-of-site) leave placeholder divs in the
30
+ // AsciiDoc-derived pass blocks and inject the real content into the page's
31
+ // converted HTML at documentsConverted — which has already run by the time we
32
+ // re-load the source. Splice the generated markup back into the placeholders.
33
+ const PLACEHOLDER_RX = /<div\s+class="list-of-site-placeholder[^"]*"\s+data-element="([^"]+)">\s*<\/div>/g
34
+ function createPassTransform (page) {
35
+ const html = page.contents && page.contents.toString()
36
+ if (!html || !html.includes('list-of')) return null
37
+ const used = {}
38
+ return (passHtml) =>
39
+ passHtml.replace(PLACEHOLDER_RX, (match, element) => {
40
+ const generated =
41
+ html.match(new RegExp(`<ul class="list-of-${element}[^"]*">[\\s\\S]*?</ul>`, 'g')) || []
42
+ const idx = (used[element] = (used[element] ?? -1) + 1)
43
+ return generated[idx] || ''
44
+ })
45
+ }
46
+
47
+ // Never surface a raw source path as a page title: fall back to the nav link
48
+ // text, then to a titleized filename stem.
49
+ function pageTitle (page, key, navTitles) {
50
+ const doctitle = plainText(page.title)
51
+ if (doctitle) return doctitle
52
+ const navTitle = plainText(navTitles.get(key))
53
+ if (navTitle) return navTitle
54
+ const stem = page.src.relative.replace(/\.adoc$/, '').split('/').pop().replace(/[-_]+/g, ' ')
55
+ return stem.charAt(0).toUpperCase() + stem.slice(1)
56
+ }
57
+
58
+ module.exports = async function convertPages ({
59
+ playbook,
60
+ contentCatalog,
61
+ navigationCatalog,
62
+ loadAsciiDoc,
63
+ config,
64
+ logger,
65
+ }) {
66
+ const outputDir = ospath.resolve(playbook.dir || '.', config.outputDir || path.join('build', 'adf'))
67
+ const included = buildVersionFilter(contentCatalog, config)
68
+
69
+ const asciidocConfigs = new Map()
70
+ for (const component of contentCatalog.getComponents()) {
71
+ for (const { version, asciidoc } of component.versions) {
72
+ asciidocConfigs.set(`${component.name}@${version}`, asciidoc)
73
+ }
74
+ }
75
+
76
+ const pages = contentCatalog.getPages(
77
+ (page) => page.out && page.src.contents && included(page.src, pageKey(page.src))
78
+ )
79
+ const urlIndex = new Map(pages.map((page) => [page.pub.url, page]))
80
+ const { nav, parents, order, navTitles } = extractNav(contentCatalog, navigationCatalog, urlIndex, pageKey, included)
81
+
82
+ // Nav parity: pages absent from the Antora navigation are orphans — they
83
+ // never appear in the site's sidebar, so by default they don't publish
84
+ // either (links to them fall back to the static site). Component start
85
+ // pages always publish; set orphans: include to publish everything.
86
+ const startKeys = new Set(nav.map((entry) => entry.startKey).filter(Boolean))
87
+ let publishable = pages
88
+ if (config.orphans !== 'include') {
89
+ publishable = pages.filter((page) => {
90
+ const key = pageKey(page.src)
91
+ if (order.has(key) || startKeys.has(key)) return true
92
+ logger.info(`${key}: not in the Antora navigation; skipping (set orphans: include to publish)`)
93
+ return false
94
+ })
95
+ }
96
+
97
+ const writtenAttachments = new Map() // output-relative path -> contents
98
+ const manifestPages = []
99
+
100
+ for (const page of publishable) {
101
+ const key = pageKey(page.src)
102
+ const asciidocConfig = asciidocConfigs.get(`${page.src.component}@${page.src.version}`) || {}
103
+ const doc = loadAsciiDoc(
104
+ {
105
+ contents: page.src.contents,
106
+ src: page.src,
107
+ pub: page.pub,
108
+ out: page.out,
109
+ path: page.path,
110
+ dirname: ospath.dirname(page.path),
111
+ },
112
+ contentCatalog,
113
+ // Root-relative refs map directly onto the pub.url page index.
114
+ Object.assign({}, asciidocConfig, { relativizeResourceRefs: false })
115
+ )
116
+
117
+ const attachments = []
118
+ const attachmentsBySource = new Map()
119
+ const xrefs = []
120
+
121
+ const result = convert(doc, {
122
+ transformPassContent: createPassTransform(page),
123
+ resolveImage: (target) => {
124
+ if (/^(https?|data):/.test(target)) return { url: target }
125
+ const image = contentCatalog.resolveResource(target, page.src, 'image', ['image'])
126
+ if (!image || !image.contents) return null
127
+ const sourceKey = pageKey(image.src)
128
+ let entry = attachmentsBySource.get(sourceKey)
129
+ if (!entry) {
130
+ const hash = md5(image.contents)
131
+ const relPath = path.join(
132
+ 'attachments',
133
+ image.src.component,
134
+ image.src.version,
135
+ image.src.module,
136
+ image.src.relative
137
+ )
138
+ writtenAttachments.set(relPath, image.contents)
139
+ entry = {
140
+ placeholderId: hash,
141
+ path: relPath,
142
+ filename: path.basename(image.src.relative),
143
+ md5: hash,
144
+ }
145
+ attachmentsBySource.set(sourceKey, entry)
146
+ attachments.push(entry)
147
+ }
148
+ return { id: entry.placeholderId, collection: '' }
149
+ },
150
+ resolveXref: (href, attribs = {}) => {
151
+ if (!href) return { href }
152
+ // Extension-generated HTML (e.g. list-of-site) links relative to the
153
+ // page; normalize to root-relative so the publisher can map it.
154
+ let resolved = href
155
+ if (href.charAt(0) !== '/' && !/^[a-z][a-z0-9+.-]*:/i.test(href) && href.charAt(0) !== '#') {
156
+ resolved = path.resolve(path.dirname(page.pub.url), href)
157
+ }
158
+ if (resolved.charAt(0) === '/') {
159
+ const [urlPath, fragment] = resolved.split('#')
160
+ const targetPage = urlIndex.get(urlPath)
161
+ const xref = { href: resolved, targetKey: targetPage ? pageKey(targetPage.src) : null }
162
+ if (fragment) xref.fragment = fragment
163
+ if (!targetPage && /(^| )xref( |$)/.test(attribs.class || '')) {
164
+ logger.warn(`${key}: xref "${resolved}" does not resolve to a published page`)
165
+ }
166
+ xrefs.push(xref)
167
+ return { href: resolved }
168
+ }
169
+ return { href }
170
+ },
171
+ })
172
+
173
+ for (const warning of result.warnings) logger.warn(`${key}: ${warning}`)
174
+
175
+ const adfJson = JSON.stringify(result.adf, null, 2) + '\n'
176
+ const adfPath = path.join(
177
+ 'pages',
178
+ page.src.component,
179
+ page.src.version,
180
+ page.src.module,
181
+ page.src.relative.replace(/\.adoc$/, '.adf.json')
182
+ )
183
+ const outFile = ospath.join(outputDir, ...adfPath.split('/'))
184
+ await fsp.mkdir(ospath.dirname(outFile), { recursive: true })
185
+ await fsp.writeFile(outFile, adfJson)
186
+
187
+ manifestPages.push({
188
+ key,
189
+ title: pageTitle(page, key, navTitles),
190
+ adfPath,
191
+ url: page.pub.url,
192
+ // The page's source in its git repository, as a VIEW url — Antora's
193
+ // editUrl points at the edit form, which forces a forge login; the
194
+ // blob/view page is public and still offers Edit to those who can.
195
+ // Local-worktree builds yield file:// urls — never publishable.
196
+ sourceUrl:
197
+ page.src.editUrl && /^https?:/.test(page.src.editUrl)
198
+ ? page.src.editUrl.replace(/\/edit\//, '/blob/')
199
+ : null,
200
+ navOrder: order.has(key) ? order.get(key) : null,
201
+ parentKey: parents.get(key) || null,
202
+ xrefs,
203
+ attachments,
204
+ anchors: result.anchors,
205
+ contentHash: sha1(adfJson),
206
+ })
207
+ }
208
+
209
+ for (const [relPath, contents] of writtenAttachments) {
210
+ const outFile = ospath.join(outputDir, ...relPath.split('/'))
211
+ await fsp.mkdir(ospath.dirname(outFile), { recursive: true })
212
+ await fsp.writeFile(outFile, contents)
213
+ }
214
+
215
+ // Stable page order: nav pages first (nav order), then the rest by key.
216
+ manifestPages.sort((a, b) => {
217
+ if (a.navOrder != null && b.navOrder != null) return a.navOrder - b.navOrder
218
+ if (a.navOrder != null) return -1
219
+ if (b.navOrder != null) return 1
220
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0
221
+ })
222
+
223
+ logger.info(`wrote ${manifestPages.length} ADF page(s) and ${writtenAttachments.size} attachment(s) to ${outputDir}`)
224
+ // The manifest itself is written by finalizeManifest (on beforePublish), so
225
+ // assembler-produced exports (family "export") are collected regardless of
226
+ // extension registration order.
227
+ return { outputDir, manifestPages, nav, included }
228
+ }
229
+
230
+ // Component/version scoping: `components` limits to the listed component
231
+ // names; `versions: latest` keeps only each component's latest version;
232
+ // `exclude` is a list of globs matched against component names and full page
233
+ // keys (component:version:module:relative).
234
+ function buildVersionFilter (contentCatalog, config) {
235
+ const components = Array.isArray(config.components) && config.components.length ? config.components : null
236
+ const latestOnly = config.versions === 'latest'
237
+ const latest = new Map(contentCatalog.getComponents().map((c) => [c.name, c.latest && c.latest.version]))
238
+ const excludes = (Array.isArray(config.exclude) ? config.exclude : []).map(globToRx)
239
+ return (src, key) => {
240
+ if (components && !components.includes(src.component)) return false
241
+ if (latestOnly && latest.get(src.component) !== src.version) return false
242
+ for (const rx of excludes) {
243
+ if (rx.test(src.component)) return false
244
+ if (key && rx.test(key)) return false
245
+ }
246
+ return true
247
+ }
248
+ }
249
+
250
+ function globToRx (pattern) {
251
+ const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
252
+ return new RegExp(`^${escaped}$`)
253
+ }
254
+
255
+ /**
256
+ * beforePublish hook: collect assembler exports (family "export", e.g. PDFs)
257
+ * scoped to the included component versions, copy their bytes, and write the
258
+ * final manifest.json.
259
+ */
260
+ async function finalizeManifest ({ playbook, contentCatalog, state, logger }) {
261
+ const { outputDir, manifestPages, nav, included } = state
262
+ const exports_ = []
263
+ for (const file of contentCatalog.getFiles()) {
264
+ if (!file.src || file.src.family !== 'export' || !file.out || !file.contents) continue
265
+ if (!included(file.src)) continue
266
+ // Assembler exports carry their bytes as a lazy stream, not a Buffer.
267
+ const contents = Buffer.isBuffer(file.contents) ? file.contents : await bufferStream(file.contents)
268
+ // A generic "index.pdf" makes a poor download; qualify it.
269
+ let filename = path.basename(file.out.path)
270
+ if (filename.startsWith('index.')) filename = `${file.src.component}-${file.src.version}${filename.slice(5)}`
271
+ const relPath = path.join('exports', file.src.component, file.src.version, filename)
272
+ const outFile = ospath.join(outputDir, ...relPath.split('/'))
273
+ await fsp.mkdir(ospath.dirname(outFile), { recursive: true })
274
+ await fsp.writeFile(outFile, contents)
275
+ exports_.push({
276
+ component: file.src.component,
277
+ version: file.src.version,
278
+ filename,
279
+ path: relPath,
280
+ md5: md5(contents),
281
+ })
282
+ logger.info(`collected export ${filename} (${file.src.component}@${file.src.version})`)
283
+ }
284
+
285
+ const manifest = {
286
+ version: 1,
287
+ site: { title: playbook.site.title || null, url: playbook.site.url || null },
288
+ pages: manifestPages,
289
+ nav,
290
+ exports: exports_,
291
+ }
292
+ await fsp.writeFile(ospath.join(outputDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
293
+ logger.info(`wrote manifest: ${manifestPages.length} page(s), ${exports_.length} export(s)`)
294
+ return manifest
295
+ }
296
+
297
+ async function bufferStream (stream) {
298
+ const chunks = []
299
+ for await (const chunk of stream) chunks.push(chunk)
300
+ return Buffer.concat(chunks)
301
+ }
302
+
303
+ module.exports.finalizeManifest = finalizeManifest
package/lib/index.js ADDED
@@ -0,0 +1,35 @@
1
+ 'use strict'
2
+
3
+ const convertPages = require('./convert-pages')
4
+ const { finalizeManifest } = require('./convert-pages')
5
+ const loadConfig = require('./load-config')
6
+
7
+ module.exports.register = function ({ config = {} }) {
8
+ const logger = this.getLogger('adf-extension')
9
+ let state
10
+
11
+ // The AsciiDoc source of each page must survive HTML conversion so it can
12
+ // be re-loaded and walked for ADF output.
13
+ this.once('beforeProcess', ({ siteAsciiDocConfig }) => {
14
+ siteAsciiDocConfig.keepSource = true
15
+ })
16
+
17
+ this.once('navigationBuilt', async ({ playbook, contentCatalog, navigationCatalog }) => {
18
+ const loadAsciiDoc = this.require('@antora/asciidoc-loader')
19
+ const resolvedConfig = loadConfig(playbook, config, logger)
20
+ state = await convertPages({
21
+ playbook,
22
+ contentCatalog,
23
+ navigationCatalog,
24
+ loadAsciiDoc,
25
+ config: resolvedConfig,
26
+ logger,
27
+ })
28
+ })
29
+
30
+ // Deferred to beforePublish so assembler exports (PDFs etc., family
31
+ // "export") exist in the catalog no matter the extension order.
32
+ this.once('beforePublish', async ({ playbook, contentCatalog }) => {
33
+ if (state) await finalizeManifest({ playbook, contentCatalog, state, logger })
34
+ })
35
+ }
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const ospath = require('node:path')
5
+ const yaml = require('js-yaml')
6
+
7
+ // Confluence-specific site configuration lives in a YAML file next to the
8
+ // playbook (default: confluence.yml, override with the config_file key), so
9
+ // publishing concerns stay out of the Antora playbook proper:
10
+ //
11
+ // nav_exclude: # not published to Confluence (globs vs component
12
+ // - my-presentation # names / page keys); the sources stay in the
13
+ // versions: latest # Antora catalog, so includes and xrefs resolve —
14
+ // components: [docs] # links to nav-excluded pages fall back to the
15
+ // output_dir: ./build/adf # static site url. `exclude` is an alias.
16
+ //
17
+ // Keys given directly on the extension entry in the playbook win.
18
+
19
+ function loadConfig (playbook, config, logger) {
20
+ const filename = config.configFile || 'confluence.yml'
21
+ const configPath = ospath.resolve(playbook.dir || '.', filename)
22
+ if (!fs.existsSync(configPath)) {
23
+ if (config.configFile) logger.warn(`confluence config file not found: ${configPath}`)
24
+ return config
25
+ }
26
+ let fileConfig
27
+ try {
28
+ fileConfig = yaml.load(fs.readFileSync(configPath, 'utf8')) || {}
29
+ } catch (err) {
30
+ logger.warn(`could not parse ${configPath}: ${err.message}`)
31
+ return config
32
+ }
33
+ const normalized = {}
34
+ if (fileConfig.exclude || fileConfig.nav_exclude) {
35
+ normalized.exclude = [...(fileConfig.exclude || []), ...(fileConfig.nav_exclude || [])]
36
+ }
37
+ if (fileConfig.versions) normalized.versions = fileConfig.versions
38
+ if (fileConfig.orphans) normalized.orphans = fileConfig.orphans
39
+ if (fileConfig.components) normalized.components = fileConfig.components
40
+ if (fileConfig.output_dir) normalized.outputDir = fileConfig.output_dir
41
+ logger.info(`loaded confluence config from ${configPath}`)
42
+ return { ...normalized, ...config }
43
+ }
44
+
45
+ module.exports = loadConfig
package/lib/nav.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict'
2
+
3
+ // Extracts the navigation tree per component version, resolving internal nav
4
+ // URLs back to page keys. Returns the manifest nav plus page parent/order
5
+ // maps derived from a depth-first walk.
6
+
7
+ module.exports = function extractNav (contentCatalog, navigationCatalog, urlIndex, pageKey, included = () => true) {
8
+ const nav = []
9
+ const parents = new Map()
10
+ const order = new Map()
11
+ const navTitles = new Map()
12
+ let counter = 0
13
+
14
+ const visit = (item, parentKey) => {
15
+ let key = null
16
+ if (item.urlType === 'internal' && item.url) {
17
+ const page = urlIndex.get(item.url.split('#')[0])
18
+ if (page) {
19
+ key = pageKey(page.src)
20
+ if (!parents.has(key)) {
21
+ parents.set(key, parentKey)
22
+ order.set(key, counter++)
23
+ if (item.content) navTitles.set(key, item.content)
24
+ }
25
+ }
26
+ }
27
+ const entry = { title: item.content || null, key }
28
+ const children = (item.items || []).map((child) => visit(child, key || parentKey))
29
+ if (children.length) entry.children = children
30
+ return entry
31
+ }
32
+
33
+ for (const component of contentCatalog.getComponents()) {
34
+ for (const { version, displayVersion, title, url } of component.versions) {
35
+ if (!included({ component: component.name, version })) continue
36
+ const menus = navigationCatalog.getNavigation(component.name, version) || []
37
+ const entries = []
38
+ for (const menu of menus) {
39
+ const rootEntry = menu.url || menu.content ? visit(menu, null) : null
40
+ if (rootEntry) {
41
+ entries.push(rootEntry)
42
+ } else {
43
+ for (const item of menu.items || []) entries.push(visit(item, null))
44
+ }
45
+ }
46
+ // The component version's start page anchors its subtree in Confluence.
47
+ const startPage = url && urlIndex.get(url.split('#')[0])
48
+ const startKey = startPage ? pageKey(startPage.src) : null
49
+ nav.push({ component: component.name, version, displayVersion, title, startKey, entries })
50
+ }
51
+ }
52
+
53
+ return { nav, parents, order, navTitles }
54
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@baiyibai-antora/adf-extension",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Antora extension that emits Confluence ADF JSON and a publish manifest for every page during a site build",
5
+ "license": "MIT",
6
+ "author": "baiyibai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://gitlab.com/baiyibai-antora/confluora.git",
10
+ "directory": "packages/adf-extension"
11
+ },
12
+ "type": "commonjs",
13
+ "main": "lib/index.js",
14
+ "files": [
15
+ "lib"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test test/*-test.js"
19
+ },
20
+ "dependencies": {
21
+ "@baiyibai-antora/asciidoc-adf-converter": "^1.0.0-beta.1",
22
+ "js-yaml": "^4.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "antora": "^3.1.10"
26
+ },
27
+ "keywords": [
28
+ "confluora",
29
+ "antora",
30
+ "antora-extension",
31
+ "adf",
32
+ "confluence"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "homepage": "https://gitlab.com/baiyibai-antora/confluora",
41
+ "bugs": "https://gitlab.com/baiyibai-antora/confluora/-/issues"
42
+ }