@repo-toolkit/confluence 0.18.0 → 0.20.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
@@ -76,6 +76,7 @@ Flags:
76
76
  italic source notice. The `--folder` path is added to the notice link when it
77
77
  is relative to `--cwd`. When omitted, GitHub Actions runs infer this from
78
78
  `GITHUB_SERVER_URL` and `GITHUB_REPOSITORY`.
79
+ - `--page-title-strategy <value>` — leaf page title strategy (default: `filename-stem`). One of `filename-stem`, `filename`, `sentence-case-parent`, `sentence-case-parents`, `sentence-case-path`. Applied only to Markdown leaf pages; folder-generated parent pages keep their raw directory-segment titles. See [leaf page title strategies](#leaf-page-title-strategies).
79
80
  - `--skip-unchanged` / `--no-skip-unchanged` — skip pages whose body is unchanged (default: `skip`)
80
81
  - `--dry-run` — walk the doc tree and validate every markdown file and local
81
82
  image source (same preflight as a real sync) then log the plan. No API
@@ -93,26 +94,49 @@ both read for every option. Boolean env values accept `true|1|yes|on` /
93
94
  `false|0|no|off` (empty string is falsy); any other value exits nonzero with
94
95
  `Invalid boolean value for <ENV_NAME>: <raw>`.
95
96
 
96
- | Option | `CONFLUENCE_*` | `INPUT_*` (Actions form) |
97
- | ----------------------- | ------------------------------- | --------------------------------------------- |
98
- | folder | `CONFLUENCE_FOLDER` | `INPUT_FOLDER` |
99
- | username | `CONFLUENCE_USERNAME` | `INPUT_USERNAME` |
100
- | apiToken | `CONFLUENCE_API_TOKEN` | `INPUT_API-TOKEN`, `INPUT_PASSWORD` |
101
- | apiTokenFile | `CONFLUENCE_API_TOKEN_FILE` | `INPUT_API-TOKEN-FILE`, `INPUT_PASSWORD-FILE` |
102
- | baseUrl | `CONFLUENCE_BASE_URL` | `INPUT_CONFLUENCE-BASE-URL` |
103
- | spaceKey | `CONFLUENCE_SPACE_KEY` | `INPUT_SPACE-KEY` |
104
- | parentPageId | `CONFLUENCE_PARENT_PAGE_ID` | `INPUT_PARENT-PAGE-ID` |
105
- | versionMessage | `CONFLUENCE_VERSION_MESSAGE` | `INPUT_VERSION-MESSAGE` |
106
- | repositoryUrl | `CONFLUENCE_REPOSITORY_URL` | `INPUT_REPOSITORY-URL` |
107
- | skipUnchanged (bool) | `CONFLUENCE_SKIP_UNCHANGED` | `INPUT_SKIP-UNCHANGED` |
108
- | dryRun (bool) | `CONFLUENCE_DRY_RUN` | `INPUT_DRY-RUN` |
109
- | renderHtmlBlocks (bool) | `CONFLUENCE_RENDER_HTML_BLOCKS` | `INPUT_RENDER-HTML-BLOCKS` |
97
+ | Option | `CONFLUENCE_*` | `INPUT_*` (Actions form) |
98
+ | ----------------------- | -------------------------------- | --------------------------------------------- |
99
+ | folder | `CONFLUENCE_FOLDER` | `INPUT_FOLDER` |
100
+ | username | `CONFLUENCE_USERNAME` | `INPUT_USERNAME` |
101
+ | apiToken | `CONFLUENCE_API_TOKEN` | `INPUT_API-TOKEN`, `INPUT_PASSWORD` |
102
+ | apiTokenFile | `CONFLUENCE_API_TOKEN_FILE` | `INPUT_API-TOKEN-FILE`, `INPUT_PASSWORD-FILE` |
103
+ | baseUrl | `CONFLUENCE_BASE_URL` | `INPUT_CONFLUENCE-BASE-URL` |
104
+ | spaceKey | `CONFLUENCE_SPACE_KEY` | `INPUT_SPACE-KEY` |
105
+ | parentPageId | `CONFLUENCE_PARENT_PAGE_ID` | `INPUT_PARENT-PAGE-ID` |
106
+ | versionMessage | `CONFLUENCE_VERSION_MESSAGE` | `INPUT_VERSION-MESSAGE` |
107
+ | repositoryUrl | `CONFLUENCE_REPOSITORY_URL` | `INPUT_REPOSITORY-URL` |
108
+ | pageTitleStrategy | `CONFLUENCE_PAGE_TITLE_STRATEGY` | `INPUT_PAGE-TITLE-STRATEGY` |
109
+ | skipUnchanged (bool) | `CONFLUENCE_SKIP_UNCHANGED` | `INPUT_SKIP-UNCHANGED` |
110
+ | dryRun (bool) | `CONFLUENCE_DRY_RUN` | `INPUT_DRY-RUN` |
111
+ | renderHtmlBlocks (bool) | `CONFLUENCE_RENDER_HTML_BLOCKS` | `INPUT_RENDER-HTML-BLOCKS` |
110
112
 
111
113
  Errors, `--help`, and all log lines never print the supplied token value. The
112
114
  secret-file loader wraps fs errors via `Error.cause` (`Failed to read
113
115
  apiTokenFile at <path>`, `apiTokenFile at <path> is empty`) without revealing
114
116
  the file contents.
115
117
 
118
+ ## Leaf page title strategies
119
+
120
+ Leaf Confluence page titles are derived from the Markdown file's relative path segments. Directory segments become synthetic parent-page titles and retain their raw segment text for every strategy — only the Markdown leaf file uses `pageTitleStrategy`.
121
+
122
+ | Strategy | Behavior | Example for `community-nodes/cdogs-document-generator/credentials.md` |
123
+ | ------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
124
+ | `filename-stem` (default) | Original filename without the final `.md` extension | `credentials` |
125
+ | `filename` | Original filename including extension | `credentials.md` |
126
+ | `sentence-case-parent` | Sentence-case filename stem plus immediate parent folder | `Credentials (cdogs-document-generator)` |
127
+ | `sentence-case-parents` | Sentence-case filename stem plus all parent folders | `Credentials (community-nodes/cdogs-document-generator)` |
128
+ | `sentence-case-path` | Sentence-case filename stem plus all parent folders and original filename including extension | `Credentials (community-nodes/cdogs-document-generator/credentials.md)` |
129
+
130
+ Sentence case is deterministic and dependency-free: remove only the final case-insensitive `.md`; replace each run of `-` and `_` with one space; trim; lowercase ASCII letters; uppercase the first ASCII letter. Digits and other punctuation are preserved. Examples: `failed-deployment.md` → `Failed deployment`, `n8n_setup.md` → `N8n setup`, `README.md` → `Readme`. Parent folders are the segments before the filename, joined with `/` exactly as stored. Preserve the original filename and extension casing in `filename` and inside `sentence-case-path` (for example, `Guide.MD` remains `Guide.MD` where the original filename is included).
131
+
132
+ Root-file behavior: for `sentence-case-parent` and `sentence-case-parents` the parentheses are omitted when a file is directly under the documentation root (`overview.md` → `Overview`). For `sentence-case-path`, the root parenthesized path is the filename itself (`overview.md` → `Overview (overview.md)`).
133
+
134
+ ### Migration and uniqueness notes
135
+
136
+ > **Changing the strategy changes title-based identity.** Sync is additive and non-pruning and looks up Confluence pages by title under parent. Switching the strategy therefore seeks a new title and may **create a new page while leaving the old page untouched** — it does not rename, move, or delete existing pages. Operators must manually rename, delete, or archive old generated pages (or migrate them) before switching strategies in production.
137
+
138
+ Path-based strategies reduce predictable local collisions (for example, repeated basenames such as `credentials.md` or `README.md` in separate subtrees) but cannot guarantee uniqueness against unrelated or manually created pages already present in the target Confluence space. Existing spaces may still cause Confluence API conflicts if a generated title collides with an unrelated page under the same parent. Do not truncate or hash titles locally; if Confluence imposes a remote title-length limit, the existing API error behavior applies.
139
+
116
140
  ## JavaScript API
117
141
 
118
142
  ```ts
@@ -126,6 +150,7 @@ await syncConfluenceToDocs({
126
150
  spaceKey: 'ENG',
127
151
  parentPageId: '123456789',
128
152
  versionMessage: 'chore(docs): sync',
153
+ pageTitleStrategy: 'sentence-case-parents', // optional; default: 'filename-stem'
129
154
  });
130
155
  ```
131
156
 
@@ -185,6 +210,7 @@ inputs:
185
210
  space-key: { required: true }
186
211
  parent-page-id: { required: true }
187
212
  version-message: { required: false }
213
+ page-title-strategy: { required: false, default: 'filename-stem' }
188
214
  dry-run: { required: false, default: 'false' }
189
215
  skip-unchanged: { required: false, default: 'true' }
190
216
  render-html-blocks: { required: false, default: 'false' }
package/cli.js CHANGED
@@ -588,6 +588,64 @@ function titleFromSegment(segment) {
588
588
  }
589
589
  return segment;
590
590
  }
