confluence-md-sync 0.2.1 → 0.3.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.
@@ -2,25 +2,53 @@ import MarkdownIt from 'markdown-it';
2
2
  // xhtmlOut: true — Confluence storage format = XHTML, void-элементы
3
3
  // (<hr/>, <br/>, <img/>) обязаны быть самозакрывающимися.
4
4
  // html: true — разрешить HTML (нужно для <!-- MACRO:... --> комментариев)
5
- const md = new MarkdownIt({
5
+ const mdOptions = {
6
6
  html: true,
7
7
  linkify: true,
8
8
  typographer: false,
9
9
  breaks: false,
10
10
  xhtmlOut: true,
11
- });
12
- export const PLACEHOLDER_RE = /\{\{(img|file):([^}]+)\}\}/g;
11
+ };
12
+ const md = new MarkdownIt(mdOptions);
13
+ // Вариант без linkify: голые URL в тексте остаются текстом. Используется
14
+ // для страниц из exportPage — иначе round-trip превращал бы нессылочные
15
+ // упоминания URL в <a>.
16
+ const mdNoLinkify = new MarkdownIt({ ...mdOptions, linkify: false });
17
+ /**
18
+ * Плейсхолдеры: `{{img:name}}`, `{{file:name}}`, `{{page:Title}}`.
19
+ * После имени допустимы `|key=value`-атрибуты:
20
+ * {{img:chart.png|thumbnail=true|height=250}}
21
+ * {{page:Другая страница|space=DOCS|text=якорный текст}}
22
+ */
23
+ export const PLACEHOLDER_RE = /\{\{(img|file|page):([^}]+)\}\}/g;
24
+ /** Разбирает содержимое плейсхолдера: `name|k=v|k2=v2`. */
25
+ export function parsePlaceholder(body) {
26
+ const parts = body.split('|');
27
+ const name = parts[0].trim();
28
+ const attrs = [];
29
+ for (const part of parts.slice(1)) {
30
+ const eq = part.indexOf('=');
31
+ if (eq === -1)
32
+ attrs.push([part.trim(), '']);
33
+ else
34
+ attrs.push([part.slice(0, eq).trim(), part.slice(eq + 1).trim()]);
35
+ }
36
+ return { name, attrs };
37
+ }
13
38
  export function extractPlaceholders(markdown) {
14
39
  const images = new Set();
15
40
  const files = new Set();
41
+ const pages = new Set();
16
42
  for (const m of markdown.matchAll(PLACEHOLDER_RE)) {
17
- const name = m[2].trim();
43
+ const { name } = parsePlaceholder(m[2]);
18
44
  if (m[1] === 'img')
19
45
  images.add(name);
20
- else
46
+ else if (m[1] === 'file')
21
47
  files.add(name);
48
+ else
49
+ pages.add(name);
22
50
  }
23
- return { images: [...images], files: [...files] };
51
+ return { images: [...images], files: [...files], pages: [...pages] };
24
52
  }
