confluence-md-sync 0.2.1 → 0.4.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 +107 -9
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +84 -28
- package/dist/export/canonical.d.ts +38 -0
- package/dist/export/canonical.js +272 -0
- package/dist/export/export-page.d.ts +36 -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 +49 -0
- package/dist/export/to-markdown.js +1132 -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 +43 -4
- package/dist/publish/remote.d.ts +25 -0
- package/dist/publish/remote.js +52 -0
- package/package.json +1 -1
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
|
+
- **Export & round-trip** — pull a page into Markdown and publish it back
|
|
32
|
+
loss-free (verified by canonical storage comparison), or export a
|
|
33
|
+
`readable`, pure-Markdown version for humans.
|
|
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
|
|
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,65 @@ 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
|
|
175
|
+
|
|
176
|
+
Pull an existing page by ID and turn its storage format into Markdown.
|
|
177
|
+
Attachments and page links become `{{img:...}}` / `{{file:...}}` /
|
|
178
|
+
`{{page:...}}` placeholders. Two modes trade fidelity against readability:
|
|
179
|
+
|
|
180
|
+
| Mode | For | What it does |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| `faithful` *(default)* | round-trip, editing then re-publishing | **Loss-free by construction.** Clean Markdown → macro markers → verbatim storage (raw HTML, or a ` ```confluence-storage ` fence) as a fallback. Perfect fidelity, but the raw-HTML blocks (complex tables, wrappers) render poorly in some Markdown viewers. |
|
|
183
|
+
| `readable` | reading, diffing, docs you won't publish back | **Clean Markdown, no raw HTML.** Complex tables are flattened to GFM (merged cells → filled grid, block cells → `• …` joined by `<br>`), styled spans/wrappers are unwrapped, entities decoded. Keeps the content; **drops** colours, exact merge geometry, wrappers — *not* round-trippable. |
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { exportPage } from 'confluence-md-sync';
|
|
187
|
+
|
|
188
|
+
const { markdownPath, images, downloaded } = await exportPage(
|
|
189
|
+
'123456789',
|
|
190
|
+
{ outDir: 'exported' }, // faithful; writes page.md + attachments/
|
|
191
|
+
cfg,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Readable variant for humans:
|
|
195
|
+
await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Round-trip means *edit the Markdown, then publish it straight back*:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
await publishPage({
|
|
202
|
+
pageId: '123456789',
|
|
203
|
+
markdownPath: 'exported/page.md',
|
|
204
|
+
images: ['exported/attachments/diagram.png'],
|
|
205
|
+
// exported markup uses native attachment/link references, not URLs:
|
|
206
|
+
render: { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false },
|
|
207
|
+
}, cfg);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Verify a page survives the trip with no markup loss (writes nothing):
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { roundTripPage } from 'confluence-md-sync';
|
|
214
|
+
|
|
215
|
+
const r = await roundTripPage('123456789', cfg);
|
|
216
|
+
console.log(r.equal, r.stats); // true { markers, fenced, rawHtml }
|
|
217
|
+
// r.diffs lists any canonical differences when equal === false
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Equivalence is **canonical**, not byte-for-byte: `ac:macro-id`,
|
|
221
|
+
schema-version, attribute/parameter order and entity vs literal forms are
|
|
222
|
+
normalised away (Confluence itself rewrites these on every save). Use
|
|
223
|
+
`compareStorage(a, b)` directly to diff two storage fragments.
|
|
224
|
+
|
|
225
|
+
From the CLI:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
confluence-md-sync export 123456789 --out page.md # faithful
|
|
229
|
+
confluence-md-sync export 123456789 --out page.md --readable # clean Markdown
|
|
230
|
+
confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
|
|
231
|
+
```
|
|
232
|
+
|
|
155
233
|
## Read pages and tables
|
|
156
234
|
|
|
157
235
|
```ts
|
|
@@ -205,6 +283,7 @@ macros.tableExcerptInclude('name', 'Source Page');
|
|
|
205
283
|
// Table Filter and Charts app
|
|
206
284
|
macros.tableExcerpt(md, 'name', /* hide */ true);
|
|
207
285
|
macros.tableFilter(md, { totalrow: ',,Sum' });
|
|
286
|
+
macros.tableJoiner(includesMd, "SELECT * FROM T1 LEFT JOIN T2 ON …"); // multiline SQL ok
|
|
208
287
|
```
|
|
209
288
|
|
|
210
289
|
Or write markers by hand right in Markdown:
|
|
@@ -215,6 +294,10 @@ Any **markdown**, including nested macros.
|
|
|
215
294
|
<!-- MACRO:end:expand -->
|
|
216
295
|
```
|
|
217
296
|
|
|
297
|
+
Any macro name works out of the box — one that isn't registered passes
|
|
298
|
+
through to `<ac:structured-macro>` as-is (params + rich-text body). Disable
|
|
299
|
+
with `registry.passthroughUnknownMacros(false)`.
|
|
300
|
+
|
|
218
301
|
### Custom macro plugin
|
|
219
302
|
|
|
220
303
|
```ts
|
|
@@ -235,11 +318,25 @@ splitting; `pageLinkValue()` builds `<ac:link><ri:page/>` parameter values.
|
|
|
235
318
|
|
|
236
319
|
## CLI
|
|
237
320
|
|
|
321
|
+
Installed globally (`npm i -g confluence-md-sync`) the `confluence-md-sync`
|
|
322
|
+
command is on your PATH; otherwise prefix the examples with `npx`. The
|
|
323
|
+
instance URL and token come from the environment.
|
|
324
|
+
|
|
238
325
|
```bash
|
|
239
|
-
|
|
326
|
+
export CONFLUENCE_BASE_URL='https://confluence.example.com'
|
|
327
|
+
export CONFLUENCE_TOKEN='<your-PAT>'
|
|
328
|
+
|
|
329
|
+
# Download a page to an exact .md path (add --no-attachments for the md only)
|
|
330
|
+
confluence-md-sync export 123456789 --out ./page.md # faithful
|
|
331
|
+
confluence-md-sync export 123456789 --out ./page.md --readable # clean Markdown
|
|
332
|
+
|
|
333
|
+
confluence-md-sync publish docs/page.md --page-id 123456789 \
|
|
240
334
|
--image build/flow.png --file build/data.csv --label docs
|
|
241
335
|
|
|
242
|
-
|
|
336
|
+
confluence-md-sync publish docs/page.md --space DOCS --title "Моя страница" --dry-run
|
|
337
|
+
|
|
338
|
+
confluence-md-sync export 123456789 --out-dir exported # page.md + attachments/
|
|
339
|
+
confluence-md-sync roundtrip 123456789 # exit 2 if markup would be lost
|
|
243
340
|
```
|
|
244
341
|
|
|
245
342
|
## CI publish plans
|
|
@@ -274,7 +371,8 @@ src/
|
|
|
274
371
|
├── macros/ registry, builder, plugins (core, table-filter)
|
|
275
372
|
├── attachments/ sha256 dedup, sidecar source hashes, version history
|
|
276
373
|
├── pages/ Page/Table object model, table parse & render
|
|
277
|
-
├── publish/ idempotent publish pipeline, runPublish plans
|
|
374
|
+
├── publish/ idempotent publish pipeline, runPublish plans, remote sources
|
|
375
|
+
├── export/ storage parser, storage→Markdown, canonical compare, round-trip
|
|
278
376
|
├── wrapper.ts confluence() facade
|
|
279
377
|
├── csv.ts CSV helpers (subpath export ./csv)
|
|
280
378
|
└── cli.ts confluence-md-sync CLI
|
|
@@ -282,9 +380,9 @@ src/
|
|
|
282
380
|
|
|
283
381
|
## Roadmap
|
|
284
382
|
|
|
285
|
-
Cloud API v2 backend · page-tree sync from a directory ·
|
|
286
|
-
|
|
287
|
-
|
|
383
|
+
Cloud API v2 backend · page-tree sync from a directory · Mermaid/PlantUML
|
|
384
|
+
fences → images · fenced code → `code` macro · HTTP retry/backoff ·
|
|
385
|
+
comments API · dry-run diff preview.
|
|
288
386
|
|
|
289
387
|
Issues and PRs welcome.
|
|
290
388
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
|
23
|
+
confluence-md-sync publish <markdown-file> [options]
|
|
24
|
+
confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--readable] [--no-attachments]
|
|
25
|
+
confluence-md-sync roundtrip <page-id> [--show-markdown]
|
|
20
26
|
|
|
21
|
-
|
|
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 <
|
|
27
|
-
--file <
|
|
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
|
-
|
|
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
|
+
--readable Prefer clean Markdown over fidelity: no raw HTML blocks,
|
|
47
|
+
complex tables flattened to GFM. Not round-trippable —
|
|
48
|
+
loses styling/exact cell merges, keeps the content
|
|
49
|
+
--no-attachments Do not download referenced attachments
|
|
50
|
+
|
|
51
|
+
roundtrip options:
|
|
52
|
+
--show-markdown Print the intermediate Markdown to stdout
|
|
33
53
|
|
|
34
54
|
Environment:
|
|
35
55
|
CONFLUENCE_BASE_URL e.g. https://confluence.example.com
|
|
@@ -51,33 +71,69 @@ async function main() {
|
|
|
51
71
|
message: { type: 'string' },
|
|
52
72
|
'no-create': { type: 'boolean' },
|
|
53
73
|
'dry-run': { type: 'boolean' },
|
|
74
|
+
out: { type: 'string' },
|
|
75
|
+
'out-dir': { type: 'string' },
|
|
76
|
+
readable: { type: 'boolean' },
|
|
77
|
+
'no-attachments': { type: 'boolean' },
|
|
78
|
+
'show-markdown': { type: 'boolean' },
|
|
54
79
|
help: { type: 'boolean' },
|
|
55
80
|
},
|
|
56
81
|
});
|
|
57
|
-
|
|
82
|
+
const [command, arg] = positionals;
|
|
83
|
+
if (values.help || command === undefined) {
|
|
58
84
|
console.log(HELP);
|
|
59
85
|
process.exit(values.help ? 0 : 1);
|
|
60
86
|
}
|
|
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
87
|
const cfg = loadConfigFromEnv();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
if (command === 'publish') {
|
|
89
|
+
if (!arg)
|
|
90
|
+
throw new Error('publish: markdown file is required');
|
|
91
|
+
const result = await publishPage({
|
|
92
|
+
pageId: values['page-id'],
|
|
93
|
+
spaceKey: values.space,
|
|
94
|
+
title: values.title,
|
|
95
|
+
parentPageId: values['parent-id'],
|
|
96
|
+
markdownPath: arg,
|
|
97
|
+
images: values.image ?? [],
|
|
98
|
+
files: values.file ?? [],
|
|
99
|
+
labels: values.label,
|
|
100
|
+
versionMessage: values.message,
|
|
101
|
+
createIfMissing: values['no-create'] ? false : undefined,
|
|
102
|
+
dryRun: values['dry-run'],
|
|
103
|
+
}, cfg);
|
|
104
|
+
console.log(`[cli] ${result.updated ? 'published' : 'unchanged'}: page ${result.pageId} v${result.version}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command === 'export') {
|
|
108
|
+
if (!arg)
|
|
109
|
+
throw new Error('export: page id is required');
|
|
110
|
+
const result = await exportPage(arg, {
|
|
111
|
+
outFile: values.out,
|
|
112
|
+
outDir: values['out-dir'],
|
|
113
|
+
downloadAttachments: !values['no-attachments'],
|
|
114
|
+
mode: values.readable ? 'readable' : 'faithful',
|
|
115
|
+
}, cfg);
|
|
116
|
+
const tail = values.readable
|
|
117
|
+
? `${result.stats.lossy} block(s) simplified`
|
|
118
|
+
: `${result.stats.fenced} raw storage block(s)`;
|
|
119
|
+
console.log(`[cli] exported page ${result.pageId} "${result.title}" v${result.version} → ${result.markdownPath}` +
|
|
120
|
+
` (${result.downloaded.size} attachment(s), ${tail})`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (command === 'roundtrip') {
|
|
124
|
+
if (!arg)
|
|
125
|
+
throw new Error('roundtrip: page id is required');
|
|
126
|
+
const result = await roundTripPage(arg, cfg);
|
|
127
|
+
if (values['show-markdown'])
|
|
128
|
+
console.log(result.markdown);
|
|
129
|
+
console.log(`[cli] roundtrip page ${arg} "${result.title}": ${result.equal ? 'OK — no markup loss' : 'DIFFERENCES FOUND'}` +
|
|
130
|
+
` (markers=${result.stats.markers}, fenced=${result.stats.fenced}, rawHtml=${result.stats.rawHtml})`);
|
|
131
|
+
for (const d of result.diffs)
|
|
132
|
+
console.error(` ${d.path}: ${d.message}`);
|
|
133
|
+
process.exit(result.equal ? 0 : 2);
|
|
134
|
+
}
|
|
135
|
+
console.error(`Unknown command '${command}'. See --help.`);
|
|
136
|
+
process.exit(1);
|
|
81
137
|
}
|
|
82
138
|
main().catch((err) => {
|
|
83
139
|
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
|
+
}
|