@repo-toolkit/confluence 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,6 +72,9 @@ Flags:
72
72
  - `--space-key <key>` — Confluence space key (required; resolved to a `spaceId`)
73
73
  - `--parent-page-id <id>` — numeric Confluence page id (required)
74
74
  - `--version-message <text>` — version-message suffix appended to every PUT
75
+ - `--repository-url <url>` — repository URL appended to synced pages as an
76
+ italic source notice. When omitted, GitHub Actions runs infer this from
77
+ `GITHUB_SERVER_URL` and `GITHUB_REPOSITORY`.
75
78
  - `--skip-unchanged` / `--no-skip-unchanged` — skip pages whose body is unchanged (default: `skip`)
76
79
  - `--dry-run` — walk the doc tree and validate every markdown file and local
77
80
  image source (same preflight as a real sync) then log the plan. No API
@@ -99,6 +102,7 @@ both read for every option. Boolean env values accept `true|1|yes|on` /
99
102
  | spaceKey | `CONFLUENCE_SPACE_KEY` | `INPUT_SPACE-KEY` |
100
103
  | parentPageId | `CONFLUENCE_PARENT_PAGE_ID` | `INPUT_PARENT-PAGE-ID` |
101
104
  | versionMessage | `CONFLUENCE_VERSION_MESSAGE` | `INPUT_VERSION-MESSAGE` |
105
+ | repositoryUrl | `CONFLUENCE_REPOSITORY_URL` | `INPUT_REPOSITORY-URL` |
102
106
  | skipUnchanged (bool) | `CONFLUENCE_SKIP_UNCHANGED` | `INPUT_SKIP-UNCHANGED` |
103
107
  | dryRun (bool) | `CONFLUENCE_DRY_RUN` | `INPUT_DRY-RUN` |
104
108
  | renderHtmlBlocks (bool) | `CONFLUENCE_RENDER_HTML_BLOCKS` | `INPUT_RENDER-HTML-BLOCKS` |
package/cli.js CHANGED
@@ -282,23 +282,24 @@ Content-Type: application/octet-stream\r
282
282
  if (response.status >= 300 && response.status < 400) {
283
283
  throw new ConfluenceApiError("Redirect responses are not allowed", response.status, endpoint, "");
284
284
  }
285
- const text = await readBodyBounded(response, MAX_ERROR_BODY_LENGTH);
286
285
  if (!response.ok) {
286
+ const text2 = await readBodyBounded(response, MAX_ERROR_BODY_LENGTH);
287
287
  if (isSafe && shouldRetryStatus(response.status) && attempt < maxRetries) {
288
288
  attempt += 1;
289
- lastError = new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
289
+ lastError = new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text2);
290
290
  await sleepBackoff(response, endpoint);
291
291
  continue;
292
292
  }
293
- throw new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
293
+ throw new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text2);
294
294
  }
295
+ const text = await response.text();
295
296
  if (text.length === 0) {
296
297
  return {};
297
298
  }
298
299
  try {
299
300
  return JSON.parse(text);
300
301
  } catch {
301
- throw new ConfluenceApiError("Response was not valid JSON", response.status, endpoint, text);
302
+ throw new ConfluenceApiError("Response was not valid JSON", response.status, endpoint, truncateText(text));
302
303
  }
