@repo-toolkit/confluence 0.14.2 → 0.16.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,10 @@ 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. The `--folder` path is added to the notice link when it
77
+ is relative to `--cwd`. When omitted, GitHub Actions runs infer this from
78
+ `GITHUB_SERVER_URL` and `GITHUB_REPOSITORY`.
75
79
  - `--skip-unchanged` / `--no-skip-unchanged` — skip pages whose body is unchanged (default: `skip`)
76
80
  - `--dry-run` — walk the doc tree and validate every markdown file and local
77
81
  image source (same preflight as a real sync) then log the plan. No API
@@ -99,6 +103,7 @@ both read for every option. Boolean env values accept `true|1|yes|on` /
99
103
  | spaceKey | `CONFLUENCE_SPACE_KEY` | `INPUT_SPACE-KEY` |
100
104
  | parentPageId | `CONFLUENCE_PARENT_PAGE_ID` | `INPUT_PARENT-PAGE-ID` |
101
105
  | versionMessage | `CONFLUENCE_VERSION_MESSAGE` | `INPUT_VERSION-MESSAGE` |
106
+ | repositoryUrl | `CONFLUENCE_REPOSITORY_URL` | `INPUT_REPOSITORY-URL` |
102
107
  | skipUnchanged (bool) | `CONFLUENCE_SKIP_UNCHANGED` | `INPUT_SKIP-UNCHANGED` |
103
108
  | dryRun (bool) | `CONFLUENCE_DRY_RUN` | `INPUT_DRY-RUN` |
104
109
  | renderHtmlBlocks (bool) | `CONFLUENCE_RENDER_HTML_BLOCKS` | `INPUT_RENDER-HTML-BLOCKS` |
package/cli.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
 
15
15
  // src/index.ts
16
16
  import { readFileSync as readFileSync2 } from "fs";
17
- import { dirname, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
17
+ import { dirname, isAbsolute as isAbsolute2, relative as relative3, resolve as resolve2 } from "path";
18
18
 
19
19
  // src/confluence-client.ts
20
20
  import { Buffer } from "buffer";
@@ -720,13 +720,22 @@ function markdownToStorage(markdown, options = {}) {
720
720
  out.push(`<blockquote>${renderInline(quoteLines.join("\n"))}</blockquote>`);
721
721
  continue;
722
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
+ }
723
732
  if (/^\s{0,3}---+\s*$/.test(line) || /^\s{0,3}\*\*\*+\s*$/.test(line)) {
724
733
  out.push("<hr />");
725
734
  i += 1;
726
735
  continue;
727
736
  }
728
737
  const para = [];
729
- 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])) {
730
739
  para.push(lines[i]);
731
740
  i += 1;
732
741
  }
