confluence-md-sync 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -9
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +84 -28
- package/dist/export/canonical.d.ts +38 -0
- package/dist/export/canonical.js +272 -0
- package/dist/export/export-page.d.ts +36 -0
- package/dist/export/export-page.js +41 -0
- package/dist/export/roundtrip.d.ts +30 -0
- package/dist/export/roundtrip.js +32 -0
- package/dist/export/to-markdown.d.ts +49 -0
- package/dist/export/to-markdown.js +1132 -0
- package/dist/export/xhtml.d.ts +52 -0
- package/dist/export/xhtml.js +230 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +8 -1
- package/dist/macros/builder.d.ts +7 -0
- package/dist/macros/builder.js +23 -2
- package/dist/macros/index.d.ts +2 -1
- package/dist/macros/index.js +2 -1
- package/dist/macros/plugins/core.js +3 -0
- package/dist/macros/plugins/table-filter.d.ts +8 -0
- package/dist/macros/plugins/table-filter.js +35 -3
- package/dist/macros/registry.d.ts +11 -0
- package/dist/macros/registry.js +45 -1
- package/dist/markdown/render.d.ts +30 -1
- package/dist/markdown/render.js +83 -13
- package/dist/publish/publish.d.ts +17 -0
- package/dist/publish/publish.js +43 -4
- package/dist/publish/remote.d.ts +25 -0
- package/dist/publish/remote.js +52 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Конвертация Confluence storage → Markdown. Два режима:
|
|
3
|
+
*
|
|
4
|
+
* FAITHFUL (default) — гарантия round-trip. Трёхуровневая политика:
|
|
5
|
+
* 1. чистый Markdown — заголовки, абзацы, списки, простые таблицы,
|
|
6
|
+
* ссылки, картинки-аттачи ({{img:...}}), page-ссылки ({{page:...}});
|
|
7
|
+
* 2. маркеры макросов <!-- MACRO:start/end --> — для макросов, чей
|
|
8
|
+
* рендер восстанавливает исходный XHTML (проверяется на месте:
|
|
9
|
+
* маркер прогоняется через render-конвейер и сравнивается канонически);
|
|
10
|
+
* 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML,
|
|
11
|
+
* с ними — fenced-блоком ```confluence-storage.
|
|
12
|
+
* Потери исключены по построению; но (3) даёт сырой HTML, который многие
|
|
13
|
+
* md-редакторы показывают уродливо.
|
|
14
|
+
*
|
|
15
|
+
* READABLE — чистый Markdown ценой оформления. Round-trip НЕ гарантируется:
|
|
16
|
+
* теряются цвета/стили спанов, div-обёртки, точная геометрия объединённых
|
|
17
|
+
* ячеек; сохраняется смысловая нагрузка (текст, структура). Сложные
|
|
18
|
+
* таблицы разворачиваются в GFM (colspan/rowspan → сетка с заполнением,
|
|
19
|
+
* блочное содержимое ячеек — во flatten через <br>). Сырой HTML-блок не
|
|
20
|
+
* выдаётся никогда.
|
|
21
|
+
*/
|
|
22
|
+
import { macro } from '../macros/builder.js';
|
|
23
|
+
import { escapeXmlAttr } from '../macros/xml.js';
|
|
24
|
+
import { processMacros } from '../macros/registry.js';
|
|
25
|
+
import { defaultMacroRegistry } from '../macros/index.js';
|
|
26
|
+
import { renderToStorage } from '../markdown/render.js';
|
|
27
|
+
import { compareStorage } from './canonical.js';
|
|
28
|
+
import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
|
|
29
|
+
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
30
|
+
export function storageToMarkdown(storage, opts = {}) {
|
|
31
|
+
const conv = new Converter(opts.registry ?? defaultMacroRegistry, opts.mode === 'readable');
|
|
32
|
+
const markdown = conv.blocksToMd(parseStorage(storage));
|
|
33
|
+
const attachmentRefs = new Set([...conv.images, ...conv.files]);
|
|
34
|
+
for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
|
|
35
|
+
attachmentRefs.add(m[1]);
|
|
36
|
+
return {
|
|
37
|
+
markdown,
|
|
38
|
+
images: [...conv.images],
|
|
39
|
+
files: [...conv.files],
|
|
40
|
+
attachmentRefs: [...attachmentRefs],
|
|
41
|
+
stats: conv.stats,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Сигнал «в Markdown не выражается» — узел уходит в fallback уровнем выше. */
|
|
45
|
+
class Unrepresentable extends Error {
|
|
46
|
+
}
|
|
47
|
+
// Теги, с которых начинается HTML-блок CommonMark (type 6) — абзац,
|
|
48
|
+
// начинающийся с такого тега, нельзя отдавать как markdown-строку.
|
|
49
|
+
const CM_BLOCK_TAGS = new Set([
|
|
50
|
+
'address', 'article', 'aside', 'base', 'blockquote', 'body', 'br', 'caption',
|
|
51
|
+
'center', 'col', 'colgroup', 'dd', 'details', 'dialog', 'dir', 'div', 'dl',
|
|
52
|
+
'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'frame',
|
|
53
|
+
'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr',
|
|
54
|
+
'html', 'iframe', 'legend', 'li', 'link', 'main', 'menu', 'menuitem', 'nav',
|
|
55
|
+
'noframes', 'ol', 'optgroup', 'option', 'p', 'param', 'section', 'summary',
|
|
56
|
+
'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul',
|
|
57
|
+
'pre', 'script', 'style', 'textarea',
|
|
58
|
+
]);
|
|
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
|
+
]);
|
|
67
|
+
const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
|
|
68
|
+
class Converter {
|
|
69
|
+
registry;
|
|
70
|
+
readable;
|
|
71
|
+
images = new Set();
|
|
72
|
+
files = new Set();
|
|
73
|
+
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
|
|
74
|
+
constructor(registry, readable = false) {
|
|
75
|
+
this.registry = registry;
|
|
76
|
+
this.readable = readable;
|
|
77
|
+
}
|
|
78
|
+
// ── Блочный уровень ──────────────────────────────────────────────────
|
|
79
|
+
blocksToMd(nodes) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
for (const n of nodes) {
|
|
82
|
+
const md = this.blockToMd(n);
|
|
83
|
+
if (md !== '')
|
|
84
|
+
parts.push(md);
|
|
85
|
+
}
|
|
86
|
+
return parts.join('\n\n') + (parts.length > 0 ? '\n' : '');
|
|
87
|
+
}
|
|
88
|
+
blockToMd(node) {
|
|
89
|
+
if (node.kind === 'text') {
|
|
90
|
+
if (/^[ \t\r\n]*$/.test(node.raw))
|
|
91
|
+
return '';
|
|
92
|
+
return this.paragraphMd([node]);
|
|
93
|
+
}
|
|
94
|
+
if (node.kind === 'comment') {
|
|
95
|
+
// Дословный комментарий; MACRO-подобные — через fence, чтобы не
|
|
96
|
+
// конфликтовали с маркерами макросов.
|
|
97
|
+
if (node.text.includes('MACRO:'))
|
|
98
|
+
return this.fence(`<!--${node.text}-->`);
|
|
99
|
+
return `<!--${node.text}-->`;
|
|
100
|
+
}
|
|
101
|
+
if (node.kind === 'cdata')
|
|
102
|
+
return this.fence(serializeStorage([node]));
|
|
103
|
+
const el = node;
|
|
104
|
+
const h = /^h([1-6])$/.exec(el.name);
|
|
105
|
+
try {
|
|
106
|
+
if (h && el.attrs.length === 0) {
|
|
107
|
+
const inline = this.inlineToMd(el.children);
|
|
108
|
+
if (inline.includes('\n') || inline.trim() === '')
|
|
109
|
+
throw new Unrepresentable();
|
|
110
|
+
return '#'.repeat(Number(h[1])) + ' ' + guardLineStart(inline.trim());
|
|
111
|
+
}
|
|
112
|
+
if (el.name === 'p' && el.attrs.length === 0) {
|
|
113
|
+
// Редактор оборачивает блочные макросы в <p> — разворачиваем:
|
|
114
|
+
// маркер и так блочный, канонизация считает формы эквивалентными.
|
|
115
|
+
const meaningful = el.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
116
|
+
if (meaningful.length === 1 && meaningful[0].kind === 'el' && meaningful[0].name === 'ac:structured-macro') {
|
|
117
|
+
return this.macroToMd(meaningful[0]);
|
|
118
|
+
}
|
|
119
|
+
return this.paragraphMd(el.children);
|
|
120
|
+
}
|
|
121
|
+
if (el.name === 'hr')
|
|
122
|
+
return '---';
|
|
123
|
+
if (el.name === 'ul' || el.name === 'ol')
|
|
124
|
+
return this.listToMd(el);
|
|
125
|
+
// Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
|
|
126
|
+
// отклоняет: faithful → сырой HTML, readable → readableFallback
|
|
127
|
+
// (lossy++ и разворот в GFM через readableTable).
|
|
128
|
+
if (el.name === 'table')
|
|
129
|
+
return this.tableToMd(el);
|
|
130
|
+
if (el.name === 'ac:structured-macro')
|
|
131
|
+
return this.macroToMd(el);
|
|
132
|
+
if (el.name === 'ac:image' || el.name === 'ac:link') {
|
|
133
|
+
// Блочная картинка/ссылка — оформляем как отдельный абзац.
|
|
134
|
+
return this.paragraphMd([el]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
if (!(e instanceof Unrepresentable))
|
|
139
|
+
throw e;
|
|
140
|
+
return this.fallbackBlock(el);
|
|
141
|
+
}
|
|
142
|
+
return this.fallbackBlock(el);
|
|
143
|
+
}
|
|
144
|
+
/** Абзац: инлайн-конвертация; не легло — дословный <p>. */
|
|
145
|
+
paragraphMd(children) {
|
|
146
|
+
const el = { kind: 'el', name: 'p', attrs: [], children, selfClosing: false };
|
|
147
|
+
if (children.length === 0)
|
|
148
|
+
return this.fallbackBlock(el);
|
|
149
|
+
try {
|
|
150
|
+
const inline = this.inlineToMd(children).trim();
|
|
151
|
+
if (inline === '')
|
|
152
|
+
return this.fallbackBlock(el);
|
|
153
|
+
// Абзац, начинающийся с блочного HTML-тега, markdown-it превратит в
|
|
154
|
+
// html-блок без <p>-обёртки — такой отдаём дословно.
|
|
155
|
+
const m = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(inline);
|
|
156
|
+
if (m && CM_BLOCK_TAGS.has(m[1].toLowerCase()))
|
|
157
|
+
return this.fallbackBlock(el);
|
|
158
|
+
return guardLineStart(inline);
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
if (!(e instanceof Unrepresentable))
|
|
162
|
+
throw e;
|
|
163
|
+
return this.fallbackBlock(el);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Дословный фрагмент: raw HTML если можно, иначе fence. Перед fence
|
|
168
|
+
* пробуем «поднять» вложенные ac:image/ac:link в плейсхолдеры — тогда
|
|
169
|
+
* блок (обычно сложная таблица с картинками) остаётся читаемым HTML,
|
|
170
|
+
* а рендер вернёт плейсхолдерам исходную ac:-форму.
|
|
171
|
+
*/
|
|
172
|
+
fallbackBlock(el) {
|
|
173
|
+
if (this.readable)
|
|
174
|
+
return this.readableFallback(el);
|
|
175
|
+
const direct = this.tryRawHtml(el);
|
|
176
|
+
if (direct !== null)
|
|
177
|
+
return direct;
|
|
178
|
+
if (!el.name.includes(':') && CM_BLOCK_TAGS.has(el.name.toLowerCase())) {
|
|
179
|
+
const images = new Set(this.images);
|
|
180
|
+
const files = new Set(this.files);
|
|
181
|
+
try {
|
|
182
|
+
const lifted = this.liftNode(el);
|
|
183
|
+
const raw = this.tryRawHtml(lifted);
|
|
184
|
+
if (raw !== null)
|
|
185
|
+
return raw;
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
if (!(e instanceof Unrepresentable))
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
this.images = images;
|
|
192
|
+
this.files = files;
|
|
193
|
+
}
|
|
194
|
+
return this.fence(serializeStorage([el]));
|
|
195
|
+
}
|
|
196
|
+
tryRawHtml(el) {
|
|
197
|
+
const serialized = serializeStorage([el]);
|
|
198
|
+
if (!hasNamespacedElements([el]) &&
|
|
199
|
+
!el.name.includes(':') &&
|
|
200
|
+
CM_BLOCK_TAGS.has(el.name.toLowerCase()) &&
|
|
201
|
+
!/\n[ \t]*\n/.test(serialized) &&
|
|
202
|
+
!serialized.includes('MACRO:')) {
|
|
203
|
+
this.stats.rawHtml++;
|
|
204
|
+
return serialized;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/** Заменяет в поддереве ac:image/ac:link на текст-плейсхолдеры. */
|
|
209
|
+
liftNode(n) {
|
|
210
|
+
if (n.kind !== 'el')
|
|
211
|
+
return n;
|
|
212
|
+
if (n.name === 'ac:image')
|
|
213
|
+
return { kind: 'text', raw: this.acImageToMd(n) };
|
|
214
|
+
if (n.name === 'ac:link')
|
|
215
|
+
return { kind: 'text', raw: this.acLinkToMd(n) };
|
|
216
|
+
if (n.name.includes(':'))
|
|
217
|
+
throw new Unrepresentable();
|
|
218
|
+
return { ...n, children: n.children.map((c) => this.liftNode(c)) };
|
|
219
|
+
}
|
|
220
|
+
fence(content) {
|
|
221
|
+
this.stats.fenced++;
|
|
222
|
+
const runs = content.match(/`+/g) ?? [];
|
|
223
|
+
const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
224
|
+
return `${ticks}confluence-storage\n${content}\n${ticks}`;
|
|
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
|
+
}
|
|
264
|
+
// ── Макросы ──────────────────────────────────────────────────────────
|
|
265
|
+
macroToMd(el) {
|
|
266
|
+
const name = getAttr(el, 'ac:name') ?? '';
|
|
267
|
+
const markerMd = this.tryMacroMarker(el, name);
|
|
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}]_`;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Пытается выразить макрос маркером. Возвращает null, если параметры
|
|
304
|
+
* не однострочны, тело не rich-text или локальная проверка (маркер →
|
|
305
|
+
* рендер → каноническое сравнение с исходником) не сошлась.
|
|
306
|
+
*/
|
|
307
|
+
tryMacroMarker(el, name) {
|
|
308
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
309
|
+
return null;
|
|
310
|
+
// Вложенный одноимённый макрос ломает парность маркеров.
|
|
311
|
+
if (this.containsMacroNamed(el.children, name))
|
|
312
|
+
return null;
|
|
313
|
+
const paramEls = [];
|
|
314
|
+
let richBody = null;
|
|
315
|
+
let plainBody = null;
|
|
316
|
+
for (const child of elements(el.children)) {
|
|
317
|
+
if (child.name === 'ac:parameter')
|
|
318
|
+
paramEls.push(child);
|
|
319
|
+
else if (child.name === 'ac:rich-text-body')
|
|
320
|
+
richBody = child;
|
|
321
|
+
else if (child.name === 'ac:plain-text-body')
|
|
322
|
+
plainBody = child;
|
|
323
|
+
else
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const extract = MACRO_EXTRACTORS[name] ?? genericExtractor;
|
|
327
|
+
const extracted = extract({ el, paramEls, richBody, plainBody });
|
|
328
|
+
if (extracted === null)
|
|
329
|
+
return null;
|
|
330
|
+
if (extracted.params.some((p) => badParam(p)))
|
|
331
|
+
return null;
|
|
332
|
+
let bodyMd = extracted.bodyMarkdown;
|
|
333
|
+
if (bodyMd === undefined && richBody !== null) {
|
|
334
|
+
const before = { images: new Set(this.images), files: new Set(this.files) };
|
|
335
|
+
try {
|
|
336
|
+
bodyMd = this.blocksToMd(richBody.children).trimEnd();
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
if (!(e instanceof Unrepresentable))
|
|
340
|
+
throw e;
|
|
341
|
+
this.images = before.images;
|
|
342
|
+
this.files = before.files;
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const builder = macro(name);
|
|
347
|
+
for (const p of extracted.params)
|
|
348
|
+
builder.param(p.name, p.value);
|
|
349
|
+
builder.body(bodyMd ?? '');
|
|
350
|
+
const markerMd = builder.toMarkdown().toString();
|
|
351
|
+
return this.verifyMacroMarker(el, markerMd) ? markerMd : null;
|
|
352
|
+
}
|
|
353
|
+
/** Маркер → render-конвейер → канонически равен исходному макросу? */
|
|
354
|
+
verifyMacroMarker(el, markerMd) {
|
|
355
|
+
try {
|
|
356
|
+
let storage = renderToStorage(markerMd, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
|
357
|
+
storage = processMacros(storage, this.registry).toString();
|
|
358
|
+
return compareStorage(serializeStorage([el]), storage).equal;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
containsMacroNamed(nodes, name) {
|
|
365
|
+
for (const n of nodes) {
|
|
366
|
+
if (n.kind !== 'el')
|
|
367
|
+
continue;
|
|
368
|
+
if (n.name === 'ac:structured-macro' && getAttr(n, 'ac:name') === name)
|
|
369
|
+
return true;
|
|
370
|
+
if (this.containsMacroNamed(n.children, name))
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
// ── Списки ───────────────────────────────────────────────────────────
|
|
376
|
+
listToMd(list) {
|
|
377
|
+
if (list.attrs.some(([k]) => !(list.name === 'ol' && k === 'start'))) {
|
|
378
|
+
throw new Unrepresentable();
|
|
379
|
+
}
|
|
380
|
+
const items = list.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
381
|
+
if (!items.every((n) => n.kind === 'el' && n.name === 'li' && n.attrs.length === 0)) {
|
|
382
|
+
throw new Unrepresentable();
|
|
383
|
+
}
|
|
384
|
+
const lis = items;
|
|
385
|
+
if (lis.length === 0)
|
|
386
|
+
throw new Unrepresentable();
|
|
387
|
+
// tight: содержимое li — инлайн (+ вложенные списки);
|
|
388
|
+
// loose: каждый абзац li обёрнут в <p>. Смешение в md не выражается.
|
|
389
|
+
const shapes = lis.map((li) => liShape(li));
|
|
390
|
+
if (shapes.some((s) => s === 'other'))
|
|
391
|
+
throw new Unrepresentable();
|
|
392
|
+
if (shapes.some((s) => s !== shapes[0]))
|
|
393
|
+
throw new Unrepresentable();
|
|
394
|
+
const loose = shapes[0] === 'loose';
|
|
395
|
+
const start = list.name === 'ol' ? Number(getAttr(list, 'start') ?? '1') : 0;
|
|
396
|
+
const lines = [];
|
|
397
|
+
lis.forEach((li, idx) => {
|
|
398
|
+
const marker = list.name === 'ol' ? `${start + idx}. ` : '- ';
|
|
399
|
+
const indent = ' '.repeat(marker.length);
|
|
400
|
+
const itemLines = this.listItemLines(li, loose);
|
|
401
|
+
lines.push(marker + itemLines[0]);
|
|
402
|
+
for (const line of itemLines.slice(1)) {
|
|
403
|
+
lines.push(line === '' ? '' : indent + line);
|
|
404
|
+
}
|
|
405
|
+
if (loose && idx < lis.length - 1)
|
|
406
|
+
lines.push('');
|
|
407
|
+
});
|
|
408
|
+
return lines.join('\n');
|
|
409
|
+
}
|
|
410
|
+
listItemLines(li, loose) {
|
|
411
|
+
const lines = [];
|
|
412
|
+
const parts = li.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
413
|
+
let inlineRun = [];
|
|
414
|
+
const flushInline = () => {
|
|
415
|
+
if (inlineRun.length === 0)
|
|
416
|
+
return;
|
|
417
|
+
const md = guardLineStart(this.inlineToMd(inlineRun).trim());
|
|
418
|
+
if (md === '')
|
|
419
|
+
throw new Unrepresentable();
|
|
420
|
+
if (lines.length > 0 && loose)
|
|
421
|
+
lines.push('');
|
|
422
|
+
lines.push(md);
|
|
423
|
+
inlineRun = [];
|
|
424
|
+
};
|
|
425
|
+
for (const part of parts) {
|
|
426
|
+
if (part.kind === 'el' && (part.name === 'ul' || part.name === 'ol')) {
|
|
427
|
+
flushInline();
|
|
428
|
+
lines.push(...this.listToMd(part).split('\n'));
|
|
429
|
+
}
|
|
430
|
+
else if (part.kind === 'el' && part.name === 'p' && part.attrs.length === 0) {
|
|
431
|
+
if (!loose)
|
|
432
|
+
throw new Unrepresentable();
|
|
433
|
+
if (lines.length > 0)
|
|
434
|
+
lines.push('');
|
|
435
|
+
const md = guardLineStart(this.inlineToMd(part.children).trim());
|
|
436
|
+
if (md === '')
|
|
437
|
+
throw new Unrepresentable();
|
|
438
|
+
lines.push(md);
|
|
439
|
+
}
|
|
440
|
+
else if (part.kind === 'el' && CM_BLOCK_TAGS.has(part.name)) {
|
|
441
|
+
throw new Unrepresentable();
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
if (loose)
|
|
445
|
+
throw new Unrepresentable();
|
|
446
|
+
inlineRun.push(part);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
flushInline();
|
|
450
|
+
if (lines.length === 0)
|
|
451
|
+
throw new Unrepresentable();
|
|
452
|
+
return lines;
|
|
453
|
+
}
|
|
454
|
+
// ── Таблицы ──────────────────────────────────────────────────────────
|
|
455
|
+
tableToMd(table) {
|
|
456
|
+
if (table.attrs.length > 0)
|
|
457
|
+
throw new Unrepresentable();
|
|
458
|
+
const groups = elements(table.children);
|
|
459
|
+
if (table.children.some((n) => n.kind === 'text' && !/^[ \t\r\n]*$/.test(n.raw))) {
|
|
460
|
+
throw new Unrepresentable();
|
|
461
|
+
}
|
|
462
|
+
if (groups.length !== 2 || groups[0].name !== 'thead' || groups[1].name !== 'tbody') {
|
|
463
|
+
throw new Unrepresentable();
|
|
464
|
+
}
|
|
465
|
+
if (groups.some((g) => g.attrs.length > 0))
|
|
466
|
+
throw new Unrepresentable();
|
|
467
|
+
const headRows = elements(groups[0].children);
|
|
468
|
+
if (headRows.length !== 1 || headRows[0].name !== 'tr')
|
|
469
|
+
throw new Unrepresentable();
|
|
470
|
+
const headerCells = elements(headRows[0].children);
|
|
471
|
+
if (!headerCells.every((c) => c.name === 'th'))
|
|
472
|
+
throw new Unrepresentable();
|
|
473
|
+
const aligns = headerCells.map((c) => cellAlign(c));
|
|
474
|
+
const header = headerCells.map((c) => this.cellMd(c));
|
|
475
|
+
const bodyRows = [];
|
|
476
|
+
for (const tr of elements(groups[1].children)) {
|
|
477
|
+
if (tr.name !== 'tr' || tr.attrs.length > 0)
|
|
478
|
+
throw new Unrepresentable();
|
|
479
|
+
const cells = elements(tr.children);
|
|
480
|
+
if (cells.length !== headerCells.length)
|
|
481
|
+
throw new Unrepresentable();
|
|
482
|
+
cells.forEach((c, i) => {
|
|
483
|
+
if (c.name !== 'td' || cellAlign(c) !== aligns[i])
|
|
484
|
+
throw new Unrepresentable();
|
|
485
|
+
});
|
|
486
|
+
bodyRows.push(cells.map((c) => this.cellMd(c)));
|
|
487
|
+
}
|
|
488
|
+
const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
|
|
489
|
+
const row = (cells) => `| ${cells.join(' | ')} |`;
|
|
490
|
+
return [row(header), row(sep), ...bodyRows.map(row)].join('\n');
|
|
491
|
+
}
|
|
492
|
+
cellMd(cell) {
|
|
493
|
+
// Единственный <p> внутри ячейки — разворачиваем (типичная форма).
|
|
494
|
+
let content = cell.children;
|
|
495
|
+
const els = elements(content);
|
|
496
|
+
if (els.length === 1 && els[0].name === 'p' && els[0].attrs.length === 0 &&
|
|
497
|
+
content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
|
|
498
|
+
content = els[0].children;
|
|
499
|
+
}
|
|
500
|
+
const md = this.inlineToMd(content, { cell: false }).trim();
|
|
501
|
+
if (md.includes('\n'))
|
|
502
|
+
throw new Unrepresentable();
|
|
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>');
|
|
634
|
+
}
|
|
635
|
+
// ── Инлайн ───────────────────────────────────────────────────────────
|
|
636
|
+
/**
|
|
637
|
+
* Нормализация инлайн-последовательности перед конвертацией:
|
|
638
|
+
* краевые обычные пробелы выносятся из strong/em/s наружу, а смежные
|
|
639
|
+
* одноимённые элементы сливаются — `<strong>a</strong><strong>b</strong>`
|
|
640
|
+
* дало бы `**a****b**`, который markdown уже не распарсит.
|
|
641
|
+
*/
|
|
642
|
+
normalizeInline(nodes) {
|
|
643
|
+
const MERGEABLE = new Set(['strong', 'em', 's']);
|
|
644
|
+
const out = [];
|
|
645
|
+
for (const n of nodes) {
|
|
646
|
+
if (!(n.kind === 'el' && MERGEABLE.has(n.name) && n.attrs.length === 0)) {
|
|
647
|
+
out.push(n);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const kids = [...n.children];
|
|
651
|
+
let lead = false;
|
|
652
|
+
let trail = false;
|
|
653
|
+
const first = kids[0];
|
|
654
|
+
if (first !== undefined && first.kind === 'text') {
|
|
655
|
+
const m = /^[ \t]+/.exec(first.raw);
|
|
656
|
+
if (m) {
|
|
657
|
+
lead = true;
|
|
658
|
+
kids[0] = { kind: 'text', raw: first.raw.slice(m[0].length) };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const last = kids[kids.length - 1];
|
|
662
|
+
if (last !== undefined && last.kind === 'text') {
|
|
663
|
+
const m = /[ \t]+$/.exec(last.raw);
|
|
664
|
+
if (m) {
|
|
665
|
+
trail = true;
|
|
666
|
+
kids[kids.length - 1] = { kind: 'text', raw: last.raw.slice(0, -m[0].length) };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const cleaned = kids.filter((k) => !(k.kind === 'text' && k.raw === ''));
|
|
670
|
+
if (lead)
|
|
671
|
+
out.push({ kind: 'text', raw: ' ' });
|
|
672
|
+
if (cleaned.length > 0) {
|
|
673
|
+
const prev = out[out.length - 1];
|
|
674
|
+
if (prev !== undefined && prev.kind === 'el' && prev.name === n.name && prev.attrs.length === 0) {
|
|
675
|
+
prev.children.push(...cleaned);
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
out.push({ kind: 'el', name: n.name, attrs: [], children: cleaned, selfClosing: false });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (trail)
|
|
682
|
+
out.push({ kind: 'text', raw: ' ' });
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
inlineToMd(nodes, ctx = {}) {
|
|
687
|
+
let out = '';
|
|
688
|
+
for (const n of this.normalizeInline(nodes)) {
|
|
689
|
+
if (n.kind === 'text') {
|
|
690
|
+
out += escapeMdText(n.raw, ctx, this.readable);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (n.kind === 'cdata') {
|
|
694
|
+
if (this.readable) {
|
|
695
|
+
out += escapeMdText(n.text, ctx, true);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
throw new Unrepresentable();
|
|
699
|
+
}
|
|
700
|
+
if (n.kind === 'comment') {
|
|
701
|
+
if (this.readable)
|
|
702
|
+
continue; // невидимый комментарий — отбрасываем
|
|
703
|
+
if (n.text.includes('MACRO:') || n.text.includes('-->'))
|
|
704
|
+
throw new Unrepresentable();
|
|
705
|
+
out += `<!--${n.text}-->`;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
out += this.inlineElementToMd(n, ctx);
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
inlineElementToMd(el, ctx) {
|
|
713
|
+
switch (el.name) {
|
|
714
|
+
case 'strong':
|
|
715
|
+
return this.wrapInline(el, '**', ctx);
|
|
716
|
+
case 'em':
|
|
717
|
+
return this.wrapInline(el, '*', ctx);
|
|
718
|
+
// <b>/<i>: faithful — сырой HTML (** рендерится в <strong>, а не <b>);
|
|
719
|
+
// readable — маппим в **/* (потеря точного тега приемлема).
|
|
720
|
+
case 'b':
|
|
721
|
+
return this.readable ? this.wrapInline(el, '**', ctx) : this.rawInline(el, ctx);
|
|
722
|
+
case 'i':
|
|
723
|
+
return this.readable ? this.wrapInline(el, '*', ctx) : this.rawInline(el, ctx);
|
|
724
|
+
case 's':
|
|
725
|
+
return this.wrapInline(el, '~~', ctx);
|
|
726
|
+
case 'code': {
|
|
727
|
+
if (el.attrs.length > 0 && !this.readable)
|
|
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(); })();
|
|
732
|
+
const runs = text.match(/`+/g) ?? [];
|
|
733
|
+
const ticks = '`'.repeat(Math.max(1, ...runs.map((r) => r.length + 1)));
|
|
734
|
+
const pad = text.startsWith('`') || text.endsWith('`') || text.startsWith(' ') || text.endsWith(' ') ? ' ' : '';
|
|
735
|
+
return `${ticks}${pad}${text}${pad}${ticks}`;
|
|
736
|
+
}
|
|
737
|
+
case 'br':
|
|
738
|
+
return '<br/>';
|
|
739
|
+
case 'a':
|
|
740
|
+
return this.linkAnchorToMd(el, ctx);
|
|
741
|
+
case 'img': {
|
|
742
|
+
const src = getAttr(el, 'src') ?? '';
|
|
743
|
+
const alt = getAttr(el, 'alt') ?? '';
|
|
744
|
+
const other = el.attrs.filter(([k]) => k !== 'src' && k !== 'alt');
|
|
745
|
+
if ((other.length === 0 || this.readable) && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
|
|
746
|
+
return ``; // readable: лишние атрибуты (class…) отбрасываются
|
|
747
|
+
}
|
|
748
|
+
return this.readable ? escapeMdText(alt, ctx, true) : this.rawInline(el, ctx);
|
|
749
|
+
}
|
|
750
|
+
case 'ac:image':
|
|
751
|
+
return this.acImageToMd(el);
|
|
752
|
+
case 'ac:link':
|
|
753
|
+
return this.acLinkToMd(el);
|
|
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);
|
|
761
|
+
if (INLINE_RAW_WRAP.has(el.name))
|
|
762
|
+
return this.rawInline(el, ctx);
|
|
763
|
+
throw new Unrepresentable();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
wrapInline(el, marker, ctx) {
|
|
767
|
+
if (el.attrs.length > 0 && !this.readable)
|
|
768
|
+
return this.rawInline(el, ctx);
|
|
769
|
+
const inner = this.inlineToMd(el.children, ctx);
|
|
770
|
+
// Краевые ПРОСТЫЕ пробелы выносим наружу — `**текст **` маркдауном не
|
|
771
|
+
// является. Юникодные пробелы ( и т.п.) выносить нельзя (изменит
|
|
772
|
+
// содержимое), а внутри маркеров они ломают flanking-правила — такой
|
|
773
|
+
// элемент отдаём сырым HTML (faithful) либо просто оставляем как есть
|
|
774
|
+
// без обёртки (readable).
|
|
775
|
+
const m = /^([ \t]*)([\s\S]*?)([ \t]*)$/.exec(inner);
|
|
776
|
+
if (!m || m[2] === '')
|
|
777
|
+
return inner;
|
|
778
|
+
const decodedEdges = decodeEntities(m[2]);
|
|
779
|
+
if (/^\s|\s$/u.test(decodedEdges))
|
|
780
|
+
return this.readable ? inner : this.rawInline(el, ctx);
|
|
781
|
+
return `${m[1]}${marker}${m[2]}${marker}${m[3]}`;
|
|
782
|
+
}
|
|
783
|
+
/** Инлайн-элемент дословно: открывающий тег + инлайн-дети + закрывающий. */
|
|
784
|
+
rawInline(el, ctx) {
|
|
785
|
+
if (hasNamespacedElements([el]))
|
|
786
|
+
throw new Unrepresentable();
|
|
787
|
+
const attrs = el.attrs.map(([k, v]) => ` ${k}="${v}"`).join('');
|
|
788
|
+
if (attrs.includes('\n'))
|
|
789
|
+
throw new Unrepresentable();
|
|
790
|
+
if (el.selfClosing)
|
|
791
|
+
return `<${el.name}${attrs} />`;
|
|
792
|
+
return `<${el.name}${attrs}>${this.inlineToMd(el.children, ctx)}</${el.name}>`;
|
|
793
|
+
}
|
|
794
|
+
linkAnchorToMd(el, ctx) {
|
|
795
|
+
const href = getAttr(el, 'href') ?? '';
|
|
796
|
+
const inner = this.inlineToMd(el.children, ctx);
|
|
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: доп. атрибуты ссылки отбрасываются
|
|
800
|
+
}
|
|
801
|
+
if (this.readable)
|
|
802
|
+
return inner; // ссылку не выразить в MD — оставляем текст
|
|
803
|
+
return this.rawInline(el, ctx);
|
|
804
|
+
}
|
|
805
|
+
acImageToMd(el) {
|
|
806
|
+
const kids = elements(el.children);
|
|
807
|
+
if (kids.length !== 1)
|
|
808
|
+
throw new Unrepresentable();
|
|
809
|
+
const ref = kids[0];
|
|
810
|
+
if (ref.name === 'ri:url') {
|
|
811
|
+
if (ref.attrs.some(([k]) => k !== 'ri:value'))
|
|
812
|
+
throw new Unrepresentable();
|
|
813
|
+
const url = getAttr(ref, 'ri:value') ?? '';
|
|
814
|
+
if (el.attrs.length === 0 && SAFE_URL_RE.test(url))
|
|
815
|
+
return ``;
|
|
816
|
+
// С атрибутами или сложным URL — сырой <img>: канонизация считает
|
|
817
|
+
// <img src=… class=…> ≡ <ac:image ac:class=…><ri:url ri:value=…/>.
|
|
818
|
+
const attrs = [['src', escapeXmlAttr(url)]];
|
|
819
|
+
for (const [k] of el.attrs) {
|
|
820
|
+
if (!k.startsWith('ac:'))
|
|
821
|
+
throw new Unrepresentable();
|
|
822
|
+
const plain = k.slice(3);
|
|
823
|
+
if (plain.includes(':') || plain === 'src')
|
|
824
|
+
throw new Unrepresentable();
|
|
825
|
+
attrs.push([plain, escapeXmlAttr(getAttr(el, k) ?? '')]);
|
|
826
|
+
}
|
|
827
|
+
return serializeStorage([{ kind: 'el', name: 'img', attrs, children: [], selfClosing: true }]);
|
|
828
|
+
}
|
|
829
|
+
if (ref.name !== 'ri:attachment')
|
|
830
|
+
throw new Unrepresentable();
|
|
831
|
+
if (!ref.attrs.every(([k]) => k === 'ri:filename' || k === 'ri:version-at-save')) {
|
|
832
|
+
throw new Unrepresentable();
|
|
833
|
+
}
|
|
834
|
+
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
835
|
+
const attrs = [];
|
|
836
|
+
for (const [k] of el.attrs) {
|
|
837
|
+
if (!k.startsWith('ac:'))
|
|
838
|
+
throw new Unrepresentable();
|
|
839
|
+
attrs.push([k.slice(3), getAttr(el, k) ?? '']);
|
|
840
|
+
}
|
|
841
|
+
for (const [, v] of attrs)
|
|
842
|
+
badPlaceholderPart(v);
|
|
843
|
+
badPlaceholderPart(filename);
|
|
844
|
+
this.images.add(filename);
|
|
845
|
+
const attrStr = attrs.map(([k, v]) => `|${k}=${v}`).join('');
|
|
846
|
+
return `{{img:${filename}${attrStr}}}`;
|
|
847
|
+
}
|
|
848
|
+
acLinkToMd(el) {
|
|
849
|
+
if (el.attrs.length > 0)
|
|
850
|
+
throw new Unrepresentable();
|
|
851
|
+
const kids = elements(el.children);
|
|
852
|
+
const ref = kids[0];
|
|
853
|
+
if (ref === undefined)
|
|
854
|
+
throw new Unrepresentable();
|
|
855
|
+
let text;
|
|
856
|
+
if (kids.length === 2) {
|
|
857
|
+
const body = kids[1];
|
|
858
|
+
if (body.name !== 'ac:plain-text-link-body')
|
|
859
|
+
throw new Unrepresentable();
|
|
860
|
+
text = textContent(body.children);
|
|
861
|
+
badPlaceholderPart(text);
|
|
862
|
+
}
|
|
863
|
+
else if (kids.length > 2) {
|
|
864
|
+
throw new Unrepresentable();
|
|
865
|
+
}
|
|
866
|
+
const textAttr = text !== undefined ? `|text=${text}` : '';
|
|
867
|
+
if (ref.name === 'ri:page') {
|
|
868
|
+
if (!ref.attrs.every(([k]) => ['ri:content-title', 'ri:space-key', 'ri:version-at-save'].includes(k))) {
|
|
869
|
+
throw new Unrepresentable();
|
|
870
|
+
}
|
|
871
|
+
const title = getAttr(ref, 'ri:content-title') ?? '';
|
|
872
|
+
const space = getAttr(ref, 'ri:space-key');
|
|
873
|
+
badPlaceholderPart(title);
|
|
874
|
+
if (space !== undefined)
|
|
875
|
+
badPlaceholderPart(space);
|
|
876
|
+
const spaceAttr = space !== undefined ? `|space=${space}` : '';
|
|
877
|
+
return `{{page:${title}${spaceAttr}${textAttr}}}`;
|
|
878
|
+
}
|
|
879
|
+
if (ref.name === 'ri:attachment') {
|
|
880
|
+
if (!ref.attrs.every(([k]) => k === 'ri:filename' || k === 'ri:version-at-save')) {
|
|
881
|
+
throw new Unrepresentable();
|
|
882
|
+
}
|
|
883
|
+
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
884
|
+
badPlaceholderPart(filename);
|
|
885
|
+
this.files.add(filename);
|
|
886
|
+
return `{{file:${filename}${textAttr}}}`;
|
|
887
|
+
}
|
|
888
|
+
throw new Unrepresentable();
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/** Параметр текстовый (без вложенных элементов)? Тогда name/value как есть. */
|
|
892
|
+
function textParams(paramEls) {
|
|
893
|
+
const params = [];
|
|
894
|
+
for (const p of paramEls) {
|
|
895
|
+
if (elements(p.children).length > 0)
|
|
896
|
+
return null;
|
|
897
|
+
params.push({ name: getAttr(p, 'ac:name') ?? '', value: textContent(p.children) });
|
|
898
|
+
}
|
|
899
|
+
return params;
|
|
900
|
+
}
|
|
901
|
+
/** Параметр-`<ac:link><ri:page/></ac:link>` → {page, space}. */
|
|
902
|
+
function pageRefParam(p) {
|
|
903
|
+
const link = elements(p.children);
|
|
904
|
+
if (link.length !== 1 || link[0].name !== 'ac:link' || link[0].attrs.length > 0)
|
|
905
|
+
return null;
|
|
906
|
+
const refs = elements(link[0].children);
|
|
907
|
+
if (refs.length !== 1 || refs[0].name !== 'ri:page')
|
|
908
|
+
return null;
|
|
909
|
+
if (!refs[0].attrs.every(([k]) => ['ri:content-title', 'ri:space-key', 'ri:version-at-save'].includes(k)))
|
|
910
|
+
return null;
|
|
911
|
+
const page = getAttr(refs[0], 'ri:content-title') ?? '';
|
|
912
|
+
const space = getAttr(refs[0], 'ri:space-key');
|
|
913
|
+
return space !== undefined ? { page, space } : { page };
|
|
914
|
+
}
|
|
915
|
+
const genericExtractor = (ctx) => {
|
|
916
|
+
if (ctx.plainBody !== null)
|
|
917
|
+
return null;
|
|
918
|
+
const params = textParams(ctx.paramEls);
|
|
919
|
+
if (params === null)
|
|
920
|
+
return null;
|
|
921
|
+
return { params };
|
|
922
|
+
};
|
|
923
|
+
const MACRO_EXTRACTORS = {
|
|
924
|
+
code: (ctx) => {
|
|
925
|
+
if (ctx.richBody !== null || ctx.plainBody === null)
|
|
926
|
+
return null;
|
|
927
|
+
const params = textParams(ctx.paramEls);
|
|
928
|
+
if (params === null)
|
|
929
|
+
return null;
|
|
930
|
+
const source = textContent(ctx.plainBody.children);
|
|
931
|
+
const runs = source.match(/`+/g) ?? [];
|
|
932
|
+
const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
933
|
+
return { params, bodyMarkdown: `${ticks}\n${source}\n${ticks}` };
|
|
934
|
+
},
|
|
935
|
+
anchor: (ctx) => {
|
|
936
|
+
if (ctx.richBody !== null || ctx.plainBody !== null || ctx.paramEls.length !== 1)
|
|
937
|
+
return null;
|
|
938
|
+
const p = ctx.paramEls[0];
|
|
939
|
+
if (getAttr(p, 'ac:name') !== '' || elements(p.children).length > 0)
|
|
940
|
+
return null;
|
|
941
|
+
return { params: [{ name: 'name', value: textContent(p.children) }] };
|
|
942
|
+
},
|
|
943
|
+
include: (ctx) => {
|
|
944
|
+
if (ctx.richBody !== null || ctx.plainBody !== null || ctx.paramEls.length !== 1)
|
|
945
|
+
return null;
|
|
946
|
+
const p = ctx.paramEls[0];
|
|
947
|
+
if (getAttr(p, 'ac:name') !== '')
|
|
948
|
+
return null;
|
|
949
|
+
const ref = pageRefParam(p);
|
|
950
|
+
if (ref === null)
|
|
951
|
+
return null;
|
|
952
|
+
const params = [{ name: 'page', value: ref.page }];
|
|
953
|
+
if (ref.space !== undefined)
|
|
954
|
+
params.push({ name: 'space', value: ref.space });
|
|
955
|
+
return { params };
|
|
956
|
+
},
|
|
957
|
+
'excerpt-include': (ctx) => {
|
|
958
|
+
if (ctx.richBody !== null || ctx.plainBody !== null)
|
|
959
|
+
return null;
|
|
960
|
+
const params = [];
|
|
961
|
+
for (const p of ctx.paramEls) {
|
|
962
|
+
const name = getAttr(p, 'ac:name') ?? '';
|
|
963
|
+
if (name === '') {
|
|
964
|
+
const ref = pageRefParam(p);
|
|
965
|
+
if (ref === null)
|
|
966
|
+
return null;
|
|
967
|
+
params.push({ name: 'page', value: ref.page });
|
|
968
|
+
if (ref.space !== undefined)
|
|
969
|
+
params.push({ name: 'space', value: ref.space });
|
|
970
|
+
}
|
|
971
|
+
else if (elements(p.children).length === 0) {
|
|
972
|
+
params.push({ name, value: textContent(p.children) });
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return { params };
|
|
979
|
+
},
|
|
980
|
+
'table-excerpt-include': (ctx) => {
|
|
981
|
+
if (ctx.richBody !== null || ctx.plainBody !== null)
|
|
982
|
+
return null;
|
|
983
|
+
const params = [];
|
|
984
|
+
for (const p of ctx.paramEls) {
|
|
985
|
+
const name = getAttr(p, 'ac:name') ?? '';
|
|
986
|
+
if (name === 'page') {
|
|
987
|
+
const ref = pageRefParam(p);
|
|
988
|
+
if (ref === null)
|
|
989
|
+
return null;
|
|
990
|
+
params.push({ name: 'page', value: ref.page });
|
|
991
|
+
if (ref.space !== undefined)
|
|
992
|
+
params.push({ name: 'space', value: ref.space });
|
|
993
|
+
}
|
|
994
|
+
else if (elements(p.children).length === 0) {
|
|
995
|
+
params.push({ name, value: textContent(p.children) });
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return { params };
|
|
1002
|
+
},
|
|
1003
|
+
};
|
|
1004
|
+
/** Форма пункта списка: инлайн-содержимое (tight) или <p>-абзацы (loose). */
|
|
1005
|
+
function liShape(li) {
|
|
1006
|
+
let hasP = false;
|
|
1007
|
+
let hasInline = false;
|
|
1008
|
+
for (const n of li.children) {
|
|
1009
|
+
if (n.kind === 'text') {
|
|
1010
|
+
if (!/^[ \t\r\n]*$/.test(n.raw))
|
|
1011
|
+
hasInline = true;
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (n.kind === 'el' && n.name === 'p')
|
|
1015
|
+
hasP = true;
|
|
1016
|
+
else if (n.kind === 'el' && (n.name === 'ul' || n.name === 'ol'))
|
|
1017
|
+
continue;
|
|
1018
|
+
else
|
|
1019
|
+
hasInline = true;
|
|
1020
|
+
}
|
|
1021
|
+
if (hasP && hasInline)
|
|
1022
|
+
return 'other';
|
|
1023
|
+
return hasP ? 'loose' : 'tight';
|
|
1024
|
+
}
|
|
1025
|
+
function badParam(p) {
|
|
1026
|
+
// %-последовательности исходника декодер маркера исказил бы.
|
|
1027
|
+
return /%(3D|3A|3C|3E|0A|0D|25)/i.test(p.name + p.value);
|
|
1028
|
+
}
|
|
1029
|
+
function badPlaceholderPart(value) {
|
|
1030
|
+
if (/[|{}\n\r]/.test(value))
|
|
1031
|
+
throw new Unrepresentable();
|
|
1032
|
+
}
|
|
1033
|
+
// ── Экранирование текста ──────────────────────────────────────────────
|
|
1034
|
+
const ENTITY_RE = /&(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
|
|
1035
|
+
/**
|
|
1036
|
+
* Экранирует markdown-активные символы, сохраняя сущности ( и т.п.)
|
|
1037
|
+
* как есть — markdown-it декодирует их при рендере.
|
|
1038
|
+
*/
|
|
1039
|
+
function escapeMdText(raw, ctx, readable = false) {
|
|
1040
|
+
const collapsed = raw.replace(/[\r\n]+/g, ' ');
|
|
1041
|
+
let out = '';
|
|
1042
|
+
let last = 0;
|
|
1043
|
+
for (const m of collapsed.matchAll(ENTITY_RE)) {
|
|
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
|
+
}
|
|
1057
|
+
last = m.index + m[0].length;
|
|
1058
|
+
}
|
|
1059
|
+
out += escapePlain(collapsed.slice(last), ctx, readable);
|
|
1060
|
+
return out;
|
|
1061
|
+
}
|
|
1062
|
+
function escapePlain(s, ctx, readable = false) {
|
|
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
|
+
}
|
|
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;
|
|
1077
|
+
}
|
|
1078
|
+
/** Экранирует конструкции, значимые в начале строки (#, >, -, 1. …). */
|
|
1079
|
+
function guardLineStart(md) {
|
|
1080
|
+
return md.replace(/^(\s*)([#>+-]|\d+[.)])(\s|$)/, (_m, ws, ch, sp) => {
|
|
1081
|
+
if (ch.length === 1)
|
|
1082
|
+
return `${ws}\\${ch}${sp}`;
|
|
1083
|
+
return `${ws}${ch.slice(0, -1)}\\${ch.slice(-1)}${sp}`;
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
function cellAlign(cell) {
|
|
1087
|
+
if (cell.attrs.length === 0)
|
|
1088
|
+
return 'none';
|
|
1089
|
+
if (cell.attrs.length > 1)
|
|
1090
|
+
throw new Unrepresentable();
|
|
1091
|
+
const [k, v] = cell.attrs[0];
|
|
1092
|
+
if (k !== 'style')
|
|
1093
|
+
throw new Unrepresentable();
|
|
1094
|
+
const m = /^text-align:\s*(left|right|center);?\s*$/.exec(v);
|
|
1095
|
+
if (!m)
|
|
1096
|
+
throw new Unrepresentable();
|
|
1097
|
+
return m[1];
|
|
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
|
+
}
|