303
304
  } catch (cause) {
304
305
  if (cause instanceof ConfluenceApiError || cause instanceof ConfluenceUploadError) {
@@ -437,6 +438,16 @@ function sanitizeFilename(name) {
437
438
  }
438
439
  return cleaned;
439
440
  }
441
+ function truncateText(text, maxBytes = MAX_ERROR_BODY_LENGTH) {
442
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) {
443
+ return text;
444
+ }
445
+ let end = text.length;
446
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) {
447
+ end -= 1;
448
+ }
449
+ return text.slice(0, end) + "...";
450
+ }
440
451
  function multipartField(boundary, name, filename, value) {
441
452
  const headerLines = [`--${boundary}\r
442
453
  `];
@@ -709,13 +720,22 @@ function markdownToStorage(markdown, options = {}) {
709
720
  out.push(`<blockquote>${renderInline(quoteLines.join("\n"))}</blockquote>`);
710
721
  continue;
711
722
  }
723
+ if (isTableStart(lines, i)) {
724
+ const tableLines = [];
725
+ while (i < lines.length && typeof lines[i] === "string" && isTableRow(lines[i])) {
726
+ tableLines.push(lines[i]);
727
+ i += 1;
728
+ }
729
+ out.push(renderTable(tableLines));
730
+ continue;
731
+ }
712
732
  if (/^\s{0,3}---+\s*$/.test(line) || /^\s{0,3}\*\*\*+\s*$/.test(line)) {
713
733
  out.push("<hr />");
714
734
  i += 1;
715
735
  continue;
716
736
  }
717
737
  const para = [];
718
- while (i < lines.length && typeof lines[i] === "string" && lines[i].trim() !== "" && !/^\s{0,3}#{1,6}\s/.test(lines[i]) && !/^\s{0,3}```/.test(lines[i]) && !/^\s{0,3}(?:-|\*|\+)\s+/.test(lines[i]) && !/^\s{0,3}\d+\.\s+/.test(lines[i]) && !/^\s{0,3}>/.test(lines[i]) && !/^\s{0,3}---+\s*$/.test(lines[i]) && !/^\s{0,3}\*\*\*+\s*$/.test(lines[i])) {
738
+ while (i < lines.length && typeof lines[i] === "string" && lines[i].trim() !== "" && !/^\s{0,3}#{1,6}\s/.test(lines[i]) && !/^\s{0,3}```/.test(lines[i]) && !/^\s{0,3}(?:-|\*|\+)\s+/.test(lines[i]) && !/^\s{0,3}\d+\.\s+/.test(lines[i]) && !/^\s{0,3}>/.test(lines[i]) && !isTableStart(lines, i) && !/^\s{0,3}---+\s*$/.test(lines[i]) && !/^\s{0,3}\*\*\*+\s*$/.test(lines[i])) {
719
739
  para.push(lines[i]);
720
740
  i += 1;
721
741
  }
@@ -775,6 +795,60 @@ function renderList(listLines) {
775
795
  const body = items.map((item) => `<li>${renderInline(item)}</li>`).join("");
776
796
  return `<${tag}>${body}</${tag}>`;
777
797
  }
798
+ function isTableStart(lines, index) {
799
+ const header = lines[index];
800
+ const separator = lines[index + 1];
801
+ if (typeof header !== "string" || typeof separator !== "string") {
802
+ return false;
803
+ }
804
+ if (!isTableRow(header) || !isTableSeparator(separator)) {
805
+ return false;
806
+ }
807
+ return splitTableRow(header).length === splitTableRow(separator).length;
808
+ }
809
+ function isTableRow(line) {
810
+ return /^\s{0,3}\|/.test(line) && splitTableRow(line).length > 1;
811
+ }
812
+ function isTableSeparator(line) {
813
+ const cells = splitTableRow(line);
814
+ if (cells.length < 2) {
815
+ return false;
816
+ }
817
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
818
+ }
819
+ function splitTableRow(line) {
820
+ const trimmed = line.trim();
821
+ const start = trimmed.startsWith("|") ? 1 : 0;
822
+ const end = trimmed.endsWith("|") && !isEscapedPipe(trimmed, trimmed.length - 1) ? trimmed.length - 1 : trimmed.length;
823
+ const cells = [];
824
+ let cellStart = start;
825
+ for (let i = start; i < end; i += 1) {
826
+ if (trimmed[i] === "|" && !isEscapedPipe(trimmed, i)) {
827
+ cells.push(unescapeTableCell(trimmed.slice(cellStart, i).trim()));
828
+ cellStart = i + 1;
829
+ }
830
+ }
831
+ cells.push(unescapeTableCell(trimmed.slice(cellStart, end).trim()));
832
+ return cells;
833
+ }
834
+ function isEscapedPipe(text, index) {
835
+ let backslashes = 0;
836
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) {
837
+ backslashes += 1;
838
+ }
839
+ return backslashes % 2 === 1;
840
+ }
841
+ function unescapeTableCell(cell) {
842
+ return cell.replace(/\\\|/g, "|");
843
+ }
844
+ function renderTable(tableLines) {
845
+ const rows = tableLines.filter((_, index) => index !== 1).map(splitTableRow);
846
+ const body = rows.map((cells, rowIndex) => {
847
+ const tag = rowIndex === 0 ? "th" : "td";
848
+ return "<tr>" + cells.map((cell) => "<" + tag + ">" + renderInline(cell) + "</" + tag + ">").join("") + "</tr>";
849
+ }).join("");
850
+ return "<table><tbody>" + body + "</tbody></table>";
851
+ }
778
852
  function renderInline(text) {
779
853
  const out = [];
780
854
  const tokens = tokenizeInline(text, 0, text.length);
@@ -1547,16 +1621,17 @@ function validateLocalSync(entries, plan) {
1547
1621
  const { html, mermaidBlocks } = markdownToStorage(markdown, {
1548
1622
  renderHtmlBlocks: plan.renderHtmlBlocks
1549
1623
  });
1624
+ const body = appendRepositoryNotice(html, plan.repositoryUrl);
1550
1625
  const markdownDir = dirname(entry.absolute);
1551
- const hasLocalImages = hasLocalImagePlaceholder(html);
1626
+ const hasLocalImages = hasLocalImagePlaceholder(body);
1552
1627
  const hasMermaidBlocks = mermaidBlocks.length > 0;
1553
- const attachments = hasLocalImages ? validateAttachmentSources(html, {
1628
+ const attachments = hasLocalImages ? validateAttachmentSources(body, {
1554
1629
  markdownDir,
1555
1630
  allowedRoot: plan.folder
1556
1631
  }) : [];
1557
1632
  plans.push({
1558
1633
  entry,
1559
- html,
1634
+ html: body,
1560
1635
  mermaidBlocks,
1561
1636
  markdownDir,
1562
1637
  hasLocalImages,
@@ -1613,6 +1688,11 @@ function resolveConfluenceSyncPlan(options = {}) {
1613
1688
  throw new Error(`parentPageId must be numeric, got: ${options.parentPageId}`);
1614
1689
  }
1615
1690
  }
1691
+ if (options.repositoryUrl && !isAllowedUrl(options.repositoryUrl)) {
1692
+ throw new Error(
1693
+ "repositoryUrl must be an http(s), protocol-relative, server-relative, mailto, tel, or relative URL"
1694
+ );
1695
+ }
1616
1696
  return {
1617
1697
  cwd,
1618
1698
  folder,
@@ -1624,7 +1704,8 @@ function resolveConfluenceSyncPlan(options = {}) {
1624
1704
  versionMessage: options.versionMessage ?? "Synced via repo-toolkit-confluence",
1625
1705
  skipUnchanged: options.skipUnchanged ?? true,
1626
1706
  dryRun: options.dryRun ?? false,
1627
- renderHtmlBlocks: options.renderHtmlBlocks === true
1707
+ renderHtmlBlocks: options.renderHtmlBlocks === true,
1708
+ repositoryUrl: options.repositoryUrl ?? ""
1628
1709
  };
1629
1710
  }
1630
1711
  async function syncConfluenceToDocs(options = {}) {
@@ -1820,6 +1901,14 @@ function hasLocalImagePlaceholder(html) {
1820
1901
  LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
1821
1902
  return LOCAL_IMAGE_PLACEHOLDER_RE.test(html);
1822
1903
  }
1904
+ function appendRepositoryNotice(html, repositoryUrl) {
1905
+ if (repositoryUrl.length === 0) {
1906
+ return html;
1907
+ }
1908
+ const url = escapeXmlAttribute(repositoryUrl);
1909
+ const text = escapeHtml(repositoryUrl);
1910
+ return html + '\n<p><em>This document is synced from repository <a href="' + url + '">' + text + "</a>.</em></p>";
1911
+ }
1823
1912
  async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
1824
1913
  let predicted = html;
1825
1914
  if (ctx.hasMermaidBlocks) {
@@ -1873,6 +1962,7 @@ var SPECS = [
1873
1962
  { name: "space-key" },
1874
1963
  { name: "parent-page-id" },
1875
1964
  { name: "version-message" },
1965
+ { name: "repository-url" },
1876
1966
  { name: "skip-unchanged", boolean: true, negatable: true },
1877
1967
  { name: "dry-run", boolean: true },
1878
1968
  { name: "render-html-blocks", boolean: true },
@@ -1917,6 +2007,7 @@ Environment variables (CLI form; GitHub Action INPUT_* form is also read):
1917
2007
  CONFLUENCE_SPACE_KEY Confluence space key
1918
2008
  CONFLUENCE_PARENT_PAGE_ID Numeric parent page id
1919
2009
  CONFLUENCE_VERSION_MESSAGE Version-message suffix for every PUT
2010
+ CONFLUENCE_REPOSITORY_URL Repository URL appended to synced pages
1920
2011
  CONFLUENCE_SKIP_UNCHANGED true|false (default: true)
1921
2012
  CONFLUENCE_DRY_RUN true|false (default: false)
1922
2013
  CONFLUENCE_RENDER_HTML_BLOCKS true|false (default: false)
@@ -1936,6 +2027,7 @@ Options:
1936
2027
  --space-key <key> Confluence space key (required). Resolved to a spaceId via the API
1937
2028
  --parent-page-id <id> Numeric page id under which docs will be published (required)
1938
2029
  --version-message <text> Commit message appended to every page/attachment PUT
2030
+ --repository-url <url> Repository URL appended to synced pages as an italic notice
1939
2031
  --skip-unchanged Skip pages whose body is unchanged (default: true)
1940
2032
  --no-skip-unchanged Re-upload every page even when unchanged
1941
2033
  --dry-run Walk the doc tree and print the plan without API calls
@@ -1954,7 +2046,8 @@ var STRING_OPTION_KEYS = [
1954
2046
  "baseUrl",
1955
2047
  "spaceKey",
1956
2048
  "parentPageId",
1957
- "versionMessage"
2049
+ "versionMessage",
2050
+ "repositoryUrl"
1958
2051
  ];
1959
2052
  function setIfString(options, key, value) {
1960
2053
  if (typeof value === "string" && value.length > 0) {
@@ -1975,6 +2068,7 @@ var ENV_BINDINGS = [
1975
2068
  { envName: "INPUT_SPACE-KEY", key: "spaceKey", kind: "string" },
1976
2069
  { envName: "INPUT_PARENT-PAGE-ID", key: "parentPageId", kind: "string" },
1977
2070
  { envName: "INPUT_VERSION-MESSAGE", key: "versionMessage", kind: "string" },
2071
+ { envName: "INPUT_REPOSITORY-URL", key: "repositoryUrl", kind: "string" },
1978
2072
  { envName: "INPUT_DRY-RUN", key: "dryRun", kind: "boolean" },
1979
2073
  { envName: "INPUT_SKIP-UNCHANGED", key: "skipUnchanged", kind: "boolean" },
1980
2074
  { envName: "INPUT_RENDER-HTML-BLOCKS", key: "renderHtmlBlocks", kind: "boolean" },
@@ -1986,6 +2080,7 @@ var ENV_BINDINGS = [
1986
2080
  { envName: "CONFLUENCE_SPACE_KEY", key: "spaceKey", kind: "string" },
1987
2081
  { envName: "CONFLUENCE_PARENT_PAGE_ID", key: "parentPageId", kind: "string" },
1988
2082
  { envName: "CONFLUENCE_VERSION_MESSAGE", key: "versionMessage", kind: "string" },
2083
+ { envName: "CONFLUENCE_REPOSITORY_URL", key: "repositoryUrl", kind: "string" },
1989
2084
  { envName: "CONFLUENCE_DRY_RUN", key: "dryRun", kind: "boolean" },
1990
2085
  { envName: "CONFLUENCE_SKIP_UNCHANGED", key: "skipUnchanged", kind: "boolean" },
1991
2086
  { envName: "CONFLUENCE_RENDER_HTML_BLOCKS", key: "renderHtmlBlocks", kind: "boolean" }
@@ -2006,8 +2101,22 @@ function optionsFromEnv(env = process.env) {
2006
2101
  options[key] = parseBooleanEnv(raw, envName);
2007
2102
  }
2008
2103
  }
2104
+ if (options.repositoryUrl === void 0) {
2105
+ const githubRepositoryUrl = repositoryUrlFromGitHubEnv(env);
2106
+ if (githubRepositoryUrl) {
2107
+ options.repositoryUrl = githubRepositoryUrl;
2108
+ }
2109
+ }
2009
2110
  return options;
2010
2111
  }
2112
+ function repositoryUrlFromGitHubEnv(env) {
2113
+ const repository = env.GITHUB_REPOSITORY;
2114
+ if (!repository) {
2115
+ return void 0;
2116
+ }
2117
+ const serverUrl = env.GITHUB_SERVER_URL || "https://github.com";
2118
+ return serverUrl.replace(/\/+$/, "") + "/" + repository.replace(/^\/+/, "");
2119
+ }
2011
2120
  function buildOptions(result) {
2012
2121
  if (!result) {
2013
2122
  return {};
@@ -2023,6 +2132,7 @@ function buildOptions(result) {
2023
2132
  setIfString(options, "spaceKey", values["space-key"]);
2024
2133
  setIfString(options, "parentPageId", values["parent-page-id"]);
2025
2134
  setIfString(options, "versionMessage", values["version-message"]);
2135
+ setIfString(options, "repositoryUrl", values["repository-url"]);
2026
2136
  if (values["skip-unchanged"] !== void 0) {
2027
2137
  options.skipUnchanged = values["skip-unchanged"] === "true";
2028
2138
  }
package/index.d.ts CHANGED
@@ -338,6 +338,8 @@ interface ConfluenceSyncOptions {
338
338
  skipUnchanged?: boolean;
339
339
  /** Render ```html fenced blocks as inline HTML via the Confluence `html` macro instead of a code box (default: false). */
340
340
  renderHtmlBlocks?: boolean;
341
+ /** Repository URL appended to synced pages as an italic source notice. */
342
+ repositoryUrl?: string;
341
343
  /**
342
344
  * Dry-run: walk the tree and validate every markdown file and local image
343
345
  * source (same preflight as a real sync) then print the plan, but make no
@@ -367,6 +369,7 @@ interface ConfluenceSyncPlan {
367
369
  skipUnchanged: boolean;
368
370
  dryRun: boolean;
369
371
  renderHtmlBlocks: boolean;
372
+ repositoryUrl: string;
370
373
  }
371
374
  /** A single markdown entry's locally-validated sync plan. */
372
375
  interface LocalSyncEntryPlan {
package/index.js CHANGED
@@ -268,23 +268,24 @@ Content-Type: application/octet-stream\r
268
268
  if (response.status >= 300 && response.status < 400) {
269
269
  throw new ConfluenceApiError("Redirect responses are not allowed", response.status, endpoint, "");
270
270
  }
271
- const text = await readBodyBounded(response, MAX_ERROR_BODY_LENGTH);
272
271
  if (!response.ok) {
272
+ const text2 = await readBodyBounded(response, MAX_ERROR_BODY_LENGTH);
273
273
  if (isSafe && shouldRetryStatus(response.status) && attempt < maxRetries) {
274
274
  attempt += 1;
275
- lastError = new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
275
+ lastError = new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text2);
276
276
  await sleepBackoff(response, endpoint);
277
277
  continue;
278
278
  }
279
- throw new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text);
279
+ throw new ConfluenceApiError(describeStatus(response.status), response.status, endpoint, text2);
280
280
  }
281
+ const text = await response.text();
281
282
  if (text.length === 0) {
282
283
  return {};
283
284
  }
284
285
  try {
285
286
  return JSON.parse(text);
286
287
  } catch {
287
- throw new ConfluenceApiError("Response was not valid JSON", response.status, endpoint, text);
288
+ throw new ConfluenceApiError("Response was not valid JSON", response.status, endpoint, truncateText(text));
288
289
  }
289
290
  } catch (cause) {
290
291
  if (cause instanceof ConfluenceApiError || cause instanceof ConfluenceUploadError) {
@@ -423,6 +424,16 @@ function sanitizeFilename(name) {
423
424
  }
424
425
  return cleaned;
425
426
  }
427
+ function truncateText(text, maxBytes = MAX_ERROR_BODY_LENGTH) {
428
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) {
429
+ return text;
430
+ }
431
+ let end = text.length;
432
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) {
433
+ end -= 1;
434
+ }
435
+ return text.slice(0, end) + "...";
436
+ }
426
437
  function multipartField(boundary, name, filename, value) {
427
438
  const headerLines = [`--${boundary}\r
428
439
  `];
@@ -695,13 +706,22 @@ function markdownToStorage(markdown, options = {}) {
695
706
  out.push(`<blockquote>${renderInline(quoteLines.join("\n"))}</blockquote>`);
696
707
  continue;
697
708
  }
709
+ if (isTableStart(lines, i)) {
710
+ const tableLines = [];
711
+ while (i < lines.length && typeof lines[i] === "string" && isTableRow(lines[i])) {
712
+ tableLines.push(lines[i]);
713
+ i += 1;
714
+ }
715
+ out.push(renderTable(tableLines));
716
+ continue;
717
+ }
698
718
  if (/^\s{0,3}---+\s*$/.test(line) || /^\s{0,3}\*\*\*+\s*$/.test(line)) {
699
719
  out.push("<hr />");
700
720
  i += 1;
701
721
  continue;
702
722
  }
703
723
  const para = [];
704
- while (i < lines.length && typeof lines[i] === "string" && lines[i].trim() !== "" && !/^\s{0,3}#{1,6}\s/.test(lines[i]) && !/^\s{0,3}```/.test(lines[i]) && !/^\s{0,3}(?:-|\*|\+)\s+/.test(lines[i]) && !/^\s{0,3}\d+\.\s+/.test(lines[i]) && !/^\s{0,3}>/.test(lines[i]) && !/^\s{0,3}---+\s*$/.test(lines[i]) && !/^\s{0,3}\*\*\*+\s*$/.test(lines[i])) {
724
+ while (i < lines.length && typeof lines[i] === "string" && lines[i].trim() !== "" && !/^\s{0,3}#{1,6}\s/.test(lines[i]) && !/^\s{0,3}```/.test(lines[i]) && !/^\s{0,3}(?:-|\*|\+)\s+/.test(lines[i]) && !/^\s{0,3}\d+\.\s+/.test(lines[i]) && !/^\s{0,3}>/.test(lines[i]) && !isTableStart(lines, i) && !/^\s{0,3}---+\s*$/.test(lines[i]) && !/^\s{0,3}\*\*\*+\s*$/.test(lines[i])) {
705
725
  para.push(lines[i]);
706
726
  i += 1;
707
727
  }
@@ -761,6 +781,60 @@ function renderList(listLines) {
761
781
  const body = items.map((item) => `<li>${renderInline(item)}</li>`).join("");
762
782
  return `<${tag}>${body}</${tag}>`;
763
783
  }
784
+ function isTableStart(lines, index) {
785
+ const header = lines[index];
786
+ const separator = lines[index + 1];
787
+ if (typeof header !== "string" || typeof separator !== "string") {
788
+ return false;
789
+ }
790
+ if (!isTableRow(header) || !isTableSeparator(separator)) {
791
+ return false;
792
+ }
793
+ return splitTableRow(header).length === splitTableRow(separator).length;
794
+ }
795
+ function isTableRow(line) {
796
+ return /^\s{0,3}\|/.test(line) && splitTableRow(line).length > 1;
797
+ }
798
+ function isTableSeparator(line) {
799
+ const cells = splitTableRow(line);
800
+ if (cells.length < 2) {
801
+ return false;
802
+ }
803
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
804
+ }
805
+ function splitTableRow(line) {
806
+ const trimmed = line.trim();
807
+ const start = trimmed.startsWith("|") ? 1 : 0;
808
+ const end = trimmed.endsWith("|") && !isEscapedPipe(trimmed, trimmed.length - 1) ? trimmed.length - 1 : trimmed.length;
809
+ const cells = [];
810
+ let cellStart = start;
811
+ for (let i = start; i < end; i += 1) {
812
+ if (trimmed[i] === "|" && !isEscapedPipe(trimmed, i)) {
813
+ cells.push(unescapeTableCell(trimmed.slice(cellStart, i).trim()));
814
+ cellStart = i + 1;
815
+ }
816
+ }
817
+ cells.push(unescapeTableCell(trimmed.slice(cellStart, end).trim()));
818
+ return cells;
819
+ }
820
+ function isEscapedPipe(text, index) {
821
+ let backslashes = 0;
822
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) {
823
+ backslashes += 1;
824
+ }
825
+ return backslashes % 2 === 1;
826
+ }
827
+ function unescapeTableCell(cell) {
828
+ return cell.replace(/\\\|/g, "|");
829
+ }
830
+ function renderTable(tableLines) {
831
+ const rows = tableLines.filter((_, index) => index !== 1).map(splitTableRow);
832
+ const body = rows.map((cells, rowIndex) => {
833
+ const tag = rowIndex === 0 ? "th" : "td";
834
+ return "<tr>" + cells.map((cell) => "<" + tag + ">" + renderInline(cell) + "</" + tag + ">").join("") + "</tr>";
835
+ }).join("");
836
+ return "<table><tbody>" + body + "</tbody></table>";
837
+ }
764
838
  function renderInline(text) {
765
839
  const out = [];
766
840
  const tokens = tokenizeInline(text, 0, text.length);
@@ -1534,16 +1608,17 @@ function validateLocalSync(entries, plan) {
1534
1608
  const { html, mermaidBlocks } = markdownToStorage(markdown, {
1535
1609
  renderHtmlBlocks: plan.renderHtmlBlocks
1536
1610
  });
1611
+ const body = appendRepositoryNotice(html, plan.repositoryUrl);
1537
1612
  const markdownDir = dirname(entry.absolute);
1538
- const hasLocalImages = hasLocalImagePlaceholder(html);
1613
+ const hasLocalImages = hasLocalImagePlaceholder(body);
1539
1614
  const hasMermaidBlocks = mermaidBlocks.length > 0;
1540
- const attachments = hasLocalImages ? validateAttachmentSources(html, {
1615
+ const attachments = hasLocalImages ? validateAttachmentSources(body, {
1541
1616
  markdownDir,
1542
1617
  allowedRoot: plan.folder
1543
1618
  }) : [];
1544
1619
  plans.push({
1545
1620
  entry,
1546
- html,
1621
+ html: body,
1547
1622
  mermaidBlocks,
1548
1623
  markdownDir,
1549
1624
  hasLocalImages,
@@ -1600,6 +1675,11 @@ function resolveConfluenceSyncPlan(options = {}) {
1600
1675
  throw new Error(`parentPageId must be numeric, got: ${options.parentPageId}`);
1601
1676
  }
1602
1677
  }
1678
+ if (options.repositoryUrl && !isAllowedUrl(options.repositoryUrl)) {
1679
+ throw new Error(
1680
+ "repositoryUrl must be an http(s), protocol-relative, server-relative, mailto, tel, or relative URL"
1681
+ );
1682
+ }
1603
1683
  return {
1604
1684
  cwd,
1605
1685
  folder,
@@ -1611,7 +1691,8 @@ function resolveConfluenceSyncPlan(options = {}) {
1611
1691
  versionMessage: options.versionMessage ?? "Synced via repo-toolkit-confluence",
1612
1692
  skipUnchanged: options.skipUnchanged ?? true,
1613
1693
  dryRun: options.dryRun ?? false,
1614
- renderHtmlBlocks: options.renderHtmlBlocks === true
1694
+ renderHtmlBlocks: options.renderHtmlBlocks === true,
1695
+ repositoryUrl: options.repositoryUrl ?? ""
1615
1696
  };
1616
1697
  }
1617
1698
  async function syncConfluenceToDocs(options = {}) {
@@ -1807,6 +1888,14 @@ function hasLocalImagePlaceholder(html) {
1807
1888
  LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
1808
1889
  return LOCAL_IMAGE_PLACEHOLDER_RE.test(html);
1809
1890
  }
1891
+ function appendRepositoryNotice(html, repositoryUrl) {
1892
+ if (repositoryUrl.length === 0) {
1893
+ return html;
1894
+ }
1895
+ const url = escapeXmlAttribute(repositoryUrl);
1896
+ const text = escapeHtml(repositoryUrl);
1897
+ return html + '\n<p><em>This document is synced from repository <a href="' + url + '">' + text + "</a>.</em></p>";
1898
+ }
1810
1899
  async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
1811
1900
  let predicted = html;
1812
1901
  if (ctx.hasMermaidBlocks) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@repo-toolkit/confluence",
3
3
  "description": "Sync a folder of markdown docs to Confluence pages and attachments (GitHub Action compatible)",
4
- "version": "0.14.1",
4
+ "version": "0.15.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "keywords": [
@@ -27,7 +27,7 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "dependencies": {
30
- "@repo-toolkit/publish-package": "0.14.1"
30
+ "@repo-toolkit/publish-package": "0.15.0"
31
31
  },
32
32
  "files": [
33
33
  "**/*",