@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.
Files changed (5) hide show
  1. package/README.md +140 -17
  2. package/cli.js +1515 -243
  3. package/index.d.ts +308 -22
  4. package/index.js +1298 -206
  5. 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 { readFileSync } from "fs";
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
- this.baseUrl = normalizeBaseUrl(options.baseUrl);
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 getPageByTitle(spaceId, title) {
56
- const query = new URLSearchParams({
57
- "space-id": spaceId,
58
- title,
59
- limit: "1",
60
- "body-format": "storage"
61
- });
62
- const data = await this.requestJson(this.v2Url(`/pages?${query.toString()}`), {
63
- method: "GET"
64
- });
65
- return data.results[0];
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
- let cursor;
109
- do {
110
- const query = new URLSearchParams({ limit: String(MAX_LIMIT) });
111
- if (cursor) {
112
- query.set("cursor", cursor);
113
- }
114
- const data = await this.requestJson(
115
- this.v2Url(`/pages/${encodeURIComponent(pageId)}/attachments?${query.toString()}`),
116
- { method: "GET" }
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
- cursor = data._links?.next;
122
- } while (cursor);
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 fileBuffer = readFileSync(filePath);
133
- const filename = basename(filePath);
134
- const boundary = "----repo-toolkit-confluence-" + Math.random().toString(16).slice(2);
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
- parts.push(Buffer.from(`--${boundary}--\r
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 data = await this.requestJson(endpoint, {
144
- method: "POST",
145
- headers: {
146
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
147
- "X-Atlassian-Token": "no-check"
148
- },
149
- body: Buffer.concat(parts)
150
- });
151
- return normalizeAttachmentResult(data);
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
- if (init.headers) {
160
- for (const [k, v] of Object.entries(init.headers)) {
161
- headers[k] = v;
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
- let response;
165
- try {
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
- const text = await response.text();
180
- if (!response.ok) {
181
- throw new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
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 (text.length === 0) {
184
- return {};
313
+ if (typeof AbortSignal.timeout === "function") {
314
+ return AbortSignal.timeout(ms);
185
315
  }
186
- try {
187
- return JSON.parse(text);
188
- } catch {
189
- throw new ConfluenceApiError("Response was not valid JSON", response.status, endpoint, text);
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 = trimmed;
231
- while (url.endsWith("/")) {
232
- url = url.slice(0, -1);
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
- return url;
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 = "&amp;";
313
571
  var LT = "&lt;";
314
572
  var GT = "&gt;";
315
573
  var QUOT = "&quot;";
316
574
  var APOS = "&#39;";
317
- function markdownToStorage(markdown) {
318
- const lines = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
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
- out.push(renderCodeBlock(buf.join("\n"), lang));
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
- let s = text;
431
- s = escapeHtml(s);
432
- s = applyImages(s);
433
- s = applyLinks(s);
434
- s = applyStrong(s);
435
- s = applyInlineCode(s);
436
- return s;
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 applyImages(text) {
447
- return replaceBalancedSyntax(text, /!\[([^\]]*)\]\(/g, (alt, src) => {
448
- if (PROTOCOL_BLOCKLIST.test(src)) {
449
- return alt;
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
- if (isRemoteUrl(src)) {
452
- return '<ac:image><ri:url ri:value="' + escapeXmlAttribute(src) + '" /></ac:image>';
780
+ const closeIdx = findEmphasisClose(tokens, i, to, open);
781
+ if (closeIdx === -1) {
782
+ out.push(renderSingleToken(text, open));
783
+ continue;
453
784
  }
454
- return '<ac:image data-local-src="' + escapeXmlAttribute(src) + '"></ac:image>';
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 replaceBalancedSyntax(text, openerRe, render) {
458
- let out = "";
459
- let cursor = 0;
460
- let m;
461
- const re = new RegExp(openerRe.source, "g");
462
- while ((m = re.exec(text)) !== null) {
463
- out += text.slice(cursor, m.index);
464
- const captured = m[1] ?? "";
465
- const after = text.slice(m.index + m[0].length);
466
- const scan = scanBalancedUrl(after);
467
- if (scan === null) {
468
- out += m[0];
469
- cursor = m.index + m[0].length;
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
- out += render(captured, scan.url);
473
- cursor = m.index + m[0].length + scan.consumed;
925
+ cursor += 1;
474
926
  }
475
- out += text.slice(cursor);
476
- return out;
927
+ flushText(end);
928
+ return tokens;
477
929
  }
478
- function scanBalancedUrl(rest) {
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 = 0;
481
- for (; i < rest.length; i += 1) {
482
- const ch = rest[i];
483
- if (ch === "(") {
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 (ch === ")") {
981
+ } else if (c === "]") {
486
982
  if (depth === 0) {
487
- const url = rest.slice(0, i);
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, consumed: i + 1 };
1012
+ return { labelEnd, url, nextCursor: j + 1 };
492
1013
  }
493
- depth -= 1;
494
- } else if (ch === " " || ch === " " || ch === "\n") {
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 resolvePlaceholders(html, pageId, client, options, uploaded) {
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 seen = /* @__PURE__ */ new Set();
549
- let m;
550
- LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
551
- while ((m = LOCAL_IMAGE_PLACEHOLDER_RE.exec(html)) !== null) {
552
- const rawSrc = decodePlaceholder(m[1] ?? "");
553
- if (seen.has(rawSrc)) {
554
- continue;
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
- seen.add(rawSrc);
557
- const abs = resolveImageForUpload(options.markdownDir, rawSrc);
558
- if (!existsSync(abs)) {
559
- continue;
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 filename = escapeAttachmentFilename(basenameLocal(abs));
566
- let attachment = existingByName.get(filename);
567
- if (attachment && attachment.id) {
568
- attachment = await client.updateAttachmentData(pageId, attachment.id, abs, `Updated via repo-toolkit-confluence`);
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, `Uploaded via repo-toolkit-confluence`);
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
- return normalize2(src);
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
- return normalize2(resolve(markdownDir, src));
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 (!options.username) {
622
- throw new Error("username is required");
623
- }
624
- if (!options.apiToken) {
625
- throw new Error("apiToken is required");
626
- }
627
- if (!options.baseUrl) {
628
- throw new Error("baseUrl is required");
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
- for (const entry of tree.entries) {
666
- log(`[dry-run] would sync ${entry.segments.join("/")}`);
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
- for (const entry of tree.entries) {
678
- await syncEntry(entry, plan, client, cache, log);
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(entry, plan, client, cache, log) {
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 page2 = await cache.findOrCreate(title, currentParentId);
693
- const pageId = page2.id;
694
- const markdown = readFileSync2(entry.absolute, "utf8");
695
- const { html } = markdownToStorage(markdown);
696
- const markdownDir = dirname(entry.absolute);
697
- let body = html;
698
- LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
699
- if (LOCAL_IMAGE_PLACEHOLDER_RE.test(body)) {
700
- const result = await rewriteImagesToAttachments(body, pageId, client, { markdownDir });
701
- body = result.html;
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
- const nextVersion = (current.version?.number ?? 0) + 1;
706
- if (plan.skipUnchanged && currentBody === body) {
707
- log(`unchanged: ${segments.join("/")} (page ${pageId})`);
708
- return;
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
- let page = await this.client.getPageByTitle(this.spaceId, title);
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
- LOCAL_IMAGE_PLACEHOLDER_RE,
1854
+ LocalSyncValidationAggregateError,
1855
+ SyncMutationError,
766
1856
  escapeAttachmentFilename,
767
1857
  escapeXmlAttribute,
1858
+ isAllowedUrl,
768
1859
  isMarkdownName,
769
- isPlainObject,
770
1860
  isRemoteUrl,
771
1861
  markdownToStorage,
772
- parseFlags,
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
  };