25
53
  function escapeXmlAttr(s) {
26
54
  return s.replace(/[<>&"']/g, (c) => {
@@ -42,9 +70,13 @@ function escapeXmlAttr(s) {
42
70
  export function renameImagePlaceholders(markdown, renames) {
43
71
  if (renames.size === 0)
44
72
  return markdown;
45
- return markdown.replace(/\{\{img:([^}]+)\}\}/g, (full, rawName) => {
46
- const renamed = renames.get(rawName.trim());
47
- return renamed ? `{{img:${renamed}}}` : full;
73
+ return markdown.replace(/\{\{img:([^}]+)\}\}/g, (full, rawBody) => {
74
+ const { name, attrs } = parsePlaceholder(rawBody);
75
+ const renamed = renames.get(name);
76
+ if (!renamed)
77
+ return full;
78
+ const attrStr = attrs.map(([k, v]) => `|${k}=${v}`).join('');
79
+ return `{{img:${renamed}${attrStr}}}`;
48
80
  });
49
81
  }
50
82
  export class MissingAttachmentUrlError extends Error {
@@ -73,17 +105,36 @@ export class MissingAttachmentUrlError extends Error {
73
105
  * HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
74
106
  * посимвольно.
75
107
  */
76
- export function renderToStorage(markdown, urls) {
77
- const html = md.render(markdown);
78
- return html.replace(PLACEHOLDER_RE, (_full, kind, rawName) => {
79
- const name = String(rawName).trim();
108
+ export function renderToStorage(markdown, urls, opts = {}) {
109
+ const renderer = opts.linkify === false ? mdNoLinkify : md;
110
+ let html = renderer.render(markdown);
111
+ // ```confluence-storage транспорт для дословного XHTML (ac:/ri:-теги
112
+ // markdown-it сквозь себя не пропускает). Fence рендерится в экранированный
113
+ // <pre><code>; здесь разэкранируем содержимое обратно в живую разметку.
114
+ html = html.replace(/<pre><code class="language-confluence-storage">([\s\S]*?)<\/code><\/pre>\n?/g, (_full, escaped) => unescapeHtml(escaped.replace(/\n$/, '')) + '\n');
115
+ return html.replace(PLACEHOLDER_RE, (_full, kind, rawBody) => {
116
+ const { name, attrs } = parsePlaceholder(String(rawBody));
117
+ if (kind === 'page') {
118
+ return renderPageLink(name, attrs);
119
+ }
80
120
  if (kind === 'img') {
121
+ if (opts.imageStyle === 'attachment') {
122
+ const acAttrs = attrs
123
+ .map(([k, v]) => ` ac:${k}="${escapeXmlAttr(v)}"`)
124
+ .join('');
125
+ return `<ac:image${acAttrs}><ri:attachment ri:filename="${escapeXmlAttr(name)}" /></ac:image>`;
126
+ }
81
127
  const url = urls.images.get(name);
82
128
  if (!url) {
83
129
  throw new MissingAttachmentUrlError(`No uploaded URL for image '${name}' — was it included in publishPage().images and successfully uploaded?`);
84
130
  }
85
131
  return `<img src="${escapeXmlAttr(url)}" alt="${escapeXmlAttr(name)}" />`;
86
132
  }
133
+ if (opts.fileStyle === 'attachment') {
134
+ const text = attrs.find(([k]) => k === 'text')?.[1];
135
+ const body = text !== undefined ? plainTextLinkBody(text) : '';
136
+ return `<ac:link><ri:attachment ri:filename="${escapeXmlAttr(name)}" />${body}</ac:link>`;
137
+ }
87
138
  const url = urls.files.get(name);
88
139
  if (!url) {
89
140
  throw new MissingAttachmentUrlError(`No uploaded URL for file '${name}' — was it included in publishPage().files and successfully uploaded?`);
@@ -91,3 +142,22 @@ export function renderToStorage(markdown, urls) {
91
142
  return `<a href="${escapeXmlAttr(url)}">${escapeXmlAttr(name)}</a>`;
92
143
  });
93
144
  }
145
+ function renderPageLink(title, attrs) {
146
+ const space = attrs.find(([k]) => k === 'space')?.[1];
147
+ const text = attrs.find(([k]) => k === 'text')?.[1];
148
+ const spaceAttr = space !== undefined ? ` ri:space-key="${escapeXmlAttr(space)}"` : '';
149
+ const body = text !== undefined ? plainTextLinkBody(text) : '';
150
+ return `<ac:link><ri:page ri:content-title="${escapeXmlAttr(title)}"${spaceAttr} />${body}</ac:link>`;
151
+ }
152
+ function plainTextLinkBody(text) {
153
+ const safe = text.replace(/\]\]>/g, ']]]]><![CDATA[>');
154
+ return `<ac:plain-text-link-body><![CDATA[${safe}]]></ac:plain-text-link-body>`;
155
+ }
156
+ /** Обратное к markdown-it escapeHtml (& < > "). */
157
+ function unescapeHtml(s) {
158
+ return s
159
+ .replace(/&lt;/g, '<')
160
+ .replace(/&gt;/g, '>')
161
+ .replace(/&quot;/g, '"')
162
+ .replace(/&amp;/g, '&');
163
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ConfluenceConfig } from '../client/config.js';
2
+ import { type RenderStorageOptions } from '../markdown/render.js';
2
3
  import type { MacroRegistry } from '../macros/registry.js';
3
4
  import { Markdown } from '../markdown/markdown.js';
4
5
  export interface TableData {
@@ -18,7 +19,13 @@ export interface PublishPageOptions {
18
19
  markdownPath?: string;
19
20
  /** Готовый markdown-контент (вместо markdownPath). */
20
21
  markdown?: Markdown | string;
22
+ /**
23
+ * Картинки для страницы: относительный или абсолютный путь на диске либо
24
+ * http(s)-URL (файл скачивается при публикации; для URL с того же
25
+ * Confluence используется токен из конфига). `*.bpmn` конвертируются в PNG.
26
+ */
21
27
  images?: string[];
28
+ /** Файлы-аттачи; те же виды источников, что и images. */
22
29
  files?: string[];
23
30
  tables?: TableData[];
24
31
  /**
@@ -43,6 +50,11 @@ export interface PublishPageOptions {
43
50
  * идёт по SHA-256 исходного BPMN через sidecar, лишних версий не будет.
44
51
  */
45
52
  bpmnOutDir?: string;
53
+ /**
54
+ * Каталог для файлов, скачанных по http(s)-URL из images[]/files[].
55
+ * Default: временный каталог на каждый запуск.
56
+ */
57
+ downloadDir?: string;
46
58
  /**
47
59
  * Ключ content property для хранения content-hash.
48
60
  * Default: 'confluence-md-sync-content-hash'.
@@ -50,6 +62,11 @@ export interface PublishPageOptions {
50
62
  hashPropertyKey?: string;
51
63
  /** Рендер и валидация без записи в Confluence. */
52
64
  dryRun?: boolean;
65
+ /**
66
+ * Опции рендера markdown → storage. Для страниц из exportPage:
67
+ * `{ imageStyle: 'attachment', fileStyle: 'attachment', linkify: false }`.
68
+ */
69
+ render?: RenderStorageOptions;
53
70
  }
54
71
  export interface PublishPageResult {
55
72
  pageId: string;
@@ -5,7 +5,8 @@ import { basename, join } from 'node:path';
5
5
  import { ConfluenceClient } from '../client/client.js';
6
6
  import { AttachmentService } from '../attachments/attachment.js';
7
7
  import { bpmnOutputName, convertBpmn, isBpmnFile } from '../bpmn/convert.js';
8
- import { renameImagePlaceholders, renderToStorage } from '../markdown/render.js';
8
+ import { downloadToFile, isHttpUrl, remoteFilename } from './remote.js';
9
+ import { renameImagePlaceholders, renderToStorage, } from '../markdown/render.js';
9
10
  import { validateMarkdown } from '../markdown/validate.js';
10
11
  import { processMacros } from '../macros/registry.js';
11
12
  import { defaultMacroRegistry } from '../macros/index.js';
@@ -76,11 +77,41 @@ export async function publishPage(opts, cfg) {
76
77
  throw new Error('publishPage: either markdownPath or markdown must be provided');
77
78
  }
78
79
  let images = opts.images ?? [];
79
- const files = opts.files ?? [];
80
+ let files = opts.files ?? [];
80
81
  const tables = opts.tables ?? [];
81
82
  const registry = opts.registry ?? defaultMacroRegistry;
82
83
  const hashKey = opts.hashPropertyKey ?? DEFAULT_HASH_PROPERTY_KEY;
83
- // 0. BPMN из коробки: *.bpmn в images[] конвертируются в PNG, а
84
+ // 0a. Удалённые источники: http(s)://-элементы в images[]/files[]
85
+ // заменяются на локальный путь в downloadDir с именем из URL — дальше
86
+ // конвейер (валидация, BPMN, аплоад) работает с обычными путями.
87
+ // Скачивание — после валидации (fail fast, до любого сетевого I/O).
88
+ const remoteDownloads = [];
89
+ if ([...images, ...files].some(isHttpUrl)) {
90
+ const dlDir = opts.downloadDir ?? mkdtempSync(join(tmpdir(), 'confluence-md-sync-dl-'));
91
+ const toLocal = (spec) => {
92
+ if (!isHttpUrl(spec))
93
+ return spec;
94
+ const dest = join(dlDir, remoteFilename(spec));
95
+ remoteDownloads.push({ url: spec, dest });
96
+ return dest;
97
+ };
98
+ images = images.map(toLocal);
99
+ files = files.map(toLocal);
100
+ }
101
+ // Плейсхолдеры и dedup-карты ключуются по basename — одинаковые имена из
102
+ // разных источников молча перетёрли бы друг друга. С URL-источниками это
103
+ // легко словить случайно, поэтому проверяем явно.
104
+ for (const list of [images, files]) {
105
+ const byName = new Map();
106
+ for (const p of list) {
107
+ const prev = byName.get(basename(p));
108
+ if (prev !== undefined && prev !== p) {
109
+ throw new Error(`publishPage: duplicate attachment filename '${basename(p)}' from different sources: '${prev}' and '${p}'`);
110
+ }
111
+ byName.set(basename(p), p);
112
+ }
113
+ }
114
+ // 0b. BPMN из коробки: *.bpmn в images[] конвертируются в PNG, а
84
115
  // {{img:p1.bpmn}} в markdown подменяется на {{img:p1.png}} ДО валидации.
85
116
  // Сама конвертация (puppeteer) — после валидации, чтобы падать быстро.
86
117
  const bpmnConversions = [];
@@ -110,6 +141,14 @@ export async function publishPage(opts, cfg) {
110
141
  const tableMarkdown = table.markdown instanceof Markdown ? table.markdown.toString() : table.markdown;
111
142
  markdown = markdown.replace(new RegExp(`\\{\\{table:${escapeRegex(table.name)}\\}\\}`, 'g'), () => tableMarkdown);
112
143
  }
144
+ // Скачивание удалённых источников — после валидации, до конвертации
145
+ // (скачанный .bpmn нужен convertBpmn). В dry-run пропускается.
146
+ if (remoteDownloads.length > 0 && !opts.dryRun) {
147
+ for (const { url, dest } of remoteDownloads) {
148
+ await downloadToFile(url, dest, cfg);
149
+ console.log(`[download] ${url} → ${basename(dest)}`);
150
+ }
151
+ }
113
152
  // Конвертация диаграмм — до аплоада; в dry-run пропускается (аттачи всё
114
153
  // равно не загружаются, URL подставляются фиктивные).
115
154
  if (bpmnConversions.length > 0 && !opts.dryRun) {
@@ -149,7 +188,7 @@ export async function publishPage(opts, cfg) {
149
188
  }
150
189
  }
151
190
  // 2. Рендер MD → storage format с подстановкой полученных URL-ов.
152
- let storage = renderToStorage(markdown, urls);
191
+ let storage = renderToStorage(markdown, urls, opts.render ?? {});
153
192
  // 2.5. Преобразование маркеров макросов в XHTML.
154
193
  storage = processMacros(storage, registry).toString();
155
194
  if (opts.dryRun) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Удалённые источники для images[]/files[]: элемент со схемой http(s)://
3
+ * скачивается во временный каталог перед публикацией и дальше проходит
4
+ * обычный конвейер (BPMN-конвертация, dedup по SHA-256, аплоад).
5
+ *
6
+ * Если URL указывает на тот же Confluence, что и cfg.baseUrl (совпадает
7
+ * origin — протокол + хост + порт), запрос уходит с Authorization из
8
+ * текущего конфига (PAT/API token). На чужие хосты токен НЕ отправляется;
9
+ * undici к тому же сам вырезает Authorization при cross-origin redirect.
10
+ */
11
+ import { type ConfluenceConfig } from '../client/config.js';
12
+ /** true для http:// и https:// (остальное трактуется как путь на диске). */
13
+ export declare function isHttpUrl(spec: string): boolean;
14
+ /**
15
+ * Имя файла из URL: basename от pathname (query/fragment отбрасываются),
16
+ * percent-encoding декодируется. `.../p1.bpmn?version=2` → `p1.bpmn` —
17
+ * это же имя используется в `{{img:...}}`/`{{file:...}}` плейсхолдерах.
18
+ */
19
+ export declare function remoteFilename(spec: string): string;
20
+ /** Совпадает ли origin URL-а с origin Confluence из конфига. */
21
+ export declare function isSameConfluenceOrigin(url: string, cfg: ConfluenceConfig): boolean;
22
+ /** Заголовки запроса: Authorization — только для своего Confluence. */
23
+ export declare function remoteRequestHeaders(url: string, cfg: ConfluenceConfig): Record<string, string>;
24
+ /** Скачивает URL в destPath. Не-2xx → ошибка с кодом и URL. */
25
+ export declare function downloadToFile(url: string, destPath: string, cfg: ConfluenceConfig): Promise<void>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Удалённые источники для images[]/files[]: элемент со схемой http(s)://
3
+ * скачивается во временный каталог перед публикацией и дальше проходит
4
+ * обычный конвейер (BPMN-конвертация, dedup по SHA-256, аплоад).
5
+ *
6
+ * Если URL указывает на тот же Confluence, что и cfg.baseUrl (совпадает
7
+ * origin — протокол + хост + порт), запрос уходит с Authorization из
8
+ * текущего конфига (PAT/API token). На чужие хосты токен НЕ отправляется;
9
+ * undici к тому же сам вырезает Authorization при cross-origin redirect.
10
+ */
11
+ import { writeFileSync } from 'node:fs';
12
+ import { basename } from 'node:path';
13
+ import { authHeader } from '../client/config.js';
14
+ /** true для http:// и https:// (остальное трактуется как путь на диске). */
15
+ export function isHttpUrl(spec) {
16
+ return /^https?:\/\//i.test(spec);
17
+ }
18
+ /**
19
+ * Имя файла из URL: basename от pathname (query/fragment отбрасываются),
20
+ * percent-encoding декодируется. `.../p1.bpmn?version=2` → `p1.bpmn` —
21
+ * это же имя используется в `{{img:...}}`/`{{file:...}}` плейсхолдерах.
22
+ */
23
+ export function remoteFilename(spec) {
24
+ const pathname = new URL(spec).pathname;
25
+ // node:path.basename('/dir/') === 'dir' — трейлинг-слэш надо ловить явно.
26
+ const name = pathname.endsWith('/') ? '' : decodeURIComponent(basename(pathname));
27
+ if (!name) {
28
+ throw new Error(`Cannot derive a filename from URL '${spec}' — the path ends with '/'`);
29
+ }
30
+ return name;
31
+ }
32
+ /** Совпадает ли origin URL-а с origin Confluence из конфига. */
33
+ export function isSameConfluenceOrigin(url, cfg) {
34
+ try {
35
+ return new URL(url).origin === new URL(cfg.baseUrl).origin;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** Заголовки запроса: Authorization — только для своего Confluence. */
42
+ export function remoteRequestHeaders(url, cfg) {
43
+ return isSameConfluenceOrigin(url, cfg) ? { Authorization: authHeader(cfg) } : {};
44
+ }
45
+ /** Скачивает URL в destPath. Не-2xx → ошибка с кодом и URL. */
46
+ export async function downloadToFile(url, destPath, cfg) {
47
+ const res = await fetch(url, { headers: remoteRequestHeaders(url, cfg) });
48
+ if (!res.ok) {
49
+ throw new Error(`Download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
50
+ }
51
+ writeFileSync(destPath, Buffer.from(await res.arrayBuffer()));
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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",