@repo-toolkit/confluence 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -17
- package/cli.js +1515 -243
- package/index.d.ts +308 -22
- package/index.js +1298 -206
- package/package.json +2 -2
package/cli.js
CHANGED
|
@@ -1,27 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import {
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
|
+
import { resolve as resolve3 } from "path";
|
|
6
|
+
import {
|
|
7
|
+
parseFlags,
|
|
8
|
+
INTERACTIVE_FLAG,
|
|
9
|
+
canPrompt,
|
|
10
|
+
promptForRequiredValue,
|
|
11
|
+
loadConfigFile,
|
|
12
|
+
isPlainObject
|
|
13
|
+
} from "@repo-toolkit/publish-package";
|
|
5
14
|
|
|
6
15
|
// src/index.ts
|
|
7
16
|
import { readFileSync as readFileSync2 } from "fs";
|
|
8
17
|
import { dirname, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
9
|
-
import { parseFlags, resolveCliOptions, isPlainObject } from "@repo-toolkit/publish-package";
|
|
10
18
|
|
|
11
19
|
// src/confluence-client.ts
|
|
12
20
|
import { Buffer } from "buffer";
|
|
13
|
-
import {
|
|
21
|
+
import { randomBytes } from "crypto";
|
|
22
|
+
import { statSync, createReadStream } from "fs";
|
|
14
23
|
import { basename } from "path";
|
|
24
|
+
import { Readable } from "stream";
|
|
15
25
|
var V2_PATH = "/api/v2";
|
|
16
26
|
var V1_PATH = "/rest/api";
|
|
17
27
|
var DEFAULT_USER_AGENT = "repo-toolkit-confluence/1.0 (+node)";
|
|
18
28
|
var MAX_LIMIT = 250;
|
|
29
|
+
var MAX_ERROR_BODY_LENGTH = 8192;
|
|
30
|
+
var MAX_PAGES_PER_QUERY = 100;
|
|
31
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
32
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
33
|
+
var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
34
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
35
|
+
var ConfluenceUploadError = class extends Error {
|
|
36
|
+
constructor(message, status, endpoint, responseBody) {
|
|
37
|
+
super(`${message} (status=${status}, endpoint=${endpoint})`);
|
|
38
|
+
this.name = "ConfluenceUploadError";
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.endpoint = endpoint;
|
|
41
|
+
this.responseBody = responseBody;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
19
44
|
var STATUS_CODES = {
|
|
20
45
|
BAD_REQUEST: 400,
|
|
21
46
|
UNAUTHORIZED: 401,
|
|
22
47
|
FORBIDDEN: 403,
|
|
23
48
|
NOT_FOUND: 404,
|
|
24
|
-
CONFLICT: 409
|
|
49
|
+
CONFLICT: 409,
|
|
50
|
+
TOO_MANY_REQUESTS: 429
|
|
25
51
|
};
|
|
26
52
|
var ConfluenceApiError = class extends Error {
|
|
27
53
|
constructor(message, status, endpoint, responseBody) {
|
|
@@ -40,10 +66,15 @@ var ConfluenceClient = class {
|
|
|
40
66
|
if (!options.username || !options.apiToken) {
|
|
41
67
|
throw new Error("ConfluenceClient: username and apiToken are required");
|
|
42
68
|
}
|
|
43
|
-
|
|
69
|
+
const normalized = normalizeBaseUrl(options.baseUrl);
|
|
70
|
+
this.baseUrl = normalized;
|
|
71
|
+
this.baseUrlOrigin = new URL(normalized).origin;
|
|
44
72
|
this.authHeader = "Basic " + Buffer.from(`${options.username}:${options.apiToken}`, "utf8").toString("base64");
|
|
45
73
|
this.fetchFn = options.fetch ?? globalThis.fetch;
|
|
46
74
|
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
75
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
76
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
77
|
+
this.maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
47
78
|
}
|
|
48
79
|
async getSpaceIdByKey(spaceKey) {
|
|
49
80
|
const query = new URLSearchParams({ keys: spaceKey, limit: "1" });
|
|
@@ -57,17 +88,33 @@ var ConfluenceClient = class {
|
|
|
57
88
|
}
|
|
58
89
|
return result.id;
|
|
59
90
|
}
|
|
60
|
-
async
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
91
|
+
async getPagesByTitle(spaceId, title) {
|
|
92
|
+
const pages = [];
|
|
93
|
+
const visited = /* @__PURE__ */ new Set();
|
|
94
|
+
let pageCount = 0;
|
|
95
|
+
const startUrl = this.v2Url(
|
|
96
|
+
`/pages?${new URLSearchParams({
|
|
97
|
+
"space-id": spaceId,
|
|
98
|
+
title,
|
|
99
|
+
limit: String(MAX_LIMIT),
|
|
100
|
+
"body-format": "storage"
|
|
101
|
+
}).toString()}`
|
|
102
|
+
);
|
|
103
|
+
let nextUrl = startUrl;
|
|
104
|
+
while (nextUrl) {
|
|
105
|
+
pageCount += 1;
|
|
106
|
+
if (pageCount > MAX_PAGES_PER_QUERY) {
|
|
107
|
+
throw new ConfluenceApiError(`Pagination limit (${MAX_PAGES_PER_QUERY}) exceeded`, 0, nextUrl, "");
|
|
108
|
+
}
|
|
109
|
+
if (visited.has(nextUrl)) {
|
|
110
|
+
throw new ConfluenceApiError("Confluence pagination loop detected", 0, nextUrl, "");
|
|
111
|
+
}
|
|
112
|
+
visited.add(nextUrl);
|
|
113
|
+
const data = await this.requestJson(nextUrl, { method: "GET" });
|
|
114
|
+
pages.push(...data.results);
|
|
115
|
+
nextUrl = resolveNextUrl(this.baseUrl, this.baseUrlOrigin, data._links?.next);
|
|
116
|
+
}
|
|
117
|
+
return pages;
|
|
71
118
|
}
|
|
72
119
|
async getPage(pageId) {
|
|
73
120
|
return this.requestJson(this.v2Url(`/pages/${encodeURIComponent(pageId)}?body-format=storage`), {
|
|
@@ -110,89 +157,182 @@ var ConfluenceClient = class {
|
|
|
110
157
|
}
|
|
111
158
|
async getAttachments(pageId) {
|
|
112
159
|
const results = [];
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
160
|
+
const visited = /* @__PURE__ */ new Set();
|
|
161
|
+
let pageCount = 0;
|
|
162
|
+
let nextUrl = this.v2Url(
|
|
163
|
+
`/pages/${encodeURIComponent(pageId)}/attachments?${new URLSearchParams({
|
|
164
|
+
limit: String(MAX_LIMIT)
|
|
165
|
+
}).toString()}`
|
|
166
|
+
);
|
|
167
|
+
while (nextUrl) {
|
|
168
|
+
pageCount += 1;
|
|
169
|
+
if (pageCount > MAX_PAGES_PER_QUERY) {
|
|
170
|
+
throw new ConfluenceApiError(`Pagination limit (${MAX_PAGES_PER_QUERY}) exceeded`, 0, nextUrl, "");
|
|
171
|
+
}
|
|
172
|
+
if (visited.has(nextUrl)) {
|
|
173
|
+
throw new ConfluenceApiError("Confluence pagination loop detected", 0, nextUrl, "");
|
|
174
|
+
}
|
|
175
|
+
visited.add(nextUrl);
|
|
176
|
+
const data = await this.requestJson(nextUrl, {
|
|
177
|
+
method: "GET"
|
|
178
|
+
});
|
|
123
179
|
for (const item of data.results) {
|
|
124
180
|
results.push(item);
|
|
125
181
|
}
|
|
126
|
-
|
|
127
|
-
}
|
|
182
|
+
nextUrl = resolveNextUrl(this.baseUrl, this.baseUrlOrigin, data._links?.next);
|
|
183
|
+
}
|
|
128
184
|
return results;
|
|
129
185
|
}
|
|
130
|
-
async uploadAttachment(pageId, filePath, comment) {
|
|
131
|
-
return this.sendAttachmentMultipart(pageId, void 0, filePath, comment);
|
|
186
|
+
async uploadAttachment(pageId, filePath, comment, filename) {
|
|
187
|
+
return this.sendAttachmentMultipart(pageId, void 0, filePath, comment, filename);
|
|
132
188
|
}
|
|
133
|
-
async updateAttachmentData(pageId, attachmentId, filePath, comment) {
|
|
134
|
-
return this.sendAttachmentMultipart(pageId, attachmentId, filePath, comment);
|
|
189
|
+
async updateAttachmentData(pageId, attachmentId, filePath, comment, filename) {
|
|
190
|
+
return this.sendAttachmentMultipart(pageId, attachmentId, filePath, comment, filename);
|
|
135
191
|
}
|
|
136
|
-
async sendAttachmentMultipart(pageId, attachmentId, filePath, comment) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const parts = [];
|
|
141
|
-
parts.push(multipartField(boundary, "file", filename, fileBuffer));
|
|
142
|
-
if (comment) {
|
|
143
|
-
parts.push(multipartField(boundary, "comment", void 0, Buffer.from(comment, "utf8")));
|
|
192
|
+
async sendAttachmentMultipart(pageId, attachmentId, filePath, comment, filenameOverride) {
|
|
193
|
+
const info = statSync(filePath);
|
|
194
|
+
if (!info.isFile()) {
|
|
195
|
+
throw new ConfluenceApiError(`Attachment source must be a regular file: ${filePath}`, 0, filePath, "");
|
|
144
196
|
}
|
|
145
|
-
|
|
146
|
-
|
|
197
|
+
if (info.size > this.maxUploadBytes) {
|
|
198
|
+
throw new ConfluenceApiError(
|
|
199
|
+
`Attachment exceeds upload size limit (${this.maxUploadBytes} bytes): ${filePath}`,
|
|
200
|
+
0,
|
|
201
|
+
filePath,
|
|
202
|
+
""
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const filename = sanitizeFilename(filenameOverride ?? basename(filePath));
|
|
147
206
|
const endpoint = attachmentId ? this.v1Url(`/content/${encodeURIComponent(pageId)}/child/attachment/${encodeURIComponent(attachmentId)}/data`) : this.v1Url(`/content/${encodeURIComponent(pageId)}/child/attachment`);
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
207
|
+
const boundary = "----repo-toolkit-confluence-" + randomBytes(16).toString("hex");
|
|
208
|
+
const fileStream = createReadStream(filePath);
|
|
209
|
+
const commentBuffer = comment ? multipartField(boundary, "comment", void 0, Buffer.from(comment, "utf8")) : null;
|
|
210
|
+
const body = Readable.from(
|
|
211
|
+
(async function* () {
|
|
212
|
+
const fileHeader = Buffer.from(
|
|
213
|
+
`--${boundary}\r
|
|
214
|
+
Content-Disposition: form-data; name="file"; filename="${filename}"\r
|
|
215
|
+
Content-Type: application/octet-stream\r
|
|
216
|
+
\r
|
|
217
|
+
`,
|
|
218
|
+
"utf8"
|
|
219
|
+
);
|
|
220
|
+
yield fileHeader;
|
|
221
|
+
for await (const chunk of fileStream) {
|
|
222
|
+
yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
223
|
+
}
|
|
224
|
+
yield Buffer.from("\r\n", "utf8");
|
|
225
|
+
if (commentBuffer) {
|
|
226
|
+
yield commentBuffer;
|
|
227
|
+
}
|
|
228
|
+
yield Buffer.from(`--${boundary}--\r
|
|
229
|
+
`, "utf8");
|
|
230
|
+
})()
|
|
231
|
+
);
|
|
232
|
+
try {
|
|
233
|
+
const data = await this.requestJson(endpoint, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: {
|
|
236
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
237
|
+
"X-Atlassian-Token": "no-check"
|
|
238
|
+
},
|
|
239
|
+
body,
|
|
240
|
+
uploadKind: "attachment",
|
|
241
|
+
contentTypeExplicit: true
|
|
242
|
+
});
|
|
243
|
+
return normalizeAttachmentResult(data);
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
if (cause instanceof ConfluenceApiError) {
|
|
246
|
+
const status = cause.status;
|
|
247
|
+
if (status === STATUS_CODES.UNAUTHORIZED || status === STATUS_CODES.FORBIDDEN || status === STATUS_CODES.TOO_MANY_REQUESTS || status >= 500) {
|
|
248
|
+
const uploadErr = new ConfluenceUploadError(cause.message, status, cause.endpoint, cause.responseBody);
|
|
249
|
+
uploadErr.stack = cause.stack;
|
|
250
|
+
throw uploadErr;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw cause;
|
|
254
|
+
}
|
|
157
255
|
}
|
|
158
256
|
async requestJson(endpoint, init) {
|
|
257
|
+
void init.uploadKind;
|
|
258
|
+
void init.contentTypeExplicit;
|
|
159
259
|
const headers = {
|
|
160
260
|
Authorization: this.authHeader,
|
|
161
261
|
Accept: "application/json",
|
|
162
262
|
"User-Agent": this.userAgent
|
|
163
263
|
};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
264
|
+
for (const [k, v] of Object.entries(init.headers ?? {})) {
|
|
265
|
+
headers[k] = v;
|
|
266
|
+
}
|
|
267
|
+
const method = init.method.toUpperCase();
|
|
268
|
+
const isSafe = SAFE_METHODS.has(method);
|
|
269
|
+
const maxRetries = isSafe ? this.maxRetries : 0;
|
|
270
|
+
let attempt = 0;
|
|
271
|
+
let lastError;
|
|
272
|
+
while (attempt <= maxRetries) {
|
|
273
|
+
const signal = this.makeTimeoutSignal();
|
|
274
|
+
try {
|
|
275
|
+
const response = await this.fetchFn(endpoint, {
|
|
276
|
+
method,
|
|
277
|
+
headers,
|
|
278
|
+
body: init.body,
|
|
279
|
+
redirect: "manual",
|
|
280
|
+
signal
|
|
281
|
+
});
|
|
282
|
+
if (response.status >= 300 && response.status < 400) {
|
|
283
|
+
throw new ConfluenceApiError("Redirect responses are not allowed", response.status, endpoint, "");
|
|
284
|
+
}
|
|
285
|
+
const text = await readBodyBounded(response, MAX_ERROR_BODY_LENGTH);
|
|
286
|
+
if (!response.ok) {
|
|
287
|
+
if (isSafe && shouldRetryStatus(response.status) && attempt < maxRetries) {
|
|
288
|
+
attempt += 1;
|
|
289
|
+
lastError = new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
|
|
290
|
+
await sleepBackoff(response, endpoint);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
throw new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
|
|
294
|
+
}
|
|
295
|
+
if (text.length === 0) {
|
|
296
|
+
return {};
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
return JSON.parse(text);
|
|
300
|
+
} catch {
|
|
301
|
+
throw new ConfluenceApiError("Response was not valid JSON", response.status, endpoint, text);
|
|
302
|
+
}
|
|
303
|
+
} catch (cause) {
|
|
304
|
+
if (cause instanceof ConfluenceApiError || cause instanceof ConfluenceUploadError) {
|
|
305
|
+
throw cause;
|
|
306
|
+
}
|
|
307
|
+
const reason = cause instanceof Error ? cause : void 0;
|
|
308
|
+
if (isSafe && reason && reason.name !== "AbortError" && attempt < maxRetries) {
|
|
309
|
+
attempt += 1;
|
|
310
|
+
lastError = cause;
|
|
311
|
+
await sleepBackoff(void 0, endpoint, reason);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
throw new ConfluenceApiError(reason ? `Network error: ${reason.message}` : "Network error", 0, endpoint, "");
|
|
167
315
|
}
|
|
168
316
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
response = await this.fetchFn(endpoint, {
|
|
172
|
-
method: init.method,
|
|
173
|
-
headers,
|
|
174
|
-
body: init.body
|
|
175
|
-
});
|
|
176
|
-
} catch (cause) {
|
|
177
|
-
throw new ConfluenceApiError(
|
|
178
|
-
cause instanceof Error ? `Network error: ${cause.message}` : "Network error",
|
|
179
|
-
0,
|
|
180
|
-
endpoint,
|
|
181
|
-
""
|
|
182
|
-
);
|
|
317
|
+
if (lastError instanceof Error) {
|
|
318
|
+
throw new ConfluenceApiError(`Network error: ${lastError.message}`, 0, endpoint, "");
|
|
183
319
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
320
|
+
throw new ConfluenceApiError("Network error", 0, endpoint, "");
|
|
321
|
+
}
|
|
322
|
+
makeTimeoutSignal() {
|
|
323
|
+
const ms = this.requestTimeoutMs;
|
|
324
|
+
if (ms <= 0) {
|
|
325
|
+
return new AbortController().signal;
|
|
187
326
|
}
|
|
188
|
-
if (
|
|
189
|
-
return
|
|
327
|
+
if (typeof AbortSignal.timeout === "function") {
|
|
328
|
+
return AbortSignal.timeout(ms);
|
|
190
329
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const timer = setTimeout(() => controller.abort(new Error(`Request timed out after ${ms}ms`)), ms);
|
|
332
|
+
if (typeof timer.unref === "function") {
|
|
333
|
+
timer.unref();
|
|
195
334
|
}
|
|
335
|
+
return controller.signal;
|
|
196
336
|
}
|
|
197
337
|
v2Url(path) {
|
|
198
338
|
return this.baseUrl + V2_PATH + path;
|
|
@@ -201,6 +341,102 @@ var ConfluenceClient = class {
|
|
|
201
341
|
return this.baseUrl + V1_PATH + path;
|
|
202
342
|
}
|
|
203
343
|
};
|
|
344
|
+
function shouldRetryStatus(status) {
|
|
345
|
+
return status === STATUS_CODES.TOO_MANY_REQUESTS || status >= 500;
|
|
346
|
+
}
|
|
347
|
+
async function sleepBackoff(response, endpoint, cause) {
|
|
348
|
+
let delayMs = 0;
|
|
349
|
+
if (response) {
|
|
350
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
351
|
+
if (retryAfter) {
|
|
352
|
+
delayMs = parseRetryAfter(retryAfter, endpoint);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (delayMs <= 0) {
|
|
356
|
+
const base = cause ? 200 : 500;
|
|
357
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
358
|
+
delayMs = base + jitter;
|
|
359
|
+
}
|
|
360
|
+
const boundedDelay = Math.min(delayMs, 3e4);
|
|
361
|
+
await delay(boundedDelay);
|
|
362
|
+
}
|
|
363
|
+
function parseRetryAfter(value, endpoint) {
|
|
364
|
+
if (!value) {
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
if (/^\d+$/.test(value.trim())) {
|
|
368
|
+
const seconds = Number(value);
|
|
369
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
return Math.min(seconds * 1e3, 3e4);
|
|
373
|
+
}
|
|
374
|
+
const date = Date.parse(value);
|
|
375
|
+
if (!Number.isNaN(date)) {
|
|
376
|
+
return Math.max(0, Math.min(date - Date.now(), 3e4));
|
|
377
|
+
}
|
|
378
|
+
void endpoint;
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
function delay(ms) {
|
|
382
|
+
if (ms <= 0) {
|
|
383
|
+
return Promise.resolve();
|
|
384
|
+
}
|
|
385
|
+
return new Promise((resolve4) => {
|
|
386
|
+
setTimeout(resolve4, ms);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async function readBodyBounded(response, maxBytes) {
|
|
390
|
+
let total = 0;
|
|
391
|
+
const chunks = [];
|
|
392
|
+
const reader = response.body?.[Symbol.asyncIterator]?.();
|
|
393
|
+
if (reader) {
|
|
394
|
+
let oversized = false;
|
|
395
|
+
while (true) {
|
|
396
|
+
const step = await reader.next();
|
|
397
|
+
if (step.done) {
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
const chunk = Buffer.isBuffer(step.value) ? step.value : Buffer.from(step.value);
|
|
401
|
+
if (total + chunk.length > maxBytes) {
|
|
402
|
+
oversized = true;
|
|
403
|
+
chunks.push(chunk.subarray(0, Math.max(0, maxBytes - total)));
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
total += chunk.length;
|
|
407
|
+
chunks.push(chunk);
|
|
408
|
+
}
|
|
409
|
+
let body = Buffer.concat(chunks).toString("utf8");
|
|
410
|
+
if (oversized) {
|
|
411
|
+
body = body + "...";
|
|
412
|
+
}
|
|
413
|
+
return body;
|
|
414
|
+
}
|
|
415
|
+
const text = await response.text();
|
|
416
|
+
if (text.length > maxBytes) {
|
|
417
|
+
return text.slice(0, maxBytes) + "...";
|
|
418
|
+
}
|
|
419
|
+
return text;
|
|
420
|
+
}
|
|
421
|
+
function sanitizeFilename(name) {
|
|
422
|
+
if (name === "" || name === "." || name === "..") {
|
|
423
|
+
throw new ConfluenceApiError("Invalid attachment filename", 0, "attachment", "");
|
|
424
|
+
}
|
|
425
|
+
let cleaned = "";
|
|
426
|
+
for (let i = 0; i < name.length; i += 1) {
|
|
427
|
+
const code = name.charCodeAt(i);
|
|
428
|
+
if (code === 34 || code === 39 || code === 13 || code === 10 || code === 0) {
|
|
429
|
+
cleaned += "_";
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
cleaned += name[i] ?? "";
|
|
433
|
+
}
|
|
434
|
+
cleaned = cleaned.replace(/[/\\]/g, "_");
|
|
435
|
+
if (cleaned === "" || cleaned === "." || cleaned === "..") {
|
|
436
|
+
throw new ConfluenceApiError("Invalid attachment filename", 0, "attachment", "");
|
|
437
|
+
}
|
|
438
|
+
return cleaned;
|
|
439
|
+
}
|
|
204
440
|
function multipartField(boundary, name, filename, value) {
|
|
205
441
|
const headerLines = [`--${boundary}\r
|
|
206
442
|
`];
|
|
@@ -232,11 +468,41 @@ function normalizeBaseUrl(baseUrl) {
|
|
|
232
468
|
if (trimmed.length === 0) {
|
|
233
469
|
throw new Error("baseUrl must not be empty");
|
|
234
470
|
}
|
|
235
|
-
let url
|
|
236
|
-
|
|
237
|
-
url =
|
|
471
|
+
let url;
|
|
472
|
+
try {
|
|
473
|
+
url = new URL(trimmed);
|
|
474
|
+
} catch {
|
|
475
|
+
throw new Error(`Invalid baseUrl: ${baseUrl}`);
|
|
238
476
|
}
|
|
239
|
-
|
|
477
|
+
if (url.protocol !== "https:") {
|
|
478
|
+
throw new Error(`baseUrl must use https: ${baseUrl}`);
|
|
479
|
+
}
|
|
480
|
+
if (url.username || url.password) {
|
|
481
|
+
throw new Error("baseUrl must not include embedded credentials");
|
|
482
|
+
}
|
|
483
|
+
if (url.search || url.hash) {
|
|
484
|
+
throw new Error("baseUrl must not include query or fragment components");
|
|
485
|
+
}
|
|
486
|
+
const normalizedPath = url.pathname.replace(/\/+$/u, "") || "/";
|
|
487
|
+
if (normalizedPath !== "/" && normalizedPath !== "/wiki") {
|
|
488
|
+
throw new Error(`baseUrl path must be empty or /wiki: ${baseUrl}`);
|
|
489
|
+
}
|
|
490
|
+
return normalizedPath === "/" ? url.origin : `${url.origin}${normalizedPath}`;
|
|
491
|
+
}
|
|
492
|
+
function resolveNextUrl(baseUrl, baseUrlOrigin, next) {
|
|
493
|
+
if (!next) {
|
|
494
|
+
return void 0;
|
|
495
|
+
}
|
|
496
|
+
let resolved;
|
|
497
|
+
try {
|
|
498
|
+
resolved = new URL(next, baseUrl);
|
|
499
|
+
} catch {
|
|
500
|
+
throw new ConfluenceApiError(`Confluence pagination returned a malformed next link`, 0, next, "");
|
|
501
|
+
}
|
|
502
|
+
if (resolved.origin !== baseUrlOrigin) {
|
|
503
|
+
throw new ConfluenceApiError(`Confluence pagination returned a cross-origin next link: ${next}`, 0, next, "");
|
|
504
|
+
}
|
|
505
|
+
return resolved.toString();
|
|
240
506
|
}
|
|
241
507
|
function describeStatus(status) {
|
|
242
508
|
switch (status) {
|
|
@@ -248,6 +514,8 @@ function describeStatus(status) {
|
|
|
248
514
|
return "Resource not found";
|
|
249
515
|
case STATUS_CODES.CONFLICT:
|
|
250
516
|
return "Version conflict (page was updated concurrently)";
|
|
517
|
+
case STATUS_CODES.TOO_MANY_REQUESTS:
|
|
518
|
+
return "Rate limited by Confluence";
|
|
251
519
|
default:
|
|
252
520
|
if (status >= STATUS_CODES.BAD_REQUEST && status < 500) {
|
|
253
521
|
return `Client error (${status})`;
|
|
@@ -313,15 +581,79 @@ function titleFromSegment(segment) {
|
|
|
313
581
|
// src/markdown.ts
|
|
314
582
|
var STORAGE_LINE_BREAK = "<br />";
|
|
315
583
|
var LINE_BREAK_SENTINEL = "BR";
|
|
316
|
-
var PROTOCOL_BLOCKLIST = /^(?:javascript|data|file|vbscript):/i;
|
|
317
584
|
var AMP = "&";
|
|
318
585
|
var LT = "<";
|
|
319
586
|
var GT = ">";
|
|
320
587
|
var QUOT = """;
|
|
321
588
|
var APOS = "'";
|
|
322
|
-
|
|
323
|
-
|
|
589
|
+
var HTTP_SRE = /^(https?:)?\/\//i;
|
|
590
|
+
var ABSOLUTE_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
591
|
+
var BLOCKED_SCHEMES = /* @__PURE__ */ new Set(["javascript", "data", "file", "vbscript"]);
|
|
592
|
+
var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
|
|
593
|
+
function schemeOf(url) {
|
|
594
|
+
const match = ABSOLUTE_SCHEME_RE.exec(url);
|
|
595
|
+
if (!match) {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
return match[0].slice(0, -1).toLowerCase();
|
|
599
|
+
}
|
|
600
|
+
function decodedSchemeOrNull(url) {
|
|
601
|
+
const match = ABSOLUTE_SCHEME_RE.exec(url);
|
|
602
|
+
if (!match) {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
const rawScheme = match[0].slice(0, -1);
|
|
606
|
+
try {
|
|
607
|
+
return decodeURIComponent(rawScheme).toLowerCase();
|
|
608
|
+
} catch {
|
|
609
|
+
return rawScheme.toLowerCase();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function isAllowedUrl(url) {
|
|
613
|
+
if (url.length === 0) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
for (let i = 0; i < url.length; i += 1) {
|
|
617
|
+
const code = url.charCodeAt(i);
|
|
618
|
+
if (code < 32 || code === 127) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const scheme = schemeOf(url);
|
|
623
|
+
if (scheme === null) {
|
|
624
|
+
return HTTP_SRE.test(url) || /^[/?#]/.test(url) || !/:/.test(url);
|
|
625
|
+
}
|
|
626
|
+
const decoded = decodedSchemeOrNull(url) ?? scheme;
|
|
627
|
+
if (BLOCKED_SCHEMES.has(scheme) || BLOCKED_SCHEMES.has(decoded)) {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
return ALLOWED_SCHEMES.has(scheme);
|
|
631
|
+
}
|
|
632
|
+
var MERMAID_PLACEHOLDER_PREFIX = '<ac:structured-macro ac:name="mermaid-placeholder" data-mermaid-id="';
|
|
633
|
+
var MERMAID_PLACEHOLDER_RE_STRICT = /<ac:structured-macro ac:name="mermaid-placeholder" data-mermaid-id="([^"]+)"><\/ac:structured-macro>/g;
|
|
634
|
+
function mermaidPlaceholderRe() {
|
|
635
|
+
return new RegExp(MERMAID_PLACEHOLDER_RE_STRICT.source, "g");
|
|
636
|
+
}
|
|
637
|
+
function renderMermaidPlaceholder(id) {
|
|
638
|
+
return `${MERMAID_PLACEHOLDER_PREFIX}${escapeXmlAttribute(id)}"></ac:structured-macro>`;
|
|
639
|
+
}
|
|
640
|
+
function markdownToStorage(markdown, options = {}) {
|
|
641
|
+
const renderHtmlBlocks = options.renderHtmlBlocks === true;
|
|
642
|
+
let lines = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
643
|
+
if (lines.length > 0 && lines[0] === "---") {
|
|
644
|
+
let closeIndex = -1;
|
|
645
|
+
for (let j = 1; j < lines.length; j += 1) {
|
|
646
|
+
if (lines[j] === "---" || lines[j] === "...") {
|
|
647
|
+
closeIndex = j;
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (closeIndex !== -1) {
|
|
652
|
+
lines = lines.slice(closeIndex + 1);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
324
655
|
const out = [];
|
|
656
|
+
const mermaidBlocks = [];
|
|
325
657
|
let i = 0;
|
|
326
658
|
while (i < lines.length) {
|
|
327
659
|
const line = lines[i];
|
|
@@ -344,7 +676,16 @@ function markdownToStorage(markdown) {
|
|
|
344
676
|
i += 1;
|
|
345
677
|
}
|
|
346
678
|
i += 1;
|
|
347
|
-
|
|
679
|
+
const code = buf.join("\n");
|
|
680
|
+
if (lang === "mermaid") {
|
|
681
|
+
const id = `mermaid-${mermaidBlocks.length + 1}`;
|
|
682
|
+
mermaidBlocks.push({ id, source: code });
|
|
683
|
+
out.push(renderMermaidPlaceholder(id));
|
|
684
|
+
} else if (lang === "html" && renderHtmlBlocks) {
|
|
685
|
+
out.push(renderHtmlBlock(code));
|
|
686
|
+
} else {
|
|
687
|
+
out.push(renderCodeBlock(code, lang));
|
|
688
|
+
}
|
|
348
689
|
continue;
|
|
349
690
|
}
|
|
350
691
|
if (/^\s{0,3}(?:-|\*|\+)\s+/.test(line) || /^\s{0,3}\d+\.\s+/.test(line)) {
|
|
@@ -381,7 +722,7 @@ function markdownToStorage(markdown) {
|
|
|
381
722
|
const renderedPara = renderInline(para.join(LINE_BREAK_SENTINEL));
|
|
382
723
|
out.push(`<p>${renderedPara.split(LINE_BREAK_SENTINEL).join(STORAGE_LINE_BREAK)}</p>`);
|
|
383
724
|
}
|
|
384
|
-
return { html: out.join("\n") };
|
|
725
|
+
return { html: out.join("\n"), mermaidBlocks };
|
|
385
726
|
}
|
|
386
727
|
function isLikelyListTerminator(lines, currentIndex) {
|
|
387
728
|
for (let k = currentIndex + 1; k < lines.length; k += 1) {
|
|
@@ -408,6 +749,9 @@ function renderCodeBlock(code, _lang) {
|
|
|
408
749
|
const titleAttr = escapeXmlAttribute(lang);
|
|
409
750
|
return `<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${titleAttr}</ac:parameter><ac:plain-text-body><![CDATA[${escapeCdataTerminator(code)}]]></ac:plain-text-body></ac:structured-macro>`;
|
|
410
751
|
}
|
|
752
|
+
function renderHtmlBlock(code) {
|
|
753
|
+
return `<ac:structured-macro ac:name="html"><ac:plain-text-body><![CDATA[${escapeCdataTerminator(code)}]]></ac:plain-text-body></ac:structured-macro>`;
|
|
754
|
+
}
|
|
411
755
|
function escapeCdataTerminator(text) {
|
|
412
756
|
return text.replace(/]]>/g, "]]]]><![CDATA[>");
|
|
413
757
|
}
|
|
@@ -432,86 +776,264 @@ function renderList(listLines) {
|
|
|
432
776
|
return `<${tag}>${body}</${tag}>`;
|
|
433
777
|
}
|
|
434
778
|
function renderInline(text) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
s = applyStrong(s);
|
|
440
|
-
s = applyInlineCode(s);
|
|
441
|
-
return s;
|
|
442
|
-
}
|
|
443
|
-
function applyLinks(text) {
|
|
444
|
-
return replaceBalancedSyntax(text, /\[([^\]]+)\]\(/g, (label, url) => {
|
|
445
|
-
if (PROTOCOL_BLOCKLIST.test(url)) {
|
|
446
|
-
return label;
|
|
447
|
-
}
|
|
448
|
-
return '<a href="' + escapeXmlAttribute(url) + '">' + label + "</a>";
|
|
449
|
-
});
|
|
779
|
+
const out = [];
|
|
780
|
+
const tokens = tokenizeInline(text, 0, text.length);
|
|
781
|
+
renderTokens(text, tokens, out);
|
|
782
|
+
return out.join("");
|
|
450
783
|
}
|
|
451
|
-
function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
784
|
+
function renderTokens(text, tokens, out) {
|
|
785
|
+
renderTokenRange(text, tokens, 0, tokens.length, out);
|
|
786
|
+
}
|
|
787
|
+
function renderTokenRange(text, tokens, from, to, out) {
|
|
788
|
+
for (let i = from; i < to; i += 1) {
|
|
789
|
+
const open = tokens[i];
|
|
790
|
+
if (open.kind !== "delimiter" || !open.canOpen) {
|
|
791
|
+
out.push(renderSingleToken(text, open));
|
|
792
|
+
continue;
|
|
455
793
|
}
|
|
456
|
-
|
|
457
|
-
|
|
794
|
+
const closeIdx = findEmphasisClose(tokens, i, to, open);
|
|
795
|
+
if (closeIdx === -1) {
|
|
796
|
+
out.push(renderSingleToken(text, open));
|
|
797
|
+
continue;
|
|
458
798
|
}
|
|
459
|
-
|
|
460
|
-
|
|
799
|
+
const tag = open.runLength === 1 ? "em" : "strong";
|
|
800
|
+
const innerOut = [];
|
|
801
|
+
renderTokenRange(text, tokens, i + 1, closeIdx, innerOut);
|
|
802
|
+
out.push("<" + tag + ">" + innerOut.join("") + "</" + tag + ">");
|
|
803
|
+
i = closeIdx;
|
|
804
|
+
}
|
|
461
805
|
}
|
|
462
|
-
function
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
806
|
+
function renderSingleToken(text, token) {
|
|
807
|
+
if (token.kind === "text") {
|
|
808
|
+
return token.end > token.start ? escapeHtml(text.slice(token.start, token.end)) : "";
|
|
809
|
+
}
|
|
810
|
+
if (token.kind === "code") {
|
|
811
|
+
return "<code>" + escapeHtml(text.slice(token.contentStart, token.contentEnd)) + "</code>";
|
|
812
|
+
}
|
|
813
|
+
if (token.kind === "image") {
|
|
814
|
+
return renderImageToken(text, token);
|
|
815
|
+
}
|
|
816
|
+
if (token.kind === "link") {
|
|
817
|
+
return renderLinkToken(text, token);
|
|
818
|
+
}
|
|
819
|
+
return escapeHtml(token.marker.repeat(token.runLength));
|
|
820
|
+
}
|
|
821
|
+
function renderImageToken(text, token) {
|
|
822
|
+
const labelRaw = text.slice(token.labelStart, token.labelEnd);
|
|
823
|
+
if (!isAllowedUrl(token.url)) {
|
|
824
|
+
return escapeHtml(labelRaw);
|
|
825
|
+
}
|
|
826
|
+
if (!isRemoteUrl(token.url)) {
|
|
827
|
+
return '<ac:image data-local-src="' + escapeXmlAttribute(token.url) + '"></ac:image>';
|
|
828
|
+
}
|
|
829
|
+
const alt = renderInline(labelRaw);
|
|
830
|
+
return '<ac:image><ri:url ri:value="' + escapeXmlAttribute(token.url) + '" />' + alt + "</ac:image>";
|
|
831
|
+
}
|
|
832
|
+
function renderLinkToken(text, token) {
|
|
833
|
+
const labelRaw = text.slice(token.labelStart, token.labelEnd);
|
|
834
|
+
if (!isAllowedUrl(token.url)) {
|
|
835
|
+
return renderInline(labelRaw);
|
|
836
|
+
}
|
|
837
|
+
return '<a href="' + escapeXmlAttribute(token.url) + '">' + renderInline(labelRaw) + "</a>";
|
|
838
|
+
}
|
|
839
|
+
function findEmphasisClose(tokens, openIdx, to, open) {
|
|
840
|
+
let depth = 0;
|
|
841
|
+
for (let i = openIdx + 1; i < to; i += 1) {
|
|
842
|
+
const t = tokens[i];
|
|
843
|
+
if (t.kind !== "delimiter") {
|
|
475
844
|
continue;
|
|
476
845
|
}
|
|
477
|
-
|
|
478
|
-
|
|
846
|
+
const m = t.marker;
|
|
847
|
+
if (m === open.marker && t.runLength === open.runLength && t.canOpen && !t.canClose) {
|
|
848
|
+
depth += 1;
|
|
849
|
+
} else if (m === open.marker && t.runLength === open.runLength && t.canClose) {
|
|
850
|
+
if (depth === 0) {
|
|
851
|
+
return i;
|
|
852
|
+
}
|
|
853
|
+
depth -= 1;
|
|
854
|
+
}
|
|
479
855
|
}
|
|
480
|
-
|
|
481
|
-
return out;
|
|
856
|
+
return -1;
|
|
482
857
|
}
|
|
483
|
-
function
|
|
858
|
+
function tokenizeInline(text, start, end) {
|
|
859
|
+
const tokens = [];
|
|
860
|
+
let cursor = start;
|
|
861
|
+
let textStart = start;
|
|
862
|
+
const flushText = (stop) => {
|
|
863
|
+
if (stop > textStart) {
|
|
864
|
+
tokens.push({ kind: "text", start: textStart, end: stop });
|
|
865
|
+
}
|
|
866
|
+
textStart = stop;
|
|
867
|
+
};
|
|
868
|
+
while (cursor < end) {
|
|
869
|
+
const ch = text[cursor];
|
|
870
|
+
if (ch === "\\" && cursor + 1 < end && isAsciiPunctuation(text.charCodeAt(cursor + 1))) {
|
|
871
|
+
flushText(cursor);
|
|
872
|
+
tokens.push({ kind: "text", start: cursor + 1, end: cursor + 2 });
|
|
873
|
+
cursor += 2;
|
|
874
|
+
textStart = cursor;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (ch === "`") {
|
|
878
|
+
const codeSpan = scanInlineCode(text, cursor, end);
|
|
879
|
+
if (codeSpan) {
|
|
880
|
+
flushText(cursor);
|
|
881
|
+
tokens.push({ kind: "code", contentStart: codeSpan.contentStart, contentEnd: codeSpan.contentEnd });
|
|
882
|
+
cursor = codeSpan.nextCursor;
|
|
883
|
+
textStart = cursor;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
cursor += 1;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (ch === "!" && cursor + 1 < end && text[cursor + 1] === "[") {
|
|
890
|
+
const link = tryParseLink(text, cursor + 1, end);
|
|
891
|
+
if (link) {
|
|
892
|
+
flushText(cursor);
|
|
893
|
+
tokens.push({ kind: "image", labelStart: cursor + 2, labelEnd: link.labelEnd, url: link.url });
|
|
894
|
+
cursor = link.nextCursor;
|
|
895
|
+
textStart = cursor;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (ch === "[") {
|
|
900
|
+
const link = tryParseLink(text, cursor, end);
|
|
901
|
+
if (link) {
|
|
902
|
+
flushText(cursor);
|
|
903
|
+
tokens.push({ kind: "link", labelStart: cursor + 1, labelEnd: link.labelEnd, url: link.url });
|
|
904
|
+
cursor = link.nextCursor;
|
|
905
|
+
textStart = cursor;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (ch === "*" || ch === "_") {
|
|
910
|
+
let runEnd = cursor;
|
|
911
|
+
while (runEnd < end && text[runEnd] === ch) {
|
|
912
|
+
runEnd += 1;
|
|
913
|
+
}
|
|
914
|
+
const runLength = runEnd - cursor;
|
|
915
|
+
const beforeCode = cursor === start ? -1 : text.charCodeAt(cursor - 1);
|
|
916
|
+
const afterCode = runEnd === end ? -1 : text.charCodeAt(runEnd);
|
|
917
|
+
const beforeIsSpace = beforeCode === -1 || isInlineWhitespace(beforeCode);
|
|
918
|
+
const afterIsSpace = afterCode === -1 || isInlineWhitespace(afterCode);
|
|
919
|
+
const leftFlanking = !afterIsSpace;
|
|
920
|
+
const rightFlanking = !beforeIsSpace;
|
|
921
|
+
let canOpen;
|
|
922
|
+
let canClose;
|
|
923
|
+
if (ch === "_") {
|
|
924
|
+
const beforeIsPunct = beforeCode !== -1 && isAsciiPunctuation(beforeCode);
|
|
925
|
+
const afterIsPunct = afterCode !== -1 && isAsciiPunctuation(afterCode);
|
|
926
|
+
canOpen = leftFlanking && (!rightFlanking || afterIsPunct);
|
|
927
|
+
canClose = rightFlanking && (!leftFlanking || beforeIsPunct);
|
|
928
|
+
} else {
|
|
929
|
+
canOpen = leftFlanking;
|
|
930
|
+
canClose = rightFlanking;
|
|
931
|
+
}
|
|
932
|
+
flushText(cursor);
|
|
933
|
+
const m = ch;
|
|
934
|
+
tokens.push({ kind: "delimiter", marker: m, runLength, canOpen, canClose });
|
|
935
|
+
cursor = runEnd;
|
|
936
|
+
textStart = cursor;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
cursor += 1;
|
|
940
|
+
}
|
|
941
|
+
flushText(end);
|
|
942
|
+
return tokens;
|
|
943
|
+
}
|
|
944
|
+
function scanInlineCode(text, start, end) {
|
|
945
|
+
let openEnd = start;
|
|
946
|
+
while (openEnd < end && text[openEnd] === "`") {
|
|
947
|
+
openEnd += 1;
|
|
948
|
+
}
|
|
949
|
+
const openRun = openEnd - start;
|
|
950
|
+
let closeStart = -1;
|
|
951
|
+
let search = openEnd;
|
|
952
|
+
while (search < end) {
|
|
953
|
+
if (text[search] !== "`") {
|
|
954
|
+
search += 1;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
let runEnd = search;
|
|
958
|
+
while (runEnd < end && text[runEnd] === "`") {
|
|
959
|
+
runEnd += 1;
|
|
960
|
+
}
|
|
961
|
+
if (runEnd - search === openRun) {
|
|
962
|
+
closeStart = search;
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
search = runEnd;
|
|
966
|
+
}
|
|
967
|
+
if (closeStart === -1) {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
let contentStart = openEnd;
|
|
971
|
+
let contentEnd = closeStart;
|
|
972
|
+
if (contentEnd - contentStart >= 2 && text[contentStart] === " " && text[contentEnd - 1] === " ") {
|
|
973
|
+
contentStart += 1;
|
|
974
|
+
contentEnd -= 1;
|
|
975
|
+
}
|
|
976
|
+
return { contentStart, contentEnd, nextCursor: closeStart + openRun };
|
|
977
|
+
}
|
|
978
|
+
function isInlineWhitespace(code) {
|
|
979
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
980
|
+
}
|
|
981
|
+
function isAsciiPunctuation(code) {
|
|
982
|
+
return code >= 33 && code <= 47 || code >= 58 && code <= 64 || code >= 91 && code <= 96 || code >= 123 && code <= 126;
|
|
983
|
+
}
|
|
984
|
+
function tryParseLink(text, bracketStart, end) {
|
|
484
985
|
let depth = 0;
|
|
485
|
-
let i =
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
if (
|
|
986
|
+
let i = bracketStart + 1;
|
|
987
|
+
while (i < end) {
|
|
988
|
+
const c = text[i];
|
|
989
|
+
if (c === "\\") {
|
|
990
|
+
i += 2;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (c === "[") {
|
|
489
994
|
depth += 1;
|
|
490
|
-
} else if (
|
|
995
|
+
} else if (c === "]") {
|
|
491
996
|
if (depth === 0) {
|
|
492
|
-
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
depth -= 1;
|
|
1000
|
+
}
|
|
1001
|
+
i += 1;
|
|
1002
|
+
}
|
|
1003
|
+
if (i >= end || text[i] !== "]" || depth !== 0) {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
const labelEnd = i;
|
|
1007
|
+
if (i + 1 >= end || text[i + 1] !== "(") {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
let j = i + 2;
|
|
1011
|
+
let parenDepth = 0;
|
|
1012
|
+
while (j < end) {
|
|
1013
|
+
const c = text[j];
|
|
1014
|
+
if (c === "\\") {
|
|
1015
|
+
j += 2;
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (c === "(") {
|
|
1019
|
+
parenDepth += 1;
|
|
1020
|
+
} else if (c === ")") {
|
|
1021
|
+
if (parenDepth === 0) {
|
|
1022
|
+
const url = text.slice(i + 2, j).trim();
|
|
493
1023
|
if (url.length === 0) {
|
|
494
1024
|
return null;
|
|
495
1025
|
}
|
|
496
|
-
return { url,
|
|
1026
|
+
return { labelEnd, url, nextCursor: j + 1 };
|
|
497
1027
|
}
|
|
498
|
-
|
|
499
|
-
} else if (
|
|
1028
|
+
parenDepth -= 1;
|
|
1029
|
+
} else if (c === " " || c === " " || c === "\n") {
|
|
500
1030
|
return null;
|
|
501
1031
|
}
|
|
1032
|
+
j += 1;
|
|
502
1033
|
}
|
|
503
1034
|
return null;
|
|
504
1035
|
}
|
|
505
1036
|
var LOCAL_IMAGE_PLACEHOLDER_RE = /<ac:image\s+data-local-src="([^"]*)"\s*><\/ac:image>/g;
|
|
506
|
-
function applyStrong(text) {
|
|
507
|
-
let s = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
508
|
-
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
509
|
-
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
510
|
-
return s.replace(/_([^_]+)_/g, "<em>$1</em>");
|
|
511
|
-
}
|
|
512
|
-
function applyInlineCode(text) {
|
|
513
|
-
return text.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`);
|
|
514
|
-
}
|
|
515
1037
|
function isRemoteUrl(src) {
|
|
516
1038
|
return /^(https?:)?\/\//i.test(src) || /^\/\//.test(src);
|
|
517
1039
|
}
|
|
@@ -533,14 +1055,58 @@ function escapeAttachmentFilename(filename) {
|
|
|
533
1055
|
}
|
|
534
1056
|
|
|
535
1057
|
// src/attachments.ts
|
|
536
|
-
import { existsSync } from "fs";
|
|
537
|
-
import { normalize as normalize2, isAbsolute, resolve } from "path";
|
|
1058
|
+
import { existsSync, lstatSync, realpathSync } from "fs";
|
|
1059
|
+
import { normalize as normalize2, isAbsolute, relative as relative2, resolve } from "path";
|
|
1060
|
+
|
|
1061
|
+
// src/content-hash.ts
|
|
1062
|
+
import { createHash } from "crypto";
|
|
1063
|
+
import { readFileSync } from "fs";
|
|
1064
|
+
var SHORT_HASH_LENGTH = 16;
|
|
1065
|
+
function shortHashBytes(bytes) {
|
|
1066
|
+
return createHash("sha256").update(bytes).digest("hex").slice(0, SHORT_HASH_LENGTH);
|
|
1067
|
+
}
|
|
1068
|
+
function shortHashString(value) {
|
|
1069
|
+
return createHash("sha256").update(value, "utf8").digest("hex").slice(0, SHORT_HASH_LENGTH);
|
|
1070
|
+
}
|
|
1071
|
+
function shortHashFile(absPath) {
|
|
1072
|
+
const buf = readFileSync(absPath);
|
|
1073
|
+
return shortHashBytes(buf);
|
|
1074
|
+
}
|
|
1075
|
+
function splitStemAndExt(basename2) {
|
|
1076
|
+
const dot = basename2.lastIndexOf(".");
|
|
1077
|
+
if (dot <= 0 || dot === basename2.length - 1) {
|
|
1078
|
+
return { stem: basename2, ext: "" };
|
|
1079
|
+
}
|
|
1080
|
+
return { stem: basename2.slice(0, dot), ext: basename2.slice(dot + 1) };
|
|
1081
|
+
}
|
|
1082
|
+
function buildStableName(originalBasename, hash) {
|
|
1083
|
+
const { stem, ext } = splitStemAndExt(originalBasename);
|
|
1084
|
+
const safeStem = sanitizeNameSegment(stem);
|
|
1085
|
+
const safeExt = ext ? "." + sanitizeNameSegment(ext) : "";
|
|
1086
|
+
return `${safeStem}-${hash}${safeExt}`;
|
|
1087
|
+
}
|
|
1088
|
+
function sanitizeNameSegment(segment) {
|
|
1089
|
+
return segment.replace(/[/\\]/g, "_");
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// src/attachments.ts
|
|
1093
|
+
var DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
1094
|
+
var DEFAULT_MAX_TOTAL_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
1095
|
+
var UPLOAD_COMMENT_PREFIX = "rt-content-sha256:";
|
|
1096
|
+
function validateAttachmentSources(html, options) {
|
|
1097
|
+
const collected = collectLocalSources(html, options);
|
|
1098
|
+
return collected.map(({ src, abs }) => ({
|
|
1099
|
+
src,
|
|
1100
|
+
abs,
|
|
1101
|
+
filename: stableAttachmentFilename(abs)
|
|
1102
|
+
}));
|
|
1103
|
+
}
|
|
538
1104
|
async function rewriteImagesToAttachments(html, pageId, client, options) {
|
|
539
1105
|
const uploaded = [];
|
|
540
1106
|
const resolved = await resolvePlaceholders(html, pageId, client, options, uploaded);
|
|
541
1107
|
return { html: resolved, uploaded };
|
|
542
1108
|
}
|
|
543
|
-
async function
|
|
1109
|
+
async function preflightImagesToAttachments(html, pageId, client, options) {
|
|
544
1110
|
const existing = await client.getAttachments(pageId);
|
|
545
1111
|
const existingByName = /* @__PURE__ */ new Map();
|
|
546
1112
|
for (const a of existing) {
|
|
@@ -549,30 +1115,53 @@ async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
|
549
1115
|
existingByName.set(name, a);
|
|
550
1116
|
}
|
|
551
1117
|
}
|
|
552
|
-
const collected =
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
1118
|
+
const collected = collectLocalSources(html, options);
|
|
1119
|
+
const pending = [];
|
|
1120
|
+
const reused = [];
|
|
1121
|
+
const srcToFilename = /* @__PURE__ */ new Map();
|
|
1122
|
+
for (const { src, abs } of collected) {
|
|
1123
|
+
const hash = shortHashFile(abs);
|
|
1124
|
+
const filename = stableAttachmentFilename(abs);
|
|
1125
|
+
srcToFilename.set(src, filename);
|
|
1126
|
+
const existingAtt = existingByName.get(filename);
|
|
1127
|
+
if (existingAtt && existingAtt.id && attachmentContentHash(existingAtt) === hash) {
|
|
1128
|
+
reused.push({ src, attachment: existingAtt });
|
|
1129
|
+
} else {
|
|
1130
|
+
pending.push({ src, filename, hash });
|
|
560
1131
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
1132
|
+
}
|
|
1133
|
+
const predicted = html.replace(LOCAL_IMAGE_PLACEHOLDER_RE, (full, encodedSrc) => {
|
|
1134
|
+
const src = decodePlaceholder(encodedSrc);
|
|
1135
|
+
const filename = srcToFilename.get(src);
|
|
1136
|
+
if (!filename) {
|
|
1137
|
+
return full;
|
|
1138
|
+
}
|
|
1139
|
+
return renderAttachmentMacro(filename);
|
|
1140
|
+
});
|
|
1141
|
+
return { html: predicted, pending, reused };
|
|
1142
|
+
}
|
|
1143
|
+
async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
1144
|
+
const existing = await client.getAttachments(pageId);
|
|
1145
|
+
const existingByName = /* @__PURE__ */ new Map();
|
|
1146
|
+
for (const a of existing) {
|
|
1147
|
+
const name = a.filename ?? a.title;
|
|
1148
|
+
if (name) {
|
|
1149
|
+
existingByName.set(name, a);
|
|
565
1150
|
}
|
|
566
|
-
collected.push({ src: rawSrc, abs });
|
|
567
1151
|
}
|
|
1152
|
+
const collected = collectLocalSources(html, options);
|
|
568
1153
|
const srcToFilename = /* @__PURE__ */ new Map();
|
|
569
1154
|
for (const { src, abs } of collected) {
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1155
|
+
const hash = shortHashFile(abs);
|
|
1156
|
+
const filename = stableAttachmentFilename(abs);
|
|
1157
|
+
const existingAtt = existingByName.get(filename);
|
|
1158
|
+
let attachment;
|
|
1159
|
+
if (existingAtt && existingAtt.id && attachmentContentHash(existingAtt) === hash) {
|
|
1160
|
+
attachment = existingAtt;
|
|
1161
|
+
} else if (existingAtt && existingAtt.id) {
|
|
1162
|
+
attachment = await client.updateAttachmentData(pageId, existingAtt.id, abs, contentHashComment(hash), filename);
|
|
574
1163
|
} else {
|
|
575
|
-
attachment = await client.uploadAttachment(pageId, abs,
|
|
1164
|
+
attachment = await client.uploadAttachment(pageId, abs, contentHashComment(hash), filename);
|
|
576
1165
|
}
|
|
577
1166
|
uploaded.push({ src, attachment });
|
|
578
1167
|
srcToFilename.set(src, filename);
|
|
@@ -586,18 +1175,76 @@ async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
|
586
1175
|
return renderAttachmentMacro(filename);
|
|
587
1176
|
});
|
|
588
1177
|
}
|
|
1178
|
+
function collectLocalSources(html, options) {
|
|
1179
|
+
const collected = [];
|
|
1180
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1181
|
+
const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
1182
|
+
const maxTotalAttachmentBytes = options.maxTotalAttachmentBytes ?? DEFAULT_MAX_TOTAL_ATTACHMENT_BYTES;
|
|
1183
|
+
let totalBytes = 0;
|
|
1184
|
+
let m;
|
|
1185
|
+
LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
|
|
1186
|
+
while ((m = LOCAL_IMAGE_PLACEHOLDER_RE.exec(html)) !== null) {
|
|
1187
|
+
const rawSrc = decodePlaceholder(m[1] ?? "");
|
|
1188
|
+
if (seen.has(rawSrc)) {
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
seen.add(rawSrc);
|
|
1192
|
+
const abs = resolveImageForUpload(options.markdownDir, options.allowedRoot, rawSrc);
|
|
1193
|
+
const sourceInfo = lstatSync(abs);
|
|
1194
|
+
if (!sourceInfo.isFile()) {
|
|
1195
|
+
throw new Error(`Attachment source must be a regular file: ${rawSrc}`);
|
|
1196
|
+
}
|
|
1197
|
+
if (sourceInfo.size > maxAttachmentBytes) {
|
|
1198
|
+
throw new Error(`Attachment source exceeds the size limit: ${rawSrc}`);
|
|
1199
|
+
}
|
|
1200
|
+
totalBytes += sourceInfo.size;
|
|
1201
|
+
if (totalBytes > maxTotalAttachmentBytes) {
|
|
1202
|
+
throw new Error(`Attachment sources exceed the total size limit for this document`);
|
|
1203
|
+
}
|
|
1204
|
+
collected.push({ src: rawSrc, abs });
|
|
1205
|
+
}
|
|
1206
|
+
return collected;
|
|
1207
|
+
}
|
|
1208
|
+
function stableAttachmentFilename(absPath) {
|
|
1209
|
+
const basename2 = basenameLocal(absPath);
|
|
1210
|
+
const hash = shortHashFile(absPath);
|
|
1211
|
+
return escapeAttachmentFilename(buildStableName(basename2, hash));
|
|
1212
|
+
}
|
|
1213
|
+
function attachmentContentHash(attachment) {
|
|
1214
|
+
const message = attachment.version?.message;
|
|
1215
|
+
if (!message) {
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
if (!message.startsWith(UPLOAD_COMMENT_PREFIX)) {
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
return message.slice(UPLOAD_COMMENT_PREFIX.length) || null;
|
|
1222
|
+
}
|
|
1223
|
+
function contentHashComment(hash) {
|
|
1224
|
+
return `${UPLOAD_COMMENT_PREFIX}${hash}`;
|
|
1225
|
+
}
|
|
589
1226
|
function renderAttachmentMacro(filename) {
|
|
590
1227
|
const safe = escapeAttachmentFilename(filename);
|
|
591
1228
|
return `<ac:image><ri:attachment ri:filename="${escapeXmlAttribute(safe)}" /></ac:image>`;
|
|
592
1229
|
}
|
|
593
|
-
function resolveImageForUpload(markdownDir, src) {
|
|
1230
|
+
function resolveImageForUpload(markdownDir, allowedRoot, src) {
|
|
594
1231
|
if (isRemoteUrl(src)) {
|
|
595
1232
|
throw new Error(`Remote image should not be uploaded: ${src}`);
|
|
596
1233
|
}
|
|
597
1234
|
if (isAbsolute(src)) {
|
|
598
|
-
|
|
1235
|
+
throw new Error(`Attachment source must be relative to the documentation root: ${src}`);
|
|
1236
|
+
}
|
|
1237
|
+
const resolvedPath = normalize2(resolve(markdownDir, src));
|
|
1238
|
+
if (!existsSync(resolvedPath)) {
|
|
1239
|
+
throw new Error(`Attachment source not found: ${src}`);
|
|
1240
|
+
}
|
|
1241
|
+
const resolvedRoot = realpathSync(allowedRoot);
|
|
1242
|
+
const realSourcePath = realpathSync(resolvedPath);
|
|
1243
|
+
const relativePath = relative2(resolvedRoot, realSourcePath);
|
|
1244
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
1245
|
+
throw new Error(`Attachment source escapes the documentation root: ${src}`);
|
|
599
1246
|
}
|
|
600
|
-
return
|
|
1247
|
+
return realSourcePath;
|
|
601
1248
|
}
|
|
602
1249
|
function basenameLocal(absPath) {
|
|
603
1250
|
const norm = normalize2(absPath);
|
|
@@ -614,22 +1261,347 @@ function decodePlaceholder(value) {
|
|
|
614
1261
|
return value.replace(new RegExp(AMP2, "g"), "&").replace(new RegExp(QUOT2, "g"), '"').replace(new RegExp(LT2, "g"), "<").replace(new RegExp(GT2, "g"), ">");
|
|
615
1262
|
}
|
|
616
1263
|
|
|
1264
|
+
// src/mermaid.ts
|
|
1265
|
+
import { spawn, spawnSync } from "child_process";
|
|
1266
|
+
import { mkdtemp, rm, writeFile, readFile } from "fs/promises";
|
|
1267
|
+
import { tmpdir } from "os";
|
|
1268
|
+
import { join as join2 } from "path";
|
|
1269
|
+
var DEFAULT_MERMAID_RENDER_TIMEOUT_MS = 3e4;
|
|
1270
|
+
var DEFAULT_MERMAID_MAX_STREAM_BYTES = 1024 * 1024;
|
|
1271
|
+
var SVG_START_RE = /<svg[\s/>]/;
|
|
1272
|
+
var SVG_END_RE = /<\/svg>\s*$|\/>\s*$/;
|
|
1273
|
+
var UPLOAD_COMMENT_PREFIX2 = "rt-content-sha256:";
|
|
1274
|
+
var MERMAID_BASENAME = "mermaid.svg";
|
|
1275
|
+
async function rewriteMermaidBlocks(html, blocks, pageId, client, options = {}) {
|
|
1276
|
+
const fallbacks = [];
|
|
1277
|
+
const uploaded = [];
|
|
1278
|
+
if (blocks.length === 0) {
|
|
1279
|
+
return { html, fallbacks, uploaded };
|
|
1280
|
+
}
|
|
1281
|
+
const existing = await client.getAttachments(pageId);
|
|
1282
|
+
const existingByName = /* @__PURE__ */ new Map();
|
|
1283
|
+
for (const a of existing) {
|
|
1284
|
+
const name = a.filename ?? a.title;
|
|
1285
|
+
if (name) {
|
|
1286
|
+
existingByName.set(name, a);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const renderHook = options.renderHook ?? defaultRenderHook;
|
|
1290
|
+
let mmdcAvailable;
|
|
1291
|
+
if (options.renderHook) {
|
|
1292
|
+
mmdcAvailable = true;
|
|
1293
|
+
} else if (options.available !== void 0) {
|
|
1294
|
+
mmdcAvailable = options.available;
|
|
1295
|
+
} else {
|
|
1296
|
+
mmdcAvailable = await isMmdcAvailable(options.mmdcPath);
|
|
1297
|
+
}
|
|
1298
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1299
|
+
const remainingIds = /* @__PURE__ */ new Set();
|
|
1300
|
+
for (const block of blocks) {
|
|
1301
|
+
const hash = shortHashString(block.source);
|
|
1302
|
+
const filename = mermaidAttachmentFilename(hash);
|
|
1303
|
+
if (!mmdcAvailable) {
|
|
1304
|
+
fallbacks.push(block.id);
|
|
1305
|
+
remainingIds.add(block.id);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
const existingAtt = existingByName.get(filename);
|
|
1309
|
+
if (existingAtt && existingAtt.id && attachmentContentHash2(existingAtt) === hash) {
|
|
1310
|
+
resolved.set(block.id, { filename, attachment: existingAtt });
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
const workDir = await mkdtemp(join2(tmpdir(), "rt-mermaid-"));
|
|
1314
|
+
try {
|
|
1315
|
+
const inPath = join2(workDir, "diagram.mmd");
|
|
1316
|
+
const outPath = join2(workDir, "diagram.svg");
|
|
1317
|
+
await writeFile(inPath, block.source, "utf8");
|
|
1318
|
+
try {
|
|
1319
|
+
await renderHook(block.source, outPath, options.mmdcPath, options.renderTimeoutMs, options.maxStreamBytes);
|
|
1320
|
+
} catch {
|
|
1321
|
+
fallbacks.push(block.id);
|
|
1322
|
+
remainingIds.add(block.id);
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
let svg;
|
|
1326
|
+
try {
|
|
1327
|
+
svg = await readFile(outPath, "utf8");
|
|
1328
|
+
} catch {
|
|
1329
|
+
fallbacks.push(block.id);
|
|
1330
|
+
remainingIds.add(block.id);
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
const trimmed = svg.trim();
|
|
1334
|
+
if (trimmed.length === 0 || !SVG_START_RE.test(trimmed) || !SVG_END_RE.test(trimmed)) {
|
|
1335
|
+
fallbacks.push(block.id);
|
|
1336
|
+
remainingIds.add(block.id);
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
let attachment;
|
|
1340
|
+
if (existingAtt && existingAtt.id) {
|
|
1341
|
+
attachment = await client.updateAttachmentData(
|
|
1342
|
+
pageId,
|
|
1343
|
+
existingAtt.id,
|
|
1344
|
+
outPath,
|
|
1345
|
+
contentHashComment2(hash),
|
|
1346
|
+
filename
|
|
1347
|
+
);
|
|
1348
|
+
} else {
|
|
1349
|
+
attachment = await client.uploadAttachment(pageId, outPath, contentHashComment2(hash), filename);
|
|
1350
|
+
}
|
|
1351
|
+
if (attachment) {
|
|
1352
|
+
uploaded.push({ id: block.id, attachment });
|
|
1353
|
+
resolved.set(block.id, { filename, attachment });
|
|
1354
|
+
} else {
|
|
1355
|
+
fallbacks.push(block.id);
|
|
1356
|
+
remainingIds.add(block.id);
|
|
1357
|
+
}
|
|
1358
|
+
} finally {
|
|
1359
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
const re = mermaidPlaceholderRe();
|
|
1364
|
+
const replaced = html.replace(re, (full, id) => {
|
|
1365
|
+
const entry = resolved.get(id);
|
|
1366
|
+
if (entry) {
|
|
1367
|
+
return renderAttachmentMacro2(entry.filename);
|
|
1368
|
+
}
|
|
1369
|
+
if (remainingIds.has(id)) {
|
|
1370
|
+
const block = blocks.find((b) => b.id === id);
|
|
1371
|
+
if (block) {
|
|
1372
|
+
return renderCodeBlock(block.source, "mermaid");
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return full;
|
|
1376
|
+
});
|
|
1377
|
+
return { html: replaced, fallbacks, uploaded };
|
|
1378
|
+
}
|
|
1379
|
+
async function preflightMermaidBlocks(html, blocks, pageId, client, options = {}) {
|
|
1380
|
+
const fallbacks = [];
|
|
1381
|
+
const pending = [];
|
|
1382
|
+
const reused = [];
|
|
1383
|
+
if (blocks.length === 0) {
|
|
1384
|
+
return { html, pending, reused, fallbacks };
|
|
1385
|
+
}
|
|
1386
|
+
const existing = await client.getAttachments(pageId);
|
|
1387
|
+
const existingByName = /* @__PURE__ */ new Map();
|
|
1388
|
+
for (const a of existing) {
|
|
1389
|
+
const name = a.filename ?? a.title;
|
|
1390
|
+
if (name) {
|
|
1391
|
+
existingByName.set(name, a);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
let mmdcAvailable;
|
|
1395
|
+
if (options.renderHook) {
|
|
1396
|
+
mmdcAvailable = true;
|
|
1397
|
+
} else if (options.available !== void 0) {
|
|
1398
|
+
mmdcAvailable = options.available;
|
|
1399
|
+
} else {
|
|
1400
|
+
mmdcAvailable = await isMmdcAvailable(options.mmdcPath);
|
|
1401
|
+
}
|
|
1402
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1403
|
+
const remainingIds = /* @__PURE__ */ new Set();
|
|
1404
|
+
for (const block of blocks) {
|
|
1405
|
+
const hash = shortHashString(block.source);
|
|
1406
|
+
const filename = mermaidAttachmentFilename(hash);
|
|
1407
|
+
if (!mmdcAvailable) {
|
|
1408
|
+
fallbacks.push(block.id);
|
|
1409
|
+
remainingIds.add(block.id);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
const existingAtt = existingByName.get(filename);
|
|
1413
|
+
if (existingAtt && existingAtt.id && attachmentContentHash2(existingAtt) === hash) {
|
|
1414
|
+
reused.push({ id: block.id, attachment: existingAtt });
|
|
1415
|
+
resolved.set(block.id, filename);
|
|
1416
|
+
} else {
|
|
1417
|
+
pending.push({ id: block.id, filename, hash });
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const re = mermaidPlaceholderRe();
|
|
1421
|
+
const predicted = html.replace(re, (full, id) => {
|
|
1422
|
+
const filename = resolved.get(id);
|
|
1423
|
+
if (filename) {
|
|
1424
|
+
return renderAttachmentMacro2(filename);
|
|
1425
|
+
}
|
|
1426
|
+
if (remainingIds.has(id)) {
|
|
1427
|
+
const block = blocks.find((b) => b.id === id);
|
|
1428
|
+
if (block) {
|
|
1429
|
+
return renderCodeBlock(block.source, "mermaid");
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return full;
|
|
1433
|
+
});
|
|
1434
|
+
return { html: predicted, pending, reused, fallbacks };
|
|
1435
|
+
}
|
|
1436
|
+
function mermaidAttachmentFilename(hash) {
|
|
1437
|
+
return escapeAttachmentFilename(buildStableName(MERMAID_BASENAME, hash));
|
|
1438
|
+
}
|
|
1439
|
+
function attachmentContentHash2(attachment) {
|
|
1440
|
+
const message = attachment.version?.message;
|
|
1441
|
+
if (!message) {
|
|
1442
|
+
return null;
|
|
1443
|
+
}
|
|
1444
|
+
if (!message.startsWith(UPLOAD_COMMENT_PREFIX2)) {
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
return message.slice(UPLOAD_COMMENT_PREFIX2.length) || null;
|
|
1448
|
+
}
|
|
1449
|
+
function contentHashComment2(hash) {
|
|
1450
|
+
return `${UPLOAD_COMMENT_PREFIX2}${hash}`;
|
|
1451
|
+
}
|
|
1452
|
+
function renderAttachmentMacro2(filename) {
|
|
1453
|
+
const safe = escapeAttachmentFilename(filename);
|
|
1454
|
+
return `<ac:image><ri:attachment ri:filename="${escapeXmlAttribute(safe)}" /></ac:image>`;
|
|
1455
|
+
}
|
|
1456
|
+
async function isMmdcAvailable(override) {
|
|
1457
|
+
if (override) {
|
|
1458
|
+
return true;
|
|
1459
|
+
}
|
|
1460
|
+
return spawnSync("sh", ["-c", "command -v mmdc"], { stdio: "ignore" }).status === 0;
|
|
1461
|
+
}
|
|
1462
|
+
async function defaultRenderHook(source, outFile, mmdcPath, timeoutMs, maxStreamBytes) {
|
|
1463
|
+
const timeout = timeoutMs ?? DEFAULT_MERMAID_RENDER_TIMEOUT_MS;
|
|
1464
|
+
const maxBytes = maxStreamBytes ?? DEFAULT_MERMAID_MAX_STREAM_BYTES;
|
|
1465
|
+
const cmdPath = mmdcPath ?? "mmdc";
|
|
1466
|
+
return runMmdc(cmdPath, source, outFile, timeout, maxBytes);
|
|
1467
|
+
}
|
|
1468
|
+
function runMmdc(cmdPath, source, outFile, timeoutMs, maxStreamBytes) {
|
|
1469
|
+
return new Promise((resolve4, reject) => {
|
|
1470
|
+
const child = spawn(cmdPath, ["-i", "-", "-o", outFile, "-t", "default", "-b", "transparent"], {
|
|
1471
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1472
|
+
shell: false
|
|
1473
|
+
});
|
|
1474
|
+
let stdoutLen = 0;
|
|
1475
|
+
let stderrLen = 0;
|
|
1476
|
+
let stderr = "";
|
|
1477
|
+
let killed = false;
|
|
1478
|
+
const timer = setTimeout(() => {
|
|
1479
|
+
killed = true;
|
|
1480
|
+
child.kill("SIGTERM");
|
|
1481
|
+
const sigKillTimer = setTimeout(() => {
|
|
1482
|
+
try {
|
|
1483
|
+
child.kill("SIGKILL");
|
|
1484
|
+
} catch {
|
|
1485
|
+
}
|
|
1486
|
+
}, 2e3);
|
|
1487
|
+
if (typeof sigKillTimer.unref === "function") {
|
|
1488
|
+
sigKillTimer.unref();
|
|
1489
|
+
}
|
|
1490
|
+
}, timeoutMs);
|
|
1491
|
+
if (typeof timer.unref === "function") {
|
|
1492
|
+
timer.unref();
|
|
1493
|
+
}
|
|
1494
|
+
child.stdout?.on("data", (chunk) => {
|
|
1495
|
+
stdoutLen += chunk.length;
|
|
1496
|
+
if (stdoutLen > maxStreamBytes && !killed) {
|
|
1497
|
+
killed = true;
|
|
1498
|
+
child.kill("SIGKILL");
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
child.stderr?.on("data", (chunk) => {
|
|
1502
|
+
stderrLen += chunk.length;
|
|
1503
|
+
if (stderrLen > maxStreamBytes && !killed) {
|
|
1504
|
+
killed = true;
|
|
1505
|
+
child.kill("SIGKILL");
|
|
1506
|
+
}
|
|
1507
|
+
if (stderr.length < maxStreamBytes) {
|
|
1508
|
+
stderr += chunk.toString("utf8").slice(0, Math.max(0, maxStreamBytes - stderr.length));
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
child.on("error", (err) => {
|
|
1512
|
+
clearTimeout(timer);
|
|
1513
|
+
reject(err);
|
|
1514
|
+
});
|
|
1515
|
+
child.on("close", (code, signal) => {
|
|
1516
|
+
clearTimeout(timer);
|
|
1517
|
+
if (killed && code !== 0) {
|
|
1518
|
+
reject(new Error(`mmdc exceeded ${timeoutMs}ms or output limit (killed=${signal ?? code})`));
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (code === 0) {
|
|
1522
|
+
resolve4();
|
|
1523
|
+
} else {
|
|
1524
|
+
reject(new Error(`mmdc exited with code ${String(code)}${stderr ? `: ${stderr}` : ""}`));
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
child.stdin?.on("error", () => void 0);
|
|
1528
|
+
child.stdin?.end(source);
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
|
|
617
1532
|
// src/index.ts
|
|
1533
|
+
var LocalSyncValidationAggregateError = class extends Error {
|
|
1534
|
+
constructor(defects) {
|
|
1535
|
+
const summary = defects.map((d) => `${d.entry.segments.join("/")}: ${d.error.message}`).join("; ");
|
|
1536
|
+
super(`Local sync validation failed for ${defects.length} document(s): ${summary}`);
|
|
1537
|
+
this.name = "LocalSyncValidationAggregateError";
|
|
1538
|
+
this.defects = defects;
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
function validateLocalSync(entries, plan) {
|
|
1542
|
+
const defects = [];
|
|
1543
|
+
const plans = [];
|
|
1544
|
+
for (const entry of entries) {
|
|
1545
|
+
try {
|
|
1546
|
+
const markdown = readFileSync2(entry.absolute, "utf8");
|
|
1547
|
+
const { html, mermaidBlocks } = markdownToStorage(markdown, {
|
|
1548
|
+
renderHtmlBlocks: plan.renderHtmlBlocks
|
|
1549
|
+
});
|
|
1550
|
+
const markdownDir = dirname(entry.absolute);
|
|
1551
|
+
const hasLocalImages = hasLocalImagePlaceholder(html);
|
|
1552
|
+
const hasMermaidBlocks = mermaidBlocks.length > 0;
|
|
1553
|
+
const attachments = hasLocalImages ? validateAttachmentSources(html, {
|
|
1554
|
+
markdownDir,
|
|
1555
|
+
allowedRoot: plan.folder
|
|
1556
|
+
}) : [];
|
|
1557
|
+
plans.push({
|
|
1558
|
+
entry,
|
|
1559
|
+
html,
|
|
1560
|
+
mermaidBlocks,
|
|
1561
|
+
markdownDir,
|
|
1562
|
+
hasLocalImages,
|
|
1563
|
+
hasMermaidBlocks,
|
|
1564
|
+
attachments
|
|
1565
|
+
});
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
defects.push({
|
|
1568
|
+
entry,
|
|
1569
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (defects.length > 0) {
|
|
1574
|
+
throw new LocalSyncValidationAggregateError(defects);
|
|
1575
|
+
}
|
|
1576
|
+
return { entries: plans };
|
|
1577
|
+
}
|
|
1578
|
+
var SyncMutationError = class extends Error {
|
|
1579
|
+
constructor(input) {
|
|
1580
|
+
super(input.failure.error.message);
|
|
1581
|
+
this.name = "SyncMutationError";
|
|
1582
|
+
this.changes = input.changes;
|
|
1583
|
+
this.failure = input.failure;
|
|
1584
|
+
this.unprocessed = input.unprocessed;
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
618
1587
|
function resolveConfluenceSyncPlan(options = {}) {
|
|
619
1588
|
const cwd = resolve2(options.cwd ?? process.cwd());
|
|
620
1589
|
const folder = resolveInputPath(cwd, options.folder ?? "");
|
|
621
1590
|
if (!options.folder) {
|
|
622
1591
|
throw new Error("folder is required");
|
|
623
1592
|
}
|
|
1593
|
+
const hasGateway = options.client !== void 0;
|
|
624
1594
|
if (!options.dryRun) {
|
|
625
|
-
if (!
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1595
|
+
if (!hasGateway) {
|
|
1596
|
+
if (!options.username) {
|
|
1597
|
+
throw new Error("username is required");
|
|
1598
|
+
}
|
|
1599
|
+
if (!options.apiToken) {
|
|
1600
|
+
throw new Error("apiToken is required");
|
|
1601
|
+
}
|
|
1602
|
+
if (!options.baseUrl) {
|
|
1603
|
+
throw new Error("baseUrl is required");
|
|
1604
|
+
}
|
|
633
1605
|
}
|
|
634
1606
|
if (!options.spaceKey) {
|
|
635
1607
|
throw new Error("spaceKey is required");
|
|
@@ -651,23 +1623,28 @@ function resolveConfluenceSyncPlan(options = {}) {
|
|
|
651
1623
|
parentPageId: options.parentPageId ?? "",
|
|
652
1624
|
versionMessage: options.versionMessage ?? "Synced via repo-toolkit-confluence",
|
|
653
1625
|
skipUnchanged: options.skipUnchanged ?? true,
|
|
654
|
-
dryRun: options.dryRun ?? false
|
|
1626
|
+
dryRun: options.dryRun ?? false,
|
|
1627
|
+
renderHtmlBlocks: options.renderHtmlBlocks === true
|
|
655
1628
|
};
|
|
656
1629
|
}
|
|
657
1630
|
async function syncConfluenceToDocs(options = {}) {
|
|
658
1631
|
const plan = resolveConfluenceSyncPlan(options);
|
|
659
1632
|
const log = options.log ?? ((msg) => console.log(msg));
|
|
660
|
-
if (plan.dryRun) {
|
|
661
|
-
log("[dry-run] Walking documentation tree only.");
|
|
662
|
-
}
|
|
663
1633
|
const tree = await readDocTree(plan.folder);
|
|
664
1634
|
if (tree.entries.length === 0) {
|
|
665
1635
|
log(`No markdown files found under ${plan.folder}`);
|
|
666
1636
|
return;
|
|
667
1637
|
}
|
|
1638
|
+
validateLocalHierarchy(tree.entries);
|
|
1639
|
+
const localPlan = validateLocalSync(tree.entries, plan);
|
|
668
1640
|
if (plan.dryRun) {
|
|
669
|
-
|
|
670
|
-
|
|
1641
|
+
log("[dry-run] Walking documentation tree only.");
|
|
1642
|
+
for (const entryPlan of localPlan.entries) {
|
|
1643
|
+
const attCount = entryPlan.attachments.length;
|
|
1644
|
+
const mermaidCount = entryPlan.mermaidBlocks.length;
|
|
1645
|
+
log(
|
|
1646
|
+
`[dry-run] would sync ${entryPlan.entry.segments.join("/")}` + (attCount > 0 ? ` (${attCount} attachment${attCount === 1 ? "" : "s"} validated)` : "") + (mermaidCount > 0 ? ` (${mermaidCount} mermaid block${mermaidCount === 1 ? "" : "s"})` : "")
|
|
1647
|
+
);
|
|
671
1648
|
}
|
|
672
1649
|
return;
|
|
673
1650
|
}
|
|
@@ -678,11 +1655,23 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
678
1655
|
});
|
|
679
1656
|
const spaceId = await client.getSpaceIdByKey(plan.spaceKey);
|
|
680
1657
|
const cache = new PageTitleCache(spaceId, client);
|
|
681
|
-
|
|
682
|
-
|
|
1658
|
+
const changes = [];
|
|
1659
|
+
for (let i = 0; i < localPlan.entries.length; i += 1) {
|
|
1660
|
+
const entryPlan = localPlan.entries[i];
|
|
1661
|
+
try {
|
|
1662
|
+
await syncEntry(entryPlan, plan, client, cache, log, changes);
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
throw new SyncMutationError({
|
|
1665
|
+
changes,
|
|
1666
|
+
failure: { entry: entryPlan.entry, error: error instanceof Error ? error : new Error(String(error)) },
|
|
1667
|
+
unprocessed: localPlan.entries.slice(i + 1).map((p) => p.entry)
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
683
1670
|
}
|
|
1671
|
+
return { changes };
|
|
684
1672
|
}
|
|
685
|
-
async function syncEntry(
|
|
1673
|
+
async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
1674
|
+
const { entry, html: precomputedHtml, mermaidBlocks, markdownDir, hasLocalImages, hasMermaidBlocks } = entryPlan;
|
|
686
1675
|
const segments = entry.segments;
|
|
687
1676
|
if (segments.length === 0) {
|
|
688
1677
|
return;
|
|
@@ -693,24 +1682,53 @@ async function syncEntry(entry, plan, client, cache, log) {
|
|
|
693
1682
|
const segment = segments[idx] ?? "";
|
|
694
1683
|
if (isLast && isMarkdownName(segment)) {
|
|
695
1684
|
const title = titleFromSegment(segment);
|
|
696
|
-
const
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1685
|
+
const leafNeedsUploads = hasLocalImages || hasMermaidBlocks;
|
|
1686
|
+
const existing = await cache.find(title, currentParentId);
|
|
1687
|
+
if (!existing && !leafNeedsUploads) {
|
|
1688
|
+
const pageId2 = await cache.createEntry({
|
|
1689
|
+
title,
|
|
1690
|
+
parentId: currentParentId,
|
|
1691
|
+
body: { representation: "storage", value: precomputedHtml }
|
|
1692
|
+
});
|
|
1693
|
+
log(`created: ${segments.join("/")} (page ${pageId2})`);
|
|
1694
|
+
changes.push({ entry, pageId: pageId2, kind: "created" });
|
|
1695
|
+
return;
|
|
706
1696
|
}
|
|
1697
|
+
const existingPage = existing ?? await cache.findOrCreate(title, currentParentId);
|
|
1698
|
+
const pageId = existingPage.id;
|
|
707
1699
|
const current = await client.getPage(pageId);
|
|
708
1700
|
const currentBody = current.body?.storage?.value ?? "";
|
|
709
|
-
|
|
710
|
-
if (plan.skipUnchanged
|
|
711
|
-
|
|
712
|
-
|
|
1701
|
+
let body = precomputedHtml;
|
|
1702
|
+
if (plan.skipUnchanged) {
|
|
1703
|
+
const predicted = await predictBody(precomputedHtml, mermaidBlocks, pageId, client, {
|
|
1704
|
+
markdownDir,
|
|
1705
|
+
allowedRoot: plan.folder,
|
|
1706
|
+
hasLocalImages,
|
|
1707
|
+
hasMermaidBlocks
|
|
1708
|
+
});
|
|
1709
|
+
if (predicted !== null && predicted === currentBody) {
|
|
1710
|
+
log(`unchanged: ${segments.join("/")} (page ${pageId})`);
|
|
1711
|
+
changes.push({ entry, pageId, kind: "unchanged" });
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
if (hasMermaidBlocks) {
|
|
1716
|
+
const mermaidResult = await rewriteMermaidBlocks(body, [...mermaidBlocks], pageId, client);
|
|
1717
|
+
body = mermaidResult.html;
|
|
1718
|
+
if (mermaidResult.fallbacks.length > 0) {
|
|
1719
|
+
log(
|
|
1720
|
+
`mermaid: ${mermaidResult.fallbacks.length} block(s) not rendered (mmdc unavailable or failed); emitted as code macros`
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
713
1723
|
}
|
|
1724
|
+
if (hasLocalImages) {
|
|
1725
|
+
const result = await rewriteImagesToAttachments(body, pageId, client, {
|
|
1726
|
+
markdownDir,
|
|
1727
|
+
allowedRoot: plan.folder
|
|
1728
|
+
});
|
|
1729
|
+
body = result.html;
|
|
1730
|
+
}
|
|
1731
|
+
const nextVersion = (current.version?.number ?? 0) + 1;
|
|
714
1732
|
await client.updatePage({
|
|
715
1733
|
id: pageId,
|
|
716
1734
|
title,
|
|
@@ -718,6 +1736,7 @@ async function syncEntry(entry, plan, client, cache, log) {
|
|
|
718
1736
|
version: { number: nextVersion, message: plan.versionMessage }
|
|
719
1737
|
});
|
|
720
1738
|
log(`updated: ${segments.join("/")} (page ${pageId}, v${nextVersion})`);
|
|
1739
|
+
changes.push({ entry, pageId, kind: "updated" });
|
|
721
1740
|
return;
|
|
722
1741
|
}
|
|
723
1742
|
if (isMarkdownName(segment)) {
|
|
@@ -736,13 +1755,48 @@ var PageTitleCache = class {
|
|
|
736
1755
|
this.spaceId = spaceId;
|
|
737
1756
|
this.client = client;
|
|
738
1757
|
}
|
|
1758
|
+
async find(title, parentId) {
|
|
1759
|
+
const key = `${parentId}::${title}`;
|
|
1760
|
+
const cached = this.cache.get(key);
|
|
1761
|
+
if (cached) {
|
|
1762
|
+
return void 0;
|
|
1763
|
+
}
|
|
1764
|
+
const matches = (await this.client.getPagesByTitle(this.spaceId, title)).filter(
|
|
1765
|
+
(page) => page.parentId === parentId
|
|
1766
|
+
);
|
|
1767
|
+
if (matches.length > 1) {
|
|
1768
|
+
throw new Error(`Multiple Confluence pages matched title ${title} under parent ${parentId}`);
|
|
1769
|
+
}
|
|
1770
|
+
const found = matches[0];
|
|
1771
|
+
if (found) {
|
|
1772
|
+
this.cache.set(key, { id: found.id });
|
|
1773
|
+
}
|
|
1774
|
+
return found;
|
|
1775
|
+
}
|
|
1776
|
+
async createEntry(input) {
|
|
1777
|
+
const created = await this.client.createPage({
|
|
1778
|
+
spaceId: this.spaceId,
|
|
1779
|
+
title: input.title,
|
|
1780
|
+
parentId: input.parentId,
|
|
1781
|
+
body: { representation: input.body.representation, value: input.body.value }
|
|
1782
|
+
});
|
|
1783
|
+
const pageId = created.id;
|
|
1784
|
+
this.cache.set(`${input.parentId}::${input.title}`, { id: pageId });
|
|
1785
|
+
return pageId;
|
|
1786
|
+
}
|
|
739
1787
|
async findOrCreate(title, parentId) {
|
|
740
1788
|
const key = `${parentId}::${title}`;
|
|
741
1789
|
const existing = this.cache.get(key);
|
|
742
1790
|
if (existing) {
|
|
743
1791
|
return existing;
|
|
744
1792
|
}
|
|
745
|
-
|
|
1793
|
+
const matches = (await this.client.getPagesByTitle(this.spaceId, title)).filter(
|
|
1794
|
+
(page2) => page2.parentId === parentId
|
|
1795
|
+
);
|
|
1796
|
+
let page = matches[0];
|
|
1797
|
+
if (matches.length > 1) {
|
|
1798
|
+
throw new Error(`Multiple Confluence pages matched title ${title} under parent ${parentId}`);
|
|
1799
|
+
}
|
|
746
1800
|
if (!page) {
|
|
747
1801
|
page = await this.client.createPage({
|
|
748
1802
|
spaceId: this.spaceId,
|
|
@@ -762,6 +1816,50 @@ function resolveInputPath(baseDir, inputPath) {
|
|
|
762
1816
|
}
|
|
763
1817
|
return resolve2(baseDir, inputPath);
|
|
764
1818
|
}
|
|
1819
|
+
function hasLocalImagePlaceholder(html) {
|
|
1820
|
+
LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
|
|
1821
|
+
return LOCAL_IMAGE_PLACEHOLDER_RE.test(html);
|
|
1822
|
+
}
|
|
1823
|
+
async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
|
|
1824
|
+
let predicted = html;
|
|
1825
|
+
if (ctx.hasMermaidBlocks) {
|
|
1826
|
+
const preflight = await preflightMermaidBlocks(predicted, [...mermaidBlocks], pageId, client);
|
|
1827
|
+
if (preflight.pending.length > 0) {
|
|
1828
|
+
return null;
|
|
1829
|
+
}
|
|
1830
|
+
predicted = preflight.html;
|
|
1831
|
+
}
|
|
1832
|
+
if (ctx.hasLocalImages) {
|
|
1833
|
+
const preflight = await preflightImagesToAttachments(predicted, pageId, client, {
|
|
1834
|
+
markdownDir: ctx.markdownDir,
|
|
1835
|
+
allowedRoot: ctx.allowedRoot
|
|
1836
|
+
});
|
|
1837
|
+
if (preflight.pending.length > 0) {
|
|
1838
|
+
return null;
|
|
1839
|
+
}
|
|
1840
|
+
predicted = preflight.html;
|
|
1841
|
+
}
|
|
1842
|
+
return predicted;
|
|
1843
|
+
}
|
|
1844
|
+
function validateLocalHierarchy(entries) {
|
|
1845
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1846
|
+
for (const entry of entries) {
|
|
1847
|
+
let parentKey = "";
|
|
1848
|
+
for (let index = 0; index < entry.segments.length; index += 1) {
|
|
1849
|
+
const segment = entry.segments[index] ?? "";
|
|
1850
|
+
const isLast = index === entry.segments.length - 1;
|
|
1851
|
+
const title = isMarkdownName(segment) ? titleFromSegment(segment) : segment;
|
|
1852
|
+
const kind = isLast && isMarkdownName(segment) ? "file" : "dir";
|
|
1853
|
+
const key = `${parentKey}::${title}`;
|
|
1854
|
+
const existing = seen.get(key);
|
|
1855
|
+
if (existing && existing !== kind) {
|
|
1856
|
+
throw new Error(`Local documentation tree contains conflicting page titles under the same parent: ${title}`);
|
|
1857
|
+
}
|
|
1858
|
+
seen.set(key, existing ?? kind);
|
|
1859
|
+
parentKey = key;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
765
1863
|
|
|
766
1864
|
// src/cli.ts
|
|
767
1865
|
var SPECS = [
|
|
@@ -770,14 +1868,34 @@ var SPECS = [
|
|
|
770
1868
|
{ name: "folder" },
|
|
771
1869
|
{ name: "username" },
|
|
772
1870
|
{ name: "api-token", aliases: ["password"] },
|
|
1871
|
+
{ name: "api-token-file", aliases: ["password-file"] },
|
|
773
1872
|
{ name: "confluence-base-url", aliases: ["base-url"] },
|
|
774
1873
|
{ name: "space-key" },
|
|
775
1874
|
{ name: "parent-page-id" },
|
|
776
1875
|
{ name: "version-message" },
|
|
777
1876
|
{ name: "skip-unchanged", boolean: true, negatable: true },
|
|
778
1877
|
{ name: "dry-run", boolean: true },
|
|
1878
|
+
{ name: "render-html-blocks", boolean: true },
|
|
779
1879
|
INTERACTIVE_FLAG
|
|
780
1880
|
];
|
|
1881
|
+
var ENV_TRUTHY = /* @__PURE__ */ new Set(["true", "1", "yes", "on"]);
|
|
1882
|
+
var ENV_FALSY = /* @__PURE__ */ new Set(["false", "0", "no", "off", ""]);
|
|
1883
|
+
var BOOLEAN_ENV_KEYS = /* @__PURE__ */ new Set(["skipUnchanged", "dryRun", "renderHtmlBlocks"]);
|
|
1884
|
+
function isBooleanOption(key) {
|
|
1885
|
+
return BOOLEAN_ENV_KEYS.has(key);
|
|
1886
|
+
}
|
|
1887
|
+
function parseBooleanEnv(value, envName) {
|
|
1888
|
+
const v = value.toLowerCase();
|
|
1889
|
+
if (ENV_TRUTHY.has(v)) {
|
|
1890
|
+
return true;
|
|
1891
|
+
}
|
|
1892
|
+
if (ENV_FALSY.has(v)) {
|
|
1893
|
+
return false;
|
|
1894
|
+
}
|
|
1895
|
+
throw new Error(
|
|
1896
|
+
`Invalid boolean value for ${envName}: ${JSON.stringify(value)}. Use one of true|false|1|0|yes|no|on|off.`
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
781
1899
|
function printHelp() {
|
|
782
1900
|
console.log(`repo-toolkit-confluence
|
|
783
1901
|
|
|
@@ -785,9 +1903,27 @@ Usage:
|
|
|
785
1903
|
repo-toolkit-confluence [options]
|
|
786
1904
|
|
|
787
1905
|
Synchronizes a folder of markdown documentation to Confluence pages and
|
|
788
|
-
attachments.
|
|
789
|
-
|
|
790
|
-
|
|
1906
|
+
attachments.
|
|
1907
|
+
|
|
1908
|
+
Configuration is resolved per option with the following precedence:
|
|
1909
|
+
CLI flag > config file > environment > built-in default
|
|
1910
|
+
|
|
1911
|
+
Environment variables (CLI form; GitHub Action INPUT_* form is also read):
|
|
1912
|
+
CONFLUENCE_FOLDER Documentation folder
|
|
1913
|
+
CONFLUENCE_USERNAME Confluence username/email
|
|
1914
|
+
CONFLUENCE_API_TOKEN Confluence API token (prefers a secret file)
|
|
1915
|
+
CONFLUENCE_API_TOKEN_FILE Path to a file containing the API token
|
|
1916
|
+
CONFLUENCE_BASE_URL Confluence base URL (with /wiki)
|
|
1917
|
+
CONFLUENCE_SPACE_KEY Confluence space key
|
|
1918
|
+
CONFLUENCE_PARENT_PAGE_ID Numeric parent page id
|
|
1919
|
+
CONFLUENCE_VERSION_MESSAGE Version-message suffix for every PUT
|
|
1920
|
+
CONFLUENCE_SKIP_UNCHANGED true|false (default: true)
|
|
1921
|
+
CONFLUENCE_DRY_RUN true|false (default: false)
|
|
1922
|
+
CONFLUENCE_RENDER_HTML_BLOCKS true|false (default: false)
|
|
1923
|
+
INPUT_<UPPER-FLAG> GitHub Actions input form (lower precedence)
|
|
1924
|
+
|
|
1925
|
+
Note: prefer CONFLUENCE_API_TOKEN_FILE or CONFLUENCE_API_TOKEN over
|
|
1926
|
+
--api-token to avoid placing the token in argv / process listings.
|
|
791
1927
|
|
|
792
1928
|
Options:
|
|
793
1929
|
--config <path> Config file (JSON, .mjs, or .cjs default export)
|
|
@@ -795,6 +1931,7 @@ Options:
|
|
|
795
1931
|
--folder <path> Folder containing the documentation to publish (required)
|
|
796
1932
|
--username <value> Confluence username or email (required)
|
|
797
1933
|
--api-token <value> Confluence API token (required). Alias: --password
|
|
1934
|
+
--api-token-file <path> File whose contents are the API token. Alias: --password-file
|
|
798
1935
|
--confluence-base-url <url> Confluence URL with /wiki (required). Alias: --base-url
|
|
799
1936
|
--space-key <key> Confluence space key (required). Resolved to a spaceId via the API
|
|
800
1937
|
--parent-page-id <id> Numeric page id under which docs will be published (required)
|
|
@@ -802,65 +1939,193 @@ Options:
|
|
|
802
1939
|
--skip-unchanged Skip pages whose body is unchanged (default: true)
|
|
803
1940
|
--no-skip-unchanged Re-upload every page even when unchanged
|
|
804
1941
|
--dry-run Walk the doc tree and print the plan without API calls
|
|
805
|
-
-
|
|
1942
|
+
--render-html-blocks Render \`\`\`html fenced blocks as inline HTML via the
|
|
1943
|
+
Confluence html macro (default: false; emits as code box)
|
|
1944
|
+
-i, --interactive Prompt interactively for missing non-secret required values
|
|
806
1945
|
-h, --help Show this help message
|
|
807
1946
|
`);
|
|
808
1947
|
}
|
|
809
|
-
var
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1948
|
+
var STRING_OPTION_KEYS = [
|
|
1949
|
+
"cwd",
|
|
1950
|
+
"folder",
|
|
1951
|
+
"username",
|
|
1952
|
+
"apiToken",
|
|
1953
|
+
"apiTokenFile",
|
|
1954
|
+
"baseUrl",
|
|
1955
|
+
"spaceKey",
|
|
1956
|
+
"parentPageId",
|
|
1957
|
+
"versionMessage"
|
|
1958
|
+
];
|
|
1959
|
+
function setIfString(options, key, value) {
|
|
1960
|
+
if (typeof value === "string" && value.length > 0) {
|
|
1961
|
+
options[key] = value;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
function isStringOptionKey(key) {
|
|
1965
|
+
return STRING_OPTION_KEYS.includes(key);
|
|
1966
|
+
}
|
|
1967
|
+
var ENV_BINDINGS = [
|
|
1968
|
+
{ envName: "INPUT_FOLDER", key: "folder", kind: "string" },
|
|
1969
|
+
{ envName: "INPUT_USERNAME", key: "username", kind: "string" },
|
|
1970
|
+
{ envName: "INPUT_API-TOKEN", key: "apiToken", kind: "string" },
|
|
1971
|
+
{ envName: "INPUT_PASSWORD", key: "apiToken", kind: "string" },
|
|
1972
|
+
{ envName: "INPUT_API-TOKEN-FILE", key: "apiTokenFile", kind: "string" },
|
|
1973
|
+
{ envName: "INPUT_PASSWORD-FILE", key: "apiTokenFile", kind: "string" },
|
|
1974
|
+
{ envName: "INPUT_CONFLUENCE-BASE-URL", key: "baseUrl", kind: "string" },
|
|
1975
|
+
{ envName: "INPUT_SPACE-KEY", key: "spaceKey", kind: "string" },
|
|
1976
|
+
{ envName: "INPUT_PARENT-PAGE-ID", key: "parentPageId", kind: "string" },
|
|
1977
|
+
{ envName: "INPUT_VERSION-MESSAGE", key: "versionMessage", kind: "string" },
|
|
1978
|
+
{ envName: "INPUT_DRY-RUN", key: "dryRun", kind: "boolean" },
|
|
1979
|
+
{ envName: "INPUT_SKIP-UNCHANGED", key: "skipUnchanged", kind: "boolean" },
|
|
1980
|
+
{ envName: "INPUT_RENDER-HTML-BLOCKS", key: "renderHtmlBlocks", kind: "boolean" },
|
|
1981
|
+
{ envName: "CONFLUENCE_FOLDER", key: "folder", kind: "string" },
|
|
1982
|
+
{ envName: "CONFLUENCE_USERNAME", key: "username", kind: "string" },
|
|
1983
|
+
{ envName: "CONFLUENCE_API_TOKEN", key: "apiToken", kind: "string" },
|
|
1984
|
+
{ envName: "CONFLUENCE_API_TOKEN_FILE", key: "apiTokenFile", kind: "string" },
|
|
1985
|
+
{ envName: "CONFLUENCE_BASE_URL", key: "baseUrl", kind: "string" },
|
|
1986
|
+
{ envName: "CONFLUENCE_SPACE_KEY", key: "spaceKey", kind: "string" },
|
|
1987
|
+
{ envName: "CONFLUENCE_PARENT_PAGE_ID", key: "parentPageId", kind: "string" },
|
|
1988
|
+
{ envName: "CONFLUENCE_VERSION_MESSAGE", key: "versionMessage", kind: "string" },
|
|
1989
|
+
{ envName: "CONFLUENCE_DRY_RUN", key: "dryRun", kind: "boolean" },
|
|
1990
|
+
{ envName: "CONFLUENCE_SKIP_UNCHANGED", key: "skipUnchanged", kind: "boolean" },
|
|
1991
|
+
{ envName: "CONFLUENCE_RENDER_HTML_BLOCKS", key: "renderHtmlBlocks", kind: "boolean" }
|
|
818
1992
|
];
|
|
1993
|
+
function optionsFromEnv(env = process.env) {
|
|
1994
|
+
const options = {};
|
|
1995
|
+
for (const { envName, key, kind } of ENV_BINDINGS) {
|
|
1996
|
+
const raw = env[envName];
|
|
1997
|
+
if (raw === void 0) {
|
|
1998
|
+
continue;
|
|
1999
|
+
}
|
|
2000
|
+
if (kind === "string") {
|
|
2001
|
+
if (!isStringOptionKey(key)) {
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
setIfString(options, key, raw);
|
|
2005
|
+
} else if (isBooleanOption(key)) {
|
|
2006
|
+
options[key] = parseBooleanEnv(raw, envName);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return options;
|
|
2010
|
+
}
|
|
819
2011
|
function buildOptions(result) {
|
|
820
2012
|
if (!result) {
|
|
821
2013
|
return {};
|
|
822
2014
|
}
|
|
823
|
-
const { values
|
|
824
|
-
void _repeat;
|
|
2015
|
+
const { values } = result;
|
|
825
2016
|
const options = {};
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
if (values["
|
|
836
|
-
|
|
837
|
-
|
|
2017
|
+
setIfString(options, "cwd", values.cwd);
|
|
2018
|
+
setIfString(options, "folder", values.folder);
|
|
2019
|
+
setIfString(options, "username", values.username);
|
|
2020
|
+
setIfString(options, "apiToken", values["api-token"] ?? values.password);
|
|
2021
|
+
setIfString(options, "apiTokenFile", values["api-token-file"] ?? values["password-file"]);
|
|
2022
|
+
setIfString(options, "baseUrl", values["confluence-base-url"] ?? values["base-url"]);
|
|
2023
|
+
setIfString(options, "spaceKey", values["space-key"]);
|
|
2024
|
+
setIfString(options, "parentPageId", values["parent-page-id"]);
|
|
2025
|
+
setIfString(options, "versionMessage", values["version-message"]);
|
|
2026
|
+
if (values["skip-unchanged"] !== void 0) {
|
|
2027
|
+
options.skipUnchanged = values["skip-unchanged"] === "true";
|
|
2028
|
+
}
|
|
2029
|
+
if (values["dry-run"] !== void 0) {
|
|
2030
|
+
options.dryRun = true;
|
|
2031
|
+
}
|
|
2032
|
+
if (values["render-html-blocks"] !== void 0) {
|
|
2033
|
+
options.renderHtmlBlocks = true;
|
|
2034
|
+
}
|
|
838
2035
|
return options;
|
|
839
2036
|
}
|
|
840
|
-
function
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
2037
|
+
async function resolveConfluenceOptions(args) {
|
|
2038
|
+
const cliOptions = buildOptions(args.result);
|
|
2039
|
+
const configPath = args.result.values.config;
|
|
2040
|
+
const config = configPath ? await loadConfigFile(configPath, args.cwd ?? cliOptions.cwd) : {};
|
|
2041
|
+
if (!isPlainObject(config)) {
|
|
2042
|
+
throw new Error(`Config file must export an object: ${configPath}`);
|
|
2043
|
+
}
|
|
2044
|
+
const envOptions = optionsFromEnv();
|
|
2045
|
+
return { ...envOptions, ...config, ...cliOptions };
|
|
2046
|
+
}
|
|
2047
|
+
var REQUIRED_PRESETS = [
|
|
2048
|
+
{ field: "folder", message: "folder is required." },
|
|
2049
|
+
{ field: "username", message: "username is required." },
|
|
2050
|
+
{ field: "baseUrl", message: "baseUrl is required (CONFLUENCE_BASE_URL or --confluence-base-url)." },
|
|
2051
|
+
{ field: "spaceKey", message: "spaceKey is required (CONFLUENCE_SPACE_KEY or --space-key)." },
|
|
2052
|
+
{
|
|
2053
|
+
field: "parentPageId",
|
|
2054
|
+
message: "parentPageId is required (CONFLUENCE_PARENT_PAGE_ID or --parent-page-id)."
|
|
2055
|
+
}
|
|
2056
|
+
];
|
|
2057
|
+
var TOKEN_GUIDANCE = "apiToken is required. Provide it via --api-token-file, INPUT_API-TOKEN-FILE, CONFLUENCE_API_TOKEN_FILE, or the CONFLUENCE_API_TOKEN / INPUT_API-TOKEN environment variable. Tokens are never prompted interactively to avoid entering them on screen.";
|
|
2058
|
+
async function resolveSecretFile(opts, cwd) {
|
|
2059
|
+
if (!opts.apiTokenFile) {
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (opts.apiToken) {
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
const path = resolve3(cwd ?? process.cwd(), opts.apiTokenFile);
|
|
2066
|
+
let raw;
|
|
2067
|
+
try {
|
|
2068
|
+
raw = readFileSync3(path, "utf8");
|
|
2069
|
+
} catch (error) {
|
|
2070
|
+
const wrappedError = new Error(`Failed to read apiTokenFile at ${path}`);
|
|
2071
|
+
wrappedError.cause = error;
|
|
2072
|
+
throw wrappedError;
|
|
2073
|
+
}
|
|
2074
|
+
const token = raw.replace(/\r?\n$/, "").trim();
|
|
2075
|
+
if (token.length === 0) {
|
|
2076
|
+
throw new Error(`apiTokenFile at ${path} is empty.`);
|
|
2077
|
+
}
|
|
2078
|
+
opts.apiToken = token;
|
|
2079
|
+
}
|
|
2080
|
+
async function promptForMissing(merged, interactive) {
|
|
2081
|
+
if (!interactive) {
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (merged.dryRun === true) {
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
if (!canPrompt()) {
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
for (const { field, message } of REQUIRED_PRESETS) {
|
|
2091
|
+
merged[field] = await promptForRequiredValue({
|
|
2092
|
+
value: merged[field],
|
|
2093
|
+
interactive,
|
|
2094
|
+
canPromptNow: true,
|
|
2095
|
+
message: `${field}:`,
|
|
2096
|
+
missingMessage: message,
|
|
2097
|
+
validate: (v) => v.length === 0 ? message : void 0
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
if (!merged.apiToken) {
|
|
2101
|
+
throw new Error(TOKEN_GUIDANCE);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
function ensureRequired(merged) {
|
|
2105
|
+
if (merged.dryRun === true) {
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
for (const { field, message } of REQUIRED_PRESETS) {
|
|
2109
|
+
if (!merged[field]) {
|
|
2110
|
+
throw new Error(message);
|
|
846
2111
|
}
|
|
847
2112
|
}
|
|
848
|
-
|
|
2113
|
+
if (!merged.apiToken) {
|
|
2114
|
+
throw new Error(TOKEN_GUIDANCE);
|
|
2115
|
+
}
|
|
849
2116
|
}
|
|
850
2117
|
async function main() {
|
|
851
2118
|
const argv = process.argv.slice(2);
|
|
852
|
-
const
|
|
853
|
-
const result = parseFlags2(argv, SPECS);
|
|
2119
|
+
const result = parseFlags(argv, SPECS);
|
|
854
2120
|
if (!result) {
|
|
855
2121
|
printHelp();
|
|
856
2122
|
return;
|
|
857
2123
|
}
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
const merged = { ...envOptions, ...cliOptions };
|
|
2124
|
+
const interactive = result.values.interactive === "true";
|
|
2125
|
+
const merged = await resolveConfluenceOptions({ result });
|
|
2126
|
+
await resolveSecretFile(merged, merged.cwd);
|
|
2127
|
+
await promptForMissing(merged, interactive);
|
|
2128
|
+
ensureRequired(merged);
|
|
864
2129
|
await syncConfluenceToDocs(merged);
|
|
865
2130
|
}
|
|
866
2131
|
main().catch((error) => {
|
|
@@ -868,3 +2133,10 @@ main().catch((error) => {
|
|
|
868
2133
|
console.error(message);
|
|
869
2134
|
process.exitCode = 1;
|
|
870
2135
|
});
|
|
2136
|
+
export {
|
|
2137
|
+
SPECS,
|
|
2138
|
+
buildOptions,
|
|
2139
|
+
optionsFromEnv,
|
|
2140
|
+
resolveConfluenceOptions,
|
|
2141
|
+
resolveSecretFile
|
|
2142
|
+
};
|