confluence-md-sync 0.3.0 → 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 +21 -14
- package/dist/cli.js +10 -2
- package/dist/export/export-page.d.ts +5 -0
- package/dist/export/export-page.js +1 -1
- package/dist/export/to-markdown.d.ts +19 -7
- package/dist/export/to-markdown.js +325 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,9 +28,9 @@ npm install -g confluence-md-sync # as a CLI: `confluence-md-sync …`
|
|
|
28
28
|
- **Flexible sources** — images/files accept relative paths, absolute paths
|
|
29
29
|
or `http(s)` URLs; URLs on your Confluence host are fetched with the
|
|
30
30
|
configured token.
|
|
31
|
-
- **
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
34
34
|
|
|
35
35
|
## Configuration
|
|
36
36
|
|
|
@@ -171,23 +171,28 @@ import { convertBpmnFolder } from 'confluence-md-sync';
|
|
|
171
171
|
await convertBpmnFolder({ srcDir: 'docs/diagrams', outDir: 'build' });
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
-
## Export a page back to Markdown
|
|
174
|
+
## Export a page back to Markdown
|
|
175
175
|
|
|
176
|
-
Pull an existing page by ID and turn its storage format into Markdown
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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. |
|
|
182
184
|
|
|
183
185
|
```ts
|
|
184
186
|
import { exportPage } from 'confluence-md-sync';
|
|
185
187
|
|
|
186
188
|
const { markdownPath, images, downloaded } = await exportPage(
|
|
187
189
|
'123456789',
|
|
188
|
-
{ outDir: 'exported' },
|
|
190
|
+
{ outDir: 'exported' }, // faithful; writes page.md + attachments/
|
|
189
191
|
cfg,
|
|
190
192
|
);
|
|
193
|
+
|
|
194
|
+
// Readable variant for humans:
|
|
195
|
+
await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
|
|
191
196
|
```
|
|
192
197
|
|
|
193
198
|
Round-trip means *edit the Markdown, then publish it straight back*:
|
|
@@ -220,8 +225,9 @@ normalised away (Confluence itself rewrites these on every save). Use
|
|
|
220
225
|
From the CLI:
|
|
221
226
|
|
|
222
227
|
```bash
|
|
223
|
-
confluence-md-sync export 123456789 --out
|
|
224
|
-
confluence-md-sync
|
|
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
|
|
225
231
|
```
|
|
226
232
|
|
|
227
233
|
## Read pages and tables
|
|
@@ -321,7 +327,8 @@ export CONFLUENCE_BASE_URL='https://confluence.example.com'
|
|
|
321
327
|
export CONFLUENCE_TOKEN='<your-PAT>'
|
|
322
328
|
|
|
323
329
|
# Download a page to an exact .md path (add --no-attachments for the md only)
|
|
324
|
-
confluence-md-sync export 123456789 --out ./page.md
|
|
330
|
+
confluence-md-sync export 123456789 --out ./page.md # faithful
|
|
331
|
+
confluence-md-sync export 123456789 --out ./page.md --readable # clean Markdown
|
|
325
332
|
|
|
326
333
|
confluence-md-sync publish docs/page.md --page-id 123456789 \
|
|
327
334
|
--image build/flow.png --file build/data.csv --label docs
|
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ const HELP = `confluence-md-sync — Markdown ⇄ Confluence
|
|
|
21
21
|
|
|
22
22
|
Usage:
|
|
23
23
|
confluence-md-sync publish <markdown-file> [options]
|
|
24
|
-
confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--no-attachments]
|
|
24
|
+
confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--readable] [--no-attachments]
|
|
25
25
|
confluence-md-sync roundtrip <page-id> [--show-markdown]
|
|
26
26
|
|
|
27
27
|
publish options:
|
|
@@ -43,6 +43,9 @@ export options:
|
|
|
43
43
|
if any, go to attachments/ next to it)
|
|
44
44
|
--out-dir <dir> Output directory (default: ./<page-id>); writes page.md
|
|
45
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
|
|
46
49
|
--no-attachments Do not download referenced attachments
|
|
47
50
|
|
|
48
51
|
roundtrip options:
|
|
@@ -70,6 +73,7 @@ async function main() {
|
|
|
70
73
|
'dry-run': { type: 'boolean' },
|
|
71
74
|
out: { type: 'string' },
|
|
72
75
|
'out-dir': { type: 'string' },
|
|
76
|
+
readable: { type: 'boolean' },
|
|
73
77
|
'no-attachments': { type: 'boolean' },
|
|
74
78
|
'show-markdown': { type: 'boolean' },
|
|
75
79
|
help: { type: 'boolean' },
|
|
@@ -107,9 +111,13 @@ async function main() {
|
|
|
107
111
|
outFile: values.out,
|
|
108
112
|
outDir: values['out-dir'],
|
|
109
113
|
downloadAttachments: !values['no-attachments'],
|
|
114
|
+
mode: values.readable ? 'readable' : 'faithful',
|
|
110
115
|
}, cfg);
|
|
116
|
+
const tail = values.readable
|
|
117
|
+
? `${result.stats.lossy} block(s) simplified`
|
|
118
|
+
: `${result.stats.fenced} raw storage block(s)`;
|
|
111
119
|
console.log(`[cli] exported page ${result.pageId} "${result.title}" v${result.version} → ${result.markdownPath}` +
|
|
112
|
-
` (${result.downloaded.size} attachment(s), ${
|
|
120
|
+
` (${result.downloaded.size} attachment(s), ${tail})`);
|
|
113
121
|
return;
|
|
114
122
|
}
|
|
115
123
|
if (command === 'roundtrip') {
|
|
@@ -18,6 +18,11 @@ export interface ExportPageOptions {
|
|
|
18
18
|
outDir?: string;
|
|
19
19
|
/** Скачивать ли аттачи, на которые ссылается страница. Default: true. */
|
|
20
20
|
downloadAttachments?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* 'faithful' (default) — round-trippable, с сырым HTML в fallback;
|
|
23
|
+
* 'readable' — чистый Markdown ценой оформления (round-trip не гарантирован).
|
|
24
|
+
*/
|
|
25
|
+
mode?: 'faithful' | 'readable';
|
|
21
26
|
registry?: MacroRegistry;
|
|
22
27
|
}
|
|
23
28
|
export interface ExportPageResult extends StorageToMarkdownResult {
|
|
@@ -9,7 +9,7 @@ import { storageToMarkdown } from './to-markdown.js';
|
|
|
9
9
|
export async function exportPage(pageId, opts, cfg) {
|
|
10
10
|
const client = new ConfluenceClient(cfg);
|
|
11
11
|
const page = await client.getPageStorage(pageId);
|
|
12
|
-
const converted = storageToMarkdown(page.storage, { registry: opts.registry });
|
|
12
|
+
const converted = storageToMarkdown(page.storage, { registry: opts.registry, mode: opts.mode });
|
|
13
13
|
const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
|
|
14
14
|
mkdirSync(dirname(markdownPath), { recursive: true });
|
|
15
15
|
writeFileSync(markdownPath, converted.markdown);
|
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Конвертация Confluence storage → Markdown
|
|
2
|
+
* Конвертация Confluence storage → Markdown. Два режима:
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* FAITHFUL (default) — гарантия round-trip. Трёхуровневая политика:
|
|
5
5
|
* 1. чистый Markdown — заголовки, абзацы, списки, простые таблицы,
|
|
6
6
|
* ссылки, картинки-аттачи ({{img:...}}), page-ссылки ({{page:...}});
|
|
7
7
|
* 2. маркеры макросов <!-- MACRO:start/end --> — для макросов, чей
|
|
8
8
|
* рендер восстанавливает исходный XHTML (проверяется на месте:
|
|
9
9
|
* маркер прогоняется через render-конвейер и сравнивается канонически);
|
|
10
|
-
* 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML,
|
|
11
|
+
* с ними — fenced-блоком ```confluence-storage.
|
|
12
|
+
* Потери исключены по построению; но (3) даёт сырой HTML, который многие
|
|
13
|
+
* md-редакторы показывают уродливо.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* READABLE — чистый Markdown ценой оформления. Round-trip НЕ гарантируется:
|
|
16
|
+
* теряются цвета/стили спанов, div-обёртки, точная геометрия объединённых
|
|
17
|
+
* ячеек; сохраняется смысловая нагрузка (текст, структура). Сложные
|
|
18
|
+
* таблицы разворачиваются в GFM (colspan/rowspan → сетка с заполнением,
|
|
19
|
+
* блочное содержимое ячеек — во flatten через <br>). Сырой HTML-блок не
|
|
20
|
+
* выдаётся никогда.
|
|
16
21
|
*/
|
|
17
22
|
import { type MacroRegistry } from '../macros/registry.js';
|
|
18
23
|
export interface StorageToMarkdownOptions {
|
|
19
24
|
/** Реестр для проверки маркеров макросов (default: встроенный). */
|
|
20
25
|
registry?: MacroRegistry;
|
|
26
|
+
/**
|
|
27
|
+
* 'faithful' (default) — round-trippable, но с сырым HTML в fallback;
|
|
28
|
+
* 'readable' — чистый Markdown ценой оформления, без round-trip.
|
|
29
|
+
*/
|
|
30
|
+
mode?: 'faithful' | 'readable';
|
|
21
31
|
}
|
|
22
32
|
export interface StorageToMarkdownResult {
|
|
23
33
|
markdown: string;
|
|
@@ -31,6 +41,8 @@ export interface StorageToMarkdownResult {
|
|
|
31
41
|
markers: number;
|
|
32
42
|
fenced: number;
|
|
33
43
|
rawHtml: number;
|
|
44
|
+
/** readable-режим: сколько узлов конвертировано с потерей оформления. */
|
|
45
|
+
lossy: number;
|
|
34
46
|
};
|
|
35
47
|
}
|
|
36
48
|
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Конвертация Confluence storage → Markdown
|
|
2
|
+
* Конвертация Confluence storage → Markdown. Два режима:
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* FAITHFUL (default) — гарантия round-trip. Трёхуровневая политика:
|
|
5
5
|
* 1. чистый Markdown — заголовки, абзацы, списки, простые таблицы,
|
|
6
6
|
* ссылки, картинки-аттачи ({{img:...}}), page-ссылки ({{page:...}});
|
|
7
7
|
* 2. маркеры макросов <!-- MACRO:start/end --> — для макросов, чей
|
|
8
8
|
* рендер восстанавливает исходный XHTML (проверяется на месте:
|
|
9
9
|
* маркер прогоняется через render-конвейер и сравнивается канонически);
|
|
10
|
-
* 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML,
|
|
11
|
+
* с ними — fenced-блоком ```confluence-storage.
|
|
12
|
+
* Потери исключены по построению; но (3) даёт сырой HTML, который многие
|
|
13
|
+
* md-редакторы показывают уродливо.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* READABLE — чистый Markdown ценой оформления. Round-trip НЕ гарантируется:
|
|
16
|
+
* теряются цвета/стили спанов, div-обёртки, точная геометрия объединённых
|
|
17
|
+
* ячеек; сохраняется смысловая нагрузка (текст, структура). Сложные
|
|
18
|
+
* таблицы разворачиваются в GFM (colspan/rowspan → сетка с заполнением,
|
|
19
|
+
* блочное содержимое ячеек — во flatten через <br>). Сырой HTML-блок не
|
|
20
|
+
* выдаётся никогда.
|
|
16
21
|
*/
|
|
17
22
|
import { macro } from '../macros/builder.js';
|
|
18
23
|
import { escapeXmlAttr } from '../macros/xml.js';
|
|
@@ -23,7 +28,7 @@ import { compareStorage } from './canonical.js';
|
|
|
23
28
|
import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
|
|
24
29
|
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
25
30
|
export function storageToMarkdown(storage, opts = {}) {
|
|
26
|
-
const conv = new Converter(opts.registry ?? defaultMacroRegistry);
|
|
31
|
+
const conv = new Converter(opts.registry ?? defaultMacroRegistry, opts.mode === 'readable');
|
|
27
32
|
const markdown = conv.blocksToMd(parseStorage(storage));
|
|
28
33
|
const attachmentRefs = new Set([...conv.images, ...conv.files]);
|
|
29
34
|
for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
|
|
@@ -52,14 +57,23 @@ const CM_BLOCK_TAGS = new Set([
|
|
|
52
57
|
'pre', 'script', 'style', 'textarea',
|
|
53
58
|
]);
|
|
54
59
|
const INLINE_RAW_WRAP = new Set(['span', 'u', 'sub', 'sup', 'small', 'big', 'font', 'del', 'ins', 'abbr', 'cite', 'q', 'mark', 'time']);
|
|
60
|
+
// Блочные контейнеры-обёртки: в readable-режиме разворачиваются (их дети
|
|
61
|
+
// обрабатываются как блоки), сам тег и его атрибуты отбрасываются.
|
|
62
|
+
const UNWRAP_BLOCK = new Set([
|
|
63
|
+
'div', 'section', 'article', 'aside', 'figure', 'figcaption', 'header',
|
|
64
|
+
'footer', 'main', 'nav', 'center', 'details', 'summary',
|
|
65
|
+
'ac:layout', 'ac:layout-section', 'ac:layout-cell',
|
|
66
|
+
]);
|
|
55
67
|
const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
|
|
56
68
|
class Converter {
|
|
57
69
|
registry;
|
|
70
|
+
readable;
|
|
58
71
|
images = new Set();
|
|
59
72
|
files = new Set();
|
|
60
|
-
stats = { markers: 0, fenced: 0, rawHtml: 0 };
|
|
61
|
-
constructor(registry) {
|
|
73
|
+
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
|
|
74
|
+
constructor(registry, readable = false) {
|
|
62
75
|
this.registry = registry;
|
|
76
|
+
this.readable = readable;
|
|
63
77
|
}
|
|
64
78
|
// ── Блочный уровень ──────────────────────────────────────────────────
|
|
65
79
|
blocksToMd(nodes) {
|
|
@@ -108,6 +122,9 @@ class Converter {
|
|
|
108
122
|
return '---';
|
|
109
123
|
if (el.name === 'ul' || el.name === 'ol')
|
|
110
124
|
return this.listToMd(el);
|
|
125
|
+
// Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
|
|
126
|
+
// отклоняет: faithful → сырой HTML, readable → readableFallback
|
|
127
|
+
// (lossy++ и разворот в GFM через readableTable).
|
|
111
128
|
if (el.name === 'table')
|
|
112
129
|
return this.tableToMd(el);
|
|
113
130
|
if (el.name === 'ac:structured-macro')
|
|
@@ -153,6 +170,8 @@ class Converter {
|
|
|
153
170
|
* а рендер вернёт плейсхолдерам исходную ac:-форму.
|
|
154
171
|
*/
|
|
155
172
|
fallbackBlock(el) {
|
|
173
|
+
if (this.readable)
|
|
174
|
+
return this.readableFallback(el);
|
|
156
175
|
const direct = this.tryRawHtml(el);
|
|
157
176
|
if (direct !== null)
|
|
158
177
|
return direct;
|
|
@@ -204,14 +223,81 @@ class Converter {
|
|
|
204
223
|
const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
205
224
|
return `${ticks}confluence-storage\n${content}\n${ticks}`;
|
|
206
225
|
}
|
|
226
|
+
// ── Readable-режим: lossy-конвертация в чистый Markdown ────────────────
|
|
227
|
+
/**
|
|
228
|
+
* Fallback readable-режима: НИКОГДА не выдаёт сырой HTML-блок. Таблицы
|
|
229
|
+
* разворачивает в GFM, контейнеры-обёртки — в блоки, blockquote — в `>`,
|
|
230
|
+
* остальное — в инлайн/текст. Content сохраняется, оформление теряется.
|
|
231
|
+
*/
|
|
232
|
+
readableFallback(el) {
|
|
233
|
+
this.stats.lossy++;
|
|
234
|
+
const name = el.name.toLowerCase();
|
|
235
|
+
if (el.name === 'table') {
|
|
236
|
+
try {
|
|
237
|
+
return this.readableTable(el);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
if (!(e instanceof Unrepresentable))
|
|
241
|
+
throw e;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (UNWRAP_BLOCK.has(name)) {
|
|
245
|
+
const inner = this.blocksToMd(el.children).trimEnd();
|
|
246
|
+
if (inner !== '')
|
|
247
|
+
return inner;
|
|
248
|
+
return '';
|
|
249
|
+
}
|
|
250
|
+
if (name === 'blockquote') {
|
|
251
|
+
const inner = this.blocksToMd(el.children).trimEnd();
|
|
252
|
+
return inner
|
|
253
|
+
.split('\n')
|
|
254
|
+
.map((line) => (line === '' ? '>' : `> ${line}`))
|
|
255
|
+
.join('\n');
|
|
256
|
+
}
|
|
257
|
+
// p / td / th / li / caption и прочие «инлайн-контейнеры» → инлайн.
|
|
258
|
+
const inline = this.inlineToMd(el.children).trim();
|
|
259
|
+
if (inline !== '')
|
|
260
|
+
return guardLineStart(inline);
|
|
261
|
+
// Совсем ничего не вышло — голый текст (может быть пустым).
|
|
262
|
+
return escapeMdText(textContent(el.children), {}, this.readable).trim();
|
|
263
|
+
}
|
|
207
264
|
// ── Макросы ──────────────────────────────────────────────────────────
|
|
208
265
|
macroToMd(el) {
|
|
209
266
|
const name = getAttr(el, 'ac:name') ?? '';
|
|
210
267
|
const markerMd = this.tryMacroMarker(el, name);
|
|
211
|
-
if (markerMd
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
268
|
+
if (markerMd !== null) {
|
|
269
|
+
this.stats.markers++;
|
|
270
|
+
return markerMd;
|
|
271
|
+
}
|
|
272
|
+
if (this.readable)
|
|
273
|
+
return this.readableMacro(el);
|
|
274
|
+
return this.fence(serializeStorage([el]));
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Readable-режим для макроса, который не лёг в маркер: сохраняем
|
|
278
|
+
* содержимое, теряем «обёртку» макроса. rich-text-body → блоки;
|
|
279
|
+
* plain-text-body → код-fence (обычный ``` — чистый Markdown);
|
|
280
|
+
* иначе — заголовок-подпись, чтобы место макроса не исчезло бесследно.
|
|
281
|
+
*/
|
|
282
|
+
readableMacro(el) {
|
|
283
|
+
this.stats.lossy++;
|
|
284
|
+
const name = getAttr(el, 'ac:name') ?? 'macro';
|
|
285
|
+
for (const child of elements(el.children)) {
|
|
286
|
+
if (child.name === 'ac:rich-text-body') {
|
|
287
|
+
const inner = this.blocksToMd(child.children).trimEnd();
|
|
288
|
+
if (inner !== '')
|
|
289
|
+
return inner;
|
|
290
|
+
}
|
|
291
|
+
if (child.name === 'ac:plain-text-body') {
|
|
292
|
+
const text = textContent(child.children);
|
|
293
|
+
if (text.trim() !== '') {
|
|
294
|
+
const ticks = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((r) => r.length + 1)));
|
|
295
|
+
return `${ticks}\n${text}\n${ticks}`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Bodyless-макрос (toc, children, …) — оставляем видимый след.
|
|
300
|
+
return `_[macro: ${name}]_`;
|
|
215
301
|
}
|
|
216
302
|
/**
|
|
217
303
|
* Пытается выразить макрос маркером. Возвращает null, если параметры
|
|
@@ -411,10 +497,140 @@ class Converter {
|
|
|
411
497
|
content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
|
|
412
498
|
content = els[0].children;
|
|
413
499
|
}
|
|
414
|
-
const md = this.inlineToMd(content, { cell:
|
|
500
|
+
const md = this.inlineToMd(content, { cell: false }).trim();
|
|
415
501
|
if (md.includes('\n'))
|
|
416
502
|
throw new Unrepresentable();
|
|
417
|
-
|
|
503
|
+
// Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
|
|
504
|
+
// плейсхолдеры {{img:…|…}} (которые минуют инлайн-экранирование).
|
|
505
|
+
return md.replace(/\|/g, '\\|');
|
|
506
|
+
}
|
|
507
|
+
// ── Readable-таблицы: любая таблица → GFM ─────────────────────────────
|
|
508
|
+
/**
|
|
509
|
+
* Разворачивает произвольную таблицу (colspan/rowspan, блочные ячейки,
|
|
510
|
+
* несколько header-строк) в GFM. Объединения превращаются в плотную
|
|
511
|
+
* сетку: содержимое — в верхней-левой клетке диапазона, остальные клетки
|
|
512
|
+
* пустые. Первая строка сетки становится шапкой GFM.
|
|
513
|
+
*/
|
|
514
|
+
readableTable(table) {
|
|
515
|
+
const trs = [];
|
|
516
|
+
let caption = '';
|
|
517
|
+
for (const child of elements(table.children)) {
|
|
518
|
+
if (child.name === 'caption')
|
|
519
|
+
caption = this.inlineToMd(child.children).trim();
|
|
520
|
+
else if (['thead', 'tbody', 'tfoot'].includes(child.name)) {
|
|
521
|
+
for (const tr of elements(child.children))
|
|
522
|
+
if (tr.name === 'tr')
|
|
523
|
+
trs.push(tr);
|
|
524
|
+
}
|
|
525
|
+
else if (child.name === 'tr')
|
|
526
|
+
trs.push(child);
|
|
527
|
+
}
|
|
528
|
+
if (trs.length === 0)
|
|
529
|
+
throw new Unrepresentable();
|
|
530
|
+
// Плотная сетка с учётом colspan/rowspan.
|
|
531
|
+
const grid = [];
|
|
532
|
+
const aligns = [];
|
|
533
|
+
trs.forEach((tr, rowIdx) => {
|
|
534
|
+
if (!grid[rowIdx])
|
|
535
|
+
grid[rowIdx] = [];
|
|
536
|
+
let col = 0;
|
|
537
|
+
for (const cell of elements(tr.children)) {
|
|
538
|
+
if (cell.name !== 'td' && cell.name !== 'th')
|
|
539
|
+
continue;
|
|
540
|
+
while (grid[rowIdx][col] !== undefined)
|
|
541
|
+
col++;
|
|
542
|
+
const colspan = Math.max(1, Number(getAttr(cell, 'colspan') ?? '1') || 1);
|
|
543
|
+
const rowspan = Math.max(1, Number(getAttr(cell, 'rowspan') ?? '1') || 1);
|
|
544
|
+
// Экранируем пайпы ОДИН раз здесь — над всем содержимым ячейки
|
|
545
|
+
// (текст + плейсхолдеры {{img:…|…}} + буллеты), поэтому cellFlatten
|
|
546
|
+
// сам их не трогает (inline с cell:false).
|
|
547
|
+
const content = tidyCell(this.cellFlatten(cell.children)).replace(/\|/g, '\\|');
|
|
548
|
+
for (let r = 0; r < rowspan; r++) {
|
|
549
|
+
for (let c = 0; c < colspan; c++) {
|
|
550
|
+
const rr = rowIdx + r;
|
|
551
|
+
const cc = col + c;
|
|
552
|
+
if (!grid[rr])
|
|
553
|
+
grid[rr] = [];
|
|
554
|
+
grid[rr][cc] = r === 0 && c === 0 ? content : '';
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (rowIdx === 0) {
|
|
558
|
+
const a = readableAlign(cell);
|
|
559
|
+
for (let c = 0; c < colspan; c++)
|
|
560
|
+
aligns[col + c] = a;
|
|
561
|
+
}
|
|
562
|
+
col += colspan;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
const width = Math.max(...grid.map((r) => r.length));
|
|
566
|
+
if (width === 0)
|
|
567
|
+
throw new Unrepresentable();
|
|
568
|
+
for (const r of grid) {
|
|
569
|
+
for (let c = 0; c < width; c++)
|
|
570
|
+
if (r[c] === undefined)
|
|
571
|
+
r[c] = '';
|
|
572
|
+
}
|
|
573
|
+
while (aligns.length < width)
|
|
574
|
+
aligns.push('none');
|
|
575
|
+
const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
|
|
576
|
+
const rowMd = (cells) => `| ${cells.join(' | ')} |`;
|
|
577
|
+
const lines = [rowMd(grid[0]), rowMd(sep), ...grid.slice(1).map(rowMd)];
|
|
578
|
+
const table_ = lines.join('\n');
|
|
579
|
+
return caption !== '' ? `**${caption}**\n\n${table_}` : table_;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Сплющивает содержимое ячейки в одну строку: абзацы и пункты списков
|
|
583
|
+
* разделяются `<br>` (единственный HTML, идиоматичный для GFM-ячеек),
|
|
584
|
+
* пункты помечаются `• `. Инлайн-разметка (жирный, ссылки, плейсхолдеры)
|
|
585
|
+
* сохраняется.
|
|
586
|
+
*/
|
|
587
|
+
cellFlatten(nodes) {
|
|
588
|
+
const blocks = [];
|
|
589
|
+
let inlineRun = [];
|
|
590
|
+
const flush = () => {
|
|
591
|
+
if (inlineRun.length === 0)
|
|
592
|
+
return;
|
|
593
|
+
const s = this.inlineToMd(inlineRun, { cell: false }).replace(/\s+/g, ' ').trim();
|
|
594
|
+
if (s !== '')
|
|
595
|
+
blocks.push(s);
|
|
596
|
+
inlineRun = [];
|
|
597
|
+
};
|
|
598
|
+
for (const n of nodes) {
|
|
599
|
+
if (n.kind === 'el' && (n.name === 'ul' || n.name === 'ol')) {
|
|
600
|
+
flush();
|
|
601
|
+
blocks.push(this.flattenList(n));
|
|
602
|
+
}
|
|
603
|
+
else if (n.kind === 'el' && n.name === 'p') {
|
|
604
|
+
flush();
|
|
605
|
+
const s = this.inlineToMd(n.children, { cell: false }).replace(/\s+/g, ' ').trim();
|
|
606
|
+
if (s !== '')
|
|
607
|
+
blocks.push(s);
|
|
608
|
+
}
|
|
609
|
+
else if (n.kind === 'el' && (UNWRAP_BLOCK.has(n.name.toLowerCase()) || n.name === 'blockquote')) {
|
|
610
|
+
flush();
|
|
611
|
+
const s = this.cellFlatten(n.children);
|
|
612
|
+
if (s !== '')
|
|
613
|
+
blocks.push(s);
|
|
614
|
+
}
|
|
615
|
+
else if (n.kind === 'el' && n.name === 'table') {
|
|
616
|
+
flush();
|
|
617
|
+
const s = this.cellFlatten(collectCellText(n));
|
|
618
|
+
if (s !== '')
|
|
619
|
+
blocks.push(s);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
inlineRun.push(n);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
flush();
|
|
626
|
+
return blocks.filter((b) => b !== '').join('<br>');
|
|
627
|
+
}
|
|
628
|
+
flattenList(list) {
|
|
629
|
+
const items = elements(list.children).filter((li) => li.name === 'li');
|
|
630
|
+
return items
|
|
631
|
+
.map((li) => '• ' + this.cellFlatten(li.children))
|
|
632
|
+
.filter((s) => s !== '• ')
|
|
633
|
+
.join('<br>');
|
|
418
634
|
}
|
|
419
635
|
// ── Инлайн ───────────────────────────────────────────────────────────
|
|
420
636
|
/**
|
|
@@ -471,12 +687,19 @@ class Converter {
|
|
|
471
687
|
let out = '';
|
|
472
688
|
for (const n of this.normalizeInline(nodes)) {
|
|
473
689
|
if (n.kind === 'text') {
|
|
474
|
-
out += escapeMdText(n.raw, ctx);
|
|
690
|
+
out += escapeMdText(n.raw, ctx, this.readable);
|
|
475
691
|
continue;
|
|
476
692
|
}
|
|
477
|
-
if (n.kind === 'cdata')
|
|
693
|
+
if (n.kind === 'cdata') {
|
|
694
|
+
if (this.readable) {
|
|
695
|
+
out += escapeMdText(n.text, ctx, true);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
478
698
|
throw new Unrepresentable();
|
|
699
|
+
}
|
|
479
700
|
if (n.kind === 'comment') {
|
|
701
|
+
if (this.readable)
|
|
702
|
+
continue; // невидимый комментарий — отбрасываем
|
|
480
703
|
if (n.text.includes('MACRO:') || n.text.includes('-->'))
|
|
481
704
|
throw new Unrepresentable();
|
|
482
705
|
out += `<!--${n.text}-->`;
|
|
@@ -492,18 +715,20 @@ class Converter {
|
|
|
492
715
|
return this.wrapInline(el, '**', ctx);
|
|
493
716
|
case 'em':
|
|
494
717
|
return this.wrapInline(el, '*', ctx);
|
|
495
|
-
// <b>/<i
|
|
718
|
+
// <b>/<i>: faithful — сырой HTML (** рендерится в <strong>, а не <b>);
|
|
719
|
+
// readable — маппим в **/* (потеря точного тега приемлема).
|
|
496
720
|
case 'b':
|
|
721
|
+
return this.readable ? this.wrapInline(el, '**', ctx) : this.rawInline(el, ctx);
|
|
497
722
|
case 'i':
|
|
498
|
-
return this.rawInline(el, ctx);
|
|
723
|
+
return this.readable ? this.wrapInline(el, '*', ctx) : this.rawInline(el, ctx);
|
|
499
724
|
case 's':
|
|
500
725
|
return this.wrapInline(el, '~~', ctx);
|
|
501
726
|
case 'code': {
|
|
502
|
-
if (el.attrs.length > 0)
|
|
503
|
-
throw new Unrepresentable();
|
|
504
|
-
const text = textContent(el.children);
|
|
505
|
-
if (text.includes('\n') || text.trim() === '')
|
|
727
|
+
if (el.attrs.length > 0 && !this.readable)
|
|
506
728
|
throw new Unrepresentable();
|
|
729
|
+
const text = textContent(el.children).replace(/\s*\n\s*/g, ' ');
|
|
730
|
+
if (text.trim() === '')
|
|
731
|
+
return this.readable ? '' : (() => { throw new Unrepresentable(); })();
|
|
507
732
|
const runs = text.match(/`+/g) ?? [];
|
|
508
733
|
const ticks = '`'.repeat(Math.max(1, ...runs.map((r) => r.length + 1)));
|
|
509
734
|
const pad = text.startsWith('`') || text.endsWith('`') || text.startsWith(' ') || text.endsWith(' ') ? ' ' : '';
|
|
@@ -517,35 +742,42 @@ class Converter {
|
|
|
517
742
|
const src = getAttr(el, 'src') ?? '';
|
|
518
743
|
const alt = getAttr(el, 'alt') ?? '';
|
|
519
744
|
const other = el.attrs.filter(([k]) => k !== 'src' && k !== 'alt');
|
|
520
|
-
if (other.length === 0 && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
|
|
521
|
-
return ``;
|
|
745
|
+
if ((other.length === 0 || this.readable) && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
|
|
746
|
+
return ``; // readable: лишние атрибуты (class…) отбрасываются
|
|
522
747
|
}
|
|
523
|
-
return this.rawInline(el, ctx);
|
|
748
|
+
return this.readable ? escapeMdText(alt, ctx, true) : this.rawInline(el, ctx);
|
|
524
749
|
}
|
|
525
750
|
case 'ac:image':
|
|
526
751
|
return this.acImageToMd(el);
|
|
527
752
|
case 'ac:link':
|
|
528
753
|
return this.acLinkToMd(el);
|
|
529
754
|
default:
|
|
755
|
+
// readable: любой не-ac инлайн-контейнер (span, u, sub, font, …)
|
|
756
|
+
// разворачиваем — тег и стили теряем, содержимое оставляем.
|
|
757
|
+
if (this.readable && !el.name.includes(':'))
|
|
758
|
+
return this.inlineToMd(el.children, ctx);
|
|
759
|
+
if (this.readable)
|
|
760
|
+
return escapeMdText(textContent(el.children), ctx, true);
|
|
530
761
|
if (INLINE_RAW_WRAP.has(el.name))
|
|
531
762
|
return this.rawInline(el, ctx);
|
|
532
763
|
throw new Unrepresentable();
|
|
533
764
|
}
|
|
534
765
|
}
|
|
535
766
|
wrapInline(el, marker, ctx) {
|
|
536
|
-
if (el.attrs.length > 0)
|
|
767
|
+
if (el.attrs.length > 0 && !this.readable)
|
|
537
768
|
return this.rawInline(el, ctx);
|
|
538
769
|
const inner = this.inlineToMd(el.children, ctx);
|
|
539
770
|
// Краевые ПРОСТЫЕ пробелы выносим наружу — `**текст **` маркдауном не
|
|
540
771
|
// является. Юникодные пробелы ( и т.п.) выносить нельзя (изменит
|
|
541
772
|
// содержимое), а внутри маркеров они ломают flanking-правила — такой
|
|
542
|
-
// элемент отдаём сырым HTML
|
|
773
|
+
// элемент отдаём сырым HTML (faithful) либо просто оставляем как есть
|
|
774
|
+
// без обёртки (readable).
|
|
543
775
|
const m = /^([ \t]*)([\s\S]*?)([ \t]*)$/.exec(inner);
|
|
544
776
|
if (!m || m[2] === '')
|
|
545
777
|
return inner;
|
|
546
778
|
const decodedEdges = decodeEntities(m[2]);
|
|
547
779
|
if (/^\s|\s$/u.test(decodedEdges))
|
|
548
|
-
return this.rawInline(el, ctx);
|
|
780
|
+
return this.readable ? inner : this.rawInline(el, ctx);
|
|
549
781
|
return `${m[1]}${marker}${m[2]}${marker}${m[3]}`;
|
|
550
782
|
}
|
|
551
783
|
/** Инлайн-элемент дословно: открывающий тег + инлайн-дети + закрывающий. */
|
|
@@ -562,9 +794,12 @@ class Converter {
|
|
|
562
794
|
linkAnchorToMd(el, ctx) {
|
|
563
795
|
const href = getAttr(el, 'href') ?? '';
|
|
564
796
|
const inner = this.inlineToMd(el.children, ctx);
|
|
565
|
-
|
|
566
|
-
|
|
797
|
+
const onlyHref = el.attrs.length === 1 && el.attrs[0][0] === 'href';
|
|
798
|
+
if ((onlyHref || this.readable) && SAFE_URL_RE.test(href) && !/[[\]]/.test(inner)) {
|
|
799
|
+
return `[${inner}](${href})`; // readable: доп. атрибуты ссылки отбрасываются
|
|
567
800
|
}
|
|
801
|
+
if (this.readable)
|
|
802
|
+
return inner; // ссылку не выразить в MD — оставляем текст
|
|
568
803
|
return this.rawInline(el, ctx);
|
|
569
804
|
}
|
|
570
805
|
acImageToMd(el) {
|
|
@@ -801,23 +1036,41 @@ const ENTITY_RE = /&(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
|
|
|
801
1036
|
* Экранирует markdown-активные символы, сохраняя сущности ( и т.п.)
|
|
802
1037
|
* как есть — markdown-it декодирует их при рендере.
|
|
803
1038
|
*/
|
|
804
|
-
function escapeMdText(raw, ctx) {
|
|
1039
|
+
function escapeMdText(raw, ctx, readable = false) {
|
|
805
1040
|
const collapsed = raw.replace(/[\r\n]+/g, ' ');
|
|
806
1041
|
let out = '';
|
|
807
1042
|
let last = 0;
|
|
808
1043
|
for (const m of collapsed.matchAll(ENTITY_RE)) {
|
|
809
|
-
out += escapePlain(collapsed.slice(last, m.index), ctx);
|
|
810
|
-
|
|
1044
|
+
out += escapePlain(collapsed.slice(last, m.index), ctx, readable);
|
|
1045
|
+
if (readable) {
|
|
1046
|
+
// readable: декодируем сущность в символ ("→", —→—, nbsp→
|
|
1047
|
+
// пробел). `<`, `>`, `&` оставляем сущностями — их «живой» символ
|
|
1048
|
+
// мог бы создать случайный HTML/сущность. Нераспознанное — как есть.
|
|
1049
|
+
const dec = decodeEntities(m[0]);
|
|
1050
|
+
out += dec === m[0] || dec === '<' || dec === '>' || dec === '&'
|
|
1051
|
+
? m[0]
|
|
1052
|
+
: escapePlain(dec, ctx, readable);
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
out += m[0];
|
|
1056
|
+
}
|
|
811
1057
|
last = m.index + m[0].length;
|
|
812
1058
|
}
|
|
813
|
-
out += escapePlain(collapsed.slice(last), ctx);
|
|
1059
|
+
out += escapePlain(collapsed.slice(last), ctx, readable);
|
|
814
1060
|
return out;
|
|
815
1061
|
}
|
|
816
|
-
function escapePlain(s, ctx) {
|
|
1062
|
+
function escapePlain(s, ctx, readable = false) {
|
|
817
1063
|
let esc = s.replace(/[\\`*_[\]{}~]/g, (c) => '\\' + c);
|
|
1064
|
+
if (readable) {
|
|
1065
|
+
// readable: неразрывный пробел → обычный (чище на вид).
|
|
1066
|
+
esc = esc.replace(/\u00A0/g, ' ');
|
|
1067
|
+
if (ctx.cell)
|
|
1068
|
+
esc = esc.replace(/\|/g, '\\|');
|
|
1069
|
+
return esc;
|
|
1070
|
+
}
|
|
818
1071
|
// Сырой U+00A0 на краю абзаца съедается trim()'ом markdown-it —
|
|
819
1072
|
// в entity-форме переживает рендер (и виден при редактировании).
|
|
820
|
-
esc = esc.replace(/
|
|
1073
|
+
esc = esc.replace(/\u00A0/g, ' ');
|
|
821
1074
|
if (ctx.cell)
|
|
822
1075
|
esc = esc.replace(/\|/g, '\\|');
|
|
823
1076
|
return esc;
|
|
@@ -843,3 +1096,37 @@ function cellAlign(cell) {
|
|
|
843
1096
|
throw new Unrepresentable();
|
|
844
1097
|
return m[1];
|
|
845
1098
|
}
|
|
1099
|
+
/** Как cellAlign, но не бросает: любой нераспознанный стиль → 'none'. */
|
|
1100
|
+
function readableAlign(cell) {
|
|
1101
|
+
const style = getAttr(cell, 'style');
|
|
1102
|
+
const m = style ? /text-align:\s*(left|right|center)/.exec(style) : null;
|
|
1103
|
+
return m ? m[1] : 'none';
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Причёсывает содержимое GFM-ячейки: нормализует `<br/>`→`<br>`, схлопывает
|
|
1107
|
+
* подряд идущие переводы строк и срезает их по краям — чтобы «пустая»
|
|
1108
|
+
* ячейка (в исходнике `<p><br/></p>`) стала действительно пустой.
|
|
1109
|
+
*/
|
|
1110
|
+
function tidyCell(s) {
|
|
1111
|
+
return s
|
|
1112
|
+
.replace(/<br\s*\/?>/gi, '<br>')
|
|
1113
|
+
.replace(/(?:\s*<br>\s*)+/g, '<br>')
|
|
1114
|
+
.replace(/^<br>|<br>$/g, '')
|
|
1115
|
+
.trim();
|
|
1116
|
+
}
|
|
1117
|
+
/** Разворачивает вложенную в ячейку таблицу в плоский список её ячеек. */
|
|
1118
|
+
function collectCellText(table) {
|
|
1119
|
+
const out = [];
|
|
1120
|
+
const walk = (nodes) => {
|
|
1121
|
+
for (const n of nodes) {
|
|
1122
|
+
if (n.kind === 'el' && (n.name === 'td' || n.name === 'th')) {
|
|
1123
|
+
out.push({ kind: 'el', name: 'p', attrs: [], children: n.children, selfClosing: false });
|
|
1124
|
+
}
|
|
1125
|
+
else if (n.kind === 'el') {
|
|
1126
|
+
walk(n.children);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
walk(table.children);
|
|
1131
|
+
return out;
|
|
1132
|
+
}
|
package/package.json
CHANGED