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.
package/README.md CHANGED
@@ -10,7 +10,8 @@ typed tables back, attach files — idempotently, with a **pluggable macro
10
10
  system**. ESM-only, zero-config CI.
11
11
 
12
12
  ```bash
13
- npm install confluence-md-sync
13
+ npm install confluence-md-sync # as a library
14
+ npm install -g confluence-md-sync # as a CLI: `confluence-md-sync …`
14
15
  ```
15
16
 
16
17
  ## Highlights
@@ -23,7 +24,13 @@ npm install confluence-md-sync
23
24
  - **Fail before write** — placeholders, files and macro markers are validated
24
25
  up front; Confluence is never touched on a broken input.
25
26
  - **Pluggable macros** — built-in `core` + `table-filter` plugins, extend
26
- with your own in a few lines.
27
+ with your own in a few lines; unknown macros pass through unchanged.
28
+ - **Flexible sources** — images/files accept relative paths, absolute paths
29
+ or `http(s)` URLs; URLs on your Confluence host are fetched with the
30
+ configured token.
31
+ - **Loss-free round-trip** — export a page to Markdown and publish it back;
32
+ what Markdown can't express is kept verbatim, verified by canonical
33
+ storage comparison.
27
34
 
28
35
  ## Configuration
29
36
 
@@ -97,11 +104,23 @@ const { storage } = await publishPage({ pageId, markdownPath, dryRun: true }, cf
97
104
 
98
105
  // Inline content instead of a file
99
106
  await publishPage({ pageId, markdown: '# Generated\n\ntext' }, cfg);
107
+
108
+ // Remote sources — downloaded at publish time; the filename for {{img:...}}
109
+ // comes from the URL path. URLs on the configured Confluence host are
110
+ // fetched with your token, so you can pull attachments from other pages.
111
+ await publishPage({
112
+ pageId,
113
+ markdownPath,
114
+ images: ['https://confluence.example.com/download/attachments/999/flow.bpmn'],
115
+ files: ['https://files.example.com/exports/выгрузка.csv'],
116
+ downloadDir: 'build', // optional; default: temp dir per run
117
+ }, cfg);
100
118
  ```
101
119
 
102
120
  ## BPMN diagrams out of the box
103
121
 
104
- Pass a `.bpmn` file as an image it is rendered to PNG at publish time
122
+ Pass a `.bpmn` file as an image (a path or an `http(s)` URL) it is
123
+ rendered to PNG at publish time
105
124
  (headless Chromium via [bpmn-to-image](https://npmjs.com/package/bpmn-to-image),
106
125
  an optional peer dependency).
107
126
 
@@ -152,6 +171,59 @@ import { convertBpmnFolder } from 'confluence-md-sync';
152
171
  await convertBpmnFolder({ srcDir: 'docs/diagrams', outDir: 'build' });
153
172
  ```
154
173
 
174
+ ## Export a page back to Markdown (round-trip)
175
+
176
+ Pull an existing page by ID and turn its storage format into Markdown —
177
+ without losing markup. The conversion is **loss-free by construction**: a
178
+ three-tier policy renders each node as clean Markdown where possible,
179
+ macro markers where a macro round-trips, and verbatim storage (raw HTML,
180
+ or a ` ```confluence-storage ` fence) as a fallback. Attachments and page
181
+ links become `{{img:...}}` / `{{file:...}}` / `{{page:...}}` placeholders.
182
+
183
+ ```ts
184
+ import { exportPage } from 'confluence-md-sync';
185
+
186
+ const { markdownPath, images, downloaded } = await exportPage(
187
+ '123456789',
188
+ { outDir: 'exported' }, // writes exported/page.md + exported/attachments/
189
+ cfg,
190
+ );
191
+ ```
192
+
193
+ Round-trip means *edit the Markdown, then publish it straight back*:
194
+
195
+ ```ts
196
+ await publishPage({
197
+ pageId: '123456789',
198
+ markdownPath: 'exported/page.md',
199
+ images: ['exported/attachments/diagram.png'],
200
+ // exported markup uses native attachment/link references, not URLs:
201
+ render: { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false },
202
+ }, cfg);
203
+ ```
204
+
205
+ Verify a page survives the trip with no markup loss (writes nothing):
206
+
207
+ ```ts
208
+ import { roundTripPage } from 'confluence-md-sync';
209
+
210
+ const r = await roundTripPage('123456789', cfg);
211
+ console.log(r.equal, r.stats); // true { markers, fenced, rawHtml }
212
+ // r.diffs lists any canonical differences when equal === false
213
+ ```
214
+
215
+ Equivalence is **canonical**, not byte-for-byte: `ac:macro-id`,
216
+ schema-version, attribute/parameter order and entity vs literal forms are
217
+ normalised away (Confluence itself rewrites these on every save). Use
218
+ `compareStorage(a, b)` directly to diff two storage fragments.
219
+
220
+ From the CLI:
221
+
222
+ ```bash
223
+ confluence-md-sync export 123456789 --out-dir exported
224
+ confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
225
+ ```
226
+
155
227
  ## Read pages and tables
156
228
 
157
229
  ```ts
@@ -205,6 +277,7 @@ macros.tableExcerptInclude('name', 'Source Page');
205
277
  // Table Filter and Charts app
206
278
  macros.tableExcerpt(md, 'name', /* hide */ true);
207
279
  macros.tableFilter(md, { totalrow: ',,Sum' });
280
+ macros.tableJoiner(includesMd, "SELECT * FROM T1 LEFT JOIN T2 ON …"); // multiline SQL ok
208
281
  ```
209
282
 
210
283
  Or write markers by hand right in Markdown:
@@ -215,6 +288,10 @@ Any **markdown**, including nested macros.
215
288
  <!-- MACRO:end:expand -->
216
289
  ```
217
290
 
291
+ Any macro name works out of the box — one that isn't registered passes
292
+ through to `<ac:structured-macro>` as-is (params + rich-text body). Disable
293
+ with `registry.passthroughUnknownMacros(false)`.
294
+
218
295
  ### Custom macro plugin
219
296
 
220
297
  ```ts
@@ -235,11 +312,24 @@ splitting; `pageLinkValue()` builds `<ac:link><ri:page/>` parameter values.
235
312
 
236
313
  ## CLI
237
314
 
315
+ Installed globally (`npm i -g confluence-md-sync`) the `confluence-md-sync`
316
+ command is on your PATH; otherwise prefix the examples with `npx`. The
317
+ instance URL and token come from the environment.
318
+
238
319
  ```bash
239
- npx confluence-md-sync publish docs/page.md --page-id 123456789 \
320
+ export CONFLUENCE_BASE_URL='https://confluence.example.com'
321
+ export CONFLUENCE_TOKEN='<your-PAT>'
322
+
323
+ # Download a page to an exact .md path (add --no-attachments for the md only)
324
+ confluence-md-sync export 123456789 --out ./page.md
325
+
326
+ confluence-md-sync publish docs/page.md --page-id 123456789 \
240
327
  --image build/flow.png --file build/data.csv --label docs
241
328
 
242
- npx confluence-md-sync publish docs/page.md --space DOCS --title "Моя страница" --dry-run
329
+ confluence-md-sync publish docs/page.md --space DOCS --title "Моя страница" --dry-run
330
+
331
+ confluence-md-sync export 123456789 --out-dir exported # page.md + attachments/
332
+ confluence-md-sync roundtrip 123456789 # exit 2 if markup would be lost
243
333
  ```
244
334
 
245
335
  ## CI publish plans
@@ -274,7 +364,8 @@ src/
274
364
  ├── macros/ registry, builder, plugins (core, table-filter)
275
365
  ├── attachments/ sha256 dedup, sidecar source hashes, version history
276
366
  ├── pages/ Page/Table object model, table parse & render
277
- ├── publish/ idempotent publish pipeline, runPublish plans
367
+ ├── publish/ idempotent publish pipeline, runPublish plans, remote sources
368
+ ├── export/ storage parser, storage→Markdown, canonical compare, round-trip
278
369
  ├── wrapper.ts confluence() facade
279
370
  ├── csv.ts CSV helpers (subpath export ./csv)
280
371
  └── cli.ts confluence-md-sync CLI
@@ -282,9 +373,9 @@ src/
282
373
 
283
374
  ## Roadmap
284
375
 
285
- Cloud API v2 backend · page-tree sync from a directory · storage→Markdown
286
- reverse conversion · Mermaid/PlantUML fencesimages · fenced code
287
- `code` macro · HTTP retry/backoff · comments API · dry-run diff preview.
376
+ Cloud API v2 backend · page-tree sync from a directory · Mermaid/PlantUML
377
+ fences images · fenced code`code` macro · HTTP retry/backoff ·
378
+ comments API · dry-run diff preview.
288
379
 
289
380
  Issues and PRs welcome.
290
381
 
package/dist/cli.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Минимальный CLI:
3
+ * CLI: три подкоманды.
4
4
  *
5
5
  * confluence-md-sync publish page.md --page-id 12345 \
6
6
  * --image build/p1.png --file data.csv --label docs --dry-run
7
- *
8
7
  * confluence-md-sync publish page.md --space DOCS --title "My Page" --parent-id 777
9
8
  *
9
+ * confluence-md-sync export 12345 --out-dir ./exported
10
+ * confluence-md-sync roundtrip 12345 # проверка без потерь, ничего не пишет
11
+ *
10
12
  * Конфиг — из env: CONFLUENCE_BASE_URL, CONFLUENCE_TOKEN (или CONFLUENCE_PAT),
11
13
  * CONFLUENCE_USERNAME, CONFLUENCE_AUTH_TYPE (bearer|basic).
12
14
  */
package/dist/cli.js CHANGED
@@ -1,35 +1,52 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Минимальный CLI:
3
+ * CLI: три подкоманды.
4
4
  *
5
5
  * confluence-md-sync publish page.md --page-id 12345 \
6
6
  * --image build/p1.png --file data.csv --label docs --dry-run
7
- *
8
7
  * confluence-md-sync publish page.md --space DOCS --title "My Page" --parent-id 777
9
8
  *
9
+ * confluence-md-sync export 12345 --out-dir ./exported
10
+ * confluence-md-sync roundtrip 12345 # проверка без потерь, ничего не пишет
11
+ *
10
12
  * Конфиг — из env: CONFLUENCE_BASE_URL, CONFLUENCE_TOKEN (или CONFLUENCE_PAT),
11
13
  * CONFLUENCE_USERNAME, CONFLUENCE_AUTH_TYPE (bearer|basic).
12
14
  */
13
15
  import { parseArgs } from 'node:util';
14
16
  import { loadConfigFromEnv } from './client/config.js';
15
17
  import { publishPage } from './publish/publish.js';
16
- const HELP = `confluence-md-sync publish Markdown to Confluence
18
+ import { exportPage } from './export/export-page.js';
19
+ import { roundTripPage } from './export/roundtrip.js';
20
+ const HELP = `confluence-md-sync — Markdown ⇄ Confluence
17
21
 
18
22
  Usage:
19
- confluence-md-sync publish <markdown-file> [options]
23
+ confluence-md-sync publish <markdown-file> [options]
24
+ confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--no-attachments]
25
+ confluence-md-sync roundtrip <page-id> [--show-markdown]
20
26
 
21
- Options:
27
+ publish options:
22
28
  --page-id <id> Target page ID
23
29
  --space <key> Space key (with --title, when page ID is unknown)
24
30
  --title <title> Page title (rename, or lookup/create key with --space)
25
31
  --parent-id <id> Parent page for page creation
26
- --image <path> Attach an image (repeatable), referenced as {{img:name.png}}
27
- --file <path> Attach a file (repeatable), referenced as {{file:name.csv}}
32
+ --image <src> Attach an image (repeatable), referenced as {{img:name.png}}
33
+ --file <src> Attach a file (repeatable), referenced as {{file:name.csv}}
34
+ <src> is a relative/absolute path or an http(s) URL;
35
+ URLs on the Confluence host are fetched with your token
28
36
  --label <name> Ensure label on the page (repeatable)
29
37
  --message <text> Page version comment
30
38
  --no-create Fail instead of creating a missing page
31
39
  --dry-run Render and validate without writing to Confluence
32
- --help Show this help
40
+
41
+ export options:
42
+ --out <file> Write the Markdown to exactly this path (attachments,
43
+ if any, go to attachments/ next to it)
44
+ --out-dir <dir> Output directory (default: ./<page-id>); writes page.md
45
+ and attachments/
46
+ --no-attachments Do not download referenced attachments
47
+
48
+ roundtrip options:
49
+ --show-markdown Print the intermediate Markdown to stdout
33
50
 
34
51
  Environment:
35
52
  CONFLUENCE_BASE_URL e.g. https://confluence.example.com
@@ -51,33 +68,64 @@ async function main() {
51
68
  message: { type: 'string' },
52
69
  'no-create': { type: 'boolean' },
53
70
  'dry-run': { type: 'boolean' },
71
+ out: { type: 'string' },
72
+ 'out-dir': { type: 'string' },
73
+ 'no-attachments': { type: 'boolean' },
74
+ 'show-markdown': { type: 'boolean' },
54
75
  help: { type: 'boolean' },
55
76
  },
56
77
  });
57
- if (values.help || positionals.length === 0) {
78
+ const [command, arg] = positionals;
79
+ if (values.help || command === undefined) {
58
80
  console.log(HELP);
59
81
  process.exit(values.help ? 0 : 1);
60
82
  }
61
- const [command, markdownPath] = positionals;
62
- if (command !== 'publish' || !markdownPath) {
63
- console.error(`Unknown command or missing file. See --help.`);
64
- process.exit(1);
65
- }
66
83
  const cfg = loadConfigFromEnv();
67
- const result = await publishPage({
68
- pageId: values['page-id'],
69
- spaceKey: values.space,
70
- title: values.title,
71
- parentPageId: values['parent-id'],
72
- markdownPath,
73
- images: values.image ?? [],
74
- files: values.file ?? [],
75
- labels: values.label,
76
- versionMessage: values.message,
77
- createIfMissing: values['no-create'] ? false : undefined,
78
- dryRun: values['dry-run'],
79
- }, cfg);
80
- console.log(`[cli] ${result.updated ? 'published' : 'unchanged'}: page ${result.pageId} v${result.version}`);
84
+ if (command === 'publish') {
85
+ if (!arg)
86
+ throw new Error('publish: markdown file is required');
87
+ const result = await publishPage({
88
+ pageId: values['page-id'],
89
+ spaceKey: values.space,
90
+ title: values.title,
91
+ parentPageId: values['parent-id'],
92
+ markdownPath: arg,
93
+ images: values.image ?? [],
94
+ files: values.file ?? [],
95
+ labels: values.label,
96
+ versionMessage: values.message,
97
+ createIfMissing: values['no-create'] ? false : undefined,
98
+ dryRun: values['dry-run'],
99
+ }, cfg);
100
+ console.log(`[cli] ${result.updated ? 'published' : 'unchanged'}: page ${result.pageId} v${result.version}`);
101
+ return;
102
+ }
103
+ if (command === 'export') {
104
+ if (!arg)
105
+ throw new Error('export: page id is required');
106
+ const result = await exportPage(arg, {
107
+ outFile: values.out,
108
+ outDir: values['out-dir'],
109
+ downloadAttachments: !values['no-attachments'],
110
+ }, cfg);
111
+ console.log(`[cli] exported page ${result.pageId} "${result.title}" v${result.version} → ${result.markdownPath}` +
112
+ ` (${result.downloaded.size} attachment(s), ${result.stats.fenced} raw storage block(s))`);
113
+ return;
114
+ }
115
+ if (command === 'roundtrip') {
116
+ if (!arg)
117
+ throw new Error('roundtrip: page id is required');
118
+ const result = await roundTripPage(arg, cfg);
119
+ if (values['show-markdown'])
120
+ console.log(result.markdown);
121
+ console.log(`[cli] roundtrip page ${arg} "${result.title}": ${result.equal ? 'OK — no markup loss' : 'DIFFERENCES FOUND'}` +
122
+ ` (markers=${result.stats.markers}, fenced=${result.stats.fenced}, rawHtml=${result.stats.rawHtml})`);
123
+ for (const d of result.diffs)
124
+ console.error(` ${d.path}: ${d.message}`);
125
+ process.exit(result.equal ? 0 : 2);
126
+ }
127
+ console.error(`Unknown command '${command}'. See --help.`);
128
+ process.exit(1);
81
129
  }
82
130
  main().catch((err) => {
83
131
  console.error(`[cli] failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,38 @@
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 { type XNode } from './xhtml.js';
18
+ /** Канонический узел: element | text (после нормализации). */
19
+ export type CNode = {
20
+ kind: 'el';
21
+ name: string;
22
+ attrs: Record<string, string>;
23
+ children: CNode[];
24
+ } | {
25
+ kind: 'text';
26
+ text: string;
27
+ };
28
+ export declare function canonicalize(nodes: XNode[]): CNode[];
29
+ export interface StorageDiff {
30
+ path: string;
31
+ message: string;
32
+ }
33
+ export interface CompareResult {
34
+ equal: boolean;
35
+ diffs: StorageDiff[];
36
+ }
37
+ /** Сравнивает два storage-фрагмента с точностью до канонизации. */
38
+ export declare function compareStorage(a: string, b: string, maxDiffs?: number): CompareResult;
@@ -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
+ }