confluence-md-sync 0.3.0 → 0.4.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 +28 -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 +338 -46
- 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,34 @@ 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, 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.
|
|
182
189
|
|
|
183
190
|
```ts
|
|
184
191
|
import { exportPage } from 'confluence-md-sync';
|
|
185
192
|
|
|
186
193
|
const { markdownPath, images, downloaded } = await exportPage(
|
|
187
194
|
'123456789',
|
|
188
|
-
{ outDir: 'exported' },
|
|
195
|
+
{ outDir: 'exported' }, // faithful; exported/page.md + exported/attachments/
|
|
189
196
|
cfg,
|
|
190
197
|
);
|
|
198
|
+
console.log(downloaded); // Map<filename, localPath> of saved attachments
|
|
199
|
+
|
|
200
|
+
// Readable variant for humans (writes page.md + ./attachments/ next to it):
|
|
201
|
+
await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
|
|
191
202
|
```
|
|
192
203
|
|
|
193
204
|
Round-trip means *edit the Markdown, then publish it straight back*:
|
|
@@ -220,8 +231,10 @@ normalised away (Confluence itself rewrites these on every save). Use
|
|
|
220
231
|
From the CLI:
|
|
221
232
|
|
|
222
233
|
```bash
|
|
223
|
-
|
|
224
|
-
confluence-md-sync
|
|
234
|
+
# attachments download to ./attachments/ next to the .md (omit --no-attachments)
|
|
235
|
+
confluence-md-sync export 123456789 --out page.md # faithful
|
|
236
|
+
confluence-md-sync export 123456789 --out page.md --readable # clean Markdown
|
|
237
|
+
confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
|
|
225
238
|
```
|
|
226
239
|
|
|
227
240
|
## Read pages and tables
|
|
@@ -321,7 +334,8 @@ export CONFLUENCE_BASE_URL='https://confluence.example.com'
|
|
|
321
334
|
export CONFLUENCE_TOKEN='<your-PAT>'
|
|
322
335
|
|
|
323
336
|
# Download a page to an exact .md path (add --no-attachments for the md only)
|
|
324
|
-
confluence-md-sync export 123456789 --out ./page.md
|
|
337
|
+
confluence-md-sync export 123456789 --out ./page.md # faithful
|
|
338
|
+
confluence-md-sync export 123456789 --out ./page.md --readable # clean Markdown
|
|
325
339
|
|
|
326
340
|
confluence-md-sync publish docs/page.md --page-id 123456789 \
|
|
327
341
|
--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) {
|
|
@@ -90,7 +104,11 @@ class Converter {
|
|
|
90
104
|
const h = /^h([1-6])$/.exec(el.name);
|
|
91
105
|
try {
|
|
92
106
|
if (h && el.attrs.length === 0) {
|
|
93
|
-
|
|
107
|
+
// Заголовок однострочен по определению — в readable схлопываем
|
|
108
|
+
// возможные переводы строк (из <br>) в пробел.
|
|
109
|
+
let inline = this.inlineToMd(el.children);
|
|
110
|
+
if (this.readable)
|
|
111
|
+
inline = inline.replace(/\s*\n\s*/g, ' ');
|
|
94
112
|
if (inline.includes('\n') || inline.trim() === '')
|
|
95
113
|
throw new Unrepresentable();
|
|
96
114
|
return '#'.repeat(Number(h[1])) + ' ' + guardLineStart(inline.trim());
|
|
@@ -108,6 +126,9 @@ class Converter {
|
|
|
108
126
|
return '---';
|
|
109
127
|
if (el.name === 'ul' || el.name === 'ol')
|
|
110
128
|
return this.listToMd(el);
|
|
129
|
+
// Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
|
|
130
|
+
// отклоняет: faithful → сырой HTML, readable → readableFallback
|
|
131
|
+
// (lossy++ и разворот в GFM через readableTable).
|
|
111
132
|
if (el.name === 'table')
|
|
112
133
|
return this.tableToMd(el);
|
|
113
134
|
if (el.name === 'ac:structured-macro')
|
|
@@ -130,7 +151,10 @@ class Converter {
|
|
|
130
151
|
if (children.length === 0)
|
|
131
152
|
return this.fallbackBlock(el);
|
|
132
153
|
try {
|
|
133
|
-
|
|
154
|
+
// multiline: в readable-абзаце <br> становится настоящим переводом
|
|
155
|
+
// строки (см. case 'br'). guardLineStart применяем к КАЖДОЙ строке —
|
|
156
|
+
// после переноса тоже нельзя случайно начать список/заголовок.
|
|
157
|
+
const inline = this.inlineToMd(children, { multiline: true }).trim();
|
|
134
158
|
if (inline === '')
|
|
135
159
|
return this.fallbackBlock(el);
|
|
136
160
|
// Абзац, начинающийся с блочного HTML-тега, markdown-it превратит в
|
|
@@ -138,7 +162,7 @@ class Converter {
|
|
|
138
162
|
const m = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(inline);
|
|
139
163
|
if (m && CM_BLOCK_TAGS.has(m[1].toLowerCase()))
|
|
140
164
|
return this.fallbackBlock(el);
|
|
141
|
-
return guardLineStart(
|
|
165
|
+
return inline.split('\n').map((line) => guardLineStart(line)).join('\n');
|
|
142
166
|
}
|
|
143
167
|
catch (e) {
|
|
144
168
|
if (!(e instanceof Unrepresentable))
|
|
@@ -153,6 +177,8 @@ class Converter {
|
|
|
153
177
|
* а рендер вернёт плейсхолдерам исходную ac:-форму.
|
|
154
178
|
*/
|
|
155
179
|
fallbackBlock(el) {
|
|
180
|
+
if (this.readable)
|
|
181
|
+
return this.readableFallback(el);
|
|
156
182
|
const direct = this.tryRawHtml(el);
|
|
157
183
|
if (direct !== null)
|
|
158
184
|
return direct;
|
|
@@ -204,14 +230,82 @@ class Converter {
|
|
|
204
230
|
const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
205
231
|
return `${ticks}confluence-storage\n${content}\n${ticks}`;
|
|
206
232
|
}
|
|
233
|
+
// ── Readable-режим: lossy-конвертация в чистый Markdown ────────────────
|
|
234
|
+
/**
|
|
235
|
+
* Fallback readable-режима: НИКОГДА не выдаёт сырой HTML-блок. Таблицы
|
|
236
|
+
* разворачивает в GFM, контейнеры-обёртки — в блоки, blockquote — в `>`,
|
|
237
|
+
* остальное — в инлайн/текст. Content сохраняется, оформление теряется.
|
|
238
|
+
*/
|
|
239
|
+
readableFallback(el) {
|
|
240
|
+
this.stats.lossy++;
|
|
241
|
+
const name = el.name.toLowerCase();
|
|
242
|
+
if (el.name === 'table') {
|
|
243
|
+
try {
|
|
244
|
+
return this.readableTable(el);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
if (!(e instanceof Unrepresentable))
|
|
248
|
+
throw e;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (UNWRAP_BLOCK.has(name)) {
|
|
252
|
+
const inner = this.blocksToMd(el.children).trimEnd();
|
|
253
|
+
if (inner !== '')
|
|
254
|
+
return inner;
|
|
255
|
+
return '';
|
|
256
|
+
}
|
|
257
|
+
if (name === 'blockquote') {
|
|
258
|
+
const inner = this.blocksToMd(el.children).trimEnd();
|
|
259
|
+
return inner
|
|
260
|
+
.split('\n')
|
|
261
|
+
.map((line) => (line === '' ? '>' : `> ${line}`))
|
|
262
|
+
.join('\n');
|
|
263
|
+
}
|
|
264
|
+
// p / td / th / li / caption и прочие «инлайн-контейнеры» → инлайн.
|
|
265
|
+
// multiline: это свободный поток (не ячейка) — <br> станет переносом.
|
|
266
|
+
const inline = this.inlineToMd(el.children, { multiline: true }).trim();
|
|
267
|
+
if (inline !== '')
|
|
268
|
+
return inline.split('\n').map((l) => guardLineStart(l)).join('\n');
|
|
269
|
+
// Совсем ничего не вышло — голый текст (может быть пустым).
|
|
270
|
+
return escapeMdText(textContent(el.children), {}, this.readable).trim();
|
|
271
|
+
}
|
|
207
272
|
// ── Макросы ──────────────────────────────────────────────────────────
|
|
208
273
|
macroToMd(el) {
|
|
209
274
|
const name = getAttr(el, 'ac:name') ?? '';
|
|
210
275
|
const markerMd = this.tryMacroMarker(el, name);
|
|
211
|
-
if (markerMd
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
276
|
+
if (markerMd !== null) {
|
|
277
|
+
this.stats.markers++;
|
|
278
|
+
return markerMd;
|
|
279
|
+
}
|
|
280
|
+
if (this.readable)
|
|
281
|
+
return this.readableMacro(el);
|
|
282
|
+
return this.fence(serializeStorage([el]));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Readable-режим для макроса, который не лёг в маркер: сохраняем
|
|
286
|
+
* содержимое, теряем «обёртку» макроса. rich-text-body → блоки;
|
|
287
|
+
* plain-text-body → код-fence (обычный ``` — чистый Markdown);
|
|
288
|
+
* иначе — заголовок-подпись, чтобы место макроса не исчезло бесследно.
|
|
289
|
+
*/
|
|
290
|
+
readableMacro(el) {
|
|
291
|
+
this.stats.lossy++;
|
|
292
|
+
const name = getAttr(el, 'ac:name') ?? 'macro';
|
|
293
|
+
for (const child of elements(el.children)) {
|
|
294
|
+
if (child.name === 'ac:rich-text-body') {
|
|
295
|
+
const inner = this.blocksToMd(child.children).trimEnd();
|
|
296
|
+
if (inner !== '')
|
|
297
|
+
return inner;
|
|
298
|
+
}
|
|
299
|
+
if (child.name === 'ac:plain-text-body') {
|
|
300
|
+
const text = textContent(child.children);
|
|
301
|
+
if (text.trim() !== '') {
|
|
302
|
+
const ticks = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((r) => r.length + 1)));
|
|
303
|
+
return `${ticks}\n${text}\n${ticks}`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Bodyless-макрос (toc, children, …) — оставляем видимый след.
|
|
308
|
+
return `_[macro: ${name}]_`;
|
|
215
309
|
}
|
|
216
310
|
/**
|
|
217
311
|
* Пытается выразить макрос маркером. Возвращает null, если параметры
|
|
@@ -414,7 +508,137 @@ class Converter {
|
|
|
414
508
|
const md = this.inlineToMd(content, { cell: true }).trim();
|
|
415
509
|
if (md.includes('\n'))
|
|
416
510
|
throw new Unrepresentable();
|
|
417
|
-
|
|
511
|
+
// Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
|
|
512
|
+
// плейсхолдеры {{img:…|…}} (которые минуют инлайн-экранирование).
|
|
513
|
+
return md.replace(/\|/g, '\\|');
|
|
514
|
+
}
|
|
515
|
+
// ── Readable-таблицы: любая таблица → GFM ─────────────────────────────
|
|
516
|
+
/**
|
|
517
|
+
* Разворачивает произвольную таблицу (colspan/rowspan, блочные ячейки,
|
|
518
|
+
* несколько header-строк) в GFM. Объединения превращаются в плотную
|
|
519
|
+
* сетку: содержимое — в верхней-левой клетке диапазона, остальные клетки
|
|
520
|
+
* пустые. Первая строка сетки становится шапкой GFM.
|
|
521
|
+
*/
|
|
522
|
+
readableTable(table) {
|
|
523
|
+
const trs = [];
|
|
524
|
+
let caption = '';
|
|
525
|
+
for (const child of elements(table.children)) {
|
|
526
|
+
if (child.name === 'caption')
|
|
527
|
+
caption = this.inlineToMd(child.children).trim();
|
|
528
|
+
else if (['thead', 'tbody', 'tfoot'].includes(child.name)) {
|
|
529
|
+
for (const tr of elements(child.children))
|
|
530
|
+
if (tr.name === 'tr')
|
|
531
|
+
trs.push(tr);
|
|
532
|
+
}
|
|
533
|
+
else if (child.name === 'tr')
|
|
534
|
+
trs.push(child);
|
|
535
|
+
}
|
|
536
|
+
if (trs.length === 0)
|
|
537
|
+
throw new Unrepresentable();
|
|
538
|
+
// Плотная сетка с учётом colspan/rowspan.
|
|
539
|
+
const grid = [];
|
|
540
|
+
const aligns = [];
|
|
541
|
+
trs.forEach((tr, rowIdx) => {
|
|
542
|
+
if (!grid[rowIdx])
|
|
543
|
+
grid[rowIdx] = [];
|
|
544
|
+
let col = 0;
|
|
545
|
+
for (const cell of elements(tr.children)) {
|
|
546
|
+
if (cell.name !== 'td' && cell.name !== 'th')
|
|
547
|
+
continue;
|
|
548
|
+
while (grid[rowIdx][col] !== undefined)
|
|
549
|
+
col++;
|
|
550
|
+
const colspan = Math.max(1, Number(getAttr(cell, 'colspan') ?? '1') || 1);
|
|
551
|
+
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, '\\|');
|
|
556
|
+
for (let r = 0; r < rowspan; r++) {
|
|
557
|
+
for (let c = 0; c < colspan; c++) {
|
|
558
|
+
const rr = rowIdx + r;
|
|
559
|
+
const cc = col + c;
|
|
560
|
+
if (!grid[rr])
|
|
561
|
+
grid[rr] = [];
|
|
562
|
+
grid[rr][cc] = r === 0 && c === 0 ? content : '';
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (rowIdx === 0) {
|
|
566
|
+
const a = readableAlign(cell);
|
|
567
|
+
for (let c = 0; c < colspan; c++)
|
|
568
|
+
aligns[col + c] = a;
|
|
569
|
+
}
|
|
570
|
+
col += colspan;
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
const width = Math.max(...grid.map((r) => r.length));
|
|
574
|
+
if (width === 0)
|
|
575
|
+
throw new Unrepresentable();
|
|
576
|
+
for (const r of grid) {
|
|
577
|
+
for (let c = 0; c < width; c++)
|
|
578
|
+
if (r[c] === undefined)
|
|
579
|
+
r[c] = '';
|
|
580
|
+
}
|
|
581
|
+
while (aligns.length < width)
|
|
582
|
+
aligns.push('none');
|
|
583
|
+
const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
|
|
584
|
+
const rowMd = (cells) => `| ${cells.join(' | ')} |`;
|
|
585
|
+
const lines = [rowMd(grid[0]), rowMd(sep), ...grid.slice(1).map(rowMd)];
|
|
586
|
+
const table_ = lines.join('\n');
|
|
587
|
+
return caption !== '' ? `**${caption}**\n\n${table_}` : table_;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Сплющивает содержимое ячейки в одну строку: абзацы и пункты списков
|
|
591
|
+
* разделяются `<br>` (единственный HTML, идиоматичный для GFM-ячеек),
|
|
592
|
+
* пункты помечаются `• `. Инлайн-разметка (жирный, ссылки, плейсхолдеры)
|
|
593
|
+
* сохраняется.
|
|
594
|
+
*/
|
|
595
|
+
cellFlatten(nodes) {
|
|
596
|
+
const blocks = [];
|
|
597
|
+
let inlineRun = [];
|
|
598
|
+
const flush = () => {
|
|
599
|
+
if (inlineRun.length === 0)
|
|
600
|
+
return;
|
|
601
|
+
const s = this.inlineToMd(inlineRun, { cell: true }).replace(/\s+/g, ' ').trim();
|
|
602
|
+
if (s !== '')
|
|
603
|
+
blocks.push(s);
|
|
604
|
+
inlineRun = [];
|
|
605
|
+
};
|
|
606
|
+
for (const n of nodes) {
|
|
607
|
+
if (n.kind === 'el' && (n.name === 'ul' || n.name === 'ol')) {
|
|
608
|
+
flush();
|
|
609
|
+
blocks.push(this.flattenList(n));
|
|
610
|
+
}
|
|
611
|
+
else if (n.kind === 'el' && n.name === 'p') {
|
|
612
|
+
flush();
|
|
613
|
+
const s = this.inlineToMd(n.children, { cell: true }).replace(/\s+/g, ' ').trim();
|
|
614
|
+
if (s !== '')
|
|
615
|
+
blocks.push(s);
|
|
616
|
+
}
|
|
617
|
+
else if (n.kind === 'el' && (UNWRAP_BLOCK.has(n.name.toLowerCase()) || n.name === 'blockquote')) {
|
|
618
|
+
flush();
|
|
619
|
+
const s = this.cellFlatten(n.children);
|
|
620
|
+
if (s !== '')
|
|
621
|
+
blocks.push(s);
|
|
622
|
+
}
|
|
623
|
+
else if (n.kind === 'el' && n.name === 'table') {
|
|
624
|
+
flush();
|
|
625
|
+
const s = this.cellFlatten(collectCellText(n));
|
|
626
|
+
if (s !== '')
|
|
627
|
+
blocks.push(s);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
inlineRun.push(n);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
flush();
|
|
634
|
+
return blocks.filter((b) => b !== '').join('<br>');
|
|
635
|
+
}
|
|
636
|
+
flattenList(list) {
|
|
637
|
+
const items = elements(list.children).filter((li) => li.name === 'li');
|
|
638
|
+
return items
|
|
639
|
+
.map((li) => '• ' + this.cellFlatten(li.children))
|
|
640
|
+
.filter((s) => s !== '• ')
|
|
641
|
+
.join('<br>');
|
|
418
642
|
}
|
|
419
643
|
// ── Инлайн ───────────────────────────────────────────────────────────
|
|
420
644
|
/**
|
|
@@ -471,12 +695,19 @@ class Converter {
|
|
|
471
695
|
let out = '';
|
|
472
696
|
for (const n of this.normalizeInline(nodes)) {
|
|
473
697
|
if (n.kind === 'text') {
|
|
474
|
-
out += escapeMdText(n.raw, ctx);
|
|
698
|
+
out += escapeMdText(n.raw, ctx, this.readable);
|
|
475
699
|
continue;
|
|
476
700
|
}
|
|
477
|
-
if (n.kind === 'cdata')
|
|
701
|
+
if (n.kind === 'cdata') {
|
|
702
|
+
if (this.readable) {
|
|
703
|
+
out += escapeMdText(n.text, ctx, true);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
478
706
|
throw new Unrepresentable();
|
|
707
|
+
}
|
|
479
708
|
if (n.kind === 'comment') {
|
|
709
|
+
if (this.readable)
|
|
710
|
+
continue; // невидимый комментарий — отбрасываем
|
|
480
711
|
if (n.text.includes('MACRO:') || n.text.includes('-->'))
|
|
481
712
|
throw new Unrepresentable();
|
|
482
713
|
out += `<!--${n.text}-->`;
|
|
@@ -492,24 +723,31 @@ class Converter {
|
|
|
492
723
|
return this.wrapInline(el, '**', ctx);
|
|
493
724
|
case 'em':
|
|
494
725
|
return this.wrapInline(el, '*', ctx);
|
|
495
|
-
// <b>/<i
|
|
726
|
+
// <b>/<i>: faithful — сырой HTML (** рендерится в <strong>, а не <b>);
|
|
727
|
+
// readable — маппим в **/* (потеря точного тега приемлема).
|
|
496
728
|
case 'b':
|
|
729
|
+
return this.readable ? this.wrapInline(el, '**', ctx) : this.rawInline(el, ctx);
|
|
497
730
|
case 'i':
|
|
498
|
-
return this.rawInline(el, ctx);
|
|
731
|
+
return this.readable ? this.wrapInline(el, '*', ctx) : this.rawInline(el, ctx);
|
|
499
732
|
case 's':
|
|
500
733
|
return this.wrapInline(el, '~~', ctx);
|
|
501
734
|
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() === '')
|
|
735
|
+
if (el.attrs.length > 0 && !this.readable)
|
|
506
736
|
throw new Unrepresentable();
|
|
737
|
+
const text = textContent(el.children).replace(/\s*\n\s*/g, ' ');
|
|
738
|
+
if (text.trim() === '')
|
|
739
|
+
return this.readable ? '' : (() => { throw new Unrepresentable(); })();
|
|
507
740
|
const runs = text.match(/`+/g) ?? [];
|
|
508
741
|
const ticks = '`'.repeat(Math.max(1, ...runs.map((r) => r.length + 1)));
|
|
509
742
|
const pad = text.startsWith('`') || text.endsWith('`') || text.startsWith(' ') || text.endsWith(' ') ? ' ' : '';
|
|
510
743
|
return `${ticks}${pad}${text}${pad}${ticks}`;
|
|
511
744
|
}
|
|
512
745
|
case 'br':
|
|
746
|
+
// readable: в свободном потоке (абзац/цитата) — настоящий перенос
|
|
747
|
+
// строки; в ячейке GFM-таблицы перенос невозможен (сломал бы строку
|
|
748
|
+
// таблицы) — там остаётся <br>. faithful — всегда <br/> (round-trip).
|
|
749
|
+
if (this.readable)
|
|
750
|
+
return ctx.multiline ? '\n' : ctx.cell ? '<br>' : '<br/>';
|
|
513
751
|
return '<br/>';
|
|
514
752
|
case 'a':
|
|
515
753
|
return this.linkAnchorToMd(el, ctx);
|
|
@@ -517,35 +755,42 @@ class Converter {
|
|
|
517
755
|
const src = getAttr(el, 'src') ?? '';
|
|
518
756
|
const alt = getAttr(el, 'alt') ?? '';
|
|
519
757
|
const other = el.attrs.filter(([k]) => k !== 'src' && k !== 'alt');
|
|
520
|
-
if (other.length === 0 && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
|
|
521
|
-
return ``;
|
|
758
|
+
if ((other.length === 0 || this.readable) && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
|
|
759
|
+
return ``; // readable: лишние атрибуты (class…) отбрасываются
|
|
522
760
|
}
|
|
523
|
-
return this.rawInline(el, ctx);
|
|
761
|
+
return this.readable ? escapeMdText(alt, ctx, true) : this.rawInline(el, ctx);
|
|
524
762
|
}
|
|
525
763
|
case 'ac:image':
|
|
526
764
|
return this.acImageToMd(el);
|
|
527
765
|
case 'ac:link':
|
|
528
766
|
return this.acLinkToMd(el);
|
|
529
767
|
default:
|
|
768
|
+
// readable: любой не-ac инлайн-контейнер (span, u, sub, font, …)
|
|
769
|
+
// разворачиваем — тег и стили теряем, содержимое оставляем.
|
|
770
|
+
if (this.readable && !el.name.includes(':'))
|
|
771
|
+
return this.inlineToMd(el.children, ctx);
|
|
772
|
+
if (this.readable)
|
|
773
|
+
return escapeMdText(textContent(el.children), ctx, true);
|
|
530
774
|
if (INLINE_RAW_WRAP.has(el.name))
|
|
531
775
|
return this.rawInline(el, ctx);
|
|
532
776
|
throw new Unrepresentable();
|
|
533
777
|
}
|
|
534
778
|
}
|
|
535
779
|
wrapInline(el, marker, ctx) {
|
|
536
|
-
if (el.attrs.length > 0)
|
|
780
|
+
if (el.attrs.length > 0 && !this.readable)
|
|
537
781
|
return this.rawInline(el, ctx);
|
|
538
782
|
const inner = this.inlineToMd(el.children, ctx);
|
|
539
783
|
// Краевые ПРОСТЫЕ пробелы выносим наружу — `**текст **` маркдауном не
|
|
540
784
|
// является. Юникодные пробелы ( и т.п.) выносить нельзя (изменит
|
|
541
785
|
// содержимое), а внутри маркеров они ломают flanking-правила — такой
|
|
542
|
-
// элемент отдаём сырым HTML
|
|
786
|
+
// элемент отдаём сырым HTML (faithful) либо просто оставляем как есть
|
|
787
|
+
// без обёртки (readable).
|
|
543
788
|
const m = /^([ \t]*)([\s\S]*?)([ \t]*)$/.exec(inner);
|
|
544
789
|
if (!m || m[2] === '')
|
|
545
790
|
return inner;
|
|
546
791
|
const decodedEdges = decodeEntities(m[2]);
|
|
547
792
|
if (/^\s|\s$/u.test(decodedEdges))
|
|
548
|
-
return this.rawInline(el, ctx);
|
|
793
|
+
return this.readable ? inner : this.rawInline(el, ctx);
|
|
549
794
|
return `${m[1]}${marker}${m[2]}${marker}${m[3]}`;
|
|
550
795
|
}
|
|
551
796
|
/** Инлайн-элемент дословно: открывающий тег + инлайн-дети + закрывающий. */
|
|
@@ -562,9 +807,12 @@ class Converter {
|
|
|
562
807
|
linkAnchorToMd(el, ctx) {
|
|
563
808
|
const href = getAttr(el, 'href') ?? '';
|
|
564
809
|
const inner = this.inlineToMd(el.children, ctx);
|
|
565
|
-
|
|
566
|
-
|
|
810
|
+
const onlyHref = el.attrs.length === 1 && el.attrs[0][0] === 'href';
|
|
811
|
+
if ((onlyHref || this.readable) && SAFE_URL_RE.test(href) && !/[[\]]/.test(inner)) {
|
|
812
|
+
return `[${inner}](${href})`; // readable: доп. атрибуты ссылки отбрасываются
|
|
567
813
|
}
|
|
814
|
+
if (this.readable)
|
|
815
|
+
return inner; // ссылку не выразить в MD — оставляем текст
|
|
568
816
|
return this.rawInline(el, ctx);
|
|
569
817
|
}
|
|
570
818
|
acImageToMd(el) {
|
|
@@ -801,26 +1049,36 @@ const ENTITY_RE = /&(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
|
|
|
801
1049
|
* Экранирует markdown-активные символы, сохраняя сущности ( и т.п.)
|
|
802
1050
|
* как есть — markdown-it декодирует их при рендере.
|
|
803
1051
|
*/
|
|
804
|
-
function escapeMdText(raw, ctx) {
|
|
1052
|
+
function escapeMdText(raw, ctx, readable = false) {
|
|
805
1053
|
const collapsed = raw.replace(/[\r\n]+/g, ' ');
|
|
806
1054
|
let out = '';
|
|
807
1055
|
let last = 0;
|
|
808
1056
|
for (const m of collapsed.matchAll(ENTITY_RE)) {
|
|
809
|
-
out += escapePlain(collapsed.slice(last, m.index), ctx);
|
|
810
|
-
|
|
1057
|
+
out += escapePlain(collapsed.slice(last, m.index), ctx, readable);
|
|
1058
|
+
if (readable) {
|
|
1059
|
+
// readable: декодируем сущность в символ ("→", —→—, nbsp→
|
|
1060
|
+
// пробел). `<`, `>`, `&` оставляем сущностями — их «живой» символ
|
|
1061
|
+
// мог бы создать случайный HTML/сущность. Нераспознанное — как есть.
|
|
1062
|
+
const dec = decodeEntities(m[0]);
|
|
1063
|
+
out += dec === m[0] || dec === '<' || dec === '>' || dec === '&'
|
|
1064
|
+
? m[0]
|
|
1065
|
+
: escapePlain(dec, ctx, readable);
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
out += m[0];
|
|
1069
|
+
}
|
|
811
1070
|
last = m.index + m[0].length;
|
|
812
1071
|
}
|
|
813
|
-
out += escapePlain(collapsed.slice(last), ctx);
|
|
1072
|
+
out += escapePlain(collapsed.slice(last), ctx, readable);
|
|
814
1073
|
return out;
|
|
815
1074
|
}
|
|
816
|
-
function escapePlain(s,
|
|
817
|
-
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
return esc;
|
|
1075
|
+
function escapePlain(s, _ctx, readable = false) {
|
|
1076
|
+
const esc = s.replace(/[\\`*_[\]{}~]/g, (c) => '\\' + c);
|
|
1077
|
+
// Пайпы здесь НЕ трогаем — они экранируются один раз на границе ячейки
|
|
1078
|
+
// (cellMd / readableTable), иначе плейсхолдеры {{img:…|…}} двоились бы.
|
|
1079
|
+
// readable: неразрывный пробел → обычный; faithful: → entity (переживает
|
|
1080
|
+
// trim() markdown-it на краю абзаца).
|
|
1081
|
+
return esc.replace(/\u00A0/g, readable ? ' ' : ' ');
|
|
824
1082
|
}
|
|
825
1083
|
/** Экранирует конструкции, значимые в начале строки (#, >, -, 1. …). */
|
|
826
1084
|
function guardLineStart(md) {
|
|
@@ -843,3 +1101,37 @@ function cellAlign(cell) {
|
|
|
843
1101
|
throw new Unrepresentable();
|
|
844
1102
|
return m[1];
|
|
845
1103
|
}
|
|
1104
|
+
/** Как cellAlign, но не бросает: любой нераспознанный стиль → 'none'. */
|
|
1105
|
+
function readableAlign(cell) {
|
|
1106
|
+
const style = getAttr(cell, 'style');
|
|
1107
|
+
const m = style ? /text-align:\s*(left|right|center)/.exec(style) : null;
|
|
1108
|
+
return m ? m[1] : 'none';
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Причёсывает содержимое GFM-ячейки: нормализует `<br/>`→`<br>`, схлопывает
|
|
1112
|
+
* подряд идущие переводы строк и срезает их по краям — чтобы «пустая»
|
|
1113
|
+
* ячейка (в исходнике `<p><br/></p>`) стала действительно пустой.
|
|
1114
|
+
*/
|
|
1115
|
+
function tidyCell(s) {
|
|
1116
|
+
return s
|
|
1117
|
+
.replace(/<br\s*\/?>/gi, '<br>')
|
|
1118
|
+
.replace(/(?:\s*<br>\s*)+/g, '<br>')
|
|
1119
|
+
.replace(/^<br>|<br>$/g, '')
|
|
1120
|
+
.trim();
|
|
1121
|
+
}
|
|
1122
|
+
/** Разворачивает вложенную в ячейку таблицу в плоский список её ячеек. */
|
|
1123
|
+
function collectCellText(table) {
|
|
1124
|
+
const out = [];
|
|
1125
|
+
const walk = (nodes) => {
|
|
1126
|
+
for (const n of nodes) {
|
|
1127
|
+
if (n.kind === 'el' && (n.name === 'td' || n.name === 'th')) {
|
|
1128
|
+
out.push({ kind: 'el', name: 'p', attrs: [], children: n.children, selfClosing: false });
|
|
1129
|
+
}
|
|
1130
|
+
else if (n.kind === 'el') {
|
|
1131
|
+
walk(n.children);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
walk(table.children);
|
|
1136
|
+
return out;
|
|
1137
|
+
}
|
package/package.json
CHANGED