@repo-toolkit/confluence 0.9.0 → 0.12.0

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