@repo-toolkit/confluence 0.9.0 → 0.12.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 +1381 -273
- package/index.d.ts +288 -34
- package/index.js +1165 -230
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { readFileSync as readFileSync2 } from "fs";
|
|
3
3
|
import { dirname, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
4
|
-
import { parseFlags, resolveCliOptions, isPlainObject } from "@repo-toolkit/publish-package";
|
|
5
4
|
|
|
6
5
|
// src/confluence-client.ts
|
|
7
6
|
import { Buffer } from "buffer";
|
|
8
|
-
import {
|
|
7
|
+
import { randomBytes } from "crypto";
|
|
8
|
+
import { statSync, createReadStream } from "fs";
|
|
9
9
|
import { basename } from "path";
|
|
10
|
+
import { Readable } from "stream";
|
|
10
11
|
var V2_PATH = "/api/v2";
|
|
11
12
|
var V1_PATH = "/rest/api";
|
|
12
13
|
var DEFAULT_USER_AGENT = "repo-toolkit-confluence/1.0 (+node)";
|
|
13
14
|
var MAX_LIMIT = 250;
|
|
15
|
+
var MAX_ERROR_BODY_LENGTH = 8192;
|
|
16
|
+
var MAX_PAGES_PER_QUERY = 100;
|
|
17
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
18
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
19
|
+
var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
20
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
21
|
+
var ConfluenceUploadError = class extends Error {
|
|
22
|
+
constructor(message, status, endpoint, responseBody) {
|
|
23
|
+
super(`${message} (status=${status}, endpoint=${endpoint})`);
|
|
24
|
+
this.name = "ConfluenceUploadError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.endpoint = endpoint;
|
|
27
|
+
this.responseBody = responseBody;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
14
30
|
var STATUS_CODES = {
|
|
15
31
|
BAD_REQUEST: 400,
|
|
16
32
|
UNAUTHORIZED: 401,
|
|
17
33
|
FORBIDDEN: 403,
|
|
18
34
|
NOT_FOUND: 404,
|
|
19
|
-
CONFLICT: 409
|
|
35
|
+
CONFLICT: 409,
|
|
36
|
+
TOO_MANY_REQUESTS: 429
|
|
20
37
|
};
|
|
21
38
|
var ConfluenceApiError = class extends Error {
|
|
22
39
|
constructor(message, status, endpoint, responseBody) {
|
|
@@ -35,10 +52,15 @@ var ConfluenceClient = class {
|
|
|
35
52
|
if (!options.username || !options.apiToken) {
|
|
36
53
|
throw new Error("ConfluenceClient: username and apiToken are required");
|
|
37
54
|
}
|
|
38
|
-
|
|
55
|
+
const normalized = normalizeBaseUrl(options.baseUrl);
|
|
56
|
+
this.baseUrl = normalized;
|
|
57
|
+
this.baseUrlOrigin = new URL(normalized).origin;
|
|
39
58
|
this.authHeader = "Basic " + Buffer.from(`${options.username}:${options.apiToken}`, "utf8").toString("base64");
|
|
40
59
|
this.fetchFn = options.fetch ?? globalThis.fetch;
|
|
41
60
|
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
61
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
62
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
63
|
+
this.maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
42
64
|
}
|
|
43
65
|
async getSpaceIdByKey(spaceKey) {
|
|
44
66
|
const query = new URLSearchParams({ keys: spaceKey, limit: "1" });
|
|
@@ -52,17 +74,33 @@ var ConfluenceClient = class {
|
|
|
52
74
|
}
|
|
53
75
|
return result.id;
|
|
54
76
|
}
|
|
55
|
-
async
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
async getPagesByTitle(spaceId, title) {
|
|
78
|
+
const pages = [];
|
|
79
|
+
const visited = /* @__PURE__ */ new Set();
|
|
80
|
+
let pageCount = 0;
|
|
81
|
+
const startUrl = this.v2Url(
|
|
82
|
+
`/pages?${new URLSearchParams({
|
|
83
|
+
"space-id": spaceId,
|
|
84
|
+
title,
|
|
85
|
+
limit: String(MAX_LIMIT),
|
|
86
|
+
"body-format": "storage"
|
|
87
|
+
}).toString()}`
|
|
88
|
+
);
|
|
89
|
+
let nextUrl = startUrl;
|
|
90
|
+
while (nextUrl) {
|
|
91
|
+
pageCount += 1;
|
|
92
|
+
if (pageCount > MAX_PAGES_PER_QUERY) {
|
|
93
|
+
throw new ConfluenceApiError(`Pagination limit (${MAX_PAGES_PER_QUERY}) exceeded`, 0, nextUrl, "");
|
|
94
|
+
}
|
|
95
|
+
if (visited.has(nextUrl)) {
|
|
96
|
+
throw new ConfluenceApiError("Confluence pagination loop detected", 0, nextUrl, "");
|
|
97
|
+
}
|
|
98
|
+
visited.add(nextUrl);
|
|
99
|
+
const data = await this.requestJson(nextUrl, { method: "GET" });
|
|
100
|
+
pages.push(...data.results);
|
|
101
|
+
nextUrl = resolveNextUrl(this.baseUrl, this.baseUrlOrigin, data._links?.next);
|
|
102
|
+
}
|
|
103
|
+
return pages;
|
|
66
104
|
}
|
|
67
105
|
async getPage(pageId) {
|
|
68
106
|
return this.requestJson(this.v2Url(`/pages/${encodeURIComponent(pageId)}?body-format=storage`), {
|
|
@@ -105,89 +143,182 @@ var ConfluenceClient = class {
|
|
|
105
143
|
}
|
|
106
144
|
async getAttachments(pageId) {
|
|
107
145
|
const results = [];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
146
|
+
const visited = /* @__PURE__ */ new Set();
|
|
147
|
+
let pageCount = 0;
|
|
148
|
+
let nextUrl = this.v2Url(
|
|
149
|
+
`/pages/${encodeURIComponent(pageId)}/attachments?${new URLSearchParams({
|
|
150
|
+
limit: String(MAX_LIMIT)
|
|
151
|
+
}).toString()}`
|
|
152
|
+
);
|
|
153
|
+
while (nextUrl) {
|
|
154
|
+
pageCount += 1;
|
|
155
|
+
if (pageCount > MAX_PAGES_PER_QUERY) {
|
|
156
|
+
throw new ConfluenceApiError(`Pagination limit (${MAX_PAGES_PER_QUERY}) exceeded`, 0, nextUrl, "");
|
|
157
|
+
}
|
|
158
|
+
if (visited.has(nextUrl)) {
|
|
159
|
+
throw new ConfluenceApiError("Confluence pagination loop detected", 0, nextUrl, "");
|
|
160
|
+
}
|
|
161
|
+
visited.add(nextUrl);
|
|
162
|
+
const data = await this.requestJson(nextUrl, {
|
|
163
|
+
method: "GET"
|
|
164
|
+
});
|
|
118
165
|
for (const item of data.results) {
|
|
119
166
|
results.push(item);
|
|
120
167
|
}
|
|
121
|
-
|
|
122
|
-
}
|
|
168
|
+
nextUrl = resolveNextUrl(this.baseUrl, this.baseUrlOrigin, data._links?.next);
|
|
169
|
+
}
|
|
123
170
|
return results;
|
|
124
171
|
}
|
|
125
|
-
async uploadAttachment(pageId, filePath, comment) {
|
|
126
|
-
return this.sendAttachmentMultipart(pageId, void 0, filePath, comment);
|
|
172
|
+
async uploadAttachment(pageId, filePath, comment, filename) {
|
|
173
|
+
return this.sendAttachmentMultipart(pageId, void 0, filePath, comment, filename);
|
|
127
174
|
}
|
|
128
|
-
async updateAttachmentData(pageId, attachmentId, filePath, comment) {
|
|
129
|
-
return this.sendAttachmentMultipart(pageId, attachmentId, filePath, comment);
|
|
175
|
+
async updateAttachmentData(pageId, attachmentId, filePath, comment, filename) {
|
|
176
|
+
return this.sendAttachmentMultipart(pageId, attachmentId, filePath, comment, filename);
|
|
130
177
|
}
|
|
131
|
-
async sendAttachmentMultipart(pageId, attachmentId, filePath, comment) {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
178
|
+
async sendAttachmentMultipart(pageId, attachmentId, filePath, comment, filenameOverride) {
|
|
179
|
+
const info = statSync(filePath);
|
|
180
|
+
if (!info.isFile()) {
|
|
181
|
+
throw new ConfluenceApiError(`Attachment source must be a regular file: ${filePath}`, 0, filePath, "");
|
|
182
|
+
}
|
|
183
|
+
if (info.size > this.maxUploadBytes) {
|
|
184
|
+
throw new ConfluenceApiError(
|
|
185
|
+
`Attachment exceeds upload size limit (${this.maxUploadBytes} bytes): ${filePath}`,
|
|
186
|
+
0,
|
|
187
|
+
filePath,
|
|
188
|
+
""
|
|
189
|
+
);
|
|
139
190
|
}
|
|
140
|
-
|
|
141
|
-
`));
|
|
191
|
+
const filename = sanitizeFilename(filenameOverride ?? basename(filePath));
|
|
142
192
|
const endpoint = attachmentId ? this.v1Url(`/content/${encodeURIComponent(pageId)}/child/attachment/${encodeURIComponent(attachmentId)}/data`) : this.v1Url(`/content/${encodeURIComponent(pageId)}/child/attachment`);
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
193
|
+
const boundary = "----repo-toolkit-confluence-" + randomBytes(16).toString("hex");
|
|
194
|
+
const fileStream = createReadStream(filePath);
|
|
195
|
+
const commentBuffer = comment ? multipartField(boundary, "comment", void 0, Buffer.from(comment, "utf8")) : null;
|
|
196
|
+
const body = Readable.from(
|
|
197
|
+
(async function* () {
|
|
198
|
+
const fileHeader = Buffer.from(
|
|
199
|
+
`--${boundary}\r
|
|
200
|
+
Content-Disposition: form-data; name="file"; filename="${filename}"\r
|
|
201
|
+
Content-Type: application/octet-stream\r
|
|
202
|
+
\r
|
|
203
|
+
`,
|
|
204
|
+
"utf8"
|
|
205
|
+
);
|
|
206
|
+
yield fileHeader;
|
|
207
|
+
for await (const chunk of fileStream) {
|
|
208
|
+
yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
209
|
+
}
|
|
210
|
+
yield Buffer.from("\r\n", "utf8");
|
|
211
|
+
if (commentBuffer) {
|
|
212
|
+
yield commentBuffer;
|
|
213
|
+
}
|
|
214
|
+
yield Buffer.from(`--${boundary}--\r
|
|
215
|
+
`, "utf8");
|
|
216
|
+
})()
|
|
217
|
+
);
|
|
218
|
+
try {
|
|
219
|
+
const data = await this.requestJson(endpoint, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: {
|
|
222
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
223
|
+
"X-Atlassian-Token": "no-check"
|
|
224
|
+
},
|
|
225
|
+
body,
|
|
226
|
+
uploadKind: "attachment",
|
|
227
|
+
contentTypeExplicit: true
|
|
228
|
+
});
|
|
229
|
+
return normalizeAttachmentResult(data);
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
if (cause instanceof ConfluenceApiError) {
|
|
232
|
+
const status = cause.status;
|
|
233
|
+
if (status === STATUS_CODES.UNAUTHORIZED || status === STATUS_CODES.FORBIDDEN || status === STATUS_CODES.TOO_MANY_REQUESTS || status >= 500) {
|
|
234
|
+
const uploadErr = new ConfluenceUploadError(cause.message, status, cause.endpoint, cause.responseBody);
|
|
235
|
+
uploadErr.stack = cause.stack;
|
|
236
|
+
throw uploadErr;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw cause;
|
|
240
|
+
}
|
|
152
241
|
}
|
|
153
242
|
async requestJson(endpoint, init) {
|
|
243
|
+
void init.uploadKind;
|
|
244
|
+
void init.contentTypeExplicit;
|
|
154
245
|
const headers = {
|
|
155
246
|
Authorization: this.authHeader,
|
|
156
247
|
Accept: "application/json",
|
|
157
248
|
"User-Agent": this.userAgent
|
|
158
249
|
};
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
250
|
+
for (const [k, v] of Object.entries(init.headers ?? {})) {
|
|
251
|
+
headers[k] = v;
|
|
252
|
+
}
|
|
253
|
+
const method = init.method.toUpperCase();
|
|
254
|
+
const isSafe = SAFE_METHODS.has(method);
|
|
255
|
+
const maxRetries = isSafe ? this.maxRetries : 0;
|
|
256
|
+
let attempt = 0;
|
|
257
|
+
let lastError;
|
|
258
|
+
while (attempt <= maxRetries) {
|
|
259
|
+
const signal = this.makeTimeoutSignal();
|
|
260
|
+
try {
|
|
261
|
+
const response = await this.fetchFn(endpoint, {
|
|
262
|
+
method,
|
|
263
|
+
headers,
|
|
264
|
+
body: init.body,
|
|
265
|
+
redirect: "manual",
|
|
266
|
+
signal
|
|
267
|
+
});
|
|
268
|
+
if (response.status >= 300 && response.status < 400) {
|
|
269
|
+
throw new ConfluenceApiError("Redirect responses are not allowed", response.status, endpoint, "");
|
|
270
|
+
}
|
|
271
|
+
const text = await readBodyBounded(response, MAX_ERROR_BODY_LENGTH);
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
if (isSafe && shouldRetryStatus(response.status) && attempt < maxRetries) {
|
|
274
|
+
attempt += 1;
|
|
275
|
+
lastError = new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
|
|
276
|
+
await sleepBackoff(response, endpoint);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
throw new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
|
|
280
|
+
}
|
|
281
|
+
if (text.length === 0) {
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(text);
|
|
286
|
+
} catch {
|
|
287
|
+
throw new ConfluenceApiError("Response was not valid JSON", response.status, endpoint, text);
|
|
288
|
+
}
|
|
289
|
+
} catch (cause) {
|
|
290
|
+
if (cause instanceof ConfluenceApiError || cause instanceof ConfluenceUploadError) {
|
|
291
|
+
throw cause;
|
|
292
|
+
}
|
|
293
|
+
const reason = cause instanceof Error ? cause : void 0;
|
|
294
|
+
if (isSafe && reason && reason.name !== "AbortError" && attempt < maxRetries) {
|
|
295
|
+
attempt += 1;
|
|
296
|
+
lastError = cause;
|
|
297
|
+
await sleepBackoff(void 0, endpoint, reason);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
throw new ConfluenceApiError(reason ? `Network error: ${reason.message}` : "Network error", 0, endpoint, "");
|
|
162
301
|
}
|
|
163
302
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
response = await this.fetchFn(endpoint, {
|
|
167
|
-
method: init.method,
|
|
168
|
-
headers,
|
|
169
|
-
body: init.body
|
|
170
|
-
});
|
|
171
|
-
} catch (cause) {
|
|
172
|
-
throw new ConfluenceApiError(
|
|
173
|
-
cause instanceof Error ? `Network error: ${cause.message}` : "Network error",
|
|
174
|
-
0,
|
|
175
|
-
endpoint,
|
|
176
|
-
""
|
|
177
|
-
);
|
|
303
|
+
if (lastError instanceof Error) {
|
|
304
|
+
throw new ConfluenceApiError(`Network error: ${lastError.message}`, 0, endpoint, "");
|
|
178
305
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
306
|
+
throw new ConfluenceApiError("Network error", 0, endpoint, "");
|
|
307
|
+
}
|
|
308
|
+
makeTimeoutSignal() {
|
|
309
|
+
const ms = this.requestTimeoutMs;
|
|
310
|
+
if (ms <= 0) {
|
|
311
|
+
return new AbortController().signal;
|
|
182
312
|
}
|
|
183
|
-
if (
|
|
184
|
-
return
|
|
313
|
+
if (typeof AbortSignal.timeout === "function") {
|
|
314
|
+
return AbortSignal.timeout(ms);
|
|
185
315
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
const timer = setTimeout(() => controller.abort(new Error(`Request timed out after ${ms}ms`)), ms);
|
|
318
|
+
if (typeof timer.unref === "function") {
|
|
319
|
+
timer.unref();
|
|
190
320
|
}
|
|
321
|
+
return controller.signal;
|
|
191
322
|
}
|
|
192
323
|
v2Url(path) {
|
|
193
324
|
return this.baseUrl + V2_PATH + path;
|
|
@@ -196,6 +327,102 @@ var ConfluenceClient = class {
|
|
|
196
327
|
return this.baseUrl + V1_PATH + path;
|
|
197
328
|
}
|
|
198
329
|
};
|
|
330
|
+
function shouldRetryStatus(status) {
|
|
331
|
+
return status === STATUS_CODES.TOO_MANY_REQUESTS || status >= 500;
|
|
332
|
+
}
|
|
333
|
+
async function sleepBackoff(response, endpoint, cause) {
|
|
334
|
+
let delayMs = 0;
|
|
335
|
+
if (response) {
|
|
336
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
337
|
+
if (retryAfter) {
|
|
338
|
+
delayMs = parseRetryAfter(retryAfter, endpoint);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (delayMs <= 0) {
|
|
342
|
+
const base = cause ? 200 : 500;
|
|
343
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
344
|
+
delayMs = base + jitter;
|
|
345
|
+
}
|
|
346
|
+
const boundedDelay = Math.min(delayMs, 3e4);
|
|
347
|
+
await delay(boundedDelay);
|
|
348
|
+
}
|
|
349
|
+
function parseRetryAfter(value, endpoint) {
|
|
350
|
+
if (!value) {
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
if (/^\d+$/.test(value.trim())) {
|
|
354
|
+
const seconds = Number(value);
|
|
355
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
return Math.min(seconds * 1e3, 3e4);
|
|
359
|
+
}
|
|
360
|
+
const date = Date.parse(value);
|
|
361
|
+
if (!Number.isNaN(date)) {
|
|
362
|
+
return Math.max(0, Math.min(date - Date.now(), 3e4));
|
|
363
|
+
}
|
|
364
|
+
void endpoint;
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
function delay(ms) {
|
|
368
|
+
if (ms <= 0) {
|
|
369
|
+
return Promise.resolve();
|
|
370
|
+
}
|
|
371
|
+
return new Promise((resolve3) => {
|
|
372
|
+
setTimeout(resolve3, ms);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
async function readBodyBounded(response, maxBytes) {
|
|
376
|
+
let total = 0;
|
|
377
|
+
const chunks = [];
|
|
378
|
+
const reader = response.body?.[Symbol.asyncIterator]?.();
|
|
379
|
+
if (reader) {
|
|
380
|
+
let oversized = false;
|
|
381
|
+
while (true) {
|
|
382
|
+
const step = await reader.next();
|
|
383
|
+
if (step.done) {
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
const chunk = Buffer.isBuffer(step.value) ? step.value : Buffer.from(step.value);
|
|
387
|
+
if (total + chunk.length > maxBytes) {
|
|
388
|
+
oversized = true;
|
|
389
|
+
chunks.push(chunk.subarray(0, Math.max(0, maxBytes - total)));
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
total += chunk.length;
|
|
393
|
+
chunks.push(chunk);
|
|
394
|
+
}
|
|
395
|
+
let body = Buffer.concat(chunks).toString("utf8");
|
|
396
|
+
if (oversized) {
|
|
397
|
+
body = body + "...";
|
|
398
|
+
}
|
|
399
|
+
return body;
|
|
400
|
+
}
|
|
401
|
+
const text = await response.text();
|
|
402
|
+
if (text.length > maxBytes) {
|
|
403
|
+
return text.slice(0, maxBytes) + "...";
|
|
404
|
+
}
|
|
405
|
+
return text;
|
|
406
|
+
}
|
|
407
|
+
function sanitizeFilename(name) {
|
|
408
|
+
if (name === "" || name === "." || name === "..") {
|
|
409
|
+
throw new ConfluenceApiError("Invalid attachment filename", 0, "attachment", "");
|
|
410
|
+
}
|
|
411
|
+
let cleaned = "";
|
|
412
|
+
for (let i = 0; i < name.length; i += 1) {
|
|
413
|
+
const code = name.charCodeAt(i);
|
|
414
|
+
if (code === 34 || code === 39 || code === 13 || code === 10 || code === 0) {
|
|
415
|
+
cleaned += "_";
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
cleaned += name[i] ?? "";
|
|
419
|
+
}
|
|
420
|
+
cleaned = cleaned.replace(/[/\\]/g, "_");
|
|
421
|
+
if (cleaned === "" || cleaned === "." || cleaned === "..") {
|
|
422
|
+
throw new ConfluenceApiError("Invalid attachment filename", 0, "attachment", "");
|
|
423
|
+
}
|
|
424
|
+
return cleaned;
|
|
425
|
+
}
|
|
199
426
|
function multipartField(boundary, name, filename, value) {
|
|
200
427
|
const headerLines = [`--${boundary}\r
|
|
201
428
|
`];
|
|
@@ -227,11 +454,41 @@ function normalizeBaseUrl(baseUrl) {
|
|
|
227
454
|
if (trimmed.length === 0) {
|
|
228
455
|
throw new Error("baseUrl must not be empty");
|
|
229
456
|
}
|
|
230
|
-
let url
|
|
231
|
-
|
|
232
|
-
url =
|
|
457
|
+
let url;
|
|
458
|
+
try {
|
|
459
|
+
url = new URL(trimmed);
|
|
460
|
+
} catch {
|
|
461
|
+
throw new Error(`Invalid baseUrl: ${baseUrl}`);
|
|
462
|
+
}
|
|
463
|
+
if (url.protocol !== "https:") {
|
|
464
|
+
throw new Error(`baseUrl must use https: ${baseUrl}`);
|
|
465
|
+
}
|
|
466
|
+
if (url.username || url.password) {
|
|
467
|
+
throw new Error("baseUrl must not include embedded credentials");
|
|
468
|
+
}
|
|
469
|
+
if (url.search || url.hash) {
|
|
470
|
+
throw new Error("baseUrl must not include query or fragment components");
|
|
471
|
+
}
|
|
472
|
+
const normalizedPath = url.pathname.replace(/\/+$/u, "") || "/";
|
|
473
|
+
if (normalizedPath !== "/" && normalizedPath !== "/wiki") {
|
|
474
|
+
throw new Error(`baseUrl path must be empty or /wiki: ${baseUrl}`);
|
|
475
|
+
}
|
|
476
|
+
return normalizedPath === "/" ? url.origin : `${url.origin}${normalizedPath}`;
|
|
477
|
+
}
|
|
478
|
+
function resolveNextUrl(baseUrl, baseUrlOrigin, next) {
|
|
479
|
+
if (!next) {
|
|
480
|
+
return void 0;
|
|
481
|
+
}
|
|
482
|
+
let resolved;
|
|
483
|
+
try {
|
|
484
|
+
resolved = new URL(next, baseUrl);
|
|
485
|
+
} catch {
|
|
486
|
+
throw new ConfluenceApiError(`Confluence pagination returned a malformed next link`, 0, next, "");
|
|
487
|
+
}
|
|
488
|
+
if (resolved.origin !== baseUrlOrigin) {
|
|
489
|
+
throw new ConfluenceApiError(`Confluence pagination returned a cross-origin next link: ${next}`, 0, next, "");
|
|
233
490
|
}
|
|
234
|
-
return
|
|
491
|
+
return resolved.toString();
|
|
235
492
|
}
|
|
236
493
|
function describeStatus(status) {
|
|
237
494
|
switch (status) {
|
|
@@ -243,6 +500,8 @@ function describeStatus(status) {
|
|
|
243
500
|
return "Resource not found";
|
|
244
501
|
case STATUS_CODES.CONFLICT:
|
|
245
502
|
return "Version conflict (page was updated concurrently)";
|
|
503
|
+
case STATUS_CODES.TOO_MANY_REQUESTS:
|
|
504
|
+
return "Rate limited by Confluence";
|
|
246
505
|
default:
|
|
247
506
|
if (status >= STATUS_CODES.BAD_REQUEST && status < 500) {
|
|
248
507
|
return `Client error (${status})`;
|
|
@@ -308,12 +567,54 @@ function titleFromSegment(segment) {
|
|
|
308
567
|
// src/markdown.ts
|
|
309
568
|
var STORAGE_LINE_BREAK = "<br />";
|
|
310
569
|
var LINE_BREAK_SENTINEL = "BR";
|
|
311
|
-
var PROTOCOL_BLOCKLIST = /^(?:javascript|data|file|vbscript):/i;
|
|
312
570
|
var AMP = "&";
|
|
313
571
|
var LT = "<";
|
|
314
572
|
var GT = ">";
|
|
315
573
|
var QUOT = """;
|
|
316
574
|
var APOS = "'";
|
|
575
|
+
var HTTP_SRE = /^(https?:)?\/\//i;
|
|
576
|
+
var ABSOLUTE_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
577
|
+
var BLOCKED_SCHEMES = /* @__PURE__ */ new Set(["javascript", "data", "file", "vbscript"]);
|
|
578
|
+
var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
|
|
579
|
+
function schemeOf(url) {
|
|
580
|
+
const match = ABSOLUTE_SCHEME_RE.exec(url);
|
|
581
|
+
if (!match) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
return match[0].slice(0, -1).toLowerCase();
|
|
585
|
+
}
|
|
586
|
+
function decodedSchemeOrNull(url) {
|
|
587
|
+
const match = ABSOLUTE_SCHEME_RE.exec(url);
|
|
588
|
+
if (!match) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const rawScheme = match[0].slice(0, -1);
|
|
592
|
+
try {
|
|
593
|
+
return decodeURIComponent(rawScheme).toLowerCase();
|
|
594
|
+
} catch {
|
|
595
|
+
return rawScheme.toLowerCase();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function isAllowedUrl(url) {
|
|
599
|
+
if (url.length === 0) {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
for (let i = 0; i < url.length; i += 1) {
|
|
603
|
+
const code = url.charCodeAt(i);
|
|
604
|
+
if (code < 32 || code === 127) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const scheme = schemeOf(url);
|
|
609
|
+
if (scheme === null) {
|
|
610
|
+
return HTTP_SRE.test(url) || /^[/?#]/.test(url) || !/:/.test(url);
|
|
611
|
+
}
|
|
612
|
+
const decoded = decodedSchemeOrNull(url) ?? scheme;
|
|
613
|
+
if (BLOCKED_SCHEMES.has(scheme) || BLOCKED_SCHEMES.has(decoded)) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
return ALLOWED_SCHEMES.has(scheme);
|
|
617
|
+
}
|
|
317
618
|
var MERMAID_PLACEHOLDER_PREFIX = '<ac:structured-macro ac:name="mermaid-placeholder" data-mermaid-id="';
|
|
318
619
|
var MERMAID_PLACEHOLDER_RE_STRICT = /<ac:structured-macro ac:name="mermaid-placeholder" data-mermaid-id="([^"]+)"><\/ac:structured-macro>/g;
|
|
319
620
|
function mermaidPlaceholderRe() {
|
|
@@ -461,86 +762,264 @@ function renderList(listLines) {
|
|
|
461
762
|
return `<${tag}>${body}</${tag}>`;
|
|
462
763
|
}
|
|
463
764
|
function renderInline(text) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
s = applyStrong(s);
|
|
469
|
-
s = applyInlineCode(s);
|
|
470
|
-
return s;
|
|
471
|
-
}
|
|
472
|
-
function applyLinks(text) {
|
|
473
|
-
return replaceBalancedSyntax(text, /\[([^\]]+)\]\(/g, (label, url) => {
|
|
474
|
-
if (PROTOCOL_BLOCKLIST.test(url)) {
|
|
475
|
-
return label;
|
|
476
|
-
}
|
|
477
|
-
return '<a href="' + escapeXmlAttribute(url) + '">' + label + "</a>";
|
|
478
|
-
});
|
|
765
|
+
const out = [];
|
|
766
|
+
const tokens = tokenizeInline(text, 0, text.length);
|
|
767
|
+
renderTokens(text, tokens, out);
|
|
768
|
+
return out.join("");
|
|
479
769
|
}
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
770
|
+
function renderTokens(text, tokens, out) {
|
|
771
|
+
renderTokenRange(text, tokens, 0, tokens.length, out);
|
|
772
|
+
}
|
|
773
|
+
function renderTokenRange(text, tokens, from, to, out) {
|
|
774
|
+
for (let i = from; i < to; i += 1) {
|
|
775
|
+
const open = tokens[i];
|
|
776
|
+
if (open.kind !== "delimiter" || !open.canOpen) {
|
|
777
|
+
out.push(renderSingleToken(text, open));
|
|
778
|
+
continue;
|
|
484
779
|
}
|
|
485
|
-
|
|
486
|
-
|
|
780
|
+
const closeIdx = findEmphasisClose(tokens, i, to, open);
|
|
781
|
+
if (closeIdx === -1) {
|
|
782
|
+
out.push(renderSingleToken(text, open));
|
|
783
|
+
continue;
|
|
487
784
|
}
|
|
488
|
-
|
|
489
|
-
|
|
785
|
+
const tag = open.runLength === 1 ? "em" : "strong";
|
|
786
|
+
const innerOut = [];
|
|
787
|
+
renderTokenRange(text, tokens, i + 1, closeIdx, innerOut);
|
|
788
|
+
out.push("<" + tag + ">" + innerOut.join("") + "</" + tag + ">");
|
|
789
|
+
i = closeIdx;
|
|
790
|
+
}
|
|
490
791
|
}
|
|
491
|
-
function
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
792
|
+
function renderSingleToken(text, token) {
|
|
793
|
+
if (token.kind === "text") {
|
|
794
|
+
return token.end > token.start ? escapeHtml(text.slice(token.start, token.end)) : "";
|
|
795
|
+
}
|
|
796
|
+
if (token.kind === "code") {
|
|
797
|
+
return "<code>" + escapeHtml(text.slice(token.contentStart, token.contentEnd)) + "</code>";
|
|
798
|
+
}
|
|
799
|
+
if (token.kind === "image") {
|
|
800
|
+
return renderImageToken(text, token);
|
|
801
|
+
}
|
|
802
|
+
if (token.kind === "link") {
|
|
803
|
+
return renderLinkToken(text, token);
|
|
804
|
+
}
|
|
805
|
+
return escapeHtml(token.marker.repeat(token.runLength));
|
|
806
|
+
}
|
|
807
|
+
function renderImageToken(text, token) {
|
|
808
|
+
const labelRaw = text.slice(token.labelStart, token.labelEnd);
|
|
809
|
+
if (!isAllowedUrl(token.url)) {
|
|
810
|
+
return escapeHtml(labelRaw);
|
|
811
|
+
}
|
|
812
|
+
if (!isRemoteUrl(token.url)) {
|
|
813
|
+
return '<ac:image data-local-src="' + escapeXmlAttribute(token.url) + '"></ac:image>';
|
|
814
|
+
}
|
|
815
|
+
const alt = renderInline(labelRaw);
|
|
816
|
+
return '<ac:image><ri:url ri:value="' + escapeXmlAttribute(token.url) + '" />' + alt + "</ac:image>";
|
|
817
|
+
}
|
|
818
|
+
function renderLinkToken(text, token) {
|
|
819
|
+
const labelRaw = text.slice(token.labelStart, token.labelEnd);
|
|
820
|
+
if (!isAllowedUrl(token.url)) {
|
|
821
|
+
return renderInline(labelRaw);
|
|
822
|
+
}
|
|
823
|
+
return '<a href="' + escapeXmlAttribute(token.url) + '">' + renderInline(labelRaw) + "</a>";
|
|
824
|
+
}
|
|
825
|
+
function findEmphasisClose(tokens, openIdx, to, open) {
|
|
826
|
+
let depth = 0;
|
|
827
|
+
for (let i = openIdx + 1; i < to; i += 1) {
|
|
828
|
+
const t = tokens[i];
|
|
829
|
+
if (t.kind !== "delimiter") {
|
|
504
830
|
continue;
|
|
505
831
|
}
|
|
506
|
-
|
|
507
|
-
|
|
832
|
+
const m = t.marker;
|
|
833
|
+
if (m === open.marker && t.runLength === open.runLength && t.canOpen && !t.canClose) {
|
|
834
|
+
depth += 1;
|
|
835
|
+
} else if (m === open.marker && t.runLength === open.runLength && t.canClose) {
|
|
836
|
+
if (depth === 0) {
|
|
837
|
+
return i;
|
|
838
|
+
}
|
|
839
|
+
depth -= 1;
|
|
840
|
+
}
|
|
508
841
|
}
|
|
509
|
-
|
|
510
|
-
return out;
|
|
842
|
+
return -1;
|
|
511
843
|
}
|
|
512
|
-
function
|
|
844
|
+
function tokenizeInline(text, start, end) {
|
|
845
|
+
const tokens = [];
|
|
846
|
+
let cursor = start;
|
|
847
|
+
let textStart = start;
|
|
848
|
+
const flushText = (stop) => {
|
|
849
|
+
if (stop > textStart) {
|
|
850
|
+
tokens.push({ kind: "text", start: textStart, end: stop });
|
|
851
|
+
}
|
|
852
|
+
textStart = stop;
|
|
853
|
+
};
|
|
854
|
+
while (cursor < end) {
|
|
855
|
+
const ch = text[cursor];
|
|
856
|
+
if (ch === "\\" && cursor + 1 < end && isAsciiPunctuation(text.charCodeAt(cursor + 1))) {
|
|
857
|
+
flushText(cursor);
|
|
858
|
+
tokens.push({ kind: "text", start: cursor + 1, end: cursor + 2 });
|
|
859
|
+
cursor += 2;
|
|
860
|
+
textStart = cursor;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (ch === "`") {
|
|
864
|
+
const codeSpan = scanInlineCode(text, cursor, end);
|
|
865
|
+
if (codeSpan) {
|
|
866
|
+
flushText(cursor);
|
|
867
|
+
tokens.push({ kind: "code", contentStart: codeSpan.contentStart, contentEnd: codeSpan.contentEnd });
|
|
868
|
+
cursor = codeSpan.nextCursor;
|
|
869
|
+
textStart = cursor;
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
cursor += 1;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (ch === "!" && cursor + 1 < end && text[cursor + 1] === "[") {
|
|
876
|
+
const link = tryParseLink(text, cursor + 1, end);
|
|
877
|
+
if (link) {
|
|
878
|
+
flushText(cursor);
|
|
879
|
+
tokens.push({ kind: "image", labelStart: cursor + 2, labelEnd: link.labelEnd, url: link.url });
|
|
880
|
+
cursor = link.nextCursor;
|
|
881
|
+
textStart = cursor;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (ch === "[") {
|
|
886
|
+
const link = tryParseLink(text, cursor, end);
|
|
887
|
+
if (link) {
|
|
888
|
+
flushText(cursor);
|
|
889
|
+
tokens.push({ kind: "link", labelStart: cursor + 1, labelEnd: link.labelEnd, url: link.url });
|
|
890
|
+
cursor = link.nextCursor;
|
|
891
|
+
textStart = cursor;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (ch === "*" || ch === "_") {
|
|
896
|
+
let runEnd = cursor;
|
|
897
|
+
while (runEnd < end && text[runEnd] === ch) {
|
|
898
|
+
runEnd += 1;
|
|
899
|
+
}
|
|
900
|
+
const runLength = runEnd - cursor;
|
|
901
|
+
const beforeCode = cursor === start ? -1 : text.charCodeAt(cursor - 1);
|
|
902
|
+
const afterCode = runEnd === end ? -1 : text.charCodeAt(runEnd);
|
|
903
|
+
const beforeIsSpace = beforeCode === -1 || isInlineWhitespace(beforeCode);
|
|
904
|
+
const afterIsSpace = afterCode === -1 || isInlineWhitespace(afterCode);
|
|
905
|
+
const leftFlanking = !afterIsSpace;
|
|
906
|
+
const rightFlanking = !beforeIsSpace;
|
|
907
|
+
let canOpen;
|
|
908
|
+
let canClose;
|
|
909
|
+
if (ch === "_") {
|
|
910
|
+
const beforeIsPunct = beforeCode !== -1 && isAsciiPunctuation(beforeCode);
|
|
911
|
+
const afterIsPunct = afterCode !== -1 && isAsciiPunctuation(afterCode);
|
|
912
|
+
canOpen = leftFlanking && (!rightFlanking || afterIsPunct);
|
|
913
|
+
canClose = rightFlanking && (!leftFlanking || beforeIsPunct);
|
|
914
|
+
} else {
|
|
915
|
+
canOpen = leftFlanking;
|
|
916
|
+
canClose = rightFlanking;
|
|
917
|
+
}
|
|
918
|
+
flushText(cursor);
|
|
919
|
+
const m = ch;
|
|
920
|
+
tokens.push({ kind: "delimiter", marker: m, runLength, canOpen, canClose });
|
|
921
|
+
cursor = runEnd;
|
|
922
|
+
textStart = cursor;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
cursor += 1;
|
|
926
|
+
}
|
|
927
|
+
flushText(end);
|
|
928
|
+
return tokens;
|
|
929
|
+
}
|
|
930
|
+
function scanInlineCode(text, start, end) {
|
|
931
|
+
let openEnd = start;
|
|
932
|
+
while (openEnd < end && text[openEnd] === "`") {
|
|
933
|
+
openEnd += 1;
|
|
934
|
+
}
|
|
935
|
+
const openRun = openEnd - start;
|
|
936
|
+
let closeStart = -1;
|
|
937
|
+
let search = openEnd;
|
|
938
|
+
while (search < end) {
|
|
939
|
+
if (text[search] !== "`") {
|
|
940
|
+
search += 1;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
let runEnd = search;
|
|
944
|
+
while (runEnd < end && text[runEnd] === "`") {
|
|
945
|
+
runEnd += 1;
|
|
946
|
+
}
|
|
947
|
+
if (runEnd - search === openRun) {
|
|
948
|
+
closeStart = search;
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
search = runEnd;
|
|
952
|
+
}
|
|
953
|
+
if (closeStart === -1) {
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
let contentStart = openEnd;
|
|
957
|
+
let contentEnd = closeStart;
|
|
958
|
+
if (contentEnd - contentStart >= 2 && text[contentStart] === " " && text[contentEnd - 1] === " ") {
|
|
959
|
+
contentStart += 1;
|
|
960
|
+
contentEnd -= 1;
|
|
961
|
+
}
|
|
962
|
+
return { contentStart, contentEnd, nextCursor: closeStart + openRun };
|
|
963
|
+
}
|
|
964
|
+
function isInlineWhitespace(code) {
|
|
965
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
966
|
+
}
|
|
967
|
+
function isAsciiPunctuation(code) {
|
|
968
|
+
return code >= 33 && code <= 47 || code >= 58 && code <= 64 || code >= 91 && code <= 96 || code >= 123 && code <= 126;
|
|
969
|
+
}
|
|
970
|
+
function tryParseLink(text, bracketStart, end) {
|
|
513
971
|
let depth = 0;
|
|
514
|
-
let i =
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
if (
|
|
972
|
+
let i = bracketStart + 1;
|
|
973
|
+
while (i < end) {
|
|
974
|
+
const c = text[i];
|
|
975
|
+
if (c === "\\") {
|
|
976
|
+
i += 2;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (c === "[") {
|
|
518
980
|
depth += 1;
|
|
519
|
-
} else if (
|
|
981
|
+
} else if (c === "]") {
|
|
520
982
|
if (depth === 0) {
|
|
521
|
-
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
depth -= 1;
|
|
986
|
+
}
|
|
987
|
+
i += 1;
|
|
988
|
+
}
|
|
989
|
+
if (i >= end || text[i] !== "]" || depth !== 0) {
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
const labelEnd = i;
|
|
993
|
+
if (i + 1 >= end || text[i + 1] !== "(") {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
let j = i + 2;
|
|
997
|
+
let parenDepth = 0;
|
|
998
|
+
while (j < end) {
|
|
999
|
+
const c = text[j];
|
|
1000
|
+
if (c === "\\") {
|
|
1001
|
+
j += 2;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
if (c === "(") {
|
|
1005
|
+
parenDepth += 1;
|
|
1006
|
+
} else if (c === ")") {
|
|
1007
|
+
if (parenDepth === 0) {
|
|
1008
|
+
const url = text.slice(i + 2, j).trim();
|
|
522
1009
|
if (url.length === 0) {
|
|
523
1010
|
return null;
|
|
524
1011
|
}
|
|
525
|
-
return { url,
|
|
1012
|
+
return { labelEnd, url, nextCursor: j + 1 };
|
|
526
1013
|
}
|
|
527
|
-
|
|
528
|
-
} else if (
|
|
1014
|
+
parenDepth -= 1;
|
|
1015
|
+
} else if (c === " " || c === " " || c === "\n") {
|
|
529
1016
|
return null;
|
|
530
1017
|
}
|
|
1018
|
+
j += 1;
|
|
531
1019
|
}
|
|
532
1020
|
return null;
|
|
533
1021
|
}
|
|
534
1022
|
var LOCAL_IMAGE_PLACEHOLDER_RE = /<ac:image\s+data-local-src="([^"]*)"\s*><\/ac:image>/g;
|
|
535
|
-
function applyStrong(text) {
|
|
536
|
-
let s = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
537
|
-
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
538
|
-
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
539
|
-
return s.replace(/_([^_]+)_/g, "<em>$1</em>");
|
|
540
|
-
}
|
|
541
|
-
function applyInlineCode(text) {
|
|
542
|
-
return text.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`);
|
|
543
|
-
}
|
|
544
1023
|
function isRemoteUrl(src) {
|
|
545
1024
|
return /^(https?:)?\/\//i.test(src) || /^\/\//.test(src);
|
|
546
1025
|
}
|
|
@@ -562,14 +1041,58 @@ function escapeAttachmentFilename(filename) {
|
|
|
562
1041
|
}
|
|
563
1042
|
|
|
564
1043
|
// src/attachments.ts
|
|
565
|
-
import { existsSync } from "fs";
|
|
566
|
-
import { normalize as normalize2, isAbsolute, resolve } from "path";
|
|
1044
|
+
import { existsSync, lstatSync, realpathSync } from "fs";
|
|
1045
|
+
import { normalize as normalize2, isAbsolute, relative as relative2, resolve } from "path";
|
|
1046
|
+
|
|
1047
|
+
// src/content-hash.ts
|
|
1048
|
+
import { createHash } from "crypto";
|
|
1049
|
+
import { readFileSync } from "fs";
|
|
1050
|
+
var SHORT_HASH_LENGTH = 16;
|
|
1051
|
+
function shortHashBytes(bytes) {
|
|
1052
|
+
return createHash("sha256").update(bytes).digest("hex").slice(0, SHORT_HASH_LENGTH);
|
|
1053
|
+
}
|
|
1054
|
+
function shortHashString(value) {
|
|
1055
|
+
return createHash("sha256").update(value, "utf8").digest("hex").slice(0, SHORT_HASH_LENGTH);
|
|
1056
|
+
}
|
|
1057
|
+
function shortHashFile(absPath) {
|
|
1058
|
+
const buf = readFileSync(absPath);
|
|
1059
|
+
return shortHashBytes(buf);
|
|
1060
|
+
}
|
|
1061
|
+
function splitStemAndExt(basename2) {
|
|
1062
|
+
const dot = basename2.lastIndexOf(".");
|
|
1063
|
+
if (dot <= 0 || dot === basename2.length - 1) {
|
|
1064
|
+
return { stem: basename2, ext: "" };
|
|
1065
|
+
}
|
|
1066
|
+
return { stem: basename2.slice(0, dot), ext: basename2.slice(dot + 1) };
|
|
1067
|
+
}
|
|
1068
|
+
function buildStableName(originalBasename, hash) {
|
|
1069
|
+
const { stem, ext } = splitStemAndExt(originalBasename);
|
|
1070
|
+
const safeStem = sanitizeNameSegment(stem);
|
|
1071
|
+
const safeExt = ext ? "." + sanitizeNameSegment(ext) : "";
|
|
1072
|
+
return `${safeStem}-${hash}${safeExt}`;
|
|
1073
|
+
}
|
|
1074
|
+
function sanitizeNameSegment(segment) {
|
|
1075
|
+
return segment.replace(/[/\\]/g, "_");
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// src/attachments.ts
|
|
1079
|
+
var DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
1080
|
+
var DEFAULT_MAX_TOTAL_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
1081
|
+
var UPLOAD_COMMENT_PREFIX = "rt-content-sha256:";
|
|
1082
|
+
function validateAttachmentSources(html, options) {
|
|
1083
|
+
const collected = collectLocalSources(html, options);
|
|
1084
|
+
return collected.map(({ src, abs }) => ({
|
|
1085
|
+
src,
|
|
1086
|
+
abs,
|
|
1087
|
+
filename: stableAttachmentFilename(abs)
|
|
1088
|
+
}));
|
|
1089
|
+
}
|
|
567
1090
|
async function rewriteImagesToAttachments(html, pageId, client, options) {
|
|
568
1091
|
const uploaded = [];
|
|
569
1092
|
const resolved = await resolvePlaceholders(html, pageId, client, options, uploaded);
|
|
570
1093
|
return { html: resolved, uploaded };
|
|
571
1094
|
}
|
|
572
|
-
async function
|
|
1095
|
+
async function preflightImagesToAttachments(html, pageId, client, options) {
|
|
573
1096
|
const existing = await client.getAttachments(pageId);
|
|
574
1097
|
const existingByName = /* @__PURE__ */ new Map();
|
|
575
1098
|
for (const a of existing) {
|
|
@@ -578,30 +1101,53 @@ async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
|
578
1101
|
existingByName.set(name, a);
|
|
579
1102
|
}
|
|
580
1103
|
}
|
|
581
|
-
const collected =
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
1104
|
+
const collected = collectLocalSources(html, options);
|
|
1105
|
+
const pending = [];
|
|
1106
|
+
const reused = [];
|
|
1107
|
+
const srcToFilename = /* @__PURE__ */ new Map();
|
|
1108
|
+
for (const { src, abs } of collected) {
|
|
1109
|
+
const hash = shortHashFile(abs);
|
|
1110
|
+
const filename = stableAttachmentFilename(abs);
|
|
1111
|
+
srcToFilename.set(src, filename);
|
|
1112
|
+
const existingAtt = existingByName.get(filename);
|
|
1113
|
+
if (existingAtt && existingAtt.id && attachmentContentHash(existingAtt) === hash) {
|
|
1114
|
+
reused.push({ src, attachment: existingAtt });
|
|
1115
|
+
} else {
|
|
1116
|
+
pending.push({ src, filename, hash });
|
|
589
1117
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1118
|
+
}
|
|
1119
|
+
const predicted = html.replace(LOCAL_IMAGE_PLACEHOLDER_RE, (full, encodedSrc) => {
|
|
1120
|
+
const src = decodePlaceholder(encodedSrc);
|
|
1121
|
+
const filename = srcToFilename.get(src);
|
|
1122
|
+
if (!filename) {
|
|
1123
|
+
return full;
|
|
1124
|
+
}
|
|
1125
|
+
return renderAttachmentMacro(filename);
|
|
1126
|
+
});
|
|
1127
|
+
return { html: predicted, pending, reused };
|
|
1128
|
+
}
|
|
1129
|
+
async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
1130
|
+
const existing = await client.getAttachments(pageId);
|
|
1131
|
+
const existingByName = /* @__PURE__ */ new Map();
|
|
1132
|
+
for (const a of existing) {
|
|
1133
|
+
const name = a.filename ?? a.title;
|
|
1134
|
+
if (name) {
|
|
1135
|
+
existingByName.set(name, a);
|
|
594
1136
|
}
|
|
595
|
-
collected.push({ src: rawSrc, abs });
|
|
596
1137
|
}
|
|
1138
|
+
const collected = collectLocalSources(html, options);
|
|
597
1139
|
const srcToFilename = /* @__PURE__ */ new Map();
|
|
598
1140
|
for (const { src, abs } of collected) {
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
1141
|
+
const hash = shortHashFile(abs);
|
|
1142
|
+
const filename = stableAttachmentFilename(abs);
|
|
1143
|
+
const existingAtt = existingByName.get(filename);
|
|
1144
|
+
let attachment;
|
|
1145
|
+
if (existingAtt && existingAtt.id && attachmentContentHash(existingAtt) === hash) {
|
|
1146
|
+
attachment = existingAtt;
|
|
1147
|
+
} else if (existingAtt && existingAtt.id) {
|
|
1148
|
+
attachment = await client.updateAttachmentData(pageId, existingAtt.id, abs, contentHashComment(hash), filename);
|
|
603
1149
|
} else {
|
|
604
|
-
attachment = await client.uploadAttachment(pageId, abs,
|
|
1150
|
+
attachment = await client.uploadAttachment(pageId, abs, contentHashComment(hash), filename);
|
|
605
1151
|
}
|
|
606
1152
|
uploaded.push({ src, attachment });
|
|
607
1153
|
srcToFilename.set(src, filename);
|
|
@@ -615,18 +1161,76 @@ async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
|
615
1161
|
return renderAttachmentMacro(filename);
|
|
616
1162
|
});
|
|
617
1163
|
}
|
|
1164
|
+
function collectLocalSources(html, options) {
|
|
1165
|
+
const collected = [];
|
|
1166
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1167
|
+
const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
1168
|
+
const maxTotalAttachmentBytes = options.maxTotalAttachmentBytes ?? DEFAULT_MAX_TOTAL_ATTACHMENT_BYTES;
|
|
1169
|
+
let totalBytes = 0;
|
|
1170
|
+
let m;
|
|
1171
|
+
LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
|
|
1172
|
+
while ((m = LOCAL_IMAGE_PLACEHOLDER_RE.exec(html)) !== null) {
|
|
1173
|
+
const rawSrc = decodePlaceholder(m[1] ?? "");
|
|
1174
|
+
if (seen.has(rawSrc)) {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
seen.add(rawSrc);
|
|
1178
|
+
const abs = resolveImageForUpload(options.markdownDir, options.allowedRoot, rawSrc);
|
|
1179
|
+
const sourceInfo = lstatSync(abs);
|
|
1180
|
+
if (!sourceInfo.isFile()) {
|
|
1181
|
+
throw new Error(`Attachment source must be a regular file: ${rawSrc}`);
|
|
1182
|
+
}
|
|
1183
|
+
if (sourceInfo.size > maxAttachmentBytes) {
|
|
1184
|
+
throw new Error(`Attachment source exceeds the size limit: ${rawSrc}`);
|
|
1185
|
+
}
|
|
1186
|
+
totalBytes += sourceInfo.size;
|
|
1187
|
+
if (totalBytes > maxTotalAttachmentBytes) {
|
|
1188
|
+
throw new Error(`Attachment sources exceed the total size limit for this document`);
|
|
1189
|
+
}
|
|
1190
|
+
collected.push({ src: rawSrc, abs });
|
|
1191
|
+
}
|
|
1192
|
+
return collected;
|
|
1193
|
+
}
|
|
1194
|
+
function stableAttachmentFilename(absPath) {
|
|
1195
|
+
const basename2 = basenameLocal(absPath);
|
|
1196
|
+
const hash = shortHashFile(absPath);
|
|
1197
|
+
return escapeAttachmentFilename(buildStableName(basename2, hash));
|
|
1198
|
+
}
|
|
1199
|
+
function attachmentContentHash(attachment) {
|
|
1200
|
+
const message = attachment.version?.message;
|
|
1201
|
+
if (!message) {
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
if (!message.startsWith(UPLOAD_COMMENT_PREFIX)) {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
return message.slice(UPLOAD_COMMENT_PREFIX.length) || null;
|
|
1208
|
+
}
|
|
1209
|
+
function contentHashComment(hash) {
|
|
1210
|
+
return `${UPLOAD_COMMENT_PREFIX}${hash}`;
|
|
1211
|
+
}
|
|
618
1212
|
function renderAttachmentMacro(filename) {
|
|
619
1213
|
const safe = escapeAttachmentFilename(filename);
|
|
620
1214
|
return `<ac:image><ri:attachment ri:filename="${escapeXmlAttribute(safe)}" /></ac:image>`;
|
|
621
1215
|
}
|
|
622
|
-
function resolveImageForUpload(markdownDir, src) {
|
|
1216
|
+
function resolveImageForUpload(markdownDir, allowedRoot, src) {
|
|
623
1217
|
if (isRemoteUrl(src)) {
|
|
624
1218
|
throw new Error(`Remote image should not be uploaded: ${src}`);
|
|
625
1219
|
}
|
|
626
1220
|
if (isAbsolute(src)) {
|
|
627
|
-
|
|
1221
|
+
throw new Error(`Attachment source must be relative to the documentation root: ${src}`);
|
|
628
1222
|
}
|
|
629
|
-
|
|
1223
|
+
const resolvedPath = normalize2(resolve(markdownDir, src));
|
|
1224
|
+
if (!existsSync(resolvedPath)) {
|
|
1225
|
+
throw new Error(`Attachment source not found: ${src}`);
|
|
1226
|
+
}
|
|
1227
|
+
const resolvedRoot = realpathSync(allowedRoot);
|
|
1228
|
+
const realSourcePath = realpathSync(resolvedPath);
|
|
1229
|
+
const relativePath = relative2(resolvedRoot, realSourcePath);
|
|
1230
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
1231
|
+
throw new Error(`Attachment source escapes the documentation root: ${src}`);
|
|
1232
|
+
}
|
|
1233
|
+
return realSourcePath;
|
|
630
1234
|
}
|
|
631
1235
|
function basenameLocal(absPath) {
|
|
632
1236
|
const norm = normalize2(absPath);
|
|
@@ -645,9 +1249,15 @@ function decodePlaceholder(value) {
|
|
|
645
1249
|
|
|
646
1250
|
// src/mermaid.ts
|
|
647
1251
|
import { spawn, spawnSync } from "child_process";
|
|
648
|
-
import { mkdtemp, rm, writeFile } from "fs/promises";
|
|
1252
|
+
import { mkdtemp, rm, writeFile, readFile } from "fs/promises";
|
|
649
1253
|
import { tmpdir } from "os";
|
|
650
1254
|
import { join as join2 } from "path";
|
|
1255
|
+
var DEFAULT_MERMAID_RENDER_TIMEOUT_MS = 3e4;
|
|
1256
|
+
var DEFAULT_MERMAID_MAX_STREAM_BYTES = 1024 * 1024;
|
|
1257
|
+
var SVG_START_RE = /<svg[\s/>]/;
|
|
1258
|
+
var SVG_END_RE = /<\/svg>\s*$|\/>\s*$/;
|
|
1259
|
+
var UPLOAD_COMMENT_PREFIX2 = "rt-content-sha256:";
|
|
1260
|
+
var MERMAID_BASENAME = "mermaid.svg";
|
|
651
1261
|
async function rewriteMermaidBlocks(html, blocks, pageId, client, options = {}) {
|
|
652
1262
|
const fallbacks = [];
|
|
653
1263
|
const uploaded = [];
|
|
@@ -671,10 +1281,19 @@ async function rewriteMermaidBlocks(html, blocks, pageId, client, options = {})
|
|
|
671
1281
|
} else {
|
|
672
1282
|
mmdcAvailable = await isMmdcAvailable(options.mmdcPath);
|
|
673
1283
|
}
|
|
674
|
-
const
|
|
1284
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1285
|
+
const remainingIds = /* @__PURE__ */ new Set();
|
|
675
1286
|
for (const block of blocks) {
|
|
1287
|
+
const hash = shortHashString(block.source);
|
|
1288
|
+
const filename = mermaidAttachmentFilename(hash);
|
|
676
1289
|
if (!mmdcAvailable) {
|
|
677
1290
|
fallbacks.push(block.id);
|
|
1291
|
+
remainingIds.add(block.id);
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
const existingAtt = existingByName.get(filename);
|
|
1295
|
+
if (existingAtt && existingAtt.id && attachmentContentHash2(existingAtt) === hash) {
|
|
1296
|
+
resolved.set(block.id, { filename, attachment: existingAtt });
|
|
678
1297
|
continue;
|
|
679
1298
|
}
|
|
680
1299
|
const workDir = await mkdtemp(join2(tmpdir(), "rt-mermaid-"));
|
|
@@ -682,32 +1301,111 @@ async function rewriteMermaidBlocks(html, blocks, pageId, client, options = {})
|
|
|
682
1301
|
const inPath = join2(workDir, "diagram.mmd");
|
|
683
1302
|
const outPath = join2(workDir, "diagram.svg");
|
|
684
1303
|
await writeFile(inPath, block.source, "utf8");
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1304
|
+
try {
|
|
1305
|
+
await renderHook(block.source, outPath, options.mmdcPath, options.renderTimeoutMs, options.maxStreamBytes);
|
|
1306
|
+
} catch {
|
|
1307
|
+
fallbacks.push(block.id);
|
|
1308
|
+
remainingIds.add(block.id);
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
let svg;
|
|
1312
|
+
try {
|
|
1313
|
+
svg = await readFile(outPath, "utf8");
|
|
1314
|
+
} catch {
|
|
1315
|
+
fallbacks.push(block.id);
|
|
1316
|
+
remainingIds.add(block.id);
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
const trimmed = svg.trim();
|
|
1320
|
+
if (trimmed.length === 0 || !SVG_START_RE.test(trimmed) || !SVG_END_RE.test(trimmed)) {
|
|
1321
|
+
fallbacks.push(block.id);
|
|
1322
|
+
remainingIds.add(block.id);
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
let attachment;
|
|
1326
|
+
if (existingAtt && existingAtt.id) {
|
|
689
1327
|
attachment = await client.updateAttachmentData(
|
|
690
1328
|
pageId,
|
|
691
|
-
|
|
1329
|
+
existingAtt.id,
|
|
692
1330
|
outPath,
|
|
693
|
-
|
|
1331
|
+
contentHashComment2(hash),
|
|
1332
|
+
filename
|
|
694
1333
|
);
|
|
695
1334
|
} else {
|
|
696
|
-
attachment = await client.uploadAttachment(pageId, outPath,
|
|
1335
|
+
attachment = await client.uploadAttachment(pageId, outPath, contentHashComment2(hash), filename);
|
|
1336
|
+
}
|
|
1337
|
+
if (attachment) {
|
|
1338
|
+
uploaded.push({ id: block.id, attachment });
|
|
1339
|
+
resolved.set(block.id, { filename, attachment });
|
|
1340
|
+
} else {
|
|
1341
|
+
fallbacks.push(block.id);
|
|
1342
|
+
remainingIds.add(block.id);
|
|
697
1343
|
}
|
|
698
|
-
uploaded.push({ id: block.id, attachment });
|
|
699
|
-
placeholderToAttachment.set(block.id, filename);
|
|
700
|
-
} catch {
|
|
701
|
-
fallbacks.push(block.id);
|
|
702
1344
|
} finally {
|
|
703
1345
|
await rm(workDir, { recursive: true, force: true }).catch(() => {
|
|
704
1346
|
});
|
|
705
1347
|
}
|
|
706
1348
|
}
|
|
707
|
-
const remainingIds = new Set(fallbacks);
|
|
708
1349
|
const re = mermaidPlaceholderRe();
|
|
709
1350
|
const replaced = html.replace(re, (full, id) => {
|
|
710
|
-
const
|
|
1351
|
+
const entry = resolved.get(id);
|
|
1352
|
+
if (entry) {
|
|
1353
|
+
return renderAttachmentMacro2(entry.filename);
|
|
1354
|
+
}
|
|
1355
|
+
if (remainingIds.has(id)) {
|
|
1356
|
+
const block = blocks.find((b) => b.id === id);
|
|
1357
|
+
if (block) {
|
|
1358
|
+
return renderCodeBlock(block.source, "mermaid");
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return full;
|
|
1362
|
+
});
|
|
1363
|
+
return { html: replaced, fallbacks, uploaded };
|
|
1364
|
+
}
|
|
1365
|
+
async function preflightMermaidBlocks(html, blocks, pageId, client, options = {}) {
|
|
1366
|
+
const fallbacks = [];
|
|
1367
|
+
const pending = [];
|
|
1368
|
+
const reused = [];
|
|
1369
|
+
if (blocks.length === 0) {
|
|
1370
|
+
return { html, pending, reused, fallbacks };
|
|
1371
|
+
}
|
|
1372
|
+
const existing = await client.getAttachments(pageId);
|
|
1373
|
+
const existingByName = /* @__PURE__ */ new Map();
|
|
1374
|
+
for (const a of existing) {
|
|
1375
|
+
const name = a.filename ?? a.title;
|
|
1376
|
+
if (name) {
|
|
1377
|
+
existingByName.set(name, a);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
let mmdcAvailable;
|
|
1381
|
+
if (options.renderHook) {
|
|
1382
|
+
mmdcAvailable = true;
|
|
1383
|
+
} else if (options.available !== void 0) {
|
|
1384
|
+
mmdcAvailable = options.available;
|
|
1385
|
+
} else {
|
|
1386
|
+
mmdcAvailable = await isMmdcAvailable(options.mmdcPath);
|
|
1387
|
+
}
|
|
1388
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1389
|
+
const remainingIds = /* @__PURE__ */ new Set();
|
|
1390
|
+
for (const block of blocks) {
|
|
1391
|
+
const hash = shortHashString(block.source);
|
|
1392
|
+
const filename = mermaidAttachmentFilename(hash);
|
|
1393
|
+
if (!mmdcAvailable) {
|
|
1394
|
+
fallbacks.push(block.id);
|
|
1395
|
+
remainingIds.add(block.id);
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
const existingAtt = existingByName.get(filename);
|
|
1399
|
+
if (existingAtt && existingAtt.id && attachmentContentHash2(existingAtt) === hash) {
|
|
1400
|
+
reused.push({ id: block.id, attachment: existingAtt });
|
|
1401
|
+
resolved.set(block.id, filename);
|
|
1402
|
+
} else {
|
|
1403
|
+
pending.push({ id: block.id, filename, hash });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const re = mermaidPlaceholderRe();
|
|
1407
|
+
const predicted = html.replace(re, (full, id) => {
|
|
1408
|
+
const filename = resolved.get(id);
|
|
711
1409
|
if (filename) {
|
|
712
1410
|
return renderAttachmentMacro2(filename);
|
|
713
1411
|
}
|
|
@@ -719,7 +1417,23 @@ async function rewriteMermaidBlocks(html, blocks, pageId, client, options = {})
|
|
|
719
1417
|
}
|
|
720
1418
|
return full;
|
|
721
1419
|
});
|
|
722
|
-
return { html:
|
|
1420
|
+
return { html: predicted, pending, reused, fallbacks };
|
|
1421
|
+
}
|
|
1422
|
+
function mermaidAttachmentFilename(hash) {
|
|
1423
|
+
return escapeAttachmentFilename(buildStableName(MERMAID_BASENAME, hash));
|
|
1424
|
+
}
|
|
1425
|
+
function attachmentContentHash2(attachment) {
|
|
1426
|
+
const message = attachment.version?.message;
|
|
1427
|
+
if (!message) {
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
if (!message.startsWith(UPLOAD_COMMENT_PREFIX2)) {
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
return message.slice(UPLOAD_COMMENT_PREFIX2.length) || null;
|
|
1434
|
+
}
|
|
1435
|
+
function contentHashComment2(hash) {
|
|
1436
|
+
return `${UPLOAD_COMMENT_PREFIX2}${hash}`;
|
|
723
1437
|
}
|
|
724
1438
|
function renderAttachmentMacro2(filename) {
|
|
725
1439
|
const safe = escapeAttachmentFilename(filename);
|
|
@@ -731,44 +1445,150 @@ async function isMmdcAvailable(override) {
|
|
|
731
1445
|
}
|
|
732
1446
|
return spawnSync("sh", ["-c", "command -v mmdc"], { stdio: "ignore" }).status === 0;
|
|
733
1447
|
}
|
|
734
|
-
async function defaultRenderHook(source, outFile) {
|
|
1448
|
+
async function defaultRenderHook(source, outFile, mmdcPath, timeoutMs, maxStreamBytes) {
|
|
1449
|
+
const timeout = timeoutMs ?? DEFAULT_MERMAID_RENDER_TIMEOUT_MS;
|
|
1450
|
+
const maxBytes = maxStreamBytes ?? DEFAULT_MERMAID_MAX_STREAM_BYTES;
|
|
1451
|
+
const cmdPath = mmdcPath ?? "mmdc";
|
|
1452
|
+
return runMmdc(cmdPath, source, outFile, timeout, maxBytes);
|
|
1453
|
+
}
|
|
1454
|
+
function runMmdc(cmdPath, source, outFile, timeoutMs, maxStreamBytes) {
|
|
735
1455
|
return new Promise((resolve3, reject) => {
|
|
736
|
-
const child = spawn(
|
|
737
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1456
|
+
const child = spawn(cmdPath, ["-i", "-", "-o", outFile, "-t", "default", "-b", "transparent"], {
|
|
1457
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1458
|
+
shell: false
|
|
738
1459
|
});
|
|
1460
|
+
let stdoutLen = 0;
|
|
1461
|
+
let stderrLen = 0;
|
|
739
1462
|
let stderr = "";
|
|
1463
|
+
let killed = false;
|
|
1464
|
+
const timer = setTimeout(() => {
|
|
1465
|
+
killed = true;
|
|
1466
|
+
child.kill("SIGTERM");
|
|
1467
|
+
const sigKillTimer = setTimeout(() => {
|
|
1468
|
+
try {
|
|
1469
|
+
child.kill("SIGKILL");
|
|
1470
|
+
} catch {
|
|
1471
|
+
}
|
|
1472
|
+
}, 2e3);
|
|
1473
|
+
if (typeof sigKillTimer.unref === "function") {
|
|
1474
|
+
sigKillTimer.unref();
|
|
1475
|
+
}
|
|
1476
|
+
}, timeoutMs);
|
|
1477
|
+
if (typeof timer.unref === "function") {
|
|
1478
|
+
timer.unref();
|
|
1479
|
+
}
|
|
1480
|
+
child.stdout?.on("data", (chunk) => {
|
|
1481
|
+
stdoutLen += chunk.length;
|
|
1482
|
+
if (stdoutLen > maxStreamBytes && !killed) {
|
|
1483
|
+
killed = true;
|
|
1484
|
+
child.kill("SIGKILL");
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
740
1487
|
child.stderr?.on("data", (chunk) => {
|
|
741
|
-
|
|
1488
|
+
stderrLen += chunk.length;
|
|
1489
|
+
if (stderrLen > maxStreamBytes && !killed) {
|
|
1490
|
+
killed = true;
|
|
1491
|
+
child.kill("SIGKILL");
|
|
1492
|
+
}
|
|
1493
|
+
if (stderr.length < maxStreamBytes) {
|
|
1494
|
+
stderr += chunk.toString("utf8").slice(0, Math.max(0, maxStreamBytes - stderr.length));
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
child.on("error", (err) => {
|
|
1498
|
+
clearTimeout(timer);
|
|
1499
|
+
reject(err);
|
|
742
1500
|
});
|
|
743
|
-
child.on("
|
|
744
|
-
|
|
1501
|
+
child.on("close", (code, signal) => {
|
|
1502
|
+
clearTimeout(timer);
|
|
1503
|
+
if (killed && code !== 0) {
|
|
1504
|
+
reject(new Error(`mmdc exceeded ${timeoutMs}ms or output limit (killed=${signal ?? code})`));
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
745
1507
|
if (code === 0) {
|
|
746
1508
|
resolve3();
|
|
747
1509
|
} else {
|
|
748
|
-
reject(new Error(`mmdc exited with code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
1510
|
+
reject(new Error(`mmdc exited with code ${String(code)}${stderr ? `: ${stderr}` : ""}`));
|
|
749
1511
|
}
|
|
750
1512
|
});
|
|
1513
|
+
child.stdin?.on("error", () => void 0);
|
|
751
1514
|
child.stdin?.end(source);
|
|
752
1515
|
});
|
|
753
1516
|
}
|
|
754
1517
|
|
|
755
1518
|
// src/index.ts
|
|
756
1519
|
var INTERACTIVE_FLAG = { name: "interactive", aliases: ["i"], boolean: true };
|
|
1520
|
+
var LocalSyncValidationAggregateError = class extends Error {
|
|
1521
|
+
constructor(defects) {
|
|
1522
|
+
const summary = defects.map((d) => `${d.entry.segments.join("/")}: ${d.error.message}`).join("; ");
|
|
1523
|
+
super(`Local sync validation failed for ${defects.length} document(s): ${summary}`);
|
|
1524
|
+
this.name = "LocalSyncValidationAggregateError";
|
|
1525
|
+
this.defects = defects;
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
function validateLocalSync(entries, plan) {
|
|
1529
|
+
const defects = [];
|
|
1530
|
+
const plans = [];
|
|
1531
|
+
for (const entry of entries) {
|
|
1532
|
+
try {
|
|
1533
|
+
const markdown = readFileSync2(entry.absolute, "utf8");
|
|
1534
|
+
const { html, mermaidBlocks } = markdownToStorage(markdown, {
|
|
1535
|
+
renderHtmlBlocks: plan.renderHtmlBlocks
|
|
1536
|
+
});
|
|
1537
|
+
const markdownDir = dirname(entry.absolute);
|
|
1538
|
+
const hasLocalImages = hasLocalImagePlaceholder(html);
|
|
1539
|
+
const hasMermaidBlocks = mermaidBlocks.length > 0;
|
|
1540
|
+
const attachments = hasLocalImages ? validateAttachmentSources(html, {
|
|
1541
|
+
markdownDir,
|
|
1542
|
+
allowedRoot: plan.folder
|
|
1543
|
+
}) : [];
|
|
1544
|
+
plans.push({
|
|
1545
|
+
entry,
|
|
1546
|
+
html,
|
|
1547
|
+
mermaidBlocks,
|
|
1548
|
+
markdownDir,
|
|
1549
|
+
hasLocalImages,
|
|
1550
|
+
hasMermaidBlocks,
|
|
1551
|
+
attachments
|
|
1552
|
+
});
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
defects.push({
|
|
1555
|
+
entry,
|
|
1556
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
if (defects.length > 0) {
|
|
1561
|
+
throw new LocalSyncValidationAggregateError(defects);
|
|
1562
|
+
}
|
|
1563
|
+
return { entries: plans };
|
|
1564
|
+
}
|
|
1565
|
+
var SyncMutationError = class extends Error {
|
|
1566
|
+
constructor(input) {
|
|
1567
|
+
super(input.failure.error.message);
|
|
1568
|
+
this.name = "SyncMutationError";
|
|
1569
|
+
this.changes = input.changes;
|
|
1570
|
+
this.failure = input.failure;
|
|
1571
|
+
this.unprocessed = input.unprocessed;
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
757
1574
|
function resolveConfluenceSyncPlan(options = {}) {
|
|
758
1575
|
const cwd = resolve2(options.cwd ?? process.cwd());
|
|
759
1576
|
const folder = resolveInputPath(cwd, options.folder ?? "");
|
|
760
1577
|
if (!options.folder) {
|
|
761
1578
|
throw new Error("folder is required");
|
|
762
1579
|
}
|
|
1580
|
+
const hasGateway = options.client !== void 0;
|
|
763
1581
|
if (!options.dryRun) {
|
|
764
|
-
if (!
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1582
|
+
if (!hasGateway) {
|
|
1583
|
+
if (!options.username) {
|
|
1584
|
+
throw new Error("username is required");
|
|
1585
|
+
}
|
|
1586
|
+
if (!options.apiToken) {
|
|
1587
|
+
throw new Error("apiToken is required");
|
|
1588
|
+
}
|
|
1589
|
+
if (!options.baseUrl) {
|
|
1590
|
+
throw new Error("baseUrl is required");
|
|
1591
|
+
}
|
|
772
1592
|
}
|
|
773
1593
|
if (!options.spaceKey) {
|
|
774
1594
|
throw new Error("spaceKey is required");
|
|
@@ -797,17 +1617,21 @@ function resolveConfluenceSyncPlan(options = {}) {
|
|
|
797
1617
|
async function syncConfluenceToDocs(options = {}) {
|
|
798
1618
|
const plan = resolveConfluenceSyncPlan(options);
|
|
799
1619
|
const log = options.log ?? ((msg) => console.log(msg));
|
|
800
|
-
if (plan.dryRun) {
|
|
801
|
-
log("[dry-run] Walking documentation tree only.");
|
|
802
|
-
}
|
|
803
1620
|
const tree = await readDocTree(plan.folder);
|
|
804
1621
|
if (tree.entries.length === 0) {
|
|
805
1622
|
log(`No markdown files found under ${plan.folder}`);
|
|
806
1623
|
return;
|
|
807
1624
|
}
|
|
1625
|
+
validateLocalHierarchy(tree.entries);
|
|
1626
|
+
const localPlan = validateLocalSync(tree.entries, plan);
|
|
808
1627
|
if (plan.dryRun) {
|
|
809
|
-
|
|
810
|
-
|
|
1628
|
+
log("[dry-run] Walking documentation tree only.");
|
|
1629
|
+
for (const entryPlan of localPlan.entries) {
|
|
1630
|
+
const attCount = entryPlan.attachments.length;
|
|
1631
|
+
const mermaidCount = entryPlan.mermaidBlocks.length;
|
|
1632
|
+
log(
|
|
1633
|
+
`[dry-run] would sync ${entryPlan.entry.segments.join("/")}` + (attCount > 0 ? ` (${attCount} attachment${attCount === 1 ? "" : "s"} validated)` : "") + (mermaidCount > 0 ? ` (${mermaidCount} mermaid block${mermaidCount === 1 ? "" : "s"})` : "")
|
|
1634
|
+
);
|
|
811
1635
|
}
|
|
812
1636
|
return;
|
|
813
1637
|
}
|
|
@@ -818,11 +1642,23 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
818
1642
|
});
|
|
819
1643
|
const spaceId = await client.getSpaceIdByKey(plan.spaceKey);
|
|
820
1644
|
const cache = new PageTitleCache(spaceId, client);
|
|
821
|
-
|
|
822
|
-
|
|
1645
|
+
const changes = [];
|
|
1646
|
+
for (let i = 0; i < localPlan.entries.length; i += 1) {
|
|
1647
|
+
const entryPlan = localPlan.entries[i];
|
|
1648
|
+
try {
|
|
1649
|
+
await syncEntry(entryPlan, plan, client, cache, log, changes);
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
throw new SyncMutationError({
|
|
1652
|
+
changes,
|
|
1653
|
+
failure: { entry: entryPlan.entry, error: error instanceof Error ? error : new Error(String(error)) },
|
|
1654
|
+
unprocessed: localPlan.entries.slice(i + 1).map((p) => p.entry)
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
823
1657
|
}
|
|
1658
|
+
return { changes };
|
|
824
1659
|
}
|
|
825
|
-
async function syncEntry(
|
|
1660
|
+
async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
1661
|
+
const { entry, html: precomputedHtml, mermaidBlocks, markdownDir, hasLocalImages, hasMermaidBlocks } = entryPlan;
|
|
826
1662
|
const segments = entry.segments;
|
|
827
1663
|
if (segments.length === 0) {
|
|
828
1664
|
return;
|
|
@@ -833,16 +1669,38 @@ async function syncEntry(entry, plan, client, cache, log) {
|
|
|
833
1669
|
const segment = segments[idx] ?? "";
|
|
834
1670
|
if (isLast && isMarkdownName(segment)) {
|
|
835
1671
|
const title = titleFromSegment(segment);
|
|
836
|
-
const
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1672
|
+
const leafNeedsUploads = hasLocalImages || hasMermaidBlocks;
|
|
1673
|
+
const existing = await cache.find(title, currentParentId);
|
|
1674
|
+
if (!existing && !leafNeedsUploads) {
|
|
1675
|
+
const pageId2 = await cache.createEntry({
|
|
1676
|
+
title,
|
|
1677
|
+
parentId: currentParentId,
|
|
1678
|
+
body: { representation: "storage", value: precomputedHtml }
|
|
1679
|
+
});
|
|
1680
|
+
log(`created: ${segments.join("/")} (page ${pageId2})`);
|
|
1681
|
+
changes.push({ entry, pageId: pageId2, kind: "created" });
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const existingPage = existing ?? await cache.findOrCreate(title, currentParentId);
|
|
1685
|
+
const pageId = existingPage.id;
|
|
1686
|
+
const current = await client.getPage(pageId);
|
|
1687
|
+
const currentBody = current.body?.storage?.value ?? "";
|
|
1688
|
+
let body = precomputedHtml;
|
|
1689
|
+
if (plan.skipUnchanged) {
|
|
1690
|
+
const predicted = await predictBody(precomputedHtml, mermaidBlocks, pageId, client, {
|
|
1691
|
+
markdownDir,
|
|
1692
|
+
allowedRoot: plan.folder,
|
|
1693
|
+
hasLocalImages,
|
|
1694
|
+
hasMermaidBlocks
|
|
1695
|
+
});
|
|
1696
|
+
if (predicted !== null && predicted === currentBody) {
|
|
1697
|
+
log(`unchanged: ${segments.join("/")} (page ${pageId})`);
|
|
1698
|
+
changes.push({ entry, pageId, kind: "unchanged" });
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
if (hasMermaidBlocks) {
|
|
1703
|
+
const mermaidResult = await rewriteMermaidBlocks(body, [...mermaidBlocks], pageId, client);
|
|
846
1704
|
body = mermaidResult.html;
|
|
847
1705
|
if (mermaidResult.fallbacks.length > 0) {
|
|
848
1706
|
log(
|
|
@@ -850,18 +1708,14 @@ async function syncEntry(entry, plan, client, cache, log) {
|
|
|
850
1708
|
);
|
|
851
1709
|
}
|
|
852
1710
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1711
|
+
if (hasLocalImages) {
|
|
1712
|
+
const result = await rewriteImagesToAttachments(body, pageId, client, {
|
|
1713
|
+
markdownDir,
|
|
1714
|
+
allowedRoot: plan.folder
|
|
1715
|
+
});
|
|
856
1716
|
body = result.html;
|
|
857
1717
|
}
|
|
858
|
-
const current = await client.getPage(pageId);
|
|
859
|
-
const currentBody = current.body?.storage?.value ?? "";
|
|
860
1718
|
const nextVersion = (current.version?.number ?? 0) + 1;
|
|
861
|
-
if (plan.skipUnchanged && currentBody === body) {
|
|
862
|
-
log(`unchanged: ${segments.join("/")} (page ${pageId})`);
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
1719
|
await client.updatePage({
|
|
866
1720
|
id: pageId,
|
|
867
1721
|
title,
|
|
@@ -869,6 +1723,7 @@ async function syncEntry(entry, plan, client, cache, log) {
|
|
|
869
1723
|
version: { number: nextVersion, message: plan.versionMessage }
|
|
870
1724
|
});
|
|
871
1725
|
log(`updated: ${segments.join("/")} (page ${pageId}, v${nextVersion})`);
|
|
1726
|
+
changes.push({ entry, pageId, kind: "updated" });
|
|
872
1727
|
return;
|
|
873
1728
|
}
|
|
874
1729
|
if (isMarkdownName(segment)) {
|
|
@@ -887,13 +1742,48 @@ var PageTitleCache = class {
|
|
|
887
1742
|
this.spaceId = spaceId;
|
|
888
1743
|
this.client = client;
|
|
889
1744
|
}
|
|
1745
|
+
async find(title, parentId) {
|
|
1746
|
+
const key = `${parentId}::${title}`;
|
|
1747
|
+
const cached = this.cache.get(key);
|
|
1748
|
+
if (cached) {
|
|
1749
|
+
return void 0;
|
|
1750
|
+
}
|
|
1751
|
+
const matches = (await this.client.getPagesByTitle(this.spaceId, title)).filter(
|
|
1752
|
+
(page) => page.parentId === parentId
|
|
1753
|
+
);
|
|
1754
|
+
if (matches.length > 1) {
|
|
1755
|
+
throw new Error(`Multiple Confluence pages matched title ${title} under parent ${parentId}`);
|
|
1756
|
+
}
|
|
1757
|
+
const found = matches[0];
|
|
1758
|
+
if (found) {
|
|
1759
|
+
this.cache.set(key, { id: found.id });
|
|
1760
|
+
}
|
|
1761
|
+
return found;
|
|
1762
|
+
}
|
|
1763
|
+
async createEntry(input) {
|
|
1764
|
+
const created = await this.client.createPage({
|
|
1765
|
+
spaceId: this.spaceId,
|
|
1766
|
+
title: input.title,
|
|
1767
|
+
parentId: input.parentId,
|
|
1768
|
+
body: { representation: input.body.representation, value: input.body.value }
|
|
1769
|
+
});
|
|
1770
|
+
const pageId = created.id;
|
|
1771
|
+
this.cache.set(`${input.parentId}::${input.title}`, { id: pageId });
|
|
1772
|
+
return pageId;
|
|
1773
|
+
}
|
|
890
1774
|
async findOrCreate(title, parentId) {
|
|
891
1775
|
const key = `${parentId}::${title}`;
|
|
892
1776
|
const existing = this.cache.get(key);
|
|
893
1777
|
if (existing) {
|
|
894
1778
|
return existing;
|
|
895
1779
|
}
|
|
896
|
-
|
|
1780
|
+
const matches = (await this.client.getPagesByTitle(this.spaceId, title)).filter(
|
|
1781
|
+
(page2) => page2.parentId === parentId
|
|
1782
|
+
);
|
|
1783
|
+
let page = matches[0];
|
|
1784
|
+
if (matches.length > 1) {
|
|
1785
|
+
throw new Error(`Multiple Confluence pages matched title ${title} under parent ${parentId}`);
|
|
1786
|
+
}
|
|
897
1787
|
if (!page) {
|
|
898
1788
|
page = await this.client.createPage({
|
|
899
1789
|
spaceId: this.spaceId,
|
|
@@ -913,26 +1803,71 @@ function resolveInputPath(baseDir, inputPath) {
|
|
|
913
1803
|
}
|
|
914
1804
|
return resolve2(baseDir, inputPath);
|
|
915
1805
|
}
|
|
1806
|
+
function hasLocalImagePlaceholder(html) {
|
|
1807
|
+
LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
|
|
1808
|
+
return LOCAL_IMAGE_PLACEHOLDER_RE.test(html);
|
|
1809
|
+
}
|
|
1810
|
+
async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
|
|
1811
|
+
let predicted = html;
|
|
1812
|
+
if (ctx.hasMermaidBlocks) {
|
|
1813
|
+
const preflight = await preflightMermaidBlocks(predicted, [...mermaidBlocks], pageId, client);
|
|
1814
|
+
if (preflight.pending.length > 0) {
|
|
1815
|
+
return null;
|
|
1816
|
+
}
|
|
1817
|
+
predicted = preflight.html;
|
|
1818
|
+
}
|
|
1819
|
+
if (ctx.hasLocalImages) {
|
|
1820
|
+
const preflight = await preflightImagesToAttachments(predicted, pageId, client, {
|
|
1821
|
+
markdownDir: ctx.markdownDir,
|
|
1822
|
+
allowedRoot: ctx.allowedRoot
|
|
1823
|
+
});
|
|
1824
|
+
if (preflight.pending.length > 0) {
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
predicted = preflight.html;
|
|
1828
|
+
}
|
|
1829
|
+
return predicted;
|
|
1830
|
+
}
|
|
1831
|
+
function validateLocalHierarchy(entries) {
|
|
1832
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1833
|
+
for (const entry of entries) {
|
|
1834
|
+
let parentKey = "";
|
|
1835
|
+
for (let index = 0; index < entry.segments.length; index += 1) {
|
|
1836
|
+
const segment = entry.segments[index] ?? "";
|
|
1837
|
+
const isLast = index === entry.segments.length - 1;
|
|
1838
|
+
const title = isMarkdownName(segment) ? titleFromSegment(segment) : segment;
|
|
1839
|
+
const kind = isLast && isMarkdownName(segment) ? "file" : "dir";
|
|
1840
|
+
const key = `${parentKey}::${title}`;
|
|
1841
|
+
const existing = seen.get(key);
|
|
1842
|
+
if (existing && existing !== kind) {
|
|
1843
|
+
throw new Error(`Local documentation tree contains conflicting page titles under the same parent: ${title}`);
|
|
1844
|
+
}
|
|
1845
|
+
seen.set(key, existing ?? kind);
|
|
1846
|
+
parentKey = key;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
916
1850
|
export {
|
|
917
1851
|
ConfluenceApiError,
|
|
918
1852
|
ConfluenceClient,
|
|
919
1853
|
INTERACTIVE_FLAG,
|
|
920
|
-
|
|
1854
|
+
LocalSyncValidationAggregateError,
|
|
1855
|
+
SyncMutationError,
|
|
921
1856
|
escapeAttachmentFilename,
|
|
922
1857
|
escapeXmlAttribute,
|
|
1858
|
+
isAllowedUrl,
|
|
923
1859
|
isMarkdownName,
|
|
924
|
-
isPlainObject,
|
|
925
1860
|
isRemoteUrl,
|
|
926
1861
|
markdownToStorage,
|
|
927
|
-
|
|
1862
|
+
preflightImagesToAttachments,
|
|
1863
|
+
preflightMermaidBlocks,
|
|
928
1864
|
readDocTree,
|
|
929
|
-
renderHtmlBlock,
|
|
930
|
-
renderInline,
|
|
931
|
-
resolveCliOptions,
|
|
932
1865
|
resolveConfluenceSyncPlan,
|
|
933
1866
|
resolveConfluenceSyncPlan as resolveSyncPlan,
|
|
934
1867
|
rewriteImagesToAttachments,
|
|
935
1868
|
rewriteMermaidBlocks,
|
|
936
1869
|
syncConfluenceToDocs,
|
|
937
|
-
titleFromSegment
|
|
1870
|
+
titleFromSegment,
|
|
1871
|
+
validateAttachmentSources,
|
|
1872
|
+
validateLocalSync
|
|
938
1873
|
};
|