confluence-md-sync 0.11.1 → 0.11.2

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.
@@ -5,7 +5,9 @@
5
5
  import type { ConfluenceConfig } from '../client/config.js';
6
6
  import type { MacroRegistry } from '../macros/registry.js';
7
7
  import { type StorageToMarkdownResult } from './to-markdown.js';
8
+ import type { StorageLinkResolver } from './links.js';
8
9
  export interface ExportPageOptions {
10
+ linkResolver?: StorageLinkResolver;
9
11
  /**
10
12
  * Точный путь до итогового md-файла. Альтернатива `outDir`. Аттачи (если
11
13
  * включены) кладутся в `attachments/` рядом с этим файлом.
@@ -16,6 +16,7 @@ export async function exportPage(pageId, opts, cfg) {
16
16
  mode: opts.mode,
17
17
  attachments: opts.attachments,
18
18
  tables: opts.tables,
19
+ linkResolver: opts.linkResolver,
19
20
  });
20
21
  const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
21
22
  mkdirSync(dirname(markdownPath), { recursive: true });
@@ -0,0 +1,12 @@
1
+ export interface StorageLink {
2
+ href?: string;
3
+ pageId?: string;
4
+ title?: string;
5
+ space?: string;
6
+ anchor?: string;
7
+ }
8
+ export type StorageLinkResolver = (link: StorageLink) => string | null | undefined;
9
+ /** Collect targets before a batch determines their final file paths. */
10
+ export declare function storageLinks(storage: string): StorageLink[];
11
+ /** Rewrite links only after the caller has prepared every destination. */
12
+ export declare function rewriteStorageLinks(storage: string, resolver: StorageLinkResolver): string;
@@ -0,0 +1,42 @@
1
+ import { escapeXmlAttr } from '../macros/xml.js';
2
+ import { elements, getAttr, parseStorage, serializeStorage, textContent } from './xhtml.js';
3
+ function visit(nodes, fn) {
4
+ for (const el of nodes) {
5
+ if (el.kind !== 'el' || ['code', 'pre', 'ac:parameter', 'ac:plain-text-body'].includes(el.name))
6
+ continue;
7
+ if (el.name === 'a' && getAttr(el, 'href'))
8
+ fn(el, { href: getAttr(el, 'href') });
9
+ else if (el.name === 'ac:link') {
10
+ const page = elements(el.children).find(c => c.name === 'ri:page');
11
+ if (page)
12
+ fn(el, { pageId: getAttr(page, 'ri:content-id'), title: getAttr(page, 'ri:content-title'), space: getAttr(page, 'ri:space-key'), anchor: getAttr(el, 'ac:anchor') });
13
+ }
14
+ visit(el.children, fn);
15
+ }
16
+ }
17
+ /** Collect targets before a batch determines their final file paths. */
18
+ export function storageLinks(storage) {
19
+ const result = [];
20
+ visit(parseStorage(storage), (_el, link) => result.push(link));
21
+ return result;
22
+ }
23
+ /** Rewrite links only after the caller has prepared every destination. */
24
+ export function rewriteStorageLinks(storage, resolver) {
25
+ const nodes = parseStorage(storage);
26
+ let changed = false;
27
+ visit(nodes, (el, link) => {
28
+ const href = resolver(link);
29
+ if (!href || href === link.href)
30
+ return;
31
+ if (el.name === 'ac:link') {
32
+ const body = elements(el.children).find(c => c.name === 'ac:link-body' || c.name === 'ac:plain-text-link-body');
33
+ el.children = body?.name === 'ac:link-body' ? body.children : [{ kind: 'text', raw: escapeXmlAttr(body ? textContent(body.children) : link.title ?? href) }];
34
+ el.name = 'a';
35
+ el.attrs = [];
36
+ el.selfClosing = false;
37
+ }
38
+ el.attrs = [...el.attrs.filter(([key]) => key !== 'href'), ['href', escapeXmlAttr(href)]];
39
+ changed = true;
40
+ });
41
+ return changed ? serializeStorage(nodes) : storage;
42
+ }
@@ -20,7 +20,10 @@
20
20
  * выдаётся никогда.
21
21
  */
22
22
  import { type MacroRegistry } from '../macros/registry.js';
23
+ import { type StorageLinkResolver } from './links.js';
23
24
  export interface StorageToMarkdownOptions {
25
+ /** Map known page references to repository URLs after allocating all batch paths. */
26
+ linkResolver?: StorageLinkResolver;
24
27
  /** Known XML attachments; needed for image-mode diagrams without a .drawio suffix. */
25
28
  drawioNames?: string[];
26
29
  /** Реестр для проверки маркеров макросов (default: встроенный). */
@@ -26,9 +26,11 @@ import { defaultMacroRegistry } from '../macros/index.js';
26
26
  import { renderToStorage } from '../markdown/render.js';
27
27
  import { compareStorage } from './canonical.js';
28
28
  import { NOTICE_MACRO_ID } from '../publish/notice.js';
29
+ import { rewriteStorageLinks } from './links.js';
29
30
  import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
30
31
  /** Конвертирует storage-фрагмент страницы в Markdown. */
31
32
  export function storageToMarkdown(storage, opts = {}) {
33
+ const rewritten = opts.linkResolver ? rewriteStorageLinks(storage, opts.linkResolver) : storage;
32
34
  const local = opts.attachments === 'local';
33
35
  const records = opts.tables === 'records';
34
36
  // 'local' и 'records' — заведомо не round-trip, поэтому включают readable-
@@ -41,7 +43,9 @@ export function storageToMarkdown(storage, opts = {}) {
41
43
  localPrefix: opts.localPrefix ?? 'attachments/',
42
44
  drawioNames: opts.drawioNames ?? [],
43
45
  });
44
- const markdown = conv.blocksToMd(parseStorage(storage));
46
+ const markdown = conv.blocksToMd(parseStorage(rewritten));
47
+ if (rewritten !== storage)
48
+ conv.stats.normalized++;
45
49
  const attachmentRefs = new Set([...conv.images, ...conv.files]);
46
50
  for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
47
51
  attachmentRefs.add(m[1]);
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, type Publis
15
15
  export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DEFAULT_MANAGED_NOTICE_LINK_TEXT, type ManagedNoticeOptions, } from './publish/notice.js';
16
16
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
17
17
  export { runPublish, type Here, type Build, type PublishPlan, type RunPublishOptions, } from './publish/runner.js';
18
+ export { storageLinks, rewriteStorageLinks, type StorageLink, type StorageLinkResolver } from './export/links.js';
18
19
  export { parseStorage, serializeStorage, decodeEntities, StorageParseError, type XNode, type XElement, } from './export/xhtml.js';
19
20
  export { canonicalize, compareStorage, type CNode, type CompareResult, type StorageDiff, } from './export/canonical.js';
20
21
  export { storageToMarkdown, type StorageToMarkdownOptions, type StorageToMarkdownResult, } from './export/to-markdown.js';
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DE
23
23
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
24
24
  export { runPublish, } from './publish/runner.js';
25
25
  // Export (storage → markdown) & round-trip
26
+ export { storageLinks, rewriteStorageLinks } from './export/links.js';
26
27
  export { parseStorage, serializeStorage, decodeEntities, StorageParseError, } from './export/xhtml.js';
27
28
  export { canonicalize, compareStorage, } from './export/canonical.js';
28
29
  export { storageToMarkdown, } from './export/to-markdown.js';
@@ -54,6 +54,8 @@ export interface RenderStorageOptions {
54
54
  linkResolver?: (href: string) => {
55
55
  title: string;
56
56
  space?: string;
57
+ } | {
58
+ url: string;
57
59
  } | null;
58
60
  /** Local Markdown assets: attachment on this page, or a canonical repository URL. */
59
61
  resourceResolver?: (href: string) => {
@@ -137,9 +137,11 @@ export function renderToStorage(markdown, urls, opts = {}) {
137
137
  if (opts.linkResolver || opts.resourceResolver) {
138
138
  html = html.replace(/<a href="([^"]*)"(?:\s+title="[^"]*")?>([\s\S]*?)<\/a>/g, (full, href, inner) => {
139
139
  const decoded = unescapeHtml(href);
140
- if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(decoded))
141
- return full; // http(s):, mailto:, #anchor
140
+ if (/^(?:#|(?:mailto|tel|javascript|data):)/i.test(decoded))
141
+ return full;
142
142
  const target = opts.linkResolver?.(decoded);
143
+ if (target && 'url' in target)
144
+ return `<a href="${escapeXmlAttr(target.url)}">${inner}</a>`;
143
145
  const body = /<[^>]+>/.test(inner)
144
146
  ? `<ac:link-body>${inner}</ac:link-body>`
145
147
  : plainTextLinkBody(unescapeHtml(inner));
@@ -148,6 +150,8 @@ export function renderToStorage(markdown, urls, opts = {}) {
148
150
  const anchor = decoded.includes('#') ? ` ac:anchor="${escapeXmlAttr(decoded.slice(decoded.indexOf('#') + 1))}"` : '';
149
151
  return `<ac:link${anchor}><ri:page ri:content-title="${escapeXmlAttr(target.title)}"${space} />${body}</ac:link>`;
150
152
  }
153
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(decoded))
154
+ return full;
151
155
  const resource = opts.resourceResolver?.(decoded);
152
156
  if (!resource)
153
157
  return full;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Publish Markdown to Confluence (Data Center & Cloud): idempotent page sync, attachment dedup, tables and a pluggable macro system",
5
5
  "keywords": [
6
6
  "confluence",