591
+ var PAGE_TITLE_STRATEGIES = [
592
+ "filename-stem",
593
+ "filename",
594
+ "sentence-case-parent",
595
+ "sentence-case-parents",
596
+ "sentence-case-path"
597
+ ];
598
+ var DEFAULT_PAGE_TITLE_STRATEGY = "filename-stem";
599
+ function resolvePageTitleStrategy(value) {
600
+ if (value === void 0 || value === null) {
601
+ return DEFAULT_PAGE_TITLE_STRATEGY;
602
+ }
603
+ if (typeof value === "string" && PAGE_TITLE_STRATEGIES.includes(value)) {
604
+ return value;
605
+ }
606
+ throw new Error(
607
+ `Invalid pageTitleStrategy: expected one of ${PAGE_TITLE_STRATEGIES.join(", ")}, got ${JSON.stringify(value)}`
608
+ );
609
+ }
610
+ function sentenceCaseStem(filename) {
611
+ let stem = filename;
612
+ if (/\.md$/i.test(stem)) {
613
+ stem = stem.slice(0, stem.length - MARKDOWN_EXT.length);
614
+ }
615
+ let out = stem.replace(/[-_]+/g, " ").trim().replace(/[A-Z]/g, (c) => c.toLowerCase());
616
+ const firstLetter = /[a-z]/.exec(out);
617
+ if (firstLetter) {
618
+ const i = firstLetter.index;
619
+ out = out.slice(0, i) + out.charAt(i).toUpperCase() + out.slice(i + 1);
620
+ }
621
+ return out;
622
+ }
623
+ function pageTitleFromSegments(segments, strategy) {
624
+ const filename = segments.length > 0 ? segments[segments.length - 1] : "";
625
+ const parents = segments.slice(0, segments.length - 1);
626
+ switch (strategy) {
627
+ case "filename-stem":
628
+ return titleFromSegment(filename);
629
+ case "filename":
630
+ return filename;
631
+ case "sentence-case-parent": {
632
+ const title = sentenceCaseStem(filename);
633
+ if (parents.length === 0) {
634
+ return title;
635
+ }
636
+ return `${title} (${parents[parents.length - 1]})`;
637
+ }
638
+ case "sentence-case-parents": {
639
+ const title = sentenceCaseStem(filename);
640
+ if (parents.length === 0) {
641
+ return title;
642
+ }
643
+ return `${title} (${parents.join("/")})`;
644
+ }
645
+ case "sentence-case-path":
646
+ return `${sentenceCaseStem(filename)} (${[...parents, filename].join("/")})`;
647
+ }
648
+ }
591
649
 
