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.
- package/README.md +126 -21
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +76 -28
- package/dist/export/canonical.d.ts +38 -0
- package/dist/export/canonical.js +272 -0
- package/dist/export/export-page.d.ts +31 -0
- package/dist/export/export-page.js +41 -0
- package/dist/export/roundtrip.d.ts +30 -0
- package/dist/export/roundtrip.js +32 -0
- package/dist/export/to-markdown.d.ts +37 -0
- package/dist/export/to-markdown.js +845 -0
- package/dist/export/xhtml.d.ts +52 -0
- package/dist/export/xhtml.js +230 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +8 -1
- package/dist/macros/builder.d.ts +7 -0
- package/dist/macros/builder.js +23 -2
- package/dist/macros/index.d.ts +2 -1
- package/dist/macros/index.js +2 -1
- package/dist/macros/plugins/core.js +3 -0
- package/dist/macros/plugins/table-filter.d.ts +8 -0
- package/dist/macros/plugins/table-filter.js +35 -3
- package/dist/macros/registry.d.ts +11 -0
- package/dist/macros/registry.js +45 -1
- package/dist/markdown/render.d.ts +30 -1
- package/dist/markdown/render.js +83 -13
- package/dist/publish/publish.d.ts +17 -0
- package/dist/publish/publish.js +74 -14
- package/dist/publish/remote.d.ts +25 -0
- package/dist/publish/remote.js +52 -0
- package/package.json +1 -1
package/dist/markdown/render.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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]
|
|
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,
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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(/</g, '<')
|
|
160
|
+
.replace(/>/g, '>')
|
|
161
|
+
.replace(/"/g, '"')
|
|
162
|
+
.replace(/&/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;
|
package/dist/publish/publish.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
160
|
-
|
|
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