@@ -786,6 +795,60 @@ function renderList(listLines) {
786
795
  const body = items.map((item) => `<li>${renderInline(item)}</li>`).join("");
787
796
  return `<${tag}>${body}</${tag}>`;
788
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
+ }
789
852
  function renderInline(text) {
790
853
  const out = [];
791
854
  const tokens = tokenizeInline(text, 0, text.length);
@@ -1558,16 +1621,17 @@ function validateLocalSync(entries, plan) {
1558
1621
  const { html, mermaidBlocks } = markdownToStorage(markdown, {
1559
1622
  renderHtmlBlocks: plan.renderHtmlBlocks
1560
1623
  });
1624
+ const body = appendRepositoryNotice(html, plan.repositoryUrl);
1561
1625
  const markdownDir = dirname(entry.absolute);
1562
- const hasLocalImages = hasLocalImagePlaceholder(html);
1626
+ const hasLocalImages = hasLocalImagePlaceholder(body);
1563
1627
  const hasMermaidBlocks = mermaidBlocks.length > 0;
1564
- const attachments = hasLocalImages ? validateAttachmentSources(html, {
1628
+ const attachments = hasLocalImages ? validateAttachmentSources(body, {
1565
1629
  markdownDir,
1566
1630
  allowedRoot: plan.folder
1567
1631
  }) : [];
1568
1632
  plans.push({
1569
1633
  entry,
1570
- html,
1634
+ html: body,
1571
1635
  mermaidBlocks,
1572
1636
  markdownDir,
1573
1637
  hasLocalImages,
@@ -1624,6 +1688,12 @@ function resolveConfluenceSyncPlan(options = {}) {
1624
1688
  throw new Error(`parentPageId must be numeric, got: ${options.parentPageId}`);
1625
1689
  }
1626
1690
  }
1691
+ const repositoryUrl = options.repositoryUrl ? repositoryNoticeUrl(options.repositoryUrl, cwd, options.folder ?? "") : "";
1692
+ if (repositoryUrl && !isAllowedUrl(repositoryUrl)) {
1693
+ throw new Error(
1694
+ "repositoryUrl must be an http(s), protocol-relative, server-relative, mailto, tel, or relative URL"
1695
+ );
1696
+ }
1627
1697
  return {
1628
1698
  cwd,
1629
1699
  folder,
@@ -1635,7 +1705,8 @@ function resolveConfluenceSyncPlan(options = {}) {
1635
1705
  versionMessage: options.versionMessage ?? "Synced via repo-toolkit-confluence",
1636
1706
  skipUnchanged: options.skipUnchanged ?? true,
1637
1707
  dryRun: options.dryRun ?? false,
1638
- renderHtmlBlocks: options.renderHtmlBlocks === true
1708
+ renderHtmlBlocks: options.renderHtmlBlocks === true,
1709
+ repositoryUrl
1639
1710
  };
1640
1711
  }
1641
1712
  async function syncConfluenceToDocs(options = {}) {
@@ -1831,6 +1902,36 @@ function hasLocalImagePlaceholder(html) {
1831
1902
  LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
1832
1903
  return LOCAL_IMAGE_PLACEHOLDER_RE.test(html);
1833
1904
  }
1905
+ function appendRepositoryNotice(html, repositoryUrl) {
1906
+ if (repositoryUrl.length === 0) {
1907
+ return html;
1908
+ }
1909
+ const url = escapeXmlAttribute(repositoryUrl);
1910
+ const text = escapeHtml(repositoryUrl);
1911
+ return html + '\n<p><em>This document is synced from repository <a href="' + url + '">' + text + "</a>.</em></p>";
1912
+ }
1913
+ function repositoryNoticeUrl(repositoryUrl, cwd, folder) {
1914
+ const folderPath = repositoryFolderPath(cwd, folder);
1915
+ if (folderPath.length === 0) {
1916
+ return repositoryUrl;
1917
+ }
1918
+ const trimmedUrl = repositoryUrl.replace(/\/+$/, "").replace(/\.git$/, "");
1919
+ const encodedFolder = folderPath.split("/").map(encodeURIComponent).join("/");
1920
+ if (/^https?:\/\/github\.com\//i.test(trimmedUrl) && !/\/tree\/|\/blob\//.test(trimmedUrl)) {
1921
+ return trimmedUrl + "/tree/HEAD/" + encodedFolder;
1922
+ }
1923
+ return trimmedUrl + "/" + encodedFolder;
1924
+ }
1925
+ function repositoryFolderPath(cwd, folder) {
1926
+ if (folder.length === 0) {
1927
+ return "";
1928
+ }
1929
+ const relativePath = relative3(cwd, resolveInputPath(cwd, folder));
1930
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
1931
+ return "";
1932
+ }
1933
+ return relativePath.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
1934
+ }
1834
1935
  async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
1835
1936
  let predicted = html;
1836
1937
  if (ctx.hasMermaidBlocks) {
@@ -1884,6 +1985,7 @@ var SPECS = [
1884
1985
  { name: "space-key" },
1885
1986
  { name: "parent-page-id" },
1886
1987
  { name: "version-message" },
1988
+ { name: "repository-url" },
1887
1989
  { name: "skip-unchanged", boolean: true, negatable: true },
1888
1990
  { name: "dry-run", boolean: true },
1889
1991
  { name: "render-html-blocks", boolean: true },
@@ -1928,6 +2030,7 @@ Environment variables (CLI form; GitHub Action INPUT_* form is also read):
1928
2030
  CONFLUENCE_SPACE_KEY Confluence space key
1929
2031
  CONFLUENCE_PARENT_PAGE_ID Numeric parent page id
1930
2032
  CONFLUENCE_VERSION_MESSAGE Version-message suffix for every PUT
2033
+ CONFLUENCE_REPOSITORY_URL Repository URL appended to synced pages
1931
2034
  CONFLUENCE_SKIP_UNCHANGED true|false (default: true)
1932
2035
  CONFLUENCE_DRY_RUN true|false (default: false)
1933
2036
  CONFLUENCE_RENDER_HTML_BLOCKS true|false (default: false)
@@ -1947,6 +2050,7 @@ Options:
1947
2050
  --space-key <key> Confluence space key (required). Resolved to a spaceId via the API
1948
2051
  --parent-page-id <id> Numeric page id under which docs will be published (required)
1949
2052
  --version-message <text> Commit message appended to every page/attachment PUT
2053
+ --repository-url <url> Repository URL appended to synced pages as an italic notice
1950
2054
  --skip-unchanged Skip pages whose body is unchanged (default: true)
1951
2055
  --no-skip-unchanged Re-upload every page even when unchanged
1952
2056
  --dry-run Walk the doc tree and print the plan without API calls
@@ -1965,7 +2069,8 @@ var STRING_OPTION_KEYS = [
1965
2069
  "baseUrl",
1966
2070
  "spaceKey",
1967
2071
  "parentPageId",
1968
- "versionMessage"
2072
+ "versionMessage",
2073
+ "repositoryUrl"
1969
2074
  ];
1970
2075
  function setIfString(options, key, value) {
1971
2076
  if (typeof value === "string" && value.length > 0) {
@@ -1986,6 +2091,7 @@ var ENV_BINDINGS = [
1986
2091
  { envName: "INPUT_SPACE-KEY", key: "spaceKey", kind: "string" },
1987
2092
  { envName: "INPUT_PARENT-PAGE-ID", key: "parentPageId", kind: "string" },
1988
2093
  { envName: "INPUT_VERSION-MESSAGE", key: "versionMessage", kind: "string" },
2094
+ { envName: "INPUT_REPOSITORY-URL", key: "repositoryUrl", kind: "string" },
1989
2095
  { envName: "INPUT_DRY-RUN", key: "dryRun", kind: "boolean" },
1990
2096
  { envName: "INPUT_SKIP-UNCHANGED", key: "skipUnchanged", kind: "boolean" },
1991
2097
  { envName: "INPUT_RENDER-HTML-BLOCKS", key: "renderHtmlBlocks", kind: "boolean" },
@@ -1997,6 +2103,7 @@ var ENV_BINDINGS = [
1997
2103
  { envName: "CONFLUENCE_SPACE_KEY", key: "spaceKey", kind: "string" },
1998
2104
  { envName: "CONFLUENCE_PARENT_PAGE_ID", key: "parentPageId", kind: "string" },
1999
2105
  { envName: "CONFLUENCE_VERSION_MESSAGE", key: "versionMessage", kind: "string" },
2106
+ { envName: "CONFLUENCE_REPOSITORY_URL", key: "repositoryUrl", kind: "string" },
2000
2107
  { envName: "CONFLUENCE_DRY_RUN", key: "dryRun", kind: "boolean" },
2001
2108
  { envName: "CONFLUENCE_SKIP_UNCHANGED", key: "skipUnchanged", kind: "boolean" },
2002
2109
  { envName: "CONFLUENCE_RENDER_HTML_BLOCKS", key: "renderHtmlBlocks", kind: "boolean" }
@@ -2017,8 +2124,22 @@ function optionsFromEnv(env = process.env) {
2017
2124
  options[key] = parseBooleanEnv(raw, envName);
2018
2125
  }
2019
2126
  }
2127
+ if (options.repositoryUrl === void 0) {
2128
+ const githubRepositoryUrl = repositoryUrlFromGitHubEnv(env);
2129
+ if (githubRepositoryUrl) {
2130
+ options.repositoryUrl = githubRepositoryUrl;
2131
+ }
2132
+ }
2020
2133
  return options;
2021
2134
  }
2135
+ function repositoryUrlFromGitHubEnv(env) {
2136
+ const repository = env.GITHUB_REPOSITORY;
2137
+ if (!repository) {
2138
+ return void 0;
2139
+ }
2140
+ const serverUrl = env.GITHUB_SERVER_URL || "https://github.com";
2141
+ return serverUrl.replace(/\/+$/, "") + "/" + repository.replace(/^\/+/, "");
2142
+ }
2022
2143
  function buildOptions(result) {
2023
2144
  if (!result) {
2024
2145
  return {};
@@ -2034,6 +2155,7 @@ function buildOptions(result) {
2034
2155
  setIfString(options, "spaceKey", values["space-key"]);
2035
2156
  setIfString(options, "parentPageId", values["parent-page-id"]);
2036
2157
  setIfString(options, "versionMessage", values["version-message"]);
2158
+ setIfString(options, "repositoryUrl", values["repository-url"]);
2037
2159
  if (values["skip-unchanged"] !== void 0) {
2038
2160
  options.skipUnchanged = values["skip-unchanged"] === "true";
2039
2161
  }
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
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import { readFileSync as readFileSync2 } from "fs";
3
- import { dirname, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
3
+ import { dirname, isAbsolute as isAbsolute2, relative as relative3, resolve as resolve2 } from "path";
4
4
 
5
5
  // src/confluence-client.ts
6
6
  import { Buffer } from "buffer";
@@ -706,13 +706,22 @@ function markdownToStorage(markdown, options = {}) {
706
706
  out.push(`<blockquote>${renderInline(quoteLines.join("\n"))}</blockquote>`);
707
707
  continue;
708
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
+ }
709
718
  if (/^\s{0,3}---+\s*$/.test(line) || /^\s{0,3}\*\*\*+\s*$/.test(line)) {
710
719
  out.push("<hr />");
711
720
  i += 1;
712
721
  continue;
713
722
  }
714
723
  const para = [];
715
- 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])) {
716
725
  para.push(lines[i]);
717
726
  i += 1;
718
727
  }
@@ -772,6 +781,60 @@ function renderList(listLines) {
772
781
  const body = items.map((item) => `<li>${renderInline(item)}</li>`).join("");
773
782
  return `<${tag}>${body}</${tag}>`;
774
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
+ }
775
838
  function renderInline(text) {
776
839
  const out = [];
777
840
  const tokens = tokenizeInline(text, 0, text.length);
@@ -1545,16 +1608,17 @@ function validateLocalSync(entries, plan) {
1545
1608
  const { html, mermaidBlocks } = markdownToStorage(markdown, {
1546
1609
  renderHtmlBlocks: plan.renderHtmlBlocks
1547
1610
  });
1611
+ const body = appendRepositoryNotice(html, plan.repositoryUrl);
1548
1612
  const markdownDir = dirname(entry.absolute);
1549
- const hasLocalImages = hasLocalImagePlaceholder(html);
1613
+ const hasLocalImages = hasLocalImagePlaceholder(body);
1550
1614
  const hasMermaidBlocks = mermaidBlocks.length > 0;
1551
- const attachments = hasLocalImages ? validateAttachmentSources(html, {
1615
+ const attachments = hasLocalImages ? validateAttachmentSources(body, {
1552
1616
  markdownDir,
1553
1617
  allowedRoot: plan.folder
1554
1618
  }) : [];
1555
1619
  plans.push({
1556
1620
  entry,
1557
- html,
1621
+ html: body,
1558
1622
  mermaidBlocks,
1559
1623
  markdownDir,
1560
1624
  hasLocalImages,
@@ -1611,6 +1675,12 @@ function resolveConfluenceSyncPlan(options = {}) {
1611
1675
  throw new Error(`parentPageId must be numeric, got: ${options.parentPageId}`);
1612
1676
  }
1613
1677
  }
1678
+ const repositoryUrl = options.repositoryUrl ? repositoryNoticeUrl(options.repositoryUrl, cwd, options.folder ?? "") : "";
1679
+ if (repositoryUrl && !isAllowedUrl(repositoryUrl)) {
1680
+ throw new Error(
1681
+ "repositoryUrl must be an http(s), protocol-relative, server-relative, mailto, tel, or relative URL"
1682
+ );
1683
+ }
1614
1684
  return {
1615
1685
  cwd,
1616
1686
  folder,
@@ -1622,7 +1692,8 @@ function resolveConfluenceSyncPlan(options = {}) {
1622
1692
  versionMessage: options.versionMessage ?? "Synced via repo-toolkit-confluence",
1623
1693
  skipUnchanged: options.skipUnchanged ?? true,
1624
1694
  dryRun: options.dryRun ?? false,
1625
- renderHtmlBlocks: options.renderHtmlBlocks === true
1695
+ renderHtmlBlocks: options.renderHtmlBlocks === true,
1696
+ repositoryUrl
1626
1697
  };
1627
1698
  }
1628
1699
  async function syncConfluenceToDocs(options = {}) {
@@ -1818,6 +1889,36 @@ function hasLocalImagePlaceholder(html) {
1818
1889
  LOCAL_IMAGE_PLACEHOLDER_RE.lastIndex = 0;
1819
1890
  return LOCAL_IMAGE_PLACEHOLDER_RE.test(html);
1820
1891
  }
1892
+ function appendRepositoryNotice(html, repositoryUrl) {
1893
+ if (repositoryUrl.length === 0) {
1894
+ return html;
1895
+ }
1896
+ const url = escapeXmlAttribute(repositoryUrl);
1897
+ const text = escapeHtml(repositoryUrl);
1898
+ return html + '\n<p><em>This document is synced from repository <a href="' + url + '">' + text + "</a>.</em></p>";
1899
+ }
1900
+ function repositoryNoticeUrl(repositoryUrl, cwd, folder) {
1901
+ const folderPath = repositoryFolderPath(cwd, folder);
1902
+ if (folderPath.length === 0) {
1903
+ return repositoryUrl;
1904
+ }
1905
+ const trimmedUrl = repositoryUrl.replace(/\/+$/, "").replace(/\.git$/, "");
1906
+ const encodedFolder = folderPath.split("/").map(encodeURIComponent).join("/");
1907
+ if (/^https?:\/\/github\.com\//i.test(trimmedUrl) && !/\/tree\/|\/blob\//.test(trimmedUrl)) {
1908
+ return trimmedUrl + "/tree/HEAD/" + encodedFolder;
1909
+ }
1910
+ return trimmedUrl + "/" + encodedFolder;
1911
+ }
1912
+ function repositoryFolderPath(cwd, folder) {
1913
+ if (folder.length === 0) {
1914
+ return "";
1915
+ }
1916
+ const relativePath = relative3(cwd, resolveInputPath(cwd, folder));
1917
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
1918
+ return "";
1919
+ }
1920
+ return relativePath.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
1921
+ }
1821
1922
  async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
1822
1923
  let predicted = html;
1823
1924
  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.2",
4
+ "version": "0.16.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.2"
30
+ "@repo-toolkit/publish-package": "0.16.0"
31
31
  },
32
32
  "files": [
33
33
  "**/*",