@baiyibai-antora/confluence-publisher 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/confluence-publisher
2
+
3
+ Publishes an Antora site's ADF manifest (produced by `@baiyibai-antora/adf-extension`) to a Confluence Cloud space: page tree, attachments, link rewriting, state tracking, and overwrite guard. Zero runtime dependencies.
4
+
5
+ Full documentation lives in the repository https://gitlab.com/baiyibai-antora/confluora/-/blob/main/README.adoc[README].
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const { parseArgs } = require('node:util')
5
+ const { publish } = require('../lib')
6
+ const ConfluenceAdapter = require('../lib/client/confluence-adapter')
7
+ const resolveSettings = require('../lib/settings')
8
+
9
+ const HELP = `Usage: confluence-publish [options] <manifest-dir>
10
+
11
+ Publishes an ADF manifest directory (produced by @baiyibai-antora/adf-extension,
12
+ default ./build/adf) to a Confluence Cloud space.
13
+
14
+ Options:
15
+ --space <key> Confluence space key (or CONFLUENCE_SPACE_KEY)
16
+ --parent <pageId> page to anchor the tree under (default: space homepage)
17
+ --base-url <url> e.g. https://your-site.atlassian.net (or CONFLUENCE_BASE_URL)
18
+ --email <email> Atlassian account email (or ATLASSIAN_EMAIL)
19
+ --dry-run print the reconcile plan without writing anything
20
+ --force overwrite pages that were edited in Confluence
21
+ --prune archive pages that are no longer in the manifest
22
+ --no-banner omit the "published automatically, do not edit" panel
23
+ --banner-text <t> override the banner wording
24
+ --help show this help
25
+
26
+ Credentials: set ATLASSIAN_API_TOKEN in the environment (never a flag).
27
+ `
28
+
29
+ async function main () {
30
+ const { values: flags, positionals } = parseArgs({
31
+ options: {
32
+ space: { type: 'string' },
33
+ parent: { type: 'string' },
34
+ 'base-url': { type: 'string' },
35
+ email: { type: 'string' },
36
+ 'dry-run': { type: 'boolean', default: false },
37
+ force: { type: 'boolean', default: false },
38
+ prune: { type: 'boolean', default: false },
39
+ 'no-banner': { type: 'boolean', default: false },
40
+ 'banner-text': { type: 'string' },
41
+ help: { type: 'boolean', default: false },
42
+ },
43
+ allowPositionals: true,
44
+ })
45
+ if (flags.help) {
46
+ process.stdout.write(HELP)
47
+ return
48
+ }
49
+ const manifestDir = positionals[0] || 'build/adf'
50
+ const settings = resolveSettings({
51
+ space: flags.space,
52
+ parent: flags.parent,
53
+ baseUrl: flags['base-url'],
54
+ email: flags.email,
55
+ })
56
+ const port = new ConfluenceAdapter(settings)
57
+ const logger = {
58
+ info: (msg) => console.log(`[info] ${msg}`),
59
+ warn: (msg) => console.warn(`[warn] ${msg}`),
60
+ }
61
+ const { plan } = await publish(manifestDir, port, {
62
+ rootPageId: settings.rootPageId,
63
+ dryRun: flags['dry-run'],
64
+ force: flags.force,
65
+ prune: flags.prune,
66
+ banner: !flags['no-banner'],
67
+ bannerText: flags['banner-text'],
68
+ logger,
69
+ })
70
+
71
+ const counts = {}
72
+ for (const entry of plan) counts[entry.action] = (counts[entry.action] || 0) + 1
73
+ if (flags['dry-run']) {
74
+ console.log('\nDry run — no changes were made. Plan:')
75
+ for (const entry of plan) {
76
+ console.log(` ${entry.action.padEnd(8)} ${entry.key}${entry.reason ? ` (${entry.reason})` : ''}`)
77
+ }
78
+ }
79
+ console.log(
80
+ '\n' +
81
+ Object.entries(counts)
82
+ .map(([action, n]) => `${action}: ${n}`)
83
+ .join(', ')
84
+ )
85
+ if (plan.some((entry) => entry.action === 'blocked')) process.exitCode = 2
86
+ }
87
+
88
+ main().catch((err) => {
89
+ console.error(`[error] ${err.message}`)
90
+ process.exitCode = 1
91
+ })
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+
3
+ // MD5-incremental attachment sync (modelled on @markdown-confluence/lib's
4
+ // Attachments.ts, Apache-2.0, which proved unusable as a dependency): the MD5
5
+ // of the file travels in the attachment comment; a matching comment on an
6
+ // existing attachment of the same name means the upload can be skipped.
7
+
8
+ async function syncAttachments (port, pageId, attachments, readFile, logger) {
9
+ if (!attachments.length) return new Map()
10
+ const existing = new Map()
11
+ for (const att of await port.getAttachments(pageId)) {
12
+ existing.set(att.title, att)
13
+ }
14
+ const mediaIds = new Map() // placeholderId -> { id: fileId, collection }
15
+ for (const att of attachments) {
16
+ const current = existing.get(att.filename)
17
+ if (current && current.comment === att.md5 && current.fileId) {
18
+ mediaIds.set(att.placeholderId, { id: current.fileId, collection: `contentId-${pageId}` })
19
+ continue
20
+ }
21
+ const buffer = await readFile(att.path)
22
+ const uploaded = await port.uploadAttachment(pageId, att.filename, buffer, att.md5)
23
+ logger.info(`uploaded attachment ${att.filename} to page ${pageId}`)
24
+ mediaIds.set(att.placeholderId, { id: uploaded.fileId, collection: uploaded.collection })
25
+ }
26
+ return mediaIds
27
+ }
28
+
29
+ module.exports = syncAttachments
package/lib/banner.js ADDED
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ // The "bumper": a note panel prepended to every published body declaring the
4
+ // content machine-published and not intended for editing (same role as
5
+ // Captain's show-banner macro). On by default; text overridable.
6
+
7
+ const DEFAULT_TEXT =
8
+ 'This page is published automatically from an Antora documentation site and is not intended to be ' +
9
+ 'edited here. Changes made in Confluence will be skipped or overwritten by the next publish. ' +
10
+ 'Comments are preserved.'
11
+
12
+ function bannerPanel ({ text = DEFAULT_TEXT, sourceUrl = null } = {}) {
13
+ const content = [{ type: 'text', text: text + (sourceUrl ? ' ' : '') }]
14
+ if (sourceUrl) {
15
+ content.push({
16
+ type: 'text',
17
+ text: 'View/Edit the source.',
18
+ marks: [{ type: 'link', attrs: { href: sourceUrl } }],
19
+ })
20
+ }
21
+ return { type: 'panel', attrs: { panelType: 'note' }, content: [{ type: 'paragraph', content }] }
22
+ }
23
+
24
+ module.exports = { bannerPanel, DEFAULT_TEXT }
@@ -0,0 +1,151 @@
1
+ 'use strict'
2
+
3
+ // Confluence Cloud adapter implementing the port (see ./port.js) over the v2
4
+ // REST API with Node's built-in fetch. Attachment upload uses the v1
5
+ // endpoint — v2 offers no multipart upload. `fetch` is injectable for tests.
6
+
7
+ class ConfluenceAdapter {
8
+ constructor ({ baseUrl, email, apiToken, spaceKey, fetch: fetchImpl }) {
9
+ if (!baseUrl || !email || !apiToken || !spaceKey) {
10
+ throw new Error('confluence adapter requires baseUrl, email, apiToken, and spaceKey')
11
+ }
12
+ this.baseUrl = baseUrl.replace(/\/$/, '')
13
+ this.spaceKey = spaceKey
14
+ this.fetch = fetchImpl || globalThis.fetch
15
+ this.authHeader = 'Basic ' + Buffer.from(`${email}:${apiToken}`).toString('base64')
16
+ this.spaceId = undefined
17
+ this.webUrlCache = new Map()
18
+ }
19
+
20
+ async request (method, path, { body, headers, form } = {}) {
21
+ const opts = { method, headers: { Authorization: this.authHeader, Accept: 'application/json', ...headers } }
22
+ if (form) {
23
+ opts.body = form
24
+ opts.headers['X-Atlassian-Token'] = 'nocheck'
25
+ } else if (body !== undefined) {
26
+ opts.headers['Content-Type'] = 'application/json'
27
+ opts.body = JSON.stringify(body)
28
+ }
29
+ const response = await this.fetch(`${this.baseUrl}${path}`, opts)
30
+ if (response.status === 404) return null
31
+ if (!response.ok) {
32
+ const detail = await response.text().catch(() => '')
33
+ throw new Error(`Confluence ${method} ${path} failed: ${response.status} ${detail.slice(0, 500)}`)
34
+ }
35
+ if (response.status === 204) return {}
36
+ return response.json()
37
+ }
38
+
39
+ async getSpace () {
40
+ if (this.spaceId === undefined) {
41
+ const result = await this.request('GET', `/wiki/api/v2/spaces?keys=${encodeURIComponent(this.spaceKey)}`)
42
+ const space = result && result.results && result.results[0]
43
+ if (!space) throw new Error(`space "${this.spaceKey}" not found`)
44
+ this.space = space
45
+ this.spaceId = space.id
46
+ }
47
+ return this.space
48
+ }
49
+
50
+ async getSpaceHomeId () {
51
+ const space = await this.getSpace()
52
+ if (!space.homepageId) throw new Error(`space "${this.spaceKey}" has no homepage to anchor the page tree`)
53
+ return String(space.homepageId)
54
+ }
55
+
56
+ async getPage (pageId) {
57
+ const page = await this.request('GET', `/wiki/api/v2/pages/${pageId}`)
58
+ if (!page) return null
59
+ if (page._links && page._links.webui) {
60
+ this.webUrlCache.set(String(page.id), this.wikiUrl(page._links.webui))
61
+ }
62
+ return {
63
+ id: String(page.id),
64
+ title: page.title,
65
+ parentId: page.parentId == null ? null : String(page.parentId),
66
+ version: { number: page.version.number },
67
+ }
68
+ }
69
+
70
+ async createPage ({ title, parentId, body }) {
71
+ const space = await this.getSpace()
72
+ const page = await this.request('POST', '/wiki/api/v2/pages', {
73
+ body: { spaceId: space.id, status: 'current', title, parentId, body },
74
+ })
75
+ if (page._links && page._links.webui) this.webUrlCache.set(String(page.id), this.wikiUrl(page._links.webui))
76
+ return { id: String(page.id), version: { number: page.version.number } }
77
+ }
78
+
79
+ async updatePage ({ id, title, parentId, version, body }) {
80
+ const payload = { id, status: 'current', title, body, version: { number: version } }
81
+ if (parentId != null) payload.parentId = parentId
82
+ const page = await this.request('PUT', `/wiki/api/v2/pages/${id}`, { body: payload })
83
+ return { id: String(page.id), version: { number: page.version.number } }
84
+ }
85
+
86
+ async archivePage (pageId) {
87
+ // v2 delete moves the page to the trash (purge is a separate call).
88
+ await this.request('DELETE', `/wiki/api/v2/pages/${pageId}`)
89
+ }
90
+
91
+ async getContentProperty (pageId, key) {
92
+ const result = await this.request(
93
+ 'GET',
94
+ `/wiki/api/v2/pages/${pageId}/properties?key=${encodeURIComponent(key)}`
95
+ )
96
+ const prop = result && result.results && result.results[0]
97
+ if (!prop) return null
98
+ return { id: String(prop.id), key: prop.key, value: prop.value, version: { number: prop.version.number } }
99
+ }
100
+
101
+ async setContentProperty (pageId, key, value, existing) {
102
+ if (existing) {
103
+ await this.request('PUT', `/wiki/api/v2/pages/${pageId}/properties/${existing.id}`, {
104
+ body: { key, value, version: { number: existing.version.number + 1 } },
105
+ })
106
+ } else {
107
+ await this.request('POST', `/wiki/api/v2/pages/${pageId}/properties`, { body: { key, value } })
108
+ }
109
+ }
110
+
111
+ async getAttachments (pageId) {
112
+ const result = await this.request('GET', `/wiki/api/v2/pages/${pageId}/attachments?limit=250`)
113
+ return ((result && result.results) || []).map((att) => ({
114
+ id: String(att.id),
115
+ title: att.title,
116
+ fileId: att.fileId,
117
+ comment: att.comment || '',
118
+ pageId: String(pageId),
119
+ }))
120
+ }
121
+
122
+ async uploadAttachment (pageId, filename, buffer, comment) {
123
+ const form = new FormData()
124
+ form.append('file', new Blob([buffer]), filename)
125
+ form.append('comment', comment)
126
+ form.append('minorEdit', 'true')
127
+ const result = await this.request('PUT', `/wiki/rest/api/content/${pageId}/child/attachment`, { form })
128
+ const uploaded = result && result.results && result.results[0]
129
+ if (!uploaded) throw new Error(`attachment upload of ${filename} to page ${pageId} returned no result`)
130
+ return {
131
+ fileId: uploaded.extensions && uploaded.extensions.fileId,
132
+ collection: `contentId-${pageId}`,
133
+ }
134
+ }
135
+
136
+ attachmentDownloadUrl (pageId, filename) {
137
+ return `${this.baseUrl}/wiki/download/attachments/${pageId}/${encodeURIComponent(filename)}`
138
+ }
139
+
140
+ async getPageWebUrl (pageId) {
141
+ const id = String(pageId)
142
+ if (!this.webUrlCache.has(id)) await this.getPage(id)
143
+ return this.webUrlCache.get(id) || null
144
+ }
145
+
146
+ wikiUrl (webui) {
147
+ return `${this.baseUrl}/wiki${webui}`
148
+ }
149
+ }
150
+
151
+ module.exports = ConfluenceAdapter
@@ -0,0 +1,23 @@
1
+ 'use strict'
2
+
3
+ // The narrow Confluence interface the publisher is written against. The real
4
+ // implementation is ./confluence-adapter (v2 REST over fetch); tests use an
5
+ // in-memory fake with the same surface.
6
+ //
7
+ // interface ConfluencePort {
8
+ // getSpaceHomeId () -> pageId // root of the configured space
9
+ // getPage (pageId) -> { id, title, parentId, version: { number } } | null
10
+ // createPage ({ title, parentId, body? }) -> { id, version: { number } }
11
+ // updatePage ({ id, title, version, body }) -> { id, version: { number } }
12
+ // archivePage (pageId)
13
+ // getContentProperty (pageId, key) -> { id, key, value, version: { number } } | null
14
+ // setContentProperty (pageId, key, value, existing?) -> void
15
+ // getAttachments (pageId) -> [{ id, title, fileId, comment, pageId }]
16
+ // uploadAttachment (pageId, filename, buffer, comment) -> { fileId, collection }
17
+ // attachmentDownloadUrl (pageId, filename) -> absolute url string (sync)
18
+ // getPageWebUrl (pageId) -> absolute url string
19
+ // }
20
+ //
21
+ // `body` is always { representation: 'atlas_doc_format', value: <ADF JSON string> }.
22
+
23
+ module.exports = {}
package/lib/index.js ADDED
@@ -0,0 +1,242 @@
1
+ 'use strict'
2
+
3
+ const fsp = require('node:fs/promises')
4
+ const ospath = require('node:path')
5
+ const { createHash } = require('node:crypto')
6
+
7
+ const buildTree = require('./tree')
8
+ const rewriteAdf = require('./rewrite')
9
+ const syncAttachments = require('./attachments')
10
+ const { bannerPanel, DEFAULT_TEXT } = require('./banner')
11
+ const { INDEX_KEY, readIndex, writeIndex, readPageState, writePageState } = require('./state')
12
+
13
+ const sha1 = (str) => createHash('sha1').update(str).digest('hex')
14
+ const STUB_BODY = { version: 1, type: 'doc', content: [] }
15
+
16
+ /**
17
+ * Publish an ADF manifest directory (produced by @baiyibai-antora/adf-extension)
18
+ * to Confluence through the given port.
19
+ *
20
+ * Three passes:
21
+ * 1. skeleton — every page gets a Confluence id (missing pages are created
22
+ * top-down with a stub body), so xrefs and attachments have targets
23
+ * 2. attachments — MD5-incremental upload per changed page
24
+ * 3. body — media/link rewrite, then the ADF body is written
25
+ *
26
+ * Overwrite guard (on by default): a page whose remote version no longer
27
+ * matches the version we last published was edited in Confluence; it is
28
+ * skipped with a warning unless `force` is set.
29
+ */
30
+ async function publish (manifestDir, port, options = {}) {
31
+ const { force = false, prune = false, dryRun = false, banner = true, bannerText, logger = console } = options
32
+ const manifest = JSON.parse(await fsp.readFile(ospath.join(manifestDir, 'manifest.json'), 'utf8'))
33
+ const { nodes, warnings } = buildTree(manifest)
34
+ for (const warning of warnings) logger.warn(warning)
35
+
36
+ const rootPageId = options.rootPageId || (await port.getSpaceHomeId())
37
+ const { entries: index } = await readIndex(port, rootPageId)
38
+ const idByKey = new Map()
39
+ const plan = []
40
+ let indexDirty = false
41
+ let newCounter = 0
42
+ // The index property may be written twice per run (after creation, after
43
+ // prune); always write against its current version.
44
+ const flushIndex = async () => {
45
+ await writeIndex(port, rootPageId, index, await port.getContentProperty(rootPageId, INDEX_KEY))
46
+ indexDirty = false
47
+ }
48
+
49
+ // Pass 1: skeleton — every node ends up with a page id.
50
+ for (const node of nodes) {
51
+ let pageId = index[node.key]
52
+ let remote = pageId ? await port.getPage(pageId) : null
53
+ if (pageId && !remote) {
54
+ logger.warn(`${node.key}: indexed page ${pageId} no longer exists; will recreate`)
55
+ pageId = null
56
+ }
57
+ node.desiredParentId = node.parentKey ? idByKey.get(node.parentKey) : rootPageId
58
+ if (!pageId) {
59
+ if (dryRun) {
60
+ pageId = `(new-${++newCounter})`
61
+ } else {
62
+ const created = await port.createPage({
63
+ title: node.title,
64
+ parentId: node.desiredParentId,
65
+ body: { representation: 'atlas_doc_format', value: JSON.stringify(STUB_BODY) },
66
+ })
67
+ pageId = created.id
68
+ index[node.key] = pageId
69
+ indexDirty = true
70
+ node.remoteVersion = created.version.number
71
+ }
72
+ node.created = true
73
+ } else {
74
+ node.remoteVersion = remote.version.number
75
+ node.remoteTitle = remote.title
76
+ node.remoteParentId = remote.parentId
77
+ }
78
+ node.pageId = pageId
79
+ idByKey.set(node.key, pageId)
80
+ }
81
+ if (indexDirty) await flushIndex()
82
+
83
+ // The banner's source link points at the page's source in its git
84
+ // repository (falling back to the page on the static site).
85
+ const bannerSourceUrl = (node) =>
86
+ (node.page && node.page.sourceUrl) ||
87
+ (manifest.site.url && node.page && node.page.url ? manifest.site.url + node.page.url : manifest.site.url || null)
88
+
89
+ // Decide per-node action before touching bodies. The banner content is part
90
+ // of the published body, so changing its text, link, or toggle
91
+ // re-publishes; so are a home page's exports (the Downloads list lives in
92
+ // its body).
93
+ for (const node of nodes) {
94
+ node.bannerSig = banner ? sha1(`${bannerText || DEFAULT_TEXT}|${bannerSourceUrl(node) || ''}`) : 'off'
95
+ const exportsSig = JSON.stringify((node.exports || []).map((e) => ({ filename: e.filename, md5: e.md5 })))
96
+ const contentHash =
97
+ node.kind === 'page'
98
+ ? node.page.contentHash
99
+ : node.kind === 'home'
100
+ ? sha1(node.page.contentHash + exportsSig)
101
+ : sha1(exportsSig)
102
+ node.contentHash = contentHash
103
+ if (node.created) {
104
+ node.action = 'create'
105
+ continue
106
+ }
107
+ const { state, prop } = await readPageState(port, node.pageId)
108
+ node.stateProp = prop
109
+ if (!state) {
110
+ node.action = force ? 'update' : 'blocked'
111
+ node.reason = 'page exists but was not published by this tool'
112
+ } else if (state.publishedVersion !== node.remoteVersion) {
113
+ node.action = force ? 'update' : 'blocked'
114
+ node.reason = `page was edited in Confluence (version ${node.remoteVersion}, last published ${state.publishedVersion})`
115
+ } else if (
116
+ state.sourceHash === contentHash &&
117
+ node.remoteTitle === node.title &&
118
+ state.bannerSig === node.bannerSig &&
119
+ // site.url feeds unpublished-target link fallbacks.
120
+ state.siteUrl === (manifest.site.url || null) &&
121
+ String(node.remoteParentId) === String(node.desiredParentId)
122
+ ) {
123
+ node.action = 'skip'
124
+ } else {
125
+ node.action = 'update'
126
+ }
127
+ if (node.action === 'blocked') {
128
+ logger.warn(`${node.key}: ${node.reason}; skipping (use --force to overwrite)`)
129
+ }
130
+ }
131
+
132
+ // Link targets: manifest page url -> Confluence web url + heading anchors.
133
+ const linkUrls = new Map()
134
+ const anchorsByUrl = new Map()
135
+ for (const node of nodes) {
136
+ if (!node.page) continue
137
+ const webUrl = dryRun && node.created ? `about:blank#${node.key}` : await port.getPageWebUrl(node.pageId)
138
+ if (node.page.url && webUrl) {
139
+ linkUrls.set(node.page.url, webUrl)
140
+ if (node.page.anchors) anchorsByUrl.set(node.page.url, node.page.anchors)
141
+ }
142
+ }
143
+
144
+ // Passes 2 + 3 per node that needs its body written.
145
+ for (const node of nodes) {
146
+ plan.push({ action: node.action, key: node.key, title: node.title, pageId: node.pageId, reason: node.reason })
147
+ if (node.action === 'skip' || node.action === 'blocked' || dryRun) continue
148
+
149
+ const readFile = (relPath) => fsp.readFile(ospath.join(manifestDir, ...relPath.split('/')))
150
+ let body
151
+ if (node.kind === 'root') {
152
+ // Page-less component version that still has downloads.
153
+ await syncAttachments(port, node.pageId, node.exports || [], readFile, logger)
154
+ const content = banner ? [bannerPanel({ text: bannerText, sourceUrl: manifest.site.url || null })] : []
155
+ content.push(...downloadsSection(node, port))
156
+ body = { version: 1, type: 'doc', content }
157
+ } else {
158
+ const adf = JSON.parse(await fsp.readFile(ospath.join(manifestDir, node.page.adfPath), 'utf8'))
159
+ const attachments = [...node.page.attachments, ...(node.exports || [])]
160
+ const mediaIds = await syncAttachments(port, node.pageId, attachments, readFile, logger)
161
+ const rewriteWarnings = []
162
+ body = rewriteAdf(
163
+ adf,
164
+ { mediaIds, linkUrls, anchorsByUrl, selfAnchors: node.page.anchors || {}, siteUrl: manifest.site.url },
165
+ rewriteWarnings
166
+ )
167
+ for (const warning of rewriteWarnings) logger.warn(`${node.key}: ${warning}`)
168
+ const content = [...body.content, ...downloadsSection(node, port)]
169
+ if (banner) content.unshift(bannerPanel({ text: bannerText, sourceUrl: bannerSourceUrl(node) }))
170
+ body = { ...body, content }
171
+ }
172
+
173
+ const updated = await port.updatePage({
174
+ id: node.pageId,
175
+ title: node.title,
176
+ parentId: node.desiredParentId,
177
+ version: node.remoteVersion + 1,
178
+ body: { representation: 'atlas_doc_format', value: JSON.stringify(body) },
179
+ })
180
+ await writePageState(
181
+ port,
182
+ node.pageId,
183
+ {
184
+ pageKey: node.key,
185
+ sourceHash: node.contentHash,
186
+ publishedVersion: updated.version.number,
187
+ bannerSig: node.bannerSig,
188
+ siteUrl: manifest.site.url || null,
189
+ },
190
+ node.stateProp
191
+ )
192
+ logger.info(`${node.action === 'create' ? 'created' : 'updated'} ${node.key} -> page ${node.pageId}`)
193
+ }
194
+
195
+ // Orphans: indexed pages that are no longer in the manifest.
196
+ const desiredKeys = new Set(nodes.map((n) => n.key))
197
+ for (const [key, pageId] of Object.entries(index)) {
198
+ if (desiredKeys.has(key)) continue
199
+ if (prune && !dryRun) {
200
+ await port.archivePage(pageId)
201
+ delete index[key]
202
+ indexDirty = true
203
+ plan.push({ action: 'pruned', key, pageId })
204
+ logger.info(`archived orphaned page ${key} (${pageId})`)
205
+ } else {
206
+ plan.push({ action: 'orphan', key, pageId })
207
+ logger.warn(`${key}: page ${pageId} is no longer in the manifest${prune ? '' : ' (use --prune to archive)'}`)
208
+ }
209
+ }
210
+ if (indexDirty && !dryRun) await flushIndex()
211
+
212
+ return { plan, rootPageId }
213
+ }
214
+
215
+ // Downloads list for assembler exports (PDFs etc.) attached to this page.
216
+ function downloadsSection (node, port) {
217
+ const exports_ = node.exports || []
218
+ if (!exports_.length) return []
219
+ return [
220
+ { type: 'paragraph', content: [{ type: 'text', text: 'Downloads', marks: [{ type: 'strong' }] }] },
221
+ {
222
+ type: 'bulletList',
223
+ content: exports_.map((e) => ({
224
+ type: 'listItem',
225
+ content: [
226
+ {
227
+ type: 'paragraph',
228
+ content: [
229
+ {
230
+ type: 'text',
231
+ text: e.filename,
232
+ marks: [{ type: 'link', attrs: { href: port.attachmentDownloadUrl(node.pageId, e.filename) } }],
233
+ },
234
+ ],
235
+ },
236
+ ],
237
+ })),
238
+ },
239
+ ]
240
+ }
241
+
242
+ module.exports = { publish }
package/lib/rewrite.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+
3
+ // Pure ADF-tree passes run just before a page body is written:
4
+ // - media nodes carrying a placeholder id -> the real attachment fileId +
5
+ // collection produced by the attachment pass
6
+ // - link marks whose href maps to a published page -> that page's Confluence
7
+ // URL (falling back to the static site URL when the target isn't published)
8
+ // - URL fragments -> Confluence heading anchors (Confluence derives anchors
9
+ // from the heading text, e.g. "Quick Start" -> #Quick-Start, not from
10
+ // Asciidoctor ids like #_quick_start)
11
+
12
+ // Confluence Cloud heading anchor: the heading text with whitespace collapsed
13
+ // to single dashes, case preserved.
14
+ const confluenceAnchor = (headingText) => headingText.trim().replace(/\s+/g, '-')
15
+
16
+ function rewriteAdf (
17
+ adf,
18
+ { mediaIds = new Map(), linkUrls = new Map(), anchorsByUrl = new Map(), selfAnchors = {}, siteUrl = null },
19
+ warnings = []
20
+ ) {
21
+ const mapFragment = (fragment, anchors) =>
22
+ fragment && anchors && anchors[fragment] ? confluenceAnchor(anchors[fragment]) : fragment
23
+
24
+ const visit = (node) => {
25
+ const out = { ...node }
26
+ if (node.type === 'media' && node.attrs && node.attrs.type === 'file') {
27
+ const real = mediaIds.get(node.attrs.id)
28
+ if (real) {
29
+ out.attrs = { ...node.attrs, id: real.id, collection: real.collection }
30
+ } else {
31
+ warnings.push(`media placeholder "${node.attrs.id}" has no uploaded attachment`)
32
+ }
33
+ }
34
+ if (node.marks) {
35
+ out.marks = node.marks.map((mark) => {
36
+ if (mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark
37
+ const href = mark.attrs.href
38
+ if (href.charAt(0) === '#') {
39
+ const fragment = mapFragment(href.slice(1), selfAnchors)
40
+ return { ...mark, attrs: { ...mark.attrs, href: `#${fragment}` } }
41
+ }
42
+ if (href.charAt(0) !== '/') return mark
43
+ const [urlPath, fragment] = href.split('#')
44
+ const target = linkUrls.get(urlPath)
45
+ if (target) {
46
+ const mapped = mapFragment(fragment, anchorsByUrl.get(urlPath))
47
+ return { ...mark, attrs: { ...mark.attrs, href: mapped ? `${target}#${mapped}` : target } }
48
+ }
49
+ if (siteUrl) {
50
+ return { ...mark, attrs: { ...mark.attrs, href: siteUrl + href } }
51
+ }
52
+ warnings.push(`link "${href}" targets an unpublished page and no site url is configured`)
53
+ return mark
54
+ })
55
+ }
56
+ if (node.content) out.content = node.content.map(visit)
57
+ return out
58
+ }
59
+ return visit(adf)
60
+ }
61
+
62
+ module.exports = rewriteAdf
63
+ module.exports.confluenceAnchor = confluenceAnchor
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+
3
+ // Credentials come from the environment (CI-friendly, never on the command
4
+ // line); everything else from CLI flags.
5
+ //
6
+ // CONFLUENCE_BASE_URL e.g. https://your-site.atlassian.net
7
+ // ATLASSIAN_EMAIL account email for the API token
8
+ // ATLASSIAN_API_TOKEN https://id.atlassian.com/manage-profile/security/api-tokens
9
+
10
+ function resolveSettings (flags, env = process.env) {
11
+ const settings = {
12
+ baseUrl: flags.baseUrl || env.CONFLUENCE_BASE_URL,
13
+ email: flags.email || env.ATLASSIAN_EMAIL || env.CONFLUENCE_USER_EMAIL,
14
+ apiToken: env.ATLASSIAN_API_TOKEN || env.CONFLUENCE_API_TOKEN,
15
+ spaceKey: flags.space || env.CONFLUENCE_SPACE_KEY,
16
+ rootPageId: flags.parent || env.CONFLUENCE_PARENT_PAGE_ID || undefined,
17
+ }
18
+ const missing = ['baseUrl', 'email', 'apiToken', 'spaceKey'].filter((key) => !settings[key])
19
+ if (missing.length) {
20
+ throw new Error(
21
+ `missing Confluence settings: ${missing.join(', ')} ` +
22
+ '(set CONFLUENCE_BASE_URL, ATLASSIAN_EMAIL, ATLASSIAN_API_TOKEN, CONFLUENCE_SPACE_KEY or pass flags)'
23
+ )
24
+ }
25
+ return settings
26
+ }
27
+
28
+ module.exports = resolveSettings
package/lib/state.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict'
2
+
3
+ // Publish state lives in Confluence content properties:
4
+ // - one index property on the root page mapping pageKey -> pageId
5
+ // - one property per published page recording what we last wrote
6
+ // Content properties survive page moves and are shared across CI runners,
7
+ // unlike a local state file.
8
+
9
+ const INDEX_KEY = 'antora-adf-publisher-index'
10
+ const PAGE_KEY = 'antora-adf-publisher'
11
+
12
+ async function readIndex (port, rootPageId) {
13
+ const prop = await port.getContentProperty(rootPageId, INDEX_KEY)
14
+ return { entries: (prop && prop.value && prop.value.entries) || {}, prop }
15
+ }
16
+
17
+ async function writeIndex (port, rootPageId, entries, prop) {
18
+ await port.setContentProperty(rootPageId, INDEX_KEY, { entries }, prop)
19
+ }
20
+
21
+ async function readPageState (port, pageId) {
22
+ const prop = await port.getContentProperty(pageId, PAGE_KEY)
23
+ return { state: (prop && prop.value) || null, prop }
24
+ }
25
+
26
+ async function writePageState (port, pageId, state, prop) {
27
+ await port.setContentProperty(pageId, PAGE_KEY, state, prop)
28
+ }
29
+
30
+ module.exports = { INDEX_KEY, PAGE_KEY, readIndex, writeIndex, readPageState, writePageState }
package/lib/tree.js ADDED
@@ -0,0 +1,154 @@
1
+ 'use strict'
2
+
3
+ // Builds the desired Confluence page tree from a manifest. Each component
4
+ // version's subtree is rooted at its own START PAGE (kind "home") — no
5
+ // synthetic container pages — with that version's exports (PDFs) attached to
6
+ // it. Component versions with no pages are skipped entirely unless they carry
7
+ // exports (then a minimal "root" page holds the downloads). Confluence
8
+ // requires unique page titles per space, so colliding titles get
9
+ // deterministic suffixes; residual collisions are a hard error.
10
+
11
+ function buildTree (manifest) {
12
+ const warnings = []
13
+ const nodes = []
14
+ const byKey = new Map()
15
+
16
+ const exportsFor = (component, version) =>
17
+ (manifest.exports || [])
18
+ .filter((e) => e.component === component && e.version === version)
19
+ .map((e) => ({ ...e, placeholderId: e.md5 }))
20
+
21
+ const groups = new Map()
22
+ for (const page of manifest.pages) {
23
+ const [component, version] = page.key.split(':')
24
+ const groupKey = `${component}@${version}`
25
+ if (!groups.has(groupKey)) groups.set(groupKey, [])
26
+ groups.get(groupKey).push(page)
27
+ }
28
+ const navByGroup = new Map(manifest.nav.map((n) => [`${n.component}@${n.version}`, n]))
29
+
30
+ // Page-less component versions: keep a minimal root only if there is
31
+ // something to download; otherwise they'd just be nav noise.
32
+ for (const navEntry of manifest.nav) {
33
+ const groupKey = `${navEntry.component}@${navEntry.version}`
34
+ if (groups.has(groupKey)) continue
35
+ const exports_ = exportsFor(navEntry.component, navEntry.version)
36
+ if (!exports_.length) continue
37
+ const node = { key: groupKey, kind: 'root', title: componentTitle(navEntry), parentKey: null, exports: exports_ }
38
+ nodes.push(node)
39
+ byKey.set(groupKey, node)
40
+ }
41
+
42
+ const homeByGroup = new Map()
43
+ const pending = []
44
+ for (const [groupKey, pages] of groups) {
45
+ const [component, version] = groupKey.split('@')
46
+ const navEntry = navByGroup.get(groupKey)
47
+ let home = navEntry && navEntry.startKey && pages.find((p) => p.key === navEntry.startKey)
48
+ if (!home) home = pages.find((p) => p.navOrder != null) || pages[0]
49
+ const homeNode = {
50
+ key: home.key,
51
+ kind: 'home',
52
+ // The subtree root represents the component: use the component title
53
+ // plus its version — the one place the version belongs.
54
+ title: componentTitle(navEntry || { component, version }),
55
+ parentKey: null,
56
+ page: home,
57
+ exports: exportsFor(component, version),
58
+ }
59
+ nodes.push(homeNode)
60
+ byKey.set(home.key, homeNode)
61
+ homeByGroup.set(groupKey, home.key)
62
+ for (const page of pages) {
63
+ if (page.key === home.key) continue
64
+ pending.push({
65
+ key: page.key,
66
+ kind: 'page',
67
+ title: page.title,
68
+ parentKey: page.parentKey && page.parentKey !== home.key ? page.parentKey : home.key,
69
+ page,
70
+ })
71
+ }
72
+ }
73
+
74
+ // Topological order: repeatedly take nodes whose parent is already placed.
75
+ const pendingKeys = new Set(pending.map((n) => n.key))
76
+ let remaining = pending
77
+ while (remaining.length) {
78
+ const ready = remaining.filter((n) => !pendingKeys.has(n.parentKey) && byKey.has(n.parentKey))
79
+ const stuck = remaining.filter((n) => !pendingKeys.has(n.parentKey) && !byKey.has(n.parentKey))
80
+ for (const node of stuck) {
81
+ // Dangling parentKey (e.g. parent filtered out): reattach to the home page.
82
+ const [component, version] = node.key.split(':')
83
+ warnings.push(`page "${node.key}" has an unresolvable parent "${node.parentKey}"; attaching to the component home page`)
84
+ node.parentKey = homeByGroup.get(`${component}@${version}`)
85
+ ready.push(node)
86
+ }
87
+ if (!ready.length) {
88
+ for (const node of remaining) {
89
+ const [component, version] = node.key.split(':')
90
+ warnings.push(`page "${node.key}" is in a parent cycle; attaching to the component home page`)
91
+ node.parentKey = homeByGroup.get(`${component}@${version}`)
92
+ pendingKeys.delete(node.key)
93
+ nodes.push(node)
94
+ byKey.set(node.key, node)
95
+ }
96
+ break
97
+ }
98
+ for (const node of ready) {
99
+ pendingKeys.delete(node.key)
100
+ nodes.push(node)
101
+ byKey.set(node.key, node)
102
+ }
103
+ remaining = remaining.filter((n) => pendingKeys.has(n.key))
104
+ }
105
+
106
+ disambiguateTitles(nodes)
107
+ return { nodes, warnings }
108
+ }
109
+
110
+ function componentTitle ({ component, version, displayVersion, title }) {
111
+ const base = title || component
112
+ const shownVersion = displayVersion || version
113
+ return shownVersion ? `${base} (${shownVersion})` : base
114
+ }
115
+
116
+ // Colliding child-page titles escalate gently: the component version scopes
117
+ // the whole subtree, so the version only appears on a child when the same
118
+ // component publishes several versions of the same page.
119
+ function disambiguateTitles (nodes) {
120
+ const collide = () => {
121
+ const seen = new Map()
122
+ const dupes = new Set()
123
+ for (const node of nodes) {
124
+ if (seen.has(node.title)) dupes.add(node.title)
125
+ seen.set(node.title, node)
126
+ }
127
+ return dupes
128
+ }
129
+
130
+ const suffix = (node, tier) => {
131
+ const [component, version, , relative] = node.key.split(':')
132
+ if (!relative) return node.title
133
+ const base = node.originalTitle || node.title
134
+ const stem = relative.replace(/\.adoc$/, '').split('/').pop()
135
+ if (tier === 1) return `${base} (${component})`
136
+ if (tier === 2) return `${base} (${component}, ${stem})`
137
+ return `${base} (${component} ${version}, ${stem})`
138
+ }
139
+
140
+ for (const tier of [1, 2, 3]) {
141
+ const dupes = collide()
142
+ if (!dupes.size) return
143
+ for (const node of nodes) {
144
+ if (node.kind === 'page' && dupes.has(node.title)) {
145
+ node.originalTitle ??= node.title
146
+ node.title = suffix(node, tier)
147
+ }
148
+ }
149
+ }
150
+ const dupes = collide()
151
+ if (dupes.size) throw new Error(`unresolvable page title collision(s): ${[...dupes].join(', ')}`)
152
+ }
153
+
154
+ module.exports = buildTree
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@baiyibai-antora/confluence-publisher",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Publish an Antora ADF manifest (from @baiyibai-antora/adf-extension) to a Confluence Cloud space",
5
+ "license": "MIT",
6
+ "author": "baiyibai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://gitlab.com/baiyibai-antora/confluora.git",
10
+ "directory": "packages/confluence-publisher"
11
+ },
12
+ "type": "commonjs",
13
+ "main": "lib/index.js",
14
+ "bin": {
15
+ "confluence-publish": "bin/confluence-publish.js"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "bin"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*-test.js"
23
+ },
24
+ "keywords": [
25
+ "confluora",
26
+ "antora",
27
+ "confluence",
28
+ "adf",
29
+ "publisher"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "homepage": "https://gitlab.com/baiyibai-antora/confluora",
38
+ "bugs": "https://gitlab.com/baiyibai-antora/confluora/-/issues"
39
+ }