592
650
  // src/markdown.ts
593
651
  var STORAGE_LINE_BREAK = "<br />";
@@ -1631,6 +1689,7 @@ function validateLocalSync(entries, plan) {
1631
1689
  }) : [];
1632
1690
  plans.push({
1633
1691
  entry,
1692
+ title: pageTitleFromSegments(entry.segments, plan.pageTitleStrategy),
1634
1693
  html: body,
1635
1694
  mermaidBlocks,
1636
1695
  markdownDir,
@@ -1660,6 +1719,7 @@ var SyncMutationError = class extends Error {
1660
1719
  }
1661
1720
  };
1662
1721
  function resolveConfluenceSyncPlan(options = {}) {
1722
+ const pageTitleStrategy = resolvePageTitleStrategy(options.pageTitleStrategy);
1663
1723
  const cwd = resolve2(options.cwd ?? process.cwd());
1664
1724
  const folder = resolveInputPath(cwd, options.folder ?? "");
1665
1725
  if (!options.folder) {
@@ -1706,7 +1766,8 @@ function resolveConfluenceSyncPlan(options = {}) {
1706
1766
  skipUnchanged: options.skipUnchanged ?? true,
1707
1767
  dryRun: options.dryRun ?? false,
1708
1768
  renderHtmlBlocks: options.renderHtmlBlocks === true,
1709
- repositoryUrl
1769
+ repositoryUrl,
1770
+ pageTitleStrategy
1710
1771
  };
1711
1772
  }
1712
1773
  async function syncConfluenceToDocs(options = {}) {
@@ -1717,15 +1778,15 @@ async function syncConfluenceToDocs(options = {}) {
1717
1778
  log(`No markdown files found under ${plan.folder}`);
1718
1779
  return;
1719
1780
  }
1720
- validateLocalHierarchy(tree.entries);
1721
1781
  const localPlan = validateLocalSync(tree.entries, plan);
1782
+ validateLocalHierarchy(localPlan.entries, plan.pageTitleStrategy);
1722
1783
  if (plan.dryRun) {
1723
1784
  log("[dry-run] Walking documentation tree only.");
1724
1785
  for (const entryPlan of localPlan.entries) {
1725
1786
  const attCount = entryPlan.attachments.length;
1726
1787
  const mermaidCount = entryPlan.mermaidBlocks.length;
1727
1788
  log(
1728
- `[dry-run] would sync ${entryPlan.entry.segments.join("/")}` + (attCount > 0 ? ` (${attCount} attachment${attCount === 1 ? "" : "s"} validated)` : "") + (mermaidCount > 0 ? ` (${mermaidCount} mermaid block${mermaidCount === 1 ? "" : "s"})` : "")
1789
+ `[dry-run] would sync ${entryPlan.entry.segments.join("/")} as "${entryPlan.title}"` + (attCount > 0 ? ` (${attCount} attachment${attCount === 1 ? "" : "s"} validated)` : "") + (mermaidCount > 0 ? ` (${mermaidCount} mermaid block${mermaidCount === 1 ? "" : "s"})` : "")
1729
1790
  );
1730
1791
  }
1731
1792
  return;
@@ -1763,7 +1824,7 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
1763
1824
  const isLast = idx === segments.length - 1;
1764
1825
  const segment = segments[idx] ?? "";
1765
1826
  if (isLast && isMarkdownName(segment)) {
1766
- const title = titleFromSegment(segment);
1827
+ const title = entryPlan.title;
1767
1828
  const leafNeedsUploads = hasLocalImages || hasMermaidBlocks;
1768
1829
  const existing = await cache.find(title, currentParentId);
1769
1830
  if (!existing && !leafNeedsUploads) {
@@ -1822,7 +1883,7 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
1822
1883
  return;
1823
1884
  }
1824
1885
  if (isMarkdownName(segment)) {
1825
- const title = titleFromSegment(segment);
1886
+ const title = pageTitleFromSegments(segments.slice(0, idx + 1), plan.pageTitleStrategy);
1826
1887
  const page2 = await cache.findOrCreate(title, currentParentId);
1827
1888
  currentParentId = page2.id;
1828
1889
  continue;
@@ -1953,14 +2014,15 @@ async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
1953
2014
  }
1954
2015
  return predicted;
1955
2016
  }
1956
- function validateLocalHierarchy(entries) {
2017
+ function validateLocalHierarchy(entries, strategy) {
1957
2018
  const seen = /* @__PURE__ */ new Map();
1958
- for (const entry of entries) {
2019
+ for (const entryPlan of entries) {
2020
+ const { entry } = entryPlan;
1959
2021
  let parentKey = "";
1960
2022
  for (let index = 0; index < entry.segments.length; index += 1) {
1961
2023
  const segment = entry.segments[index] ?? "";
1962
2024
  const isLast = index === entry.segments.length - 1;
1963
- const title = isMarkdownName(segment) ? titleFromSegment(segment) : segment;
2025
+ const title = isMarkdownName(segment) ? isLast ? entryPlan.title : pageTitleFromSegments(entry.segments.slice(0, index + 1), strategy) : segment;
1964
2026
  const kind = isLast && isMarkdownName(segment) ? "file" : "dir";
1965
2027
  const key = `${parentKey}::${title}`;
1966
2028
  const existing = seen.get(key);
@@ -1986,6 +2048,7 @@ var SPECS = [
1986
2048
  { name: "parent-page-id" },
1987
2049
  { name: "version-message" },
1988
2050
  { name: "repository-url" },
2051
+ { name: "page-title-strategy" },
1989
2052
  { name: "skip-unchanged", boolean: true, negatable: true },
1990
2053
  { name: "dry-run", boolean: true },
1991
2054
  { name: "render-html-blocks", boolean: true },
@@ -2031,6 +2094,7 @@ Environment variables (CLI form; GitHub Action INPUT_* form is also read):
2031
2094
  CONFLUENCE_PARENT_PAGE_ID Numeric parent page id
2032
2095
  CONFLUENCE_VERSION_MESSAGE Version-message suffix for every PUT
2033
2096
  CONFLUENCE_REPOSITORY_URL Repository URL appended to synced pages
2097
+ CONFLUENCE_PAGE_TITLE_STRATEGY Leaf page title strategy (default: filename-stem)
2034
2098
  CONFLUENCE_SKIP_UNCHANGED true|false (default: true)
2035
2099
  CONFLUENCE_DRY_RUN true|false (default: false)
2036
2100
  CONFLUENCE_RENDER_HTML_BLOCKS true|false (default: false)
@@ -2051,6 +2115,8 @@ Options:
2051
2115
  --parent-page-id <id> Numeric page id under which docs will be published (required)
2052
2116
  --version-message <text> Commit message appended to every page/attachment PUT
2053
2117
  --repository-url <url> Repository URL appended to synced pages as an italic notice
2118
+ --page-title-strategy <value> Leaf page title strategy: filename-stem (default), filename,
2119
+ sentence-case-parent, sentence-case-parents, sentence-case-path
2054
2120
  --skip-unchanged Skip pages whose body is unchanged (default: true)
2055
2121
  --no-skip-unchanged Re-upload every page even when unchanged
2056
2122
  --dry-run Walk the doc tree and print the plan without API calls
@@ -2092,6 +2158,7 @@ var ENV_BINDINGS = [
2092
2158
  { envName: "INPUT_PARENT-PAGE-ID", key: "parentPageId", kind: "string" },
2093
2159
  { envName: "INPUT_VERSION-MESSAGE", key: "versionMessage", kind: "string" },
2094
2160
  { envName: "INPUT_REPOSITORY-URL", key: "repositoryUrl", kind: "string" },
2161
+ { envName: "INPUT_PAGE-TITLE-STRATEGY", key: "pageTitleStrategy", kind: "string" },
2095
2162
  { envName: "INPUT_DRY-RUN", key: "dryRun", kind: "boolean" },
2096
2163
  { envName: "INPUT_SKIP-UNCHANGED", key: "skipUnchanged", kind: "boolean" },
2097
2164
  { envName: "INPUT_RENDER-HTML-BLOCKS", key: "renderHtmlBlocks", kind: "boolean" },
@@ -2104,6 +2171,7 @@ var ENV_BINDINGS = [
2104
2171
  { envName: "CONFLUENCE_PARENT_PAGE_ID", key: "parentPageId", kind: "string" },
2105
2172
  { envName: "CONFLUENCE_VERSION_MESSAGE", key: "versionMessage", kind: "string" },
2106
2173
  { envName: "CONFLUENCE_REPOSITORY_URL", key: "repositoryUrl", kind: "string" },
2174
+ { envName: "CONFLUENCE_PAGE_TITLE_STRATEGY", key: "pageTitleStrategy", kind: "string" },
2107
2175
  { envName: "CONFLUENCE_DRY_RUN", key: "dryRun", kind: "boolean" },
2108
2176
  { envName: "CONFLUENCE_SKIP_UNCHANGED", key: "skipUnchanged", kind: "boolean" },
2109
2177
  { envName: "CONFLUENCE_RENDER_HTML_BLOCKS", key: "renderHtmlBlocks", kind: "boolean" }
@@ -2116,6 +2184,10 @@ function optionsFromEnv(env = process.env) {
2116
2184
  continue;
2117
2185
  }
2118
2186
  if (kind === "string") {
2187
+ if (key === "pageTitleStrategy") {
2188
+ options[key] = raw;
2189
+ continue;
2190
+ }
2119
2191
  if (!isStringOptionKey(key)) {
2120
2192
  continue;
2121
2193
  }
@@ -2156,6 +2228,9 @@ function buildOptions(result) {
2156
2228
  setIfString(options, "parentPageId", values["parent-page-id"]);
2157
2229
  setIfString(options, "versionMessage", values["version-message"]);
2158
2230
  setIfString(options, "repositoryUrl", values["repository-url"]);
2231
+ if (values["page-title-strategy"] !== void 0) {
2232
+ options.pageTitleStrategy = values["page-title-strategy"];
2233
+ }
2159
2234
  if (values["skip-unchanged"] !== void 0) {
2160
2235
  options.skipUnchanged = values["skip-unchanged"] === "true";
2161
2236
  }
package/index.d.ts CHANGED
@@ -167,6 +167,11 @@ interface DocTree {
167
167
  declare function readDocTree(root: string, depth?: number): Promise<DocTree>;
168
168
  declare function isMarkdownName(name: string): boolean;
169
169
  declare function titleFromSegment(segment: string): string;
170
+ declare const PAGE_TITLE_STRATEGIES: readonly ["filename-stem", "filename", "sentence-case-parent", "sentence-case-parents", "sentence-case-path"];
171
+ type PageTitleStrategy = (typeof PAGE_TITLE_STRATEGIES)[number];
172
+ declare const DEFAULT_PAGE_TITLE_STRATEGY: PageTitleStrategy;
173
+ declare function resolvePageTitleStrategy(value: unknown): PageTitleStrategy;
174
+ declare function pageTitleFromSegments(segments: readonly string[], strategy: PageTitleStrategy): string;
170
175
 
171
176
  interface RewriteResult {
172
177
  html: string;
@@ -336,6 +341,12 @@ interface ConfluenceSyncOptions {
336
341
  * render/upload work is performed.
337
342
  */
338
343
  skipUnchanged?: boolean;
344
+ /**
345
+ * Leaf page title strategy (default: `filename-stem`). Applied only to
346
+ * Markdown leaf pages; folder-generated parent pages keep their raw
347
+ * directory-segment titles. See {@link PAGE_TITLE_STRATEGIES}.
348
+ */
349
+ pageTitleStrategy?: PageTitleStrategy;
339
350
  /** Render ```html fenced blocks as inline HTML via the Confluence `html` macro instead of a code box (default: false). */
340
351
  renderHtmlBlocks?: boolean;
341
352
  /** Repository URL appended to synced pages as an italic source notice. */
@@ -370,11 +381,20 @@ interface ConfluenceSyncPlan {
370
381
  dryRun: boolean;
371
382
  renderHtmlBlocks: boolean;
372
383
  repositoryUrl: string;
384
+ /** Validated leaf page title strategy applied during planning. */
385
+ pageTitleStrategy: PageTitleStrategy;
373
386
  }
374
387
  /** A single markdown entry's locally-validated sync plan. */
375
388
  interface LocalSyncEntryPlan {
376
389
  /** The original doc-tree entry this plan covers. */
377
390
  entry: DocEntry;
391
+ /**
392
+ * The resolved Confluence page title for this leaf entry, computed once via
393
+ * {@link pageTitleFromSegments} with the plan's {@link ConfluenceSyncPlan.pageTitleStrategy}.
394
+ * Consumed verbatim by local hierarchy validation, dry-run output, remote
395
+ * lookup, create, and update paths.
396
+ */
397
+ title: string;
378
398
  /** Rendered storage HTML from `markdownToStorage` (placeholder macros intact). */
379
399
  html: string;
380
400
  /** Mermaid blocks parsed from the markdown source. */
@@ -465,4 +485,4 @@ interface SyncResult {
465
485
  declare function resolveConfluenceSyncPlan(options?: ConfluenceSyncOptions): ConfluenceSyncPlan;
466
486
  declare function syncConfluenceToDocs(options?: ConfluenceSyncOptions): Promise<SyncResult | void>;
467
487
 
468
- export { type Attachment, type AttachmentGateway, type PreflightResult as AttachmentPreflightResult, ConfluenceApiError, ConfluenceClient, type ConfluenceClientOptions, type ConfluenceGateway, type ConfluenceSyncOptions, type ConfluenceSyncPlan, type CreatePageInput, type DocEntry, type DocTree, INTERACTIVE_FLAG, type LocalSyncEntryPlan, type LocalSyncPlan, LocalSyncValidationAggregateError, type LocalSyncValidationError, type MarkdownConvertOptions, type MarkdownConvertResult, type MermaidBlock, type MermaidPreflightResult, type MermaidRewriteOptions, type MermaidRewriteResult, type Page, type PageBody, type PageVersion, type RewriteOptions, type RewriteResult, type SyncChange, type SyncFailure, SyncMutationError, type SyncResult, type UpdatePageInput, type ValidatedAttachmentSource, escapeAttachmentFilename, escapeXmlAttribute, isAllowedUrl, isMarkdownName, isRemoteUrl, markdownToStorage, preflightImagesToAttachments, preflightMermaidBlocks, readDocTree, resolveConfluenceSyncPlan, resolveConfluenceSyncPlan as resolveSyncPlan, rewriteImagesToAttachments, rewriteMermaidBlocks, syncConfluenceToDocs, titleFromSegment, validateAttachmentSources, validateLocalSync };
488
+ export { type Attachment, type AttachmentGateway, type PreflightResult as AttachmentPreflightResult, ConfluenceApiError, ConfluenceClient, type ConfluenceClientOptions, type ConfluenceGateway, type ConfluenceSyncOptions, type ConfluenceSyncPlan, type CreatePageInput, DEFAULT_PAGE_TITLE_STRATEGY, type DocEntry, type DocTree, INTERACTIVE_FLAG, type LocalSyncEntryPlan, type LocalSyncPlan, LocalSyncValidationAggregateError, type LocalSyncValidationError, type MarkdownConvertOptions, type MarkdownConvertResult, type MermaidBlock, type MermaidPreflightResult, type MermaidRewriteOptions, type MermaidRewriteResult, PAGE_TITLE_STRATEGIES, type Page, type PageBody, type PageTitleStrategy, type PageVersion, type RewriteOptions, type RewriteResult, type SyncChange, type SyncFailure, SyncMutationError, type SyncResult, type UpdatePageInput, type ValidatedAttachmentSource, escapeAttachmentFilename, escapeXmlAttribute, isAllowedUrl, isMarkdownName, isRemoteUrl, markdownToStorage, pageTitleFromSegments, preflightImagesToAttachments, preflightMermaidBlocks, readDocTree, resolveConfluenceSyncPlan, resolvePageTitleStrategy, resolveConfluenceSyncPlan as resolveSyncPlan, rewriteImagesToAttachments, rewriteMermaidBlocks, syncConfluenceToDocs, titleFromSegment, validateAttachmentSources, validateLocalSync };
package/index.js CHANGED
@@ -574,6 +574,64 @@ function titleFromSegment(segment) {
574
574
  }
575
575
  return segment;
576
576
  }
577
+ var PAGE_TITLE_STRATEGIES = [
578
+ "filename-stem",
579
+ "filename",
580
+ "sentence-case-parent",
581
+ "sentence-case-parents",
582
+ "sentence-case-path"
583
+ ];
584
+ var DEFAULT_PAGE_TITLE_STRATEGY = "filename-stem";
585
+ function resolvePageTitleStrategy(value) {
586
+ if (value === void 0 || value === null) {
587
+ return DEFAULT_PAGE_TITLE_STRATEGY;
588
+ }
589
+ if (typeof value === "string" && PAGE_TITLE_STRATEGIES.includes(value)) {
590
+ return value;
591
+ }
592
+ throw new Error(
593
+ `Invalid pageTitleStrategy: expected one of ${PAGE_TITLE_STRATEGIES.join(", ")}, got ${JSON.stringify(value)}`
594
+ );
595
+ }
596
+ function sentenceCaseStem(filename) {
597
+ let stem = filename;
598
+ if (/\.md$/i.test(stem)) {
599
+ stem = stem.slice(0, stem.length - MARKDOWN_EXT.length);
600
+ }
601
+ let out = stem.replace(/[-_]+/g, " ").trim().replace(/[A-Z]/g, (c) => c.toLowerCase());
602
+ const firstLetter = /[a-z]/.exec(out);
603
+ if (firstLetter) {
604
+ const i = firstLetter.index;
605
+ out = out.slice(0, i) + out.charAt(i).toUpperCase() + out.slice(i + 1);
606
+ }
607
+ return out;
608
+ }
609
+ function pageTitleFromSegments(segments, strategy) {
610
+ const filename = segments.length > 0 ? segments[segments.length - 1] : "";
611
+ const parents = segments.slice(0, segments.length - 1);
612
+ switch (strategy) {
613
+ case "filename-stem":
614
+ return titleFromSegment(filename);
615
+ case "filename":
616
+ return filename;
617
+ case "sentence-case-parent": {
618
+ const title = sentenceCaseStem(filename);
619
+ if (parents.length === 0) {
620
+ return title;
621
+ }
622
+ return `${title} (${parents[parents.length - 1]})`;
623
+ }
624
+ case "sentence-case-parents": {
625
+ const title = sentenceCaseStem(filename);
626
+ if (parents.length === 0) {
627
+ return title;
628
+ }
629
+ return `${title} (${parents.join("/")})`;
630
+ }
631
+ case "sentence-case-path":
632
+ return `${sentenceCaseStem(filename)} (${[...parents, filename].join("/")})`;
633
+ }
634
+ }
577
635
 
578
636
  // src/markdown.ts
579
637
  var STORAGE_LINE_BREAK = "<br />";
@@ -1618,6 +1676,7 @@ function validateLocalSync(entries, plan) {
1618
1676
  }) : [];
1619
1677
  plans.push({
1620
1678
  entry,
1679
+ title: pageTitleFromSegments(entry.segments, plan.pageTitleStrategy),
1621
1680
  html: body,
1622
1681
  mermaidBlocks,
1623
1682
  markdownDir,
@@ -1647,6 +1706,7 @@ var SyncMutationError = class extends Error {
1647
1706
  }
1648
1707
  };
1649
1708
  function resolveConfluenceSyncPlan(options = {}) {
1709
+ const pageTitleStrategy = resolvePageTitleStrategy(options.pageTitleStrategy);
1650
1710
  const cwd = resolve2(options.cwd ?? process.cwd());
1651
1711
  const folder = resolveInputPath(cwd, options.folder ?? "");
1652
1712
  if (!options.folder) {
@@ -1693,7 +1753,8 @@ function resolveConfluenceSyncPlan(options = {}) {
1693
1753
  skipUnchanged: options.skipUnchanged ?? true,
1694
1754
  dryRun: options.dryRun ?? false,
1695
1755
  renderHtmlBlocks: options.renderHtmlBlocks === true,
1696
- repositoryUrl
1756
+ repositoryUrl,
1757
+ pageTitleStrategy
1697
1758
  };
1698
1759
  }
1699
1760
  async function syncConfluenceToDocs(options = {}) {
@@ -1704,15 +1765,15 @@ async function syncConfluenceToDocs(options = {}) {
1704
1765
  log(`No markdown files found under ${plan.folder}`);
1705
1766
  return;
1706
1767
  }
1707
- validateLocalHierarchy(tree.entries);
1708
1768
  const localPlan = validateLocalSync(tree.entries, plan);
1769
+ validateLocalHierarchy(localPlan.entries, plan.pageTitleStrategy);
1709
1770
  if (plan.dryRun) {
1710
1771
  log("[dry-run] Walking documentation tree only.");
1711
1772
  for (const entryPlan of localPlan.entries) {
1712
1773
  const attCount = entryPlan.attachments.length;
1713
1774
  const mermaidCount = entryPlan.mermaidBlocks.length;
1714
1775
  log(
1715
- `[dry-run] would sync ${entryPlan.entry.segments.join("/")}` + (attCount > 0 ? ` (${attCount} attachment${attCount === 1 ? "" : "s"} validated)` : "") + (mermaidCount > 0 ? ` (${mermaidCount} mermaid block${mermaidCount === 1 ? "" : "s"})` : "")
1776
+ `[dry-run] would sync ${entryPlan.entry.segments.join("/")} as "${entryPlan.title}"` + (attCount > 0 ? ` (${attCount} attachment${attCount === 1 ? "" : "s"} validated)` : "") + (mermaidCount > 0 ? ` (${mermaidCount} mermaid block${mermaidCount === 1 ? "" : "s"})` : "")
1716
1777
  );
1717
1778
  }
1718
1779
  return;
@@ -1750,7 +1811,7 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
1750
1811
  const isLast = idx === segments.length - 1;
1751
1812
  const segment = segments[idx] ?? "";
1752
1813
  if (isLast && isMarkdownName(segment)) {
1753
- const title = titleFromSegment(segment);
1814
+ const title = entryPlan.title;
1754
1815
  const leafNeedsUploads = hasLocalImages || hasMermaidBlocks;
1755
1816
  const existing = await cache.find(title, currentParentId);
1756
1817
  if (!existing && !leafNeedsUploads) {
@@ -1809,7 +1870,7 @@ async function syncEntry(entryPlan, plan, client, cache, log, changes) {
1809
1870
  return;
1810
1871
  }
1811
1872
  if (isMarkdownName(segment)) {
1812
- const title = titleFromSegment(segment);
1873
+ const title = pageTitleFromSegments(segments.slice(0, idx + 1), plan.pageTitleStrategy);
1813
1874
  const page2 = await cache.findOrCreate(title, currentParentId);
1814
1875
  currentParentId = page2.id;
1815
1876
  continue;
@@ -1940,14 +2001,15 @@ async function predictBody(html, mermaidBlocks, pageId, client, ctx) {
1940
2001
  }
1941
2002
  return predicted;
1942
2003
  }
1943
- function validateLocalHierarchy(entries) {
2004
+ function validateLocalHierarchy(entries, strategy) {
1944
2005
  const seen = /* @__PURE__ */ new Map();
1945
- for (const entry of entries) {
2006
+ for (const entryPlan of entries) {
2007
+ const { entry } = entryPlan;
1946
2008
  let parentKey = "";
1947
2009
  for (let index = 0; index < entry.segments.length; index += 1) {
1948
2010
  const segment = entry.segments[index] ?? "";
1949
2011
  const isLast = index === entry.segments.length - 1;
1950
- const title = isMarkdownName(segment) ? titleFromSegment(segment) : segment;
2012
+ const title = isMarkdownName(segment) ? isLast ? entryPlan.title : pageTitleFromSegments(entry.segments.slice(0, index + 1), strategy) : segment;
1951
2013
  const kind = isLast && isMarkdownName(segment) ? "file" : "dir";
1952
2014
  const key = `${parentKey}::${title}`;
1953
2015
  const existing = seen.get(key);
@@ -1962,8 +2024,10 @@ function validateLocalHierarchy(entries) {
1962
2024
  export {
1963
2025
  ConfluenceApiError,
1964
2026
  ConfluenceClient,
2027
+ DEFAULT_PAGE_TITLE_STRATEGY,
1965
2028
  INTERACTIVE_FLAG,
1966
2029
  LocalSyncValidationAggregateError,
2030
+ PAGE_TITLE_STRATEGIES,
1967
2031
  SyncMutationError,
1968
2032
  escapeAttachmentFilename,
1969
2033
  escapeXmlAttribute,
@@ -1971,10 +2035,12 @@ export {
1971
2035
  isMarkdownName,
1972
2036
  isRemoteUrl,
1973
2037
  markdownToStorage,
2038
+ pageTitleFromSegments,
1974
2039
  preflightImagesToAttachments,
1975
2040
  preflightMermaidBlocks,
1976
2041
  readDocTree,
1977
2042
  resolveConfluenceSyncPlan,
2043
+ resolvePageTitleStrategy,
1978
2044
  resolveConfluenceSyncPlan as resolveSyncPlan,
1979
2045
  rewriteImagesToAttachments,
1980
2046
  rewriteMermaidBlocks,
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.18.0",
4
+ "version": "0.20.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.18.0"
30
+ "@repo-toolkit/publish-package": "0.20.0"
31
31
  },
32
32
  "files": [
33
33
  "**/*",