confluence-md-sync 0.4.0 → 0.5.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 +33 -7
- package/dist/cli.js +15 -3
- package/dist/export/export-page.d.ts +7 -0
- package/dist/export/export-page.js +6 -1
- package/dist/export/to-markdown.d.ts +18 -0
- package/dist/export/to-markdown.js +129 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,19 +180,29 @@ Attachments and page links become `{{img:...}}` / `{{file:...}}` /
|
|
|
180
180
|
| Mode | For | What it does |
|
|
181
181
|
| --- | --- | --- |
|
|
182
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. |
|
|
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, and `<br>` in paragraphs becomes a real line break (inside table cells it stays `<br>` — GFM cells can't hold newlines). Keeps the content; **drops** colours, exact merge geometry, wrappers — *not* round-trippable. |
|
|
184
|
+
|
|
185
|
+
Images and attachments are downloaded by default (pass
|
|
186
|
+
`downloadAttachments: false` to skip). They land in `attachments/` next to
|
|
187
|
+
the Markdown, and the page body references them via `{{img:name}}` /
|
|
188
|
+
`{{file:name}}` — in both modes.
|
|
184
189
|
|
|
185
190
|
```ts
|
|
186
191
|
import { exportPage } from 'confluence-md-sync';
|
|
187
192
|
|
|
188
193
|
const { markdownPath, images, downloaded } = await exportPage(
|
|
189
194
|
'123456789',
|
|
190
|
-
{ outDir: 'exported' }, // faithful;
|
|
195
|
+
{ outDir: 'exported' }, // faithful; exported/page.md + exported/attachments/
|
|
191
196
|
cfg,
|
|
192
197
|
);
|
|
198
|
+
console.log(downloaded); // Map<filename, localPath> of saved attachments
|
|
193
199
|
|
|
194
|
-
// Readable variant for humans:
|
|
200
|
+
// Readable variant for humans (writes page.md + ./attachments/ next to it):
|
|
195
201
|
await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
|
|
202
|
+
|
|
203
|
+
// Self-contained (local <img>/links) with wide tables unfolded to records:
|
|
204
|
+
await exportPage('123456789',
|
|
205
|
+
{ outDir: 'doc', attachments: 'local', tables: 'records' }, cfg);
|
|
196
206
|
```
|
|
197
207
|
|
|
198
208
|
Round-trip means *edit the Markdown, then publish it straight back*:
|
|
@@ -222,12 +232,28 @@ schema-version, attribute/parameter order and entity vs literal forms are
|
|
|
222
232
|
normalised away (Confluence itself rewrites these on every save). Use
|
|
223
233
|
`compareStorage(a, b)` directly to diff two storage fragments.
|
|
224
234
|
|
|
225
|
-
|
|
235
|
+
### Which export variant?
|
|
236
|
+
|
|
237
|
+
Attachments download to `attachments/` next to the `.md` in every variant
|
|
238
|
+
(pass `--no-attachments` to skip). Flags compose; `--local` and `--records`
|
|
239
|
+
imply `--readable`.
|
|
240
|
+
|
|
241
|
+
| Command | Use it when | Output |
|
|
242
|
+
| --- | --- | --- |
|
|
243
|
+
| `export <id> --out page.md` | you'll **edit and publish it back** | Faithful, round-trippable; raw HTML kept for what Markdown can't express. |
|
|
244
|
+
| `export <id> --out page.md --readable` | **reading / diffing** in a Markdown viewer | Clean Markdown, no raw HTML; complex tables → GFM. Not round-trippable. |
|
|
245
|
+
| `export <id> --out-dir doc --local` | a **self-contained folder to view offline** | Like `--readable`, but images become `<img src="attachments/…">` (sizes kept) and files become `[name](attachments/…)` links pointing at the downloaded files. |
|
|
246
|
+
| `export <id> --out page.md --records` | tables are **too wide** to read as a grid | Every table row is unfolded into a `**Header:** value` record (records split by `---`). Best for tables that have a header row. |
|
|
247
|
+
| `export <id> --out-dir doc --local --records` | an **offline, wide-table-friendly** read | Both of the above together. |
|
|
248
|
+
| `roundtrip <id>` | **checking** a page converts with no loss (CI) | Writes nothing; exits `2` if faithful conversion would lose markup. |
|
|
226
249
|
|
|
227
250
|
```bash
|
|
228
|
-
confluence-md-sync export 123456789 --out page.md
|
|
229
|
-
confluence-md-sync export 123456789 --out page.md --readable
|
|
230
|
-
confluence-md-sync
|
|
251
|
+
confluence-md-sync export 123456789 --out page.md # faithful (edit → publish back)
|
|
252
|
+
confluence-md-sync export 123456789 --out page.md --readable # clean Markdown to read
|
|
253
|
+
confluence-md-sync export 123456789 --out-dir doc --local # self-contained: page.md + attachments/
|
|
254
|
+
confluence-md-sync export 123456789 --out page.md --records # wide tables → records
|
|
255
|
+
confluence-md-sync export 123456789 --out-dir doc --local --records # offline + records
|
|
256
|
+
confluence-md-sync roundtrip 123456789 --show-markdown # verify, exit 2 on loss
|
|
231
257
|
```
|
|
232
258
|
|
|
233
259
|
## Read pages and tables
|
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>] [--readable] [--no-attachments]
|
|
24
|
+
confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--readable] [--local] [--records] [--no-attachments]
|
|
25
25
|
confluence-md-sync roundtrip <page-id> [--show-markdown]
|
|
26
26
|
|
|
27
27
|
publish options:
|
|
@@ -46,6 +46,12 @@ export options:
|
|
|
46
46
|
--readable Prefer clean Markdown over fidelity: no raw HTML blocks,
|
|
47
47
|
complex tables flattened to GFM. Not round-trippable —
|
|
48
48
|
loses styling/exact cell merges, keeps the content
|
|
49
|
+
--local Reference downloaded attachments as local files:
|
|
50
|
+
images as HTML <img src="attachments/…">, other files
|
|
51
|
+
as [name](attachments/…) links. Implies --readable
|
|
52
|
+
--records Unfold every table row into a "**Header:** value"
|
|
53
|
+
record (records split by ---). For wide tables that
|
|
54
|
+
don't fit as GFM. Implies --readable
|
|
49
55
|
--no-attachments Do not download referenced attachments
|
|
50
56
|
|
|
51
57
|
roundtrip options:
|
|
@@ -74,6 +80,8 @@ async function main() {
|
|
|
74
80
|
out: { type: 'string' },
|
|
75
81
|
'out-dir': { type: 'string' },
|
|
76
82
|
readable: { type: 'boolean' },
|
|
83
|
+
local: { type: 'boolean' },
|
|
84
|
+
records: { type: 'boolean' },
|
|
77
85
|
'no-attachments': { type: 'boolean' },
|
|
78
86
|
'show-markdown': { type: 'boolean' },
|
|
79
87
|
help: { type: 'boolean' },
|
|
@@ -107,13 +115,17 @@ async function main() {
|
|
|
107
115
|
if (command === 'export') {
|
|
108
116
|
if (!arg)
|
|
109
117
|
throw new Error('export: page id is required');
|
|
118
|
+
// --local и --records подразумевают readable-обработку текста.
|
|
119
|
+
const readable = values.readable || values.local || values.records;
|
|
110
120
|
const result = await exportPage(arg, {
|
|
111
121
|
outFile: values.out,
|
|
112
122
|
outDir: values['out-dir'],
|
|
113
123
|
downloadAttachments: !values['no-attachments'],
|
|
114
|
-
mode:
|
|
124
|
+
mode: readable ? 'readable' : 'faithful',
|
|
125
|
+
attachments: values.local ? 'local' : 'placeholder',
|
|
126
|
+
tables: values.records ? 'records' : 'auto',
|
|
115
127
|
}, cfg);
|
|
116
|
-
const tail =
|
|
128
|
+
const tail = readable
|
|
117
129
|
? `${result.stats.lossy} block(s) simplified`
|
|
118
130
|
: `${result.stats.fenced} raw storage block(s)`;
|
|
119
131
|
console.log(`[cli] exported page ${result.pageId} "${result.title}" v${result.version} → ${result.markdownPath}` +
|
|
@@ -23,6 +23,13 @@ export interface ExportPageOptions {
|
|
|
23
23
|
* 'readable' — чистый Markdown ценой оформления (round-trip не гарантирован).
|
|
24
24
|
*/
|
|
25
25
|
mode?: 'faithful' | 'readable';
|
|
26
|
+
/**
|
|
27
|
+
* 'placeholder' (default) — {{img:}}/{{file:}}; 'local' — картинки как
|
|
28
|
+
* HTML `<img src="attachments/…">`, файлы как md-ссылки. Подразумевает readable.
|
|
29
|
+
*/
|
|
30
|
+
attachments?: 'placeholder' | 'local';
|
|
31
|
+
/** 'auto' (default) или 'records' — строки таблиц как записи. Подразумевает readable. */
|
|
32
|
+
tables?: 'auto' | 'records';
|
|
26
33
|
registry?: MacroRegistry;
|
|
27
34
|
}
|
|
28
35
|
export interface ExportPageResult extends StorageToMarkdownResult {
|
|
@@ -9,7 +9,12 @@ 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, {
|
|
12
|
+
const converted = storageToMarkdown(page.storage, {
|
|
13
|
+
registry: opts.registry,
|
|
14
|
+
mode: opts.mode,
|
|
15
|
+
attachments: opts.attachments,
|
|
16
|
+
tables: opts.tables,
|
|
17
|
+
});
|
|
13
18
|
const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
|
|
14
19
|
mkdirSync(dirname(markdownPath), { recursive: true });
|
|
15
20
|
writeFileSync(markdownPath, converted.markdown);
|
|
@@ -28,6 +28,24 @@ export interface StorageToMarkdownOptions {
|
|
|
28
28
|
* 'readable' — чистый Markdown ценой оформления, без round-trip.
|
|
29
29
|
*/
|
|
30
30
|
mode?: 'faithful' | 'readable';
|
|
31
|
+
/**
|
|
32
|
+
* Как ссылаться на аттачи:
|
|
33
|
+
* - 'placeholder' (default) — {{img:name}} / {{file:name}} (для publish);
|
|
34
|
+
* - 'local' — картинки как HTML `<img src="attachments/name">` (с
|
|
35
|
+
* сохранением размеров), файлы как md-ссылки `[name](attachments/name)`.
|
|
36
|
+
* Годится для самодостаточного просмотра рядом со скачанными файлами.
|
|
37
|
+
* Включение 'local' подразумевает readable-обработку текста.
|
|
38
|
+
*/
|
|
39
|
+
attachments?: 'placeholder' | 'local';
|
|
40
|
+
/**
|
|
41
|
+
* Как выводить таблицы:
|
|
42
|
+
* - 'auto' (default) — GFM (readable) / round-trip (faithful);
|
|
43
|
+
* - 'records' — каждую строку разворачивать в запись
|
|
44
|
+
* «**Заголовок:** значение», записи через `---`. Подразумевает readable.
|
|
45
|
+
*/
|
|
46
|
+
tables?: 'auto' | 'records';
|
|
47
|
+
/** Префикс пути к локальным аттачам для attachments:'local'. Default: 'attachments/'. */
|
|
48
|
+
localPrefix?: string;
|
|
31
49
|
}
|
|
32
50
|
export interface StorageToMarkdownResult {
|
|
33
51
|
markdown: string;
|
|
@@ -28,7 +28,17 @@ import { compareStorage } from './canonical.js';
|
|
|
28
28
|
import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
|
|
29
29
|
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
30
30
|
export function storageToMarkdown(storage, opts = {}) {
|
|
31
|
-
const
|
|
31
|
+
const local = opts.attachments === 'local';
|
|
32
|
+
const records = opts.tables === 'records';
|
|
33
|
+
// 'local' и 'records' — заведомо не round-trip, поэтому включают readable-
|
|
34
|
+
// обработку текста (сущности, спаны, переносы), даже без mode:'readable'.
|
|
35
|
+
const readable = opts.mode === 'readable' || local || records;
|
|
36
|
+
const conv = new Converter(opts.registry ?? defaultMacroRegistry, {
|
|
37
|
+
readable,
|
|
38
|
+
localFiles: local,
|
|
39
|
+
tablesAsRecords: records,
|
|
40
|
+
localPrefix: opts.localPrefix ?? 'attachments/',
|
|
41
|
+
});
|
|
32
42
|
const markdown = conv.blocksToMd(parseStorage(storage));
|
|
33
43
|
const attachmentRefs = new Set([...conv.images, ...conv.files]);
|
|
34
44
|
for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
|
|
@@ -67,13 +77,19 @@ const UNWRAP_BLOCK = new Set([
|
|
|
67
77
|
const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
|
|
68
78
|
class Converter {
|
|
69
79
|
registry;
|
|
70
|
-
readable;
|
|
71
80
|
images = new Set();
|
|
72
81
|
files = new Set();
|
|
73
82
|
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
|
|
74
|
-
|
|
83
|
+
readable;
|
|
84
|
+
localFiles;
|
|
85
|
+
tablesAsRecords;
|
|
86
|
+
localPrefix;
|
|
87
|
+
constructor(registry, opts) {
|
|
75
88
|
this.registry = registry;
|
|
76
|
-
this.readable = readable;
|
|
89
|
+
this.readable = opts.readable;
|
|
90
|
+
this.localFiles = opts.localFiles;
|
|
91
|
+
this.tablesAsRecords = opts.tablesAsRecords;
|
|
92
|
+
this.localPrefix = opts.localPrefix;
|
|
77
93
|
}
|
|
78
94
|
// ── Блочный уровень ──────────────────────────────────────────────────
|
|
79
95
|
blocksToMd(nodes) {
|
|
@@ -104,7 +120,11 @@ class Converter {
|
|
|
104
120
|
const h = /^h([1-6])$/.exec(el.name);
|
|
105
121
|
try {
|
|
106
122
|
if (h && el.attrs.length === 0) {
|
|
107
|
-
|
|
123
|
+
// Заголовок однострочен по определению — в readable схлопываем
|
|
124
|
+
// возможные переводы строк (из <br>) в пробел.
|
|
125
|
+
let inline = this.inlineToMd(el.children);
|
|
126
|
+
if (this.readable)
|
|
127
|
+
inline = inline.replace(/\s*\n\s*/g, ' ');
|
|
108
128
|
if (inline.includes('\n') || inline.trim() === '')
|
|
109
129
|
throw new Unrepresentable();
|
|
110
130
|
return '#'.repeat(Number(h[1])) + ' ' + guardLineStart(inline.trim());
|
|
@@ -122,11 +142,15 @@ class Converter {
|
|
|
122
142
|
return '---';
|
|
123
143
|
if (el.name === 'ul' || el.name === 'ol')
|
|
124
144
|
return this.listToMd(el);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
145
|
+
if (el.name === 'table') {
|
|
146
|
+
// tables:'records' — любую таблицу разворачиваем в записи.
|
|
147
|
+
if (this.tablesAsRecords)
|
|
148
|
+
return this.recordsTable(el);
|
|
149
|
+
// Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
|
|
150
|
+
// отклоняет: faithful → сырой HTML, readable → readableFallback
|
|
151
|
+
// (lossy++ и разворот в GFM через readableTable).
|
|
129
152
|
return this.tableToMd(el);
|
|
153
|
+
}
|
|
130
154
|
if (el.name === 'ac:structured-macro')
|
|
131
155
|
return this.macroToMd(el);
|
|
132
156
|
if (el.name === 'ac:image' || el.name === 'ac:link') {
|
|
@@ -147,7 +171,10 @@ class Converter {
|
|
|
147
171
|
if (children.length === 0)
|
|
148
172
|
return this.fallbackBlock(el);
|
|
149
173
|
try {
|
|
150
|
-
|
|
174
|
+
// multiline: в readable-абзаце <br> становится настоящим переводом
|
|
175
|
+
// строки (см. case 'br'). guardLineStart применяем к КАЖДОЙ строке —
|
|
176
|
+
// после переноса тоже нельзя случайно начать список/заголовок.
|
|
177
|
+
const inline = this.inlineToMd(children, { multiline: true }).trim();
|
|
151
178
|
if (inline === '')
|
|
152
179
|
return this.fallbackBlock(el);
|
|
153
180
|
// Абзац, начинающийся с блочного HTML-тега, markdown-it превратит в
|
|
@@ -155,7 +182,7 @@ class Converter {
|
|
|
155
182
|
const m = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(inline);
|
|
156
183
|
if (m && CM_BLOCK_TAGS.has(m[1].toLowerCase()))
|
|
157
184
|
return this.fallbackBlock(el);
|
|
158
|
-
return guardLineStart(
|
|
185
|
+
return inline.split('\n').map((line) => guardLineStart(line)).join('\n');
|
|
159
186
|
}
|
|
160
187
|
catch (e) {
|
|
161
188
|
if (!(e instanceof Unrepresentable))
|
|
@@ -255,9 +282,10 @@ class Converter {
|
|
|
255
282
|
.join('\n');
|
|
256
283
|
}
|
|
257
284
|
// p / td / th / li / caption и прочие «инлайн-контейнеры» → инлайн.
|
|
258
|
-
|
|
285
|
+
// multiline: это свободный поток (не ячейка) — <br> станет переносом.
|
|
286
|
+
const inline = this.inlineToMd(el.children, { multiline: true }).trim();
|
|
259
287
|
if (inline !== '')
|
|
260
|
-
return guardLineStart(
|
|
288
|
+
return inline.split('\n').map((l) => guardLineStart(l)).join('\n');
|
|
261
289
|
// Совсем ничего не вышло — голый текст (может быть пустым).
|
|
262
290
|
return escapeMdText(textContent(el.children), {}, this.readable).trim();
|
|
263
291
|
}
|
|
@@ -497,7 +525,7 @@ class Converter {
|
|
|
497
525
|
content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
|
|
498
526
|
content = els[0].children;
|
|
499
527
|
}
|
|
500
|
-
const md = this.inlineToMd(content, { cell:
|
|
528
|
+
const md = this.inlineToMd(content, { cell: true }).trim();
|
|
501
529
|
if (md.includes('\n'))
|
|
502
530
|
throw new Unrepresentable();
|
|
503
531
|
// Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
|
|
@@ -506,12 +534,12 @@ class Converter {
|
|
|
506
534
|
}
|
|
507
535
|
// ── Readable-таблицы: любая таблица → GFM ─────────────────────────────
|
|
508
536
|
/**
|
|
509
|
-
*
|
|
510
|
-
* несколько header-строк)
|
|
511
|
-
*
|
|
512
|
-
*
|
|
537
|
+
* Строит плотную сетку из произвольной таблицы (colspan/rowspan, блочные
|
|
538
|
+
* ячейки, несколько header-строк). Объединения раскрываются: содержимое —
|
|
539
|
+
* в верхней-левой клетке диапазона, остальные клетки пустые. Содержимое
|
|
540
|
+
* ячейки — flatten-строка БЕЗ экранирования пайпов (его делает вызывающий).
|
|
513
541
|
*/
|
|
514
|
-
|
|
542
|
+
buildTableGrid(table) {
|
|
515
543
|
const trs = [];
|
|
516
544
|
let caption = '';
|
|
517
545
|
for (const child of elements(table.children)) {
|
|
@@ -527,7 +555,6 @@ class Converter {
|
|
|
527
555
|
}
|
|
528
556
|
if (trs.length === 0)
|
|
529
557
|
throw new Unrepresentable();
|
|
530
|
-
// Плотная сетка с учётом colspan/rowspan.
|
|
531
558
|
const grid = [];
|
|
532
559
|
const aligns = [];
|
|
533
560
|
trs.forEach((tr, rowIdx) => {
|
|
@@ -541,10 +568,7 @@ class Converter {
|
|
|
541
568
|
col++;
|
|
542
569
|
const colspan = Math.max(1, Number(getAttr(cell, 'colspan') ?? '1') || 1);
|
|
543
570
|
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, '\\|');
|
|
571
|
+
const content = tidyCell(this.cellFlatten(cell.children));
|
|
548
572
|
for (let r = 0; r < rowspan; r++) {
|
|
549
573
|
for (let c = 0; c < colspan; c++) {
|
|
550
574
|
const rr = rowIdx + r;
|
|
@@ -572,12 +596,45 @@ class Converter {
|
|
|
572
596
|
}
|
|
573
597
|
while (aligns.length < width)
|
|
574
598
|
aligns.push('none');
|
|
599
|
+
return { grid, aligns, caption };
|
|
600
|
+
}
|
|
601
|
+
/** Разворачивает любую таблицу в GFM (сетка с заполнением объединений). */
|
|
602
|
+
readableTable(table) {
|
|
603
|
+
const { grid, aligns, caption } = this.buildTableGrid(table);
|
|
604
|
+
const esc = (s) => s.replace(/\|/g, '\\|');
|
|
575
605
|
const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
|
|
576
|
-
const rowMd = (cells) => `| ${cells.join(' | ')} |`;
|
|
577
|
-
const lines = [rowMd(grid[0]),
|
|
606
|
+
const rowMd = (cells) => `| ${cells.map(esc).join(' | ')} |`;
|
|
607
|
+
const lines = [rowMd(grid[0]), `| ${sep.join(' | ')} |`, ...grid.slice(1).map(rowMd)];
|
|
578
608
|
const table_ = lines.join('\n');
|
|
579
609
|
return caption !== '' ? `**${caption}**\n\n${table_}` : table_;
|
|
580
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* tables:'records' — каждую строку тела таблицы разворачивает в запись
|
|
613
|
+
* «**Заголовок:** значение» (пустые ячейки и колонки-филлеры пропускаются),
|
|
614
|
+
* записи разделяются `---`. Заголовки берутся из первой строки сетки.
|
|
615
|
+
*/
|
|
616
|
+
recordsTable(table) {
|
|
617
|
+
this.stats.lossy++;
|
|
618
|
+
const { grid, caption } = this.buildTableGrid(table);
|
|
619
|
+
const header = grid[0];
|
|
620
|
+
const records = [];
|
|
621
|
+
for (const row of grid.slice(1)) {
|
|
622
|
+
const lines = [];
|
|
623
|
+
row.forEach((val, i) => {
|
|
624
|
+
const key = (header[i] ?? '').trim();
|
|
625
|
+
// В записи <br> из ячейки → «; » (одна строка, без HTML).
|
|
626
|
+
const value = val.replace(/<br>/g, '; ').trim();
|
|
627
|
+
if (key !== '' && value !== '')
|
|
628
|
+
lines.push(`**${key}:** ${value}`);
|
|
629
|
+
});
|
|
630
|
+
if (lines.length > 0)
|
|
631
|
+
records.push(lines.join('\n'));
|
|
632
|
+
}
|
|
633
|
+
if (records.length === 0)
|
|
634
|
+
throw new Unrepresentable();
|
|
635
|
+
const body = records.join('\n\n---\n\n');
|
|
636
|
+
return caption !== '' ? `**${caption}**\n\n${body}` : body;
|
|
637
|
+
}
|
|
581
638
|
/**
|
|
582
639
|
* Сплющивает содержимое ячейки в одну строку: абзацы и пункты списков
|
|
583
640
|
* разделяются `<br>` (единственный HTML, идиоматичный для GFM-ячеек),
|
|
@@ -590,7 +647,7 @@ class Converter {
|
|
|
590
647
|
const flush = () => {
|
|
591
648
|
if (inlineRun.length === 0)
|
|
592
649
|
return;
|
|
593
|
-
const s = this.inlineToMd(inlineRun, { cell:
|
|
650
|
+
const s = this.inlineToMd(inlineRun, { cell: true }).replace(/\s+/g, ' ').trim();
|
|
594
651
|
if (s !== '')
|
|
595
652
|
blocks.push(s);
|
|
596
653
|
inlineRun = [];
|
|
@@ -602,7 +659,7 @@ class Converter {
|
|
|
602
659
|
}
|
|
603
660
|
else if (n.kind === 'el' && n.name === 'p') {
|
|
604
661
|
flush();
|
|
605
|
-
const s = this.inlineToMd(n.children, { cell:
|
|
662
|
+
const s = this.inlineToMd(n.children, { cell: true }).replace(/\s+/g, ' ').trim();
|
|
606
663
|
if (s !== '')
|
|
607
664
|
blocks.push(s);
|
|
608
665
|
}
|
|
@@ -735,6 +792,11 @@ class Converter {
|
|
|
735
792
|
return `${ticks}${pad}${text}${pad}${ticks}`;
|
|
736
793
|
}
|
|
737
794
|
case 'br':
|
|
795
|
+
// readable: в свободном потоке (абзац/цитата) — настоящий перенос
|
|
796
|
+
// строки; в ячейке GFM-таблицы перенос невозможен (сломал бы строку
|
|
797
|
+
// таблицы) — там остаётся <br>. faithful — всегда <br/> (round-trip).
|
|
798
|
+
if (this.readable)
|
|
799
|
+
return ctx.multiline ? '\n' : ctx.cell ? '<br>' : '<br/>';
|
|
738
800
|
return '<br/>';
|
|
739
801
|
case 'a':
|
|
740
802
|
return this.linkAnchorToMd(el, ctx);
|
|
@@ -807,6 +869,30 @@ class Converter {
|
|
|
807
869
|
if (kids.length !== 1)
|
|
808
870
|
throw new Unrepresentable();
|
|
809
871
|
const ref = kids[0];
|
|
872
|
+
// attachments:'local' — картинка как HTML <img> (сохраняет размеры).
|
|
873
|
+
// ri:attachment → локальный путь; ri:url → внешний URL как есть.
|
|
874
|
+
if (this.localFiles && (ref.name === 'ri:attachment' || ref.name === 'ri:url')) {
|
|
875
|
+
let src;
|
|
876
|
+
let alt = '';
|
|
877
|
+
if (ref.name === 'ri:attachment') {
|
|
878
|
+
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
879
|
+
this.images.add(filename);
|
|
880
|
+
src = this.localPrefix + filename;
|
|
881
|
+
alt = filename;
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
src = getAttr(ref, 'ri:value') ?? '';
|
|
885
|
+
}
|
|
886
|
+
const attrs = [['src', escapeXmlAttr(src)]];
|
|
887
|
+
for (const [k] of el.attrs) {
|
|
888
|
+
const plain = k.startsWith('ac:') ? k.slice(3) : k;
|
|
889
|
+
if (plain === 'height' || plain === 'width') {
|
|
890
|
+
attrs.push([plain, escapeXmlAttr(getAttr(el, k) ?? '')]);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
attrs.push(['alt', escapeXmlAttr(alt)]);
|
|
894
|
+
return serializeStorage([{ kind: 'el', name: 'img', attrs, children: [], selfClosing: true }]);
|
|
895
|
+
}
|
|
810
896
|
if (ref.name === 'ri:url') {
|
|
811
897
|
if (ref.attrs.some(([k]) => k !== 'ri:value'))
|
|
812
898
|
throw new Unrepresentable();
|
|
@@ -881,8 +967,15 @@ class Converter {
|
|
|
881
967
|
throw new Unrepresentable();
|
|
882
968
|
}
|
|
883
969
|
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
884
|
-
badPlaceholderPart(filename);
|
|
885
970
|
this.files.add(filename);
|
|
971
|
+
// attachments:'local' — md-ссылка на локальный файл вместо {{file:}}.
|
|
972
|
+
if (this.localFiles) {
|
|
973
|
+
const label = (text ?? filename).replace(/[[\]]/g, '\\$&');
|
|
974
|
+
const path = this.localPrefix + filename;
|
|
975
|
+
const target = /[()\s]/.test(path) ? `<${path}>` : path;
|
|
976
|
+
return `[${label}](${target})`;
|
|
977
|
+
}
|
|
978
|
+
badPlaceholderPart(filename);
|
|
886
979
|
return `{{file:${filename}${textAttr}}}`;
|
|
887
980
|
}
|
|
888
981
|
throw new Unrepresentable();
|
|
@@ -1059,21 +1152,13 @@ function escapeMdText(raw, ctx, readable = false) {
|
|
|
1059
1152
|
out += escapePlain(collapsed.slice(last), ctx, readable);
|
|
1060
1153
|
return out;
|
|
1061
1154
|
}
|
|
1062
|
-
function escapePlain(s,
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
return esc;
|
|
1070
|
-
}
|
|
1071
|
-
// Сырой U+00A0 на краю абзаца съедается trim()'ом markdown-it —
|
|
1072
|
-
// в entity-форме переживает рендер (и виден при редактировании).
|
|
1073
|
-
esc = esc.replace(/\u00A0/g, ' ');
|
|
1074
|
-
if (ctx.cell)
|
|
1075
|
-
esc = esc.replace(/\|/g, '\\|');
|
|
1076
|
-
return esc;
|
|
1155
|
+
function escapePlain(s, _ctx, readable = false) {
|
|
1156
|
+
const esc = s.replace(/[\\`*_[\]{}~]/g, (c) => '\\' + c);
|
|
1157
|
+
// Пайпы здесь НЕ трогаем — они экранируются один раз на границе ячейки
|
|
1158
|
+
// (cellMd / readableTable), иначе плейсхолдеры {{img:…|…}} двоились бы.
|
|
1159
|
+
// readable: неразрывный пробел → обычный; faithful: → entity (переживает
|
|
1160
|
+
// trim() markdown-it на краю абзаца).
|
|
1161
|
+
return esc.replace(/\u00A0/g, readable ? ' ' : ' ');
|
|
1077
1162
|
}
|
|
1078
1163
|
/** Экранирует конструкции, значимые в начале строки (#, >, -, 1. …). */
|
|
1079
1164
|
function guardLineStart(md) {
|
package/package.json
CHANGED