confluence-md-sync 0.4.1 → 0.5.1
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 +24 -5
- 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 +138 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -199,6 +199,10 @@ console.log(downloaded); // Map<filename, localPath> of saved atta
|
|
|
199
199
|
|
|
200
200
|
// Readable variant for humans (writes page.md + ./attachments/ next to it):
|
|
201
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);
|
|
202
206
|
```
|
|
203
207
|
|
|
204
208
|
Round-trip means *edit the Markdown, then publish it straight back*:
|
|
@@ -228,13 +232,28 @@ schema-version, attribute/parameter order and entity vs literal forms are
|
|
|
228
232
|
normalised away (Confluence itself rewrites these on every save). Use
|
|
229
233
|
`compareStorage(a, b)` directly to diff two storage fragments.
|
|
230
234
|
|
|
231
|
-
|
|
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. |
|
|
232
249
|
|
|
233
250
|
```bash
|
|
234
|
-
|
|
235
|
-
confluence-md-sync export 123456789 --out page.md
|
|
236
|
-
confluence-md-sync export 123456789 --out page.md
|
|
237
|
-
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
|
|
238
257
|
```
|
|
239
258
|
|
|
240
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) {
|
|
@@ -126,11 +142,15 @@ class Converter {
|
|
|
126
142
|
return '---';
|
|
127
143
|
if (el.name === 'ul' || el.name === 'ol')
|
|
128
144
|
return this.listToMd(el);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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).
|
|
133
152
|
return this.tableToMd(el);
|
|
153
|
+
}
|
|
134
154
|
if (el.name === 'ac:structured-macro')
|
|
135
155
|
return this.macroToMd(el);
|
|
136
156
|
if (el.name === 'ac:image' || el.name === 'ac:link') {
|
|
@@ -514,12 +534,12 @@ class Converter {
|
|
|
514
534
|
}
|
|
515
535
|
// ── Readable-таблицы: любая таблица → GFM ─────────────────────────────
|
|
516
536
|
/**
|
|
517
|
-
*
|
|
518
|
-
* несколько header-строк)
|
|
519
|
-
*
|
|
520
|
-
*
|
|
537
|
+
* Строит плотную сетку из произвольной таблицы (colspan/rowspan, блочные
|
|
538
|
+
* ячейки, несколько header-строк). Объединения раскрываются: содержимое —
|
|
539
|
+
* в верхней-левой клетке диапазона, остальные клетки пустые. Содержимое
|
|
540
|
+
* ячейки — flatten-строка БЕЗ экранирования пайпов (его делает вызывающий).
|
|
521
541
|
*/
|
|
522
|
-
|
|
542
|
+
buildTableGrid(table) {
|
|
523
543
|
const trs = [];
|
|
524
544
|
let caption = '';
|
|
525
545
|
for (const child of elements(table.children)) {
|
|
@@ -535,12 +555,14 @@ class Converter {
|
|
|
535
555
|
}
|
|
536
556
|
if (trs.length === 0)
|
|
537
557
|
throw new Unrepresentable();
|
|
538
|
-
// Плотная сетка с учётом colspan/rowspan.
|
|
539
558
|
const grid = [];
|
|
540
559
|
const aligns = [];
|
|
560
|
+
const headerFlags = [];
|
|
541
561
|
trs.forEach((tr, rowIdx) => {
|
|
542
562
|
if (!grid[rowIdx])
|
|
543
563
|
grid[rowIdx] = [];
|
|
564
|
+
const rowCells = elements(tr.children).filter((c) => c.name === 'td' || c.name === 'th');
|
|
565
|
+
headerFlags[rowIdx] = rowCells.length > 0 && rowCells.every((c) => c.name === 'th');
|
|
544
566
|
let col = 0;
|
|
545
567
|
for (const cell of elements(tr.children)) {
|
|
546
568
|
if (cell.name !== 'td' && cell.name !== 'th')
|
|
@@ -549,10 +571,7 @@ class Converter {
|
|
|
549
571
|
col++;
|
|
550
572
|
const colspan = Math.max(1, Number(getAttr(cell, 'colspan') ?? '1') || 1);
|
|
551
573
|
const rowspan = Math.max(1, Number(getAttr(cell, 'rowspan') ?? '1') || 1);
|
|
552
|
-
|
|
553
|
-
// (текст + плейсхолдеры {{img:…|…}} + буллеты), поэтому cellFlatten
|
|
554
|
-
// сам их не трогает (inline с cell:false).
|
|
555
|
-
const content = tidyCell(this.cellFlatten(cell.children)).replace(/\|/g, '\\|');
|
|
574
|
+
const content = tidyCell(this.cellFlatten(cell.children));
|
|
556
575
|
for (let r = 0; r < rowspan; r++) {
|
|
557
576
|
for (let c = 0; c < colspan; c++) {
|
|
558
577
|
const rr = rowIdx + r;
|
|
@@ -580,12 +599,79 @@ class Converter {
|
|
|
580
599
|
}
|
|
581
600
|
while (aligns.length < width)
|
|
582
601
|
aligns.push('none');
|
|
602
|
+
while (headerFlags.length < grid.length)
|
|
603
|
+
headerFlags.push(false);
|
|
604
|
+
return { grid, aligns, caption, headerFlags };
|
|
605
|
+
}
|
|
606
|
+
/** Разворачивает любую таблицу в GFM (сетка с заполнением объединений). */
|
|
607
|
+
readableTable(table) {
|
|
608
|
+
const { grid, aligns, caption } = this.buildTableGrid(table);
|
|
609
|
+
const esc = (s) => s.replace(/\|/g, '\\|');
|
|
583
610
|
const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
|
|
584
|
-
const rowMd = (cells) => `| ${cells.join(' | ')} |`;
|
|
585
|
-
const lines = [rowMd(grid[0]),
|
|
611
|
+
const rowMd = (cells) => `| ${cells.map(esc).join(' | ')} |`;
|
|
612
|
+
const lines = [rowMd(grid[0]), `| ${sep.join(' | ')} |`, ...grid.slice(1).map(rowMd)];
|
|
586
613
|
const table_ = lines.join('\n');
|
|
587
614
|
return caption !== '' ? `**${caption}**\n\n${table_}` : table_;
|
|
588
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* tables:'records' — каждую строку тела таблицы разворачивает в запись
|
|
618
|
+
* «**Заголовок:** значение» (пустые ячейки и колонки-филлеры пропускаются),
|
|
619
|
+
* записи разделяются `---`.
|
|
620
|
+
*
|
|
621
|
+
* Заголовок — ПОСЛЕДНЯЯ из ведущих строк, целиком состоящих из <th>
|
|
622
|
+
* (строки-титулы над ней, например «Детали» с colspan, уходят в подпись).
|
|
623
|
+
* Многострочное значение ячейки разворачивается под заголовком: <br> →
|
|
624
|
+
* перенос строки, буллеты «• » → элементы md-списка.
|
|
625
|
+
*/
|
|
626
|
+
recordsTable(table) {
|
|
627
|
+
this.stats.lossy++;
|
|
628
|
+
const { grid, caption, headerFlags } = this.buildTableGrid(table);
|
|
629
|
+
// Ведущие строки-заголовки: последняя из них — ключи, предыдущие — титулы.
|
|
630
|
+
let headerCount = 0;
|
|
631
|
+
while (headerCount < grid.length && headerFlags[headerCount])
|
|
632
|
+
headerCount++;
|
|
633
|
+
if (headerCount === 0)
|
|
634
|
+
headerCount = 1; // нет <th>-шапки → первая строка как ключи
|
|
635
|
+
if (headerCount >= grid.length)
|
|
636
|
+
headerCount = 1; // не съедать всю таблицу
|
|
637
|
+
const header = grid[headerCount - 1];
|
|
638
|
+
const titleCells = grid
|
|
639
|
+
.slice(0, headerCount - 1)
|
|
640
|
+
.flat()
|
|
641
|
+
.map((c) => c.trim())
|
|
642
|
+
.filter((c) => c !== '');
|
|
643
|
+
const title = [caption, ...titleCells].filter((c) => c !== '').join(' — ');
|
|
644
|
+
const records = [];
|
|
645
|
+
for (const row of grid.slice(headerCount)) {
|
|
646
|
+
const fields = [];
|
|
647
|
+
row.forEach((val, i) => {
|
|
648
|
+
const key = (header[i] ?? '').trim();
|
|
649
|
+
if (key === '')
|
|
650
|
+
return;
|
|
651
|
+
// Значение записи — свободный Markdown (не GFM-ячейка): <br> →
|
|
652
|
+
// настоящий перенос, «• » → элементы списка; многострочное значение
|
|
653
|
+
// выносим под заголовок.
|
|
654
|
+
const value = val
|
|
655
|
+
.replace(/<br>/g, '\n')
|
|
656
|
+
.replace(/(^|\n)• /g, '$1- ')
|
|
657
|
+
.trim();
|
|
658
|
+
if (value === '')
|
|
659
|
+
return;
|
|
660
|
+
fields.push(value.includes('\n') ? `**${key}:**\n${value}` : `**${key}:** ${value}`);
|
|
661
|
+
});
|
|
662
|
+
// Одиночные значения — поля подряд (одна строка на поле); если есть
|
|
663
|
+
// многострочные (списки), разделяем пустой строкой, чтобы md-список не
|
|
664
|
+
// «склеивался» со следующим заголовком.
|
|
665
|
+
if (fields.length > 0) {
|
|
666
|
+
const sep = fields.some((f) => f.includes('\n')) ? '\n\n' : '\n';
|
|
667
|
+
records.push(fields.join(sep));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (records.length === 0)
|
|
671
|
+
throw new Unrepresentable();
|
|
672
|
+
const body = records.join('\n\n---\n\n');
|
|
673
|
+
return title !== '' ? `**${title}**\n\n${body}` : body;
|
|
674
|
+
}
|
|
589
675
|
/**
|
|
590
676
|
* Сплющивает содержимое ячейки в одну строку: абзацы и пункты списков
|
|
591
677
|
* разделяются `<br>` (единственный HTML, идиоматичный для GFM-ячеек),
|
|
@@ -820,6 +906,30 @@ class Converter {
|
|
|
820
906
|
if (kids.length !== 1)
|
|
821
907
|
throw new Unrepresentable();
|
|
822
908
|
const ref = kids[0];
|
|
909
|
+
// attachments:'local' — картинка как HTML <img> (сохраняет размеры).
|
|
910
|
+
// ri:attachment → локальный путь; ri:url → внешний URL как есть.
|
|
911
|
+
if (this.localFiles && (ref.name === 'ri:attachment' || ref.name === 'ri:url')) {
|
|
912
|
+
let src;
|
|
913
|
+
let alt = '';
|
|
914
|
+
if (ref.name === 'ri:attachment') {
|
|
915
|
+
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
916
|
+
this.images.add(filename);
|
|
917
|
+
src = this.localPrefix + filename;
|
|
918
|
+
alt = filename;
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
src = getAttr(ref, 'ri:value') ?? '';
|
|
922
|
+
}
|
|
923
|
+
const attrs = [['src', escapeXmlAttr(src)]];
|
|
924
|
+
for (const [k] of el.attrs) {
|
|
925
|
+
const plain = k.startsWith('ac:') ? k.slice(3) : k;
|
|
926
|
+
if (plain === 'height' || plain === 'width') {
|
|
927
|
+
attrs.push([plain, escapeXmlAttr(getAttr(el, k) ?? '')]);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
attrs.push(['alt', escapeXmlAttr(alt)]);
|
|
931
|
+
return serializeStorage([{ kind: 'el', name: 'img', attrs, children: [], selfClosing: true }]);
|
|
932
|
+
}
|
|
823
933
|
if (ref.name === 'ri:url') {
|
|
824
934
|
if (ref.attrs.some(([k]) => k !== 'ri:value'))
|
|
825
935
|
throw new Unrepresentable();
|
|
@@ -894,8 +1004,15 @@ class Converter {
|
|
|
894
1004
|
throw new Unrepresentable();
|
|
895
1005
|
}
|
|
896
1006
|
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
897
|
-
badPlaceholderPart(filename);
|
|
898
1007
|
this.files.add(filename);
|
|
1008
|
+
// attachments:'local' — md-ссылка на локальный файл вместо {{file:}}.
|
|
1009
|
+
if (this.localFiles) {
|
|
1010
|
+
const label = (text ?? filename).replace(/[[\]]/g, '\\$&');
|
|
1011
|
+
const path = this.localPrefix + filename;
|
|
1012
|
+
const target = /[()\s]/.test(path) ? `<${path}>` : path;
|
|
1013
|
+
return `[${label}](${target})`;
|
|
1014
|
+
}
|
|
1015
|
+
badPlaceholderPart(filename);
|
|
899
1016
|
return `{{file:${filename}${textAttr}}}`;
|
|
900
1017
|
}
|
|
901
1018
|
throw new Unrepresentable();
|
package/package.json
CHANGED