confluence-md-sync 0.2.0 → 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';
@@ -20,14 +21,26 @@ import { Markdown } from '../markdown/markdown.js';
20
21
  export const DEFAULT_HASH_PROPERTY_KEY = 'confluence-md-sync-content-hash';
21
22
  export function computeContentHash(storage) {
22
23
  // Перед хешированием вычищаем query (?version=N&modificationDate=…) из
23
- // attachment download-URL'ов. Иначе любой ребамп аттача (новая версия
24
- // от не-детерминированной генерации картинок новый SHA новый
25
- // version N в URL) ломал бы hash страницы, хотя тело логически
26
- // не изменилось — Confluence сам отдаст последнюю версию аттача по
27
- // base-URL без query.
24
+ // attachment download-URL'ов защита для storage, писанного схемой 1
25
+ // (см. HASH_SCHEME): там в body лежали URL с пином версии. Начиная со
26
+ // схемы 2 в body подставляются канонические URL без query, и replace —
27
+ // no-op.
28
28
  const canonical = storage.replace(/(\/download\/attachments\/[^"?\s]+)\?[^"\s]*/g, '$1');
29
29
  return createHash('sha256').update(canonical, 'utf-8').digest('hex');
30
30
  }
31
+ // Схема записи download-URL в body. Схема 1 подставляла URL из
32
+ // _links.download как есть — с ?version=N&modificationDate=…; при ребампе
33
+ // аттача без изменения текста страница оставалась UNCHANGED и продолжала
34
+ // отдавать старую, пиненную версию картинки. Схема 2 подставляет
35
+ // канонический URL без query — Confluence по нему отдаёт последнюю версию
36
+ // аттача, и обновление диаграммы видно без переписывания body. Property со
37
+ // схемой ≠ текущей (в т.ч. без поля scheme) считается устаревшей — страница
38
+ // один раз переписывается каноническими URL.
39
+ const HASH_SCHEME = 2;
40
+ /** Канонический download-URL аттача: без query (?version=N&…). */
41
+ function canonicalDownloadUrl(url) {
42
+ return url.split('?')[0];
43
+ }
31
44
  async function resolvePage(client, opts) {
32
45
  if (opts.pageId)
33
46
  return { pageId: opts.pageId, created: false };
@@ -64,11 +77,41 @@ export async function publishPage(opts, cfg) {
64
77
  throw new Error('publishPage: either markdownPath or markdown must be provided');
65
78
  }
66
79
  let images = opts.images ?? [];
67
- const files = opts.files ?? [];
80
+ let files = opts.files ?? [];
68
81
  const tables = opts.tables ?? [];
69
82
  const registry = opts.registry ?? defaultMacroRegistry;
70
83
  const hashKey = opts.hashPropertyKey ?? DEFAULT_HASH_PROPERTY_KEY;
71
- // 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, а
72
115
  // {{img:p1.bpmn}} в markdown подменяется на {{img:p1.png}} ДО валидации.
73
116
  // Сама конвертация (puppeteer) — после валидации, чтобы падать быстро.
74
117
  const bpmnConversions = [];
@@ -98,6 +141,14 @@ export async function publishPage(opts, cfg) {
98
141
  const tableMarkdown = table.markdown instanceof Markdown ? table.markdown.toString() : table.markdown;
99
142
  markdown = markdown.replace(new RegExp(`\\{\\{table:${escapeRegex(table.name)}\\}\\}`, 'g'), () => tableMarkdown);
100
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
+ }
101
152
  // Конвертация диаграмм — до аплоада; в dry-run пропускается (аттачи всё
102
153
  // равно не загружаются, URL подставляются фиктивные).
103
154
  if (bpmnConversions.length > 0 && !opts.dryRun) {
@@ -120,21 +171,24 @@ export async function publishPage(opts, cfg) {
120
171
  urls.files.set(basename(p), `dry-run://file/${basename(p)}`);
121
172
  }
122
173
  else {
174
+ // В body уходит канонический URL без query (см. HASH_SCHEME) — страница
175
+ // всегда отдаёт последнюю версию аттача. Полный URL с версией остаётся
176
+ // в результате для caller'а.
123
177
  for (const p of images) {
124
178
  const r = await attachmentSvc.ensure(pageId, p);
125
- urls.images.set(r.filename, r.downloadUrl);
179
+ urls.images.set(r.filename, canonicalDownloadUrl(r.downloadUrl));
126
180
  attachments.push({ filename: r.filename, id: r.id, reused: r.reused, url: r.downloadUrl });
127
181
  console.log(`[attachment] ${r.filename}: ${r.reused ? 'reused' : 'uploaded'} (id=${r.id})`);
128
182
  }
129
183
  for (const p of files) {
130
184
  const r = await attachmentSvc.ensure(pageId, p);
131
- urls.files.set(r.filename, r.downloadUrl);
185
+ urls.files.set(r.filename, canonicalDownloadUrl(r.downloadUrl));
132
186
  attachments.push({ filename: r.filename, id: r.id, reused: r.reused, url: r.downloadUrl });
133
187
  console.log(`[attachment] ${r.filename}: ${r.reused ? 'reused' : 'uploaded'} (id=${r.id})`);
134
188
  }
135
189
  }
136
190
  // 2. Рендер MD → storage format с подстановкой полученных URL-ов.
137
- let storage = renderToStorage(markdown, urls);
191
+ let storage = renderToStorage(markdown, urls, opts.render ?? {});
138
192
  // 2.5. Преобразование маркеров макросов в XHTML.
139
193
  storage = processMacros(storage, registry).toString();
140
194
  if (opts.dryRun) {
@@ -156,8 +210,14 @@ export async function publishPage(opts, cfg) {
156
210
  client.getContentProperty(pageId, hashKey),
157
211
  ]);
158
212
  const title = opts.title ?? existing.title;
159
- const existingHash = hashProp && typeof hashProp.value === 'object' && hashProp.value !== null
160
- ? hashProp.value.hash ?? null
213
+ // Property, писанная другой схемой (или до появления scheme), не считается
214
+ // совпадением: body мог быть записан с пином версий аттачей — его нужно
215
+ // один раз переписать каноническими URL.
216
+ const propValue = hashProp && typeof hashProp.value === 'object' && hashProp.value !== null
217
+ ? hashProp.value
218
+ : null;
219
+ const existingHash = propValue && propValue.scheme === HASH_SCHEME
220
+ ? (propValue.hash ?? null)
161
221
  : null;
162
222
  if (existingHash === newHash && title === existing.title) {
163
223
  console.log(`[publish] ${pageId} "${title}" → UNCHANGED (hash ${newHash.slice(0, 12)}, v${existing.version})`);
@@ -175,7 +235,7 @@ export async function publishPage(opts, cfg) {
175
235
  });
176
236
  // 5. Запись/обновление content property с новым hash. Делаем ПОСЛЕ
177
237
  // updatePage чтобы при сбое publish hash не «опередил» реальное содержимое.
178
- await client.setContentProperty(pageId, hashKey, { hash: newHash }, hashProp ? hashProp.version : null);
238
+ await client.setContentProperty(pageId, hashKey, { hash: newHash, scheme: HASH_SCHEME }, hashProp ? hashProp.version : null);
179
239
  if (opts.labels?.length)
180
240
  await client.addLabels(pageId, opts.labels);
181
241
  console.log(`[publish] ${pageId} "${title}" → v${nextVersion} (hash ${newHash.slice(0, 12)})`);
@@ -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.0",
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",