@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 +5 -0
- package/bin/confluence-publish.js +91 -0
- package/lib/attachments.js +29 -0
- package/lib/banner.js +24 -0
- package/lib/client/confluence-adapter.js +151 -0
- package/lib/client/port.js +23 -0
- package/lib/index.js +242 -0
- package/lib/rewrite.js +63 -0
- package/lib/settings.js +28 -0
- package/lib/state.js +30 -0
- package/lib/tree.js +154 -0
- package/package.json +39 -0
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
|
package/lib/settings.js
ADDED
|
@@ -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
|
+
}
|