confluence-md-sync 0.11.0 → 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,14 @@ export interface RenderStorageOptions {
54
54
  linkResolver?: (href: string) => {
55
55
  title: string;
56
56
  space?: string;
57
+ } | {
58
+ url: string;
59
+ } | null;
60
+ /** Local Markdown assets: attachment on this page, or a canonical repository URL. */
61
+ resourceResolver?: (href: string) => {
62
+ attachment: string;
63
+ } | {
64
+ url: string;
57
65
  } | null;
58
66
  }
59
67
  export declare class MissingAttachmentUrlError extends Error {
@@ -120,22 +120,44 @@ export function renderToStorage(markdown, urls, opts = {}) {
120
120
  // манифеста). Только markdown-ссылки (`<a href>` из markdown-it); плейсхолдеры
121
121
  // {{file:}}/{{img:}} рендерятся ниже и сюда ещё не попали. Внешние URL/якоря
122
122
  // резолвер отсекает (возвращает null) — они остаются как есть.
123
- if (opts.linkResolver) {
124
- const resolve = opts.linkResolver;
125
- html = html.replace(/<a href="([^"]*)">([\s\S]*?)<\/a>/g, (full, href, inner) => {
123
+ if (opts.resourceResolver) {
124
+ html = html.replace(/<img\b[^>]*\bsrc="([^"]*)"[^>]*\/?>/g, (full, href) => {
126
125
  const decoded = unescapeHtml(href);
127
126
  if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(decoded))
128
- return full; // http(s):, mailto:, #anchor
129
- const target = resolve(decoded);
127
+ return full;
128
+ const target = opts.resourceResolver(decoded);
130
129
  if (!target)
131
130
  return full;
132
- const text = unescapeHtml(inner.replace(/<[^>]+>/g, '')).trim();
133
- const attrs = [];
134
- if (target.space)
135
- attrs.push(['space', target.space]);
136
- if (text)
137
- attrs.push(['text', text]);
138
- return renderPageLink(target.title, attrs);
131
+ if ('url' in target)
132
+ return full.replace(`src="${href}"`, `src="${escapeXmlAttr(target.url)}"`);
133
+ const alt = /\balt="([^"]*)"/.exec(full)?.[1];
134
+ return `<ac:image${alt ? ` ac:alt="${escapeXmlAttr(unescapeHtml(alt))}"` : ''}><ri:attachment ri:filename="${escapeXmlAttr(target.attachment)}" /></ac:image>`;
135
+ });
136
+ }
137
+ if (opts.linkResolver || opts.resourceResolver) {
138
+ html = html.replace(/<a href="([^"]*)"(?:\s+title="[^"]*")?>([\s\S]*?)<\/a>/g, (full, href, inner) => {
139
+ const decoded = unescapeHtml(href);
140
+ if (/^(?:#|(?:mailto|tel|javascript|data):)/i.test(decoded))
141
+ return full;
142
+ const target = opts.linkResolver?.(decoded);
143
+ if (target && 'url' in target)
144
+ return `<a href="${escapeXmlAttr(target.url)}">${inner}</a>`;
145
+ const body = /<[^>]+>/.test(inner)
146
+ ? `<ac:link-body>${inner}</ac:link-body>`
147
+ : plainTextLinkBody(unescapeHtml(inner));
148
+ if (target) {
149
+ const space = target.space ? ` ri:space-key="${escapeXmlAttr(target.space)}"` : '';
150
+ const anchor = decoded.includes('#') ? ` ac:anchor="${escapeXmlAttr(decoded.slice(decoded.indexOf('#') + 1))}"` : '';
151
+ return `<ac:link${anchor}><ri:page ri:content-title="${escapeXmlAttr(target.title)}"${space} />${body}</ac:link>`;
152
+ }
153
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(decoded))
154
+ return full;
155
+ const resource = opts.resourceResolver?.(decoded);
156
+ if (!resource)
157
+ return full;
158
+ if ('url' in resource)
159
+ return `<a href="${escapeXmlAttr(resource.url)}">${inner}</a>`;
160
+ return `<ac:link><ri:attachment ri:filename="${escapeXmlAttr(resource.attachment)}" />${body}</ac:link>`;
139
161
  });
140
162
  }
141
163
  return html.replace(PLACEHOLDER_RE, (_full, kind, rawBody) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.11.0",
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",