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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Канонизация storage-деревьев и сравнение «без потери разметки».
3
+ *
4
+ * Побайтовое равенство storage → md → storage недостижимо и не нужно:
5
+ * Confluence сам нормализует storage при каждом сохранении, ac:macro-id
6
+ * генерируется заново, markdown-it меняет форму сущностей. Критерий
7
+ * эквивалентности: XML-деревья равны после канонизации —
8
+ * - ac:macro-id / ac:schema-version отброшены;
9
+ * - сущности декодированы, пробельные последовательности схлопнуты;
10
+ * - пробельные text-узлы между блочными элементами удалены;
11
+ * - краевые пробелы вынесены из инлайн-форматирования
12
+ * (<strong>a </strong>b ≡ <strong>a</strong> b);
13
+ * - параметры макросов отсортированы по имени;
14
+ * - <img src=…> ≡ <ac:image><ri:url ri:value=…/></ac:image>;
15
+ * - атрибуты отсортированы по имени.
16
+ */
17
+ import { decodeEntities, parseStorage } from './xhtml.js';
18
+ // Элементы, внутри которых пробелы значимы и не схлопываются.
19
+ const PRESERVE_WS = new Set(['pre', 'ac:plain-text-body', 'ac:plain-text-link-body']);
20
+ // Инлайн-форматирование: краевые пробелы выносим наружу, пустые узлы убираем.
21
+ const INLINE_FORMATTING = new Set(['strong', 'b', 'em', 'i', 'u', 's', 'del', 'span', 'sub', 'sup']);
22
+ // Блочные элементы: пробельный текст рядом с ними — форматирование исходника.
23
+ const BLOCK_LEVEL = new Set([
24
+ 'p', 'div', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'colgroup', 'col',
25
+ 'ul', 'ol', 'li', 'blockquote', 'hr', 'pre',
26
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
27
+ 'ac:structured-macro', 'ac:rich-text-body', 'ac:parameter', 'ac:plain-text-body',
28
+ 'ac:layout', 'ac:layout-section', 'ac:layout-cell', 'ac:task-list', 'ac:task',
29
+ ]);
30
+ const DROP_ATTRS = new Set(['ac:macro-id', 'ac:schema-version']);
31
+ function isWsOnly(s) {
32
+ return /^[ \t\r\n]*$/.test(s);
33
+ }
34
+ function collapseWs(s) {
35
+ return s.replace(/[ \t\r\n]+/g, ' ');
36
+ }
37
+ export function canonicalize(nodes) {
38
+ return normalizeChildren(nodes, /* preserveWs */ false, /* inlineContainer */ false);
39
+ }
40
+ function normalizeChildren(nodes, preserveWs, inlineContainer) {
41
+ // 1. Узлы → канонические (рекурсивно), комментарии отбрасываются.
42
+ let out = [];
43
+ for (const n of nodes) {
44
+ if (n.kind === 'comment')
45
+ continue;
46
+ if (n.kind === 'cdata') {
47
+ out.push({ kind: 'text', text: n.text });
48
+ continue;
49
+ }
50
+ if (n.kind === 'text') {
51
+ out.push({ kind: 'text', text: decodeEntities(n.raw) });
52
+ continue;
53
+ }
54
+ out.push(canonicalizeElement(n));
55
+ }
56
+ if (!preserveWs) {
57
+ // 2. Пробельный text-узел между блочными границами (блочный элемент
58
+ // или край контейнера) — форматирование исходника, удаляем.
59
+ // Между инлайн-элементами (спанами) пробел значим — остаётся.
60
+ out = out.filter((node, idx) => {
61
+ if (node.kind !== 'text' || !isWsOnly(node.text))
62
+ return true;
63
+ const prev = out[idx - 1];
64
+ const next = out[idx + 1];
65
+ const prevBoundary = prev === undefined || (prev.kind === 'el' && BLOCK_LEVEL.has(prev.name));
66
+ const nextBoundary = next === undefined || (next.kind === 'el' && BLOCK_LEVEL.has(next.name));
67
+ return !(prevBoundary && nextBoundary);
68
+ });
69
+ for (const node of out) {
70
+ if (node.kind === 'text')
71
+ node.text = collapseWs(node.text);
72
+ }
73
+ // 3. Краевые пробелы инлайн-форматирования — наружу; пустые узлы —
74
+ // прочь; смежные одноимённые (без атрибутов) — в один:
75
+ // <strong>a</strong><strong>b</strong> ≡ <strong>ab</strong>.
76
+ out = mergeAdjacentFormatting(hoistEdgeWhitespace(out));
77
+ // 4. Соседние text-узлы сливаем, схлопываем повторно. Края обрезаем
78
+ // только в блочном контексте: краевой пробел ВНУТРИ инлайн-элемента
79
+ // значим — его выносит наружу hoistEdgeWhitespace на уровне родителя.
80
+ out = mergeTexts(out);
81
+ if (inlineContainer)
82
+ return out;
83
+ if (out.length > 0) {
84
+ const first = out[0];
85
+ if (first.kind === 'text') {
86
+ first.text = first.text.replace(/^ +/, '');
87
+ if (first.text === '')
88
+ out.shift();
89
+ }
90
+ }
91
+ if (out.length > 0) {
92
+ const last = out[out.length - 1];
93
+ if (last.kind === 'text') {
94
+ last.text = last.text.replace(/ +$/, '');
95
+ if (last.text === '')
96
+ out.pop();
97
+ }
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ function canonicalizeElement(el) {
103
+ // <img src=…> и <ac:image><ri:url ri:value=…/></ac:image> — одно и то же
104
+ // изображение по внешнему URL; канонизируем к форме ac:image.
105
+ if (el.name === 'img') {
106
+ const attrs = {};
107
+ let url = '';
108
+ for (const [k, v] of el.attrs) {
109
+ const value = decodeEntities(v);
110
+ if (k === 'src')
111
+ url = value;
112
+ else if (k === 'alt' && value === '')
113
+ continue;
114
+ else if (k === 'alt')
115
+ attrs['ac:alt'] = value;
116
+ else
117
+ attrs[`ac:${k}`] = value;
118
+ }
119
+ return {
120
+ kind: 'el',
121
+ name: 'ac:image',
122
+ attrs,
123
+ children: [{ kind: 'el', name: 'ri:url', attrs: { 'ri:value': url }, children: [] }],
124
+ };
125
+ }
126
+ const attrs = {};
127
+ for (const [k, v] of el.attrs) {
128
+ if (DROP_ATTRS.has(k))
129
+ continue;
130
+ const value = decodeEntities(v);
131
+ if (el.name === 'ac:image' && k === 'ac:alt' && value === '')
132
+ continue;
133
+ attrs[k] = value;
134
+ }
135
+ const preserve = PRESERVE_WS.has(el.name);
136
+ let children = normalizeChildren(el.children, preserve, INLINE_FORMATTING.has(el.name));
137
+ // <p><ac:structured-macro/></p> ≡ <ac:structured-macro/> — обёртка
138
+ // блочного макроса в абзац не влияет на рендер Confluence.
139
+ if (el.name === 'p' && el.attrs.length === 0 && children.length === 1 &&
140
+ children[0].kind === 'el' && children[0].name === 'ac:structured-macro') {
141
+ return children[0];
142
+ }
143
+ // Параметры макроса не зависят от порядка — сортируем по ac:name.
144
+ if (el.name === 'ac:structured-macro') {
145
+ children = [...children].sort((a, b) => {
146
+ const an = a.kind === 'el' && a.name === 'ac:parameter' ? (a.attrs['ac:name'] ?? '') : '￿';
147
+ const bn = b.kind === 'el' && b.name === 'ac:parameter' ? (b.attrs['ac:name'] ?? '') : '￿';
148
+ return an < bn ? -1 : an > bn ? 1 : 0;
149
+ });
150
+ }
151
+ return { kind: 'el', name: el.name, attrs, children };
152
+ }
153
+ /** `<strong>a </strong>b` → `<strong>a</strong> b` (рекурсивно, для сравнения). */
154
+ function hoistEdgeWhitespace(nodes) {
155
+ const out = [];
156
+ for (const node of nodes) {
157
+ if (node.kind !== 'el' || !INLINE_FORMATTING.has(node.name)) {
158
+ out.push(node);
159
+ continue;
160
+ }
161
+ let leading = '';
162
+ let trailing = '';
163
+ const kids = node.children;
164
+ if (kids.length > 0 && kids[0].kind === 'text') {
165
+ const m = kids[0].text.match(/^ +/);
166
+ if (m) {
167
+ leading = ' ';
168
+ kids[0].text = kids[0].text.slice(m[0].length);
169
+ }
170
+ }
171
+ if (kids.length > 0) {
172
+ const last = kids[kids.length - 1];
173
+ if (last.kind === 'text') {
174
+ const m = last.text.match(/ +$/);
175
+ if (m) {
176
+ trailing = ' ';
177
+ last.text = last.text.slice(0, -m[0].length);
178
+ }
179
+ }
180
+ }
181
+ node.children = mergeTexts(kids.filter((k) => !(k.kind === 'text' && k.text === '')));
182
+ if (leading)
183
+ out.push({ kind: 'text', text: leading });
184
+ if (node.children.length > 0)
185
+ out.push(node);
186
+ if (trailing)
187
+ out.push({ kind: 'text', text: trailing });
188
+ }
189
+ return out;
190
+ }
191
+ function mergeAdjacentFormatting(nodes) {
192
+ const out = [];
193
+ for (const n of nodes) {
194
+ const prev = out[out.length - 1];
195
+ if (n.kind === 'el' && INLINE_FORMATTING.has(n.name) && Object.keys(n.attrs).length === 0 &&
196
+ prev !== undefined && prev.kind === 'el' && prev.name === n.name && Object.keys(prev.attrs).length === 0) {
197
+ prev.children = mergeTexts([...prev.children, ...n.children]);
198
+ }
199
+ else {
200
+ out.push(n);
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+ function mergeTexts(nodes) {
206
+ const out = [];
207
+ for (const node of nodes) {
208
+ const prev = out[out.length - 1];
209
+ if (node.kind === 'text' && prev !== undefined && prev.kind === 'text') {
210
+ prev.text = collapseWs(prev.text + node.text);
211
+ }
212
+ else {
213
+ out.push(node);
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+ /** Сравнивает два storage-фрагмента с точностью до канонизации. */
219
+ export function compareStorage(a, b, maxDiffs = 20) {
220
+ const ca = canonicalize(parseStorage(a));
221
+ const cb = canonicalize(parseStorage(b));
222
+ const diffs = [];
223
+ diffNodes(ca, cb, 'root', diffs, maxDiffs);
224
+ return { equal: diffs.length === 0, diffs };
225
+ }
226
+ function excerpt(n) {
227
+ if (n === undefined)
228
+ return '(none)';
229
+ if (n.kind === 'text')
230
+ return `text ${JSON.stringify(n.text.slice(0, 80))}`;
231
+ const attrs = Object.entries(n.attrs).map(([k, v]) => ` ${k}="${v.slice(0, 40)}"`).join('');
232
+ return `<${n.name}${attrs.slice(0, 120)}>`;
233
+ }
234
+ function diffNodes(a, b, path, diffs, max) {
235
+ const len = Math.max(a.length, b.length);
236
+ for (let i = 0; i < len && diffs.length < max; i++) {
237
+ const na = a[i];
238
+ const nb = b[i];
239
+ const p = `${path}[${i}]`;
240
+ if (na === undefined || nb === undefined) {
241
+ diffs.push({ path: p, message: `node mismatch: ${excerpt(na)} vs ${excerpt(nb)}` });
242
+ continue;
243
+ }
244
+ if (na.kind !== nb.kind) {
245
+ diffs.push({ path: p, message: `kind mismatch: ${excerpt(na)} vs ${excerpt(nb)}` });
246
+ continue;
247
+ }
248
+ if (na.kind === 'text' && nb.kind === 'text') {
249
+ if (na.text !== nb.text) {
250
+ diffs.push({ path: p, message: `text differs: ${JSON.stringify(na.text.slice(0, 120))} vs ${JSON.stringify(nb.text.slice(0, 120))}` });
251
+ }
252
+ continue;
253
+ }
254
+ if (na.kind === 'el' && nb.kind === 'el') {
255
+ const childPath = `${p}<${na.name}>`;
256
+ if (na.name !== nb.name) {
257
+ diffs.push({ path: p, message: `element differs: <${na.name}> vs <${nb.name}>` });
258
+ continue;
259
+ }
260
+ const keys = new Set([...Object.keys(na.attrs), ...Object.keys(nb.attrs)]);
261
+ for (const k of keys) {
262
+ if (na.attrs[k] !== nb.attrs[k]) {
263
+ diffs.push({
264
+ path: childPath,
265
+ message: `attr ${k}: ${JSON.stringify(na.attrs[k] ?? null)} vs ${JSON.stringify(nb.attrs[k] ?? null)}`,
266
+ });
267
+ }
268
+ }
269
+ diffNodes(na.children, nb.children, childPath, diffs, max);
270
+ }
271
+ }
272
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Экспорт страницы Confluence в комплект «markdown + аттачи», пригодный
3
+ * для обратной публикации через publishPage (render-стиль 'attachment').
4
+ */
5
+ import type { ConfluenceConfig } from '../client/config.js';
6
+ import type { MacroRegistry } from '../macros/registry.js';
7
+ import { type StorageToMarkdownResult } from './to-markdown.js';
8
+ export interface ExportPageOptions {
9
+ /**
10
+ * Точный путь до итогового md-файла. Альтернатива `outDir`. Аттачи (если
11
+ * включены) кладутся в `attachments/` рядом с этим файлом.
12
+ */
13
+ outFile?: string;
14
+ /**
15
+ * Каталог для `page.md` и `attachments/`. Используется, если не задан
16
+ * `outFile`. Если не задан ни тот, ни другой — `./<pageId>`.
17
+ */
18
+ outDir?: string;
19
+ /** Скачивать ли аттачи, на которые ссылается страница. Default: true. */
20
+ downloadAttachments?: boolean;
21
+ registry?: MacroRegistry;
22
+ }
23
+ export interface ExportPageResult extends StorageToMarkdownResult {
24
+ pageId: string;
25
+ title: string;
26
+ version: number;
27
+ markdownPath: string;
28
+ /** Скачанные файлы: имя аттача → локальный путь. */
29
+ downloaded: Map<string, string>;
30
+ }
31
+ export declare function exportPage(pageId: string, opts: ExportPageOptions, cfg: ConfluenceConfig): Promise<ExportPageResult>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Экспорт страницы Confluence в комплект «markdown + аттачи», пригодный
3
+ * для обратной публикации через publishPage (render-стиль 'attachment').
4
+ */
5
+ import { mkdirSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { ConfluenceClient } from '../client/client.js';
8
+ import { storageToMarkdown } from './to-markdown.js';
9
+ export async function exportPage(pageId, opts, cfg) {
10
+ const client = new ConfluenceClient(cfg);
11
+ const page = await client.getPageStorage(pageId);
12
+ const converted = storageToMarkdown(page.storage, { registry: opts.registry });
13
+ const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
14
+ mkdirSync(dirname(markdownPath), { recursive: true });
15
+ writeFileSync(markdownPath, converted.markdown);
16
+ const downloaded = new Map();
17
+ if (opts.downloadAttachments !== false && converted.attachmentRefs.length > 0) {
18
+ const dir = join(dirname(markdownPath), 'attachments');
19
+ mkdirSync(dir, { recursive: true });
20
+ for (const name of converted.attachmentRefs) {
21
+ const [att] = await client.listAttachments(pageId, name);
22
+ if (att === undefined) {
23
+ console.warn(`[export] attachment '${name}' referenced by the page but not found`);
24
+ continue;
25
+ }
26
+ const link = att._links?.download ?? `/download/attachments/${pageId}/${encodeURIComponent(name)}`;
27
+ const data = await client.downloadAttachment(link);
28
+ const path = join(dir, name);
29
+ writeFileSync(path, data);
30
+ downloaded.set(name, path);
31
+ }
32
+ }
33
+ return {
34
+ ...converted,
35
+ pageId,
36
+ title: page.title,
37
+ version: page.version,
38
+ markdownPath,
39
+ downloaded,
40
+ };
41
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Round-trip-проверка: storage → markdown → storage′ и каноническое
3
+ * сравнение (см. canonical.ts — критерий «без потери разметки»).
4
+ */
5
+ import type { ConfluenceConfig } from '../client/config.js';
6
+ import { type MacroRegistry } from '../macros/registry.js';
7
+ import { type StorageDiff } from './canonical.js';
8
+ import { type StorageToMarkdownResult } from './to-markdown.js';
9
+ export interface RoundTripResult extends StorageToMarkdownResult {
10
+ /** storage, восстановленный из markdown публикационным конвейером. */
11
+ regenerated: string;
12
+ equal: boolean;
13
+ diffs: StorageDiff[];
14
+ }
15
+ /**
16
+ * Рендерит markdown из exportPage/storageToMarkdown обратно в storage тем
17
+ * же конвейером, что publishPage (attachment-стиль ссылок, без linkify).
18
+ */
19
+ export declare function renderExportedMarkdown(markdown: string, registry?: MacroRegistry): string;
20
+ /** Полный офлайн round-trip для готового storage-фрагмента. */
21
+ export declare function roundTripStorage(storage: string, opts?: {
22
+ registry?: MacroRegistry;
23
+ }): RoundTripResult;
24
+ /** Round-trip для живой страницы: тянет storage по id и проверяет офлайн. */
25
+ export declare function roundTripPage(pageId: string, cfg: ConfluenceConfig, opts?: {
26
+ registry?: MacroRegistry;
27
+ }): Promise<RoundTripResult & {
28
+ title: string;
29
+ version: number;
30
+ }>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Round-trip-проверка: storage → markdown → storage′ и каноническое
3
+ * сравнение (см. canonical.ts — критерий «без потери разметки»).
4
+ */
5
+ import { ConfluenceClient } from '../client/client.js';
6
+ import { processMacros } from '../macros/registry.js';
7
+ import { defaultMacroRegistry } from '../macros/index.js';
8
+ import { renderToStorage } from '../markdown/render.js';
9
+ import { compareStorage } from './canonical.js';
10
+ import { storageToMarkdown } from './to-markdown.js';
11
+ /**
12
+ * Рендерит markdown из exportPage/storageToMarkdown обратно в storage тем
13
+ * же конвейером, что publishPage (attachment-стиль ссылок, без linkify).
14
+ */
15
+ export function renderExportedMarkdown(markdown, registry) {
16
+ const storage = renderToStorage(markdown, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
17
+ return processMacros(storage, registry ?? defaultMacroRegistry).toString();
18
+ }
19
+ /** Полный офлайн round-trip для готового storage-фрагмента. */
20
+ export function roundTripStorage(storage, opts = {}) {
21
+ const converted = storageToMarkdown(storage, opts);
22
+ const regenerated = renderExportedMarkdown(converted.markdown, opts.registry);
23
+ const { equal, diffs } = compareStorage(storage, regenerated);
24
+ return { ...converted, regenerated, equal, diffs };
25
+ }
26
+ /** Round-trip для живой страницы: тянет storage по id и проверяет офлайн. */
27
+ export async function roundTripPage(pageId, cfg, opts = {}) {
28
+ const client = new ConfluenceClient(cfg);
29
+ const page = await client.getPageStorage(pageId);
30
+ const result = roundTripStorage(page.storage, opts);
31
+ return { ...result, title: page.title, version: page.version };
32
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Конвертация Confluence storage → Markdown с гарантией round-trip.
3
+ *
4
+ * Трёхуровневая политика на каждый узел:
5
+ * 1. чистый Markdown — заголовки, абзацы, списки, простые таблицы,
6
+ * ссылки, картинки-аттачи ({{img:...}}), page-ссылки ({{page:...}});
7
+ * 2. маркеры макросов <!-- MACRO:start/end --> — для макросов, чей
8
+ * рендер восстанавливает исходный XHTML (проверяется на месте:
9
+ * маркер прогоняется через render-конвейер и сравнивается канонически);
10
+ * 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML
11
+ * (markdown-it пропускает его насквозь), с ними — fenced-блоком
12
+ * ```confluence-storage (разворачивается обратно при рендере).
13
+ *
14
+ * Потери по построению исключены: всё, что не легло в (1)-(2), уезжает
15
+ * в (3) дословно.
16
+ */
17
+ import { type MacroRegistry } from '../macros/registry.js';
18
+ export interface StorageToMarkdownOptions {
19
+ /** Реестр для проверки маркеров макросов (default: встроенный). */
20
+ registry?: MacroRegistry;
21
+ }
22
+ export interface StorageToMarkdownResult {
23
+ markdown: string;
24
+ /** Имена аттачей, на которые ссылаются {{img:...}}. */
25
+ images: string[];
26
+ /** Имена аттачей, на которые ссылаются {{file:...}}. */
27
+ files: string[];
28
+ /** Все имена аттачей, упомянутые где-либо (включая fenced-блоки). */
29
+ attachmentRefs: string[];
30
+ stats: {
31
+ markers: number;
32
+ fenced: number;
33
+ rawHtml: number;
34
+ };
35
+ }
36
+ /** Конвертирует storage-фрагмент страницы в Markdown. */
37
+ export declare function storageToMarkdown(storage: string, opts?: StorageToMarkdownOptions): StorageToMarkdownResult;