@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/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
|
-
const parts = [];
|
|
136
|
-
parts.push(multipartField(boundary, "file", filename, fileBuffer));
|
|
137
|
-
if (comment) {
|
|
138
|
-
parts.push(multipartField(boundary, "comment", void 0, Buffer.from(comment, "utf8")));
|
|
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, "");
|
|
139
182
|
}
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
);
|
|
190
|
+
}
|
|
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");
|
|
233
468
|
}
|
|
234
|
-
|
|
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, "");
|
|
490
|
+
}
|
|
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,15 +567,79 @@ 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 = "'";
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
618
|
+
var MERMAID_PLACEHOLDER_PREFIX = '<ac:structured-macro ac:name="mermaid-placeholder" data-mermaid-id="';
|
|
619
|
+
var MERMAID_PLACEHOLDER_RE_STRICT = /<ac:structured-macro ac:name="mermaid-placeholder" data-mermaid-id="([^"]+)"><\/ac:structured-macro>/g;
|
|
620
|
+
function mermaidPlaceholderRe() {
|
|
621
|
+
return new RegExp(MERMAID_PLACEHOLDER_RE_STRICT.source, "g");
|
|
622
|
+
}
|
|
623
|
+
function renderMermaidPlaceholder(id) {
|
|
624
|
+
return `${MERMAID_PLACEHOLDER_PREFIX}${escapeXmlAttribute(id)}"></ac:structured-macro>`;
|
|
625
|
+
}
|
|
626
|
+
function markdownToStorage(markdown, options = {}) {
|
|
627
|
+
const renderHtmlBlocks = options.renderHtmlBlocks === true;
|
|
628
|
+
let lines = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
629
|
+
if (lines.length > 0 && lines[0] === "---") {
|
|
630
|
+
let closeIndex = -1;
|
|
631
|
+
for (let j = 1; j < lines.length; j += 1) {
|
|
632
|
+
if (lines[j] === "---" || lines[j] === "...") {
|
|
633
|
+
closeIndex = j;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (closeIndex !== -1) {
|
|
638
|
+
lines = lines.slice(closeIndex + 1);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
319
641
|
const out = [];
|
|
642
|
+
const mermaidBlocks = [];
|
|
320
643
|
let i = 0;
|
|
321
644
|
while (i < lines.length) {
|
|
322
645
|
const line = lines[i];
|
|
@@ -339,7 +662,16 @@ function markdownToStorage(markdown) {
|
|
|
339
662
|
i += 1;
|
|
340
663
|
}
|
|
341
664
|
i += 1;
|
|
342
|
-
|
|
665
|
+
const code = buf.join("\n");
|
|
666
|
+
if (lang === "mermaid") {
|
|
667
|
+
const id = `mermaid-${mermaidBlocks.length + 1}`;
|
|
668
|
+
mermaidBlocks.push({ id, source: code });
|
|
669
|
+
out.push(renderMermaidPlaceholder(id));
|
|
670
|
+
} else if (lang === "html" && renderHtmlBlocks) {
|
|
671
|
+
out.push(renderHtmlBlock(code));
|
|
672
|
+
} else {
|
|
673
|
+
out.push(renderCodeBlock(code, lang));
|
|
674
|
+
}
|
|
343
675
|
continue;
|
|
344
676
|
}
|
|
345
677
|
if (/^\s{0,3}(?:-|\*|\+)\s+/.test(line) || /^\s{0,3}\d+\.\s+/.test(line)) {
|
|
@@ -376,7 +708,7 @@ function markdownToStorage(markdown) {
|
|
|
376
708
|
const renderedPara = renderInline(para.join(LINE_BREAK_SENTINEL));
|
|
377
709
|
out.push(`<p>${renderedPara.split(LINE_BREAK_SENTINEL).join(STORAGE_LINE_BREAK)}</p>`);
|
|
378
710
|
}
|
|
379
|
-
return { html: out.join("\n") };
|
|
711
|
+
return { html: out.join("\n"), mermaidBlocks };
|
|
380
712
|
}
|
|
381
713
|
function isLikelyListTerminator(lines, currentIndex) {
|
|
382
714
|
for (let k = currentIndex + 1; k < lines.length; k += 1) {
|
|
@@ -403,6 +735,9 @@ function renderCodeBlock(code, _lang) {
|
|
|
403
735
|
const titleAttr = escapeXmlAttribute(lang);
|
|
404
736
|
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>`;
|
|
405
737
|
}
|
|
738
|
+
function renderHtmlBlock(code) {
|
|
739
|
+
return `<ac:structured-macro ac:name="html"><ac:plain-text-body><![CDATA[${escapeCdataTerminator(code)}]]></ac:plain-text-body></ac:structured-macro>`;
|
|
740
|
+
}
|
|
406
741
|
function escapeCdataTerminator(text) {
|
|
407
742
|
return text.replace(/]]>/g, "]]]]><![CDATA[>");
|
|
408
743
|
}
|
|
@@ -427,86 +762,264 @@ function renderList(listLines) {
|
|
|
427
762
|
return `<${tag}>${body}</${tag}>`;
|
|
428
763
|
}
|
|
429
764
|
function renderInline(text) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
function applyLinks(text) {
|
|
439
|
-
return replaceBalancedSyntax(text, /\[([^\]]+)\]\(/g, (label, url) => {
|
|
440
|
-
if (PROTOCOL_BLOCKLIST.test(url)) {
|
|
441
|
-
return label;
|
|
442
|
-
}
|
|
443
|
-
return '<a href="' + escapeXmlAttribute(url) + '">' + label + "</a>";
|
|
444
|
-
});
|
|
765
|
+
const out = [];
|
|
766
|
+
const tokens = tokenizeInline(text, 0, text.length);
|
|
767
|
+
renderTokens(text, tokens, out);
|
|
768
|
+
return out.join("");
|
|
769
|
+
}
|
|
770
|
+
function renderTokens(text, tokens, out) {
|
|
771
|
+
renderTokenRange(text, tokens, 0, tokens.length, out);
|
|
445
772
|
}
|
|
446
|
-
function
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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;
|
|
450
779
|
}
|
|
451
|
-
|
|
452
|
-
|
|
780
|
+
const closeIdx = findEmphasisClose(tokens, i, to, open);
|
|
781
|
+
if (closeIdx === -1) {
|
|
782
|
+
out.push(renderSingleToken(text, open));
|
|
783
|
+
continue;
|
|
453
784
|
}
|
|
454
|
-
|
|
455
|
-
|
|
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
|
+
}
|
|
456
791
|
}
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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") {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
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
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return -1;
|
|
843
|
+
}
|
|
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;
|
|
470
923
|
continue;
|
|
471
924
|
}
|
|
472
|
-
|
|
473
|
-
cursor = m.index + m[0].length + scan.consumed;
|
|
925
|
+
cursor += 1;
|
|
474
926
|
}
|
|
475
|
-
|
|
476
|
-
return
|
|
927
|
+
flushText(end);
|
|
928
|
+
return tokens;
|
|
477
929
|
}
|
|
478
|
-
function
|
|
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) {
|
|
479
971
|
let depth = 0;
|
|
480
|
-
let i =
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
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 === "[") {
|
|
484
980
|
depth += 1;
|
|
485
|
-
} else if (
|
|
981
|
+
} else if (c === "]") {
|
|
486
982
|
if (depth === 0) {
|
|
487
|
-
|
|
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();
|
|
488
1009
|
if (url.length === 0) {
|
|
489
1010
|
return null;
|
|
490
1011
|
}
|
|
491
|
-
return { url,
|
|
1012
|
+
return { labelEnd, url, nextCursor: j + 1 };
|
|
492
1013
|
}
|
|
493
|
-
|
|
494
|
-
} else if (
|
|
1014
|
+
parenDepth -= 1;
|
|
1015
|
+
} else if (c === " " || c === " " || c === "\n") {
|
|
495
1016
|
return null;
|
|
496
1017
|
}
|
|
1018
|
+
j += 1;
|
|
497
1019
|
}
|
|
498
1020
|
return null;
|
|
499
1021
|
}
|
|
500
1022
|
var LOCAL_IMAGE_PLACEHOLDER_RE = /<ac:image\s+data-local-src="([^"]*)"\s*><\/ac:image>/g;
|
|
501
|
-
function applyStrong(text) {
|
|
502
|
-
let s = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
503
|
-
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
504
|
-
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
505
|
-
return s.replace(/_([^_]+)_/g, "<em>$1</em>");
|
|
506
|
-
}
|
|
507
|
-
function applyInlineCode(text) {
|
|
508
|
-
return text.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`);
|
|
509
|
-
}
|
|
510
1023
|
function isRemoteUrl(src) {
|
|
511
1024
|
return /^(https?:)?\/\//i.test(src) || /^\/\//.test(src);
|
|
512
1025
|
}
|
|
@@ -528,14 +1041,58 @@ function escapeAttachmentFilename(filename) {
|
|
|
528
1041
|
}
|
|
529
1042
|
|
|
530
1043
|
// src/attachments.ts
|
|
531
|
-
import { existsSync } from "fs";
|
|
532
|
-
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
|
+
}
|
|
533
1090
|
async function rewriteImagesToAttachments(html, pageId, client, options) {
|
|
534
1091
|
const uploaded = [];
|
|
535
1092
|
const resolved = await resolvePlaceholders(html, pageId, client, options, uploaded);
|
|
536
1093
|
return { html: resolved, uploaded };
|
|
537
1094
|
}
|
|
538
|
-
async function
|
|
1095
|
+
async function preflightImagesToAttachments(html, pageId, client, options) {
|
|
539
1096
|
const existing = await client.getAttachments(pageId);
|
|
540
1097
|
const existingByName = /* @__PURE__ */ new Map();
|
|
541
1098
|
for (const a of existing) {
|
|
@@ -544,30 +1101,53 @@ async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
|
544
1101
|
existingByName.set(name, a);
|
|
545
1102
|
}
|
|
546
1103
|
}
|
|
547
|
-
const collected =
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
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 });
|
|
555
1117
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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);
|
|
560
1136
|
}
|
|
561
|
-
collected.push({ src: rawSrc, abs });
|
|
562
1137
|
}
|
|
1138
|
+
const collected = collectLocalSources(html, options);
|
|
563
1139
|
const srcToFilename = /* @__PURE__ */ new Map();
|
|
564
1140
|
for (const { src, abs } of collected) {
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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);
|
|
569
1149
|
} else {
|
|
570
|
-
attachment = await client.uploadAttachment(pageId, abs,
|
|
1150
|
+
attachment = await client.uploadAttachment(pageId, abs, contentHashComment(hash), filename);
|
|
571
1151
|
}
|
|
572
1152
|
uploaded.push({ src, attachment });
|
|
573
1153
|
srcToFilename.set(src, filename);
|
|
@@ -581,18 +1161,76 @@ async function resolvePlaceholders(html, pageId, client, options, uploaded) {
|
|
|
581
1161
|
return renderAttachmentMacro(filename);
|
|
582
1162
|
});
|
|
583
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
|
+
}
|
|
584
1212
|
function renderAttachmentMacro(filename) {
|
|
585
1213
|
const safe = escapeAttachmentFilename(filename);
|
|
586
1214
|
return `<ac:image><ri:attachment ri:filename="${escapeXmlAttribute(safe)}" /></ac:image>`;
|
|
587
1215
|
}
|
|
588
|
-
function resolveImageForUpload(markdownDir, src) {
|
|
1216
|
+
function resolveImageForUpload(markdownDir, allowedRoot, src) {
|
|
589
1217
|
if (isRemoteUrl(src)) {
|
|
590
1218
|
throw new Error(`Remote image should not be uploaded: ${src}`);
|
|
591
1219
|
}
|
|
592
1220
|
if (isAbsolute(src)) {
|
|
593
|
-
|
|
1221
|
+
throw new Error(`Attachment source must be relative to the documentation root: ${src}`);
|
|
1222
|
+
}
|
|
1223
|
+
const resolvedPath = normalize2(resolve(markdownDir, src));
|
|
1224
|
+
if (!existsSync(resolvedPath)) {
|
|
1225
|
+
throw new Error(`Attachment source not found: ${src}`);
|
|
594
1226
|
}
|
|
595
|
-
|
|
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;
|
|
596
1234
|
}
|
|
597
1235
|
function basenameLocal(absPath) {
|
|
598
1236
|
const norm = normalize2(absPath);
|
|
@@ -609,23 +1247,348 @@ function decodePlaceholder(value) {
|
|
|
609
1247
|
return value.replace(new RegExp(AMP2, "g"), "&").replace(new RegExp(QUOT2, "g"), '"').replace(new RegExp(LT2, "g"), "<").replace(new RegExp(GT2, "g"), ">");
|
|
610
1248
|
}
|
|
611
1249
|
|
|
1250
|
+
// src/mermaid.ts
|
|
1251
|
+
import { spawn, spawnSync } from "child_process";
|
|
1252
|
+
import { mkdtemp, rm, writeFile, readFile } from "fs/promises";
|
|
1253
|
+
import { tmpdir } from "os";
|
|
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";
|
|
1261
|
+
async function rewriteMermaidBlocks(html, blocks, pageId, client, options = {}) {
|
|
1262
|
+
const fallbacks = [];
|
|
1263
|
+
const uploaded = [];
|
|
1264
|
+
if (blocks.length === 0) {
|
|
1265
|
+
return { html, fallbacks, uploaded };
|
|
1266
|
+
}
|
|
1267
|
+
const existing = await client.getAttachments(pageId);
|
|
1268
|
+
const existingByName = /* @__PURE__ */ new Map();
|
|
1269
|
+
for (const a of existing) {
|
|
1270
|
+
const name = a.filename ?? a.title;
|
|
1271
|
+
if (name) {
|
|
1272
|
+
existingByName.set(name, a);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
const renderHook = options.renderHook ?? defaultRenderHook;
|
|
1276
|
+
let mmdcAvailable;
|
|
1277
|
+
if (options.renderHook) {
|
|
1278
|
+
mmdcAvailable = true;
|
|
1279
|
+
} else if (options.available !== void 0) {
|
|
1280
|
+
mmdcAvailable = options.available;
|
|
1281
|
+
} else {
|
|
1282
|
+
mmdcAvailable = await isMmdcAvailable(options.mmdcPath);
|
|
1283
|
+
}
|
|
1284
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1285
|
+
const remainingIds = /* @__PURE__ */ new Set();
|
|
1286
|
+
for (const block of blocks) {
|
|
1287
|
+
const hash = shortHashString(block.source);
|
|
1288
|
+
const filename = mermaidAttachmentFilename(hash);
|
|
1289
|
+
if (!mmdcAvailable) {
|
|
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 });
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const workDir = await mkdtemp(join2(tmpdir(), "rt-mermaid-"));
|
|
1300
|
+
try {
|
|
1301
|
+
const inPath = join2(workDir, "diagram.mmd");
|
|
1302
|
+
const outPath = join2(workDir, "diagram.svg");
|
|
1303
|
+
await writeFile(inPath, block.source, "utf8");
|
|
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) {
|
|
1327
|
+
attachment = await client.updateAttachmentData(
|
|
1328
|
+
pageId,
|
|
1329
|
+
existingAtt.id,
|
|
1330
|
+
outPath,
|
|
1331
|
+
contentHashComment2(hash),
|
|
1332
|
+
filename
|
|
1333
|
+
);
|
|
1334
|
+
} else {
|
|
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);
|
|
1343
|
+
}
|
|
1344
|
+
} finally {
|
|
1345
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
const re = mermaidPlaceholderRe();
|
|
1350
|
+
const replaced = html.replace(re, (full, id) => {
|
|
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);
|
|
1409
|
+
if (filename) {
|
|
1410
|
+
return renderAttachmentMacro2(filename);
|
|
1411
|
+
}
|
|
1412
|
+
if (remainingIds.has(id)) {
|
|
1413
|
+
const block = blocks.find((b) => b.id === id);
|
|
1414
|
+
if (block) {
|
|
1415
|
+
return renderCodeBlock(block.source, "mermaid");
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return full;
|
|
1419
|
+
});
|
|
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}`;
|
|
1437
|
+
}
|
|
1438
|
+
function renderAttachmentMacro2(filename) {
|
|
1439
|
+
const safe = escapeAttachmentFilename(filename);
|
|
1440
|
+
return `<ac:image><ri:attachment ri:filename="${escapeXmlAttribute(safe)}" /></ac:image>`;
|
|
1441
|
+
}
|
|
1442
|
+
async function isMmdcAvailable(override) {
|
|
1443
|
+
if (override) {
|
|
1444
|
+
return true;
|
|
1445
|
+
}
|
|
1446
|
+
return spawnSync("sh", ["-c", "command -v mmdc"], { stdio: "ignore" }).status === 0;
|
|
1447
|
+
}
|
|
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) {
|
|
1455
|
+
return new Promise((resolve3, reject) => {
|
|
1456
|
+
const child = spawn(cmdPath, ["-i", "-", "-o", outFile, "-t", "default", "-b", "transparent"], {
|
|
1457
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1458
|
+
shell: false
|
|
1459
|
+
});
|
|
1460
|
+
let stdoutLen = 0;
|
|
1461
|
+
let stderrLen = 0;
|
|
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
|
+
});
|
|
1487
|
+
child.stderr?.on("data", (chunk) => {
|
|
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);
|
|
1500
|
+
});
|
|
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
|
+
}
|
|
1507
|
+
if (code === 0) {
|
|
1508
|
+
resolve3();
|
|
1509
|
+
} else {
|
|
1510
|
+
reject(new Error(`mmdc exited with code ${String(code)}${stderr ? `: ${stderr}` : ""}`));
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
child.stdin?.on("error", () => void 0);
|
|
1514
|
+
child.stdin?.end(source);
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
612
1518
|
// src/index.ts
|
|
613
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
|
+
};
|
|
614
1574
|
function resolveConfluenceSyncPlan(options = {}) {
|
|
615
1575
|
const cwd = resolve2(options.cwd ?? process.cwd());
|
|
616
1576
|
const folder = resolveInputPath(cwd, options.folder ?? "");
|
|
617
1577
|
if (!options.folder) {
|
|
618
1578
|
throw new Error("folder is required");
|
|
619
1579
|
}
|
|
1580
|
+
const hasGateway = options.client !== void 0;
|
|
620
1581
|
if (!options.dryRun) {
|
|
621
|
-
if (!
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
+
}
|
|
629
1592
|
}
|
|
630
1593
|
if (!options.spaceKey) {
|
|
631
1594
|
throw new Error("spaceKey is required");
|
|
@@ -647,23 +1610,28 @@ function resolveConfluenceSyncPlan(options = {}) {
|
|
|
647
1610
|
parentPageId: options.parentPageId ?? "",
|
|
648
1611
|
versionMessage: options.versionMessage ?? "Synced via repo-toolkit-confluence",
|
|
649
1612
|
skipUnchanged: options.skipUnchanged ?? true,
|
|
650
|
-
dryRun: options.dryRun ?? false
|
|
1613
|
+
dryRun: options.dryRun ?? false,
|
|
1614
|
+
renderHtmlBlocks: options.renderHtmlBlocks === true
|
|
651
1615
|
};
|
|
652
1616
|
}
|
|
653
1617
|
async function syncConfluenceToDocs(options = {}) {
|
|
654
1618
|
const plan = resolveConfluenceSyncPlan(options);
|
|
655
1619
|
const log = options.log ?? ((msg) => console.log(msg));
|
|
656
|
-
if (plan.dryRun) {
|
|
657
|
-
log("[dry-run] Walking documentation tree only.");
|
|
658
|
-
}
|
|
659
1620
|
const tree = await readDocTree(plan.folder);
|
|
660
1621
|
if (tree.entries.length === 0) {
|
|
661
1622
|
log(`No markdown files found under ${plan.folder}`);
|
|
662
1623
|
return;
|
|
663
1624
|
}
|
|
1625
|
+
validateLocalHierarchy(tree.entries);
|
|
1626
|
+
const localPlan = validateLocalSync(tree.entries, plan);
|
|
664
1627
|
if (plan.dryRun) {
|
|
665
|
-
|
|
666
|
-
|
|
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
|
+
);
|
|
667
1635
|
}
|
|
668
1636
|
return;
|
|
669
1637
|
}
|
|
@@ -674,11 +1642,23 @@ async function syncConfluenceToDocs(options = {}) {
|
|
|
674
1642
|
});
|
|
675
1643
|
const spaceId = await client.getSpaceIdByKey(plan.spaceKey);
|
|
676
1644
|
const cache = new PageTitleCache(spaceId, client);
|
|
677
|
-
|
|
678
|
-
|
|
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
|
+
}
|
|
679
1657
|
}
|
|
1658
|
+
return { changes };
|
|
680
1659
|
}
|
|
681
|
-
async function syncEntry(
|
|
1660
|
+
async function syncEntry(entryPlan, plan, client, cache, log, changes) {
|
|
1661
|
+
const { entry, html: precomputedHtml, mermaidBlocks, markdownDir, hasLocalImages, hasMermaidBlocks } = entryPlan;
|
|
682
1662
|
const segments = entry.segments;
|
|
683
1663
|
if (segments.length === 0) {
|
|
684
1664
|
return;
|
|
@@ -689,24 +1669,53 @@ async function syncEntry(entry, plan, client, cache, log) {
|
|
|
689
1669
|
const segment = segments[idx] ?? "";
|
|
690
1670
|
if (isLast && isMarkdownName(segment)) {
|
|
691
1671
|
const title = titleFromSegment(segment);
|
|
692
|
-
const
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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;
|
|
702
1683
|
}
|
|
1684
|
+
const existingPage = existing ?? await cache.findOrCreate(title, currentParentId);
|
|
1685
|
+
const pageId = existingPage.id;
|
|
703
1686
|
const current = await client.getPage(pageId);
|
|
704
1687
|
const currentBody = current.body?.storage?.value ?? "";
|
|
705
|
-
|
|
706
|
-
if (plan.skipUnchanged
|
|
707
|
-
|
|
708
|
-
|
|
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);
|
|
1704
|
+
body = mermaidResult.html;
|
|
1705
|
+
if (mermaidResult.fallbacks.length > 0) {
|
|
1706
|
+
log(
|
|
1707
|
+
`mermaid: ${mermaidResult.fallbacks.length} block(s) not rendered (mmdc unavailable or failed); emitted as code macros`
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (hasLocalImages) {
|
|
1712
|
+
const result = await rewriteImagesToAttachments(body, pageId, client, {
|
|
1713
|
+
markdownDir,
|
|
1714
|
+
allowedRoot: plan.folder
|
|
1715
|
+
});
|
|
1716
|
+
body = result.html;
|
|
709
1717
|
}
|
|
1718
|
+
const nextVersion = (current.version?.number ?? 0) + 1;
|
|
710
1719
|
await client.updatePage({
|
|
711
1720
|
id: pageId,
|
|
712
1721
|
title,
|
|
@@ -714,6 +1723,7 @@ async function syncEntry(entry, plan, client, cache, log) {
|
|
|
714
1723
|
version: { number: nextVersion, message: plan.versionMessage }
|
|
715
1724
|
});
|
|
716
1725
|
log(`updated: ${segments.join("/")} (page ${pageId}, v${nextVersion})`);
|
|
1726
|
+
changes.push({ entry, pageId, kind: "updated" });
|
|
717
1727
|
return;
|
|
718
1728
|
}
|
|
719
1729
|
if (isMarkdownName(segment)) {
|
|
@@ -732,13 +1742,48 @@ var PageTitleCache = class {
|
|
|
732
1742
|
this.spaceId = spaceId;
|
|
733
1743
|
this.client = client;
|
|
734
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
|
+
}
|
|
735
1774
|
async findOrCreate(title, parentId) {
|
|
736
1775
|
const key = `${parentId}::${title}`;
|
|
737
1776
|
const existing = this.cache.get(key);
|
|
738
1777
|
if (existing) {
|
|
739
1778
|
return existing;
|
|
740
1779
|
}
|
|
741
|
-
|
|
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
|
+
}
|
|
742
1787
|
if (!page) {
|
|
743
1788
|
page = await this.client.createPage({
|
|
744
1789
|
spaceId: this.spaceId,
|
|
@@ -758,24 +1803,71 @@ function resolveInputPath(baseDir, inputPath) {
|
|
|
758
1803
|
}
|
|
759
1804
|
return resolve2(baseDir, inputPath);
|
|
760
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
|
+
}
|
|
761
1850
|
export {
|
|
762
1851
|
ConfluenceApiError,
|
|
763
1852
|
ConfluenceClient,
|
|
764
1853
|
INTERACTIVE_FLAG,
|
|
765
|
-
|
|
1854
|
+
LocalSyncValidationAggregateError,
|
|
1855
|
+
SyncMutationError,
|
|
766
1856
|
escapeAttachmentFilename,
|
|
767
1857
|
escapeXmlAttribute,
|
|
1858
|
+
isAllowedUrl,
|
|
768
1859
|
isMarkdownName,
|
|
769
|
-
isPlainObject,
|
|
770
1860
|
isRemoteUrl,
|
|
771
1861
|
markdownToStorage,
|
|
772
|
-
|
|
1862
|
+
preflightImagesToAttachments,
|
|
1863
|
+
preflightMermaidBlocks,
|
|
773
1864
|
readDocTree,
|
|
774
|
-
renderInline,
|
|
775
|
-
resolveCliOptions,
|
|
776
1865
|
resolveConfluenceSyncPlan,
|
|
777
1866
|
resolveConfluenceSyncPlan as resolveSyncPlan,
|
|
778
1867
|
rewriteImagesToAttachments,
|
|
1868
|
+
rewriteMermaidBlocks,
|
|
779
1869
|
syncConfluenceToDocs,
|
|
780
|
-
titleFromSegment
|
|
1870
|
+
titleFromSegment,
|
|
1871
|
+
validateAttachmentSources,
|
|
1872
|
+
validateLocalSync
|
|
781
1873
|
};
|