confluence-md-sync 0.2.1 → 0.3.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 +100 -9
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +76 -28
- package/dist/export/canonical.d.ts +38 -0
- package/dist/export/canonical.js +272 -0
- package/dist/export/export-page.d.ts +31 -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 +37 -0
- package/dist/export/to-markdown.js +845 -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,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Конвертация Confluence storage → Markdown с гарантией round-trip.
|
|
3
|
+
*
|
|
4
|
+
* Трёхуровневая политика на каждый узел:
|
|
5
|
+
* 1. чистый Markdown — заголовки, абзацы, списки, простые таблицы,
|
|
6
|
+
* ссылки, картинки-аттачи ({{img:...}}), page-ссылки ({{page:...}});
|
|
7
|
+
* 2. маркеры макросов <!-- MACRO:start/end --> — для макросов, чей
|
|
8
|
+
* рендер восстанавливает исходный XHTML (проверяется на месте:
|
|
9
|
+
* маркер прогоняется через render-конвейер и сравнивается канонически);
|
|
10
|
+
* 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML
|
|
11
|
+
* (markdown-it пропускает его насквозь), с ними — fenced-блоком
|
|
12
|
+
* ```confluence-storage (разворачивается обратно при рендере).
|
|
13
|
+
*
|
|
14
|
+
* Потери по построению исключены: всё, что не легло в (1)-(2), уезжает
|
|
15
|
+
* в (3) дословно.
|
|
16
|
+
*/
|
|
17
|
+
import { macro } from '../macros/builder.js';
|
|
18
|
+
import { escapeXmlAttr } from '../macros/xml.js';
|
|
19
|
+
import { processMacros } from '../macros/registry.js';
|
|
20
|
+
import { defaultMacroRegistry } from '../macros/index.js';
|
|
21
|
+
import { renderToStorage } from '../markdown/render.js';
|
|
22
|
+
import { compareStorage } from './canonical.js';
|
|
23
|
+
import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
|
|
24
|
+
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
25
|
+
export function storageToMarkdown(storage, opts = {}) {
|
|
26
|
+
const conv = new Converter(opts.registry ?? defaultMacroRegistry);
|
|
27
|
+
const markdown = conv.blocksToMd(parseStorage(storage));
|
|
28
|
+
const attachmentRefs = new Set([...conv.images, ...conv.files]);
|
|
29
|
+
for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
|
|
30
|
+
attachmentRefs.add(m[1]);
|
|
31
|
+
return {
|
|
32
|
+
markdown,
|
|
33
|
+
images: [...conv.images],
|
|
34
|
+
files: [...conv.files],
|
|
35
|
+
attachmentRefs: [...attachmentRefs],
|
|
36
|
+
stats: conv.stats,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Сигнал «в Markdown не выражается» — узел уходит в fallback уровнем выше. */
|
|
40
|
+
class Unrepresentable extends Error {
|
|
41
|
+
}
|
|
42
|
+
// Теги, с которых начинается HTML-блок CommonMark (type 6) — абзац,
|
|
43
|
+
// начинающийся с такого тега, нельзя отдавать как markdown-строку.
|
|
44
|
+
const CM_BLOCK_TAGS = new Set([
|
|
45
|
+
'address', 'article', 'aside', 'base', 'blockquote', 'body', 'br', 'caption',
|
|
46
|
+
'center', 'col', 'colgroup', 'dd', 'details', 'dialog', 'dir', 'div', 'dl',
|
|
47
|
+
'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'frame',
|
|
48
|
+
'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr',
|
|
49
|
+
'html', 'iframe', 'legend', 'li', 'link', 'main', 'menu', 'menuitem', 'nav',
|
|
50
|
+
'noframes', 'ol', 'optgroup', 'option', 'p', 'param', 'section', 'summary',
|
|
51
|
+
'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul',
|
|
52
|
+
'pre', 'script', 'style', 'textarea',
|
|
53
|
+
]);
|
|
54
|
+
const INLINE_RAW_WRAP = new Set(['span', 'u', 'sub', 'sup', 'small', 'big', 'font', 'del', 'ins', 'abbr', 'cite', 'q', 'mark', 'time']);
|
|
55
|
+
const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
|
|
56
|
+
class Converter {
|
|
57
|
+
registry;
|
|
58
|
+
images = new Set();
|
|
59
|
+
files = new Set();
|
|
60
|
+
stats = { markers: 0, fenced: 0, rawHtml: 0 };
|
|
61
|
+
constructor(registry) {
|
|
62
|
+
this.registry = registry;
|
|
63
|
+
}
|
|
64
|
+
// ── Блочный уровень ──────────────────────────────────────────────────
|
|
65
|
+
blocksToMd(nodes) {
|
|
66
|
+
const parts = [];
|
|
67
|
+
for (const n of nodes) {
|
|
68
|
+
const md = this.blockToMd(n);
|
|
69
|
+
if (md !== '')
|
|
70
|
+
parts.push(md);
|
|
71
|
+
}
|
|
72
|
+
return parts.join('\n\n') + (parts.length > 0 ? '\n' : '');
|
|
73
|
+
}
|
|
74
|
+
blockToMd(node) {
|
|
75
|
+
if (node.kind === 'text') {
|
|
76
|
+
if (/^[ \t\r\n]*$/.test(node.raw))
|
|
77
|
+
return '';
|
|
78
|
+
return this.paragraphMd([node]);
|
|
79
|
+
}
|
|
80
|
+
if (node.kind === 'comment') {
|
|
81
|
+
// Дословный комментарий; MACRO-подобные — через fence, чтобы не
|
|
82
|
+
// конфликтовали с маркерами макросов.
|
|
83
|
+
if (node.text.includes('MACRO:'))
|
|
84
|
+
return this.fence(`<!--${node.text}-->`);
|
|
85
|
+
return `<!--${node.text}-->`;
|
|
86
|
+
}
|
|
87
|
+
if (node.kind === 'cdata')
|
|
88
|
+
return this.fence(serializeStorage([node]));
|
|
89
|
+
const el = node;
|
|
90
|
+
const h = /^h([1-6])$/.exec(el.name);
|
|
91
|
+
try {
|
|
92
|
+
if (h && el.attrs.length === 0) {
|
|
93
|
+
const inline = this.inlineToMd(el.children);
|
|
94
|
+
if (inline.includes('\n') || inline.trim() === '')
|
|
95
|
+
throw new Unrepresentable();
|
|
96
|
+
return '#'.repeat(Number(h[1])) + ' ' + guardLineStart(inline.trim());
|
|
97
|
+
}
|
|
98
|
+
if (el.name === 'p' && el.attrs.length === 0) {
|
|
99
|
+
// Редактор оборачивает блочные макросы в <p> — разворачиваем:
|
|
100
|
+
// маркер и так блочный, канонизация считает формы эквивалентными.
|
|
101
|
+
const meaningful = el.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
102
|
+
if (meaningful.length === 1 && meaningful[0].kind === 'el' && meaningful[0].name === 'ac:structured-macro') {
|
|
103
|
+
return this.macroToMd(meaningful[0]);
|
|
104
|
+
}
|
|
105
|
+
return this.paragraphMd(el.children);
|
|
106
|
+
}
|
|
107
|
+
if (el.name === 'hr')
|
|
108
|
+
return '---';
|
|
109
|
+
if (el.name === 'ul' || el.name === 'ol')
|
|
110
|
+
return this.listToMd(el);
|
|
111
|
+
if (el.name === 'table')
|
|
112
|
+
return this.tableToMd(el);
|
|
113
|
+
if (el.name === 'ac:structured-macro')
|
|
114
|
+
return this.macroToMd(el);
|
|
115
|
+
if (el.name === 'ac:image' || el.name === 'ac:link') {
|
|
116
|
+
// Блочная картинка/ссылка — оформляем как отдельный абзац.
|
|
117
|
+
return this.paragraphMd([el]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
if (!(e instanceof Unrepresentable))
|
|
122
|
+
throw e;
|
|
123
|
+
return this.fallbackBlock(el);
|
|
124
|
+
}
|
|
125
|
+
return this.fallbackBlock(el);
|
|
126
|
+
}
|
|
127
|
+
/** Абзац: инлайн-конвертация; не легло — дословный <p>. */
|
|
128
|
+
paragraphMd(children) {
|
|
129
|
+
const el = { kind: 'el', name: 'p', attrs: [], children, selfClosing: false };
|
|
130
|
+
if (children.length === 0)
|
|
131
|
+
return this.fallbackBlock(el);
|
|
132
|
+
try {
|
|
133
|
+
const inline = this.inlineToMd(children).trim();
|
|
134
|
+
if (inline === '')
|
|
135
|
+
return this.fallbackBlock(el);
|
|
136
|
+
// Абзац, начинающийся с блочного HTML-тега, markdown-it превратит в
|
|
137
|
+
// html-блок без <p>-обёртки — такой отдаём дословно.
|
|
138
|
+
const m = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(inline);
|
|
139
|
+
if (m && CM_BLOCK_TAGS.has(m[1].toLowerCase()))
|
|
140
|
+
return this.fallbackBlock(el);
|
|
141
|
+
return guardLineStart(inline);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
if (!(e instanceof Unrepresentable))
|
|
145
|
+
throw e;
|
|
146
|
+
return this.fallbackBlock(el);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Дословный фрагмент: raw HTML если можно, иначе fence. Перед fence
|
|
151
|
+
* пробуем «поднять» вложенные ac:image/ac:link в плейсхолдеры — тогда
|
|
152
|
+
* блок (обычно сложная таблица с картинками) остаётся читаемым HTML,
|
|
153
|
+
* а рендер вернёт плейсхолдерам исходную ac:-форму.
|
|
154
|
+
*/
|
|
155
|
+
fallbackBlock(el) {
|
|
156
|
+
const direct = this.tryRawHtml(el);
|
|
157
|
+
if (direct !== null)
|
|
158
|
+
return direct;
|
|
159
|
+
if (!el.name.includes(':') && CM_BLOCK_TAGS.has(el.name.toLowerCase())) {
|
|
160
|
+
const images = new Set(this.images);
|
|
161
|
+
const files = new Set(this.files);
|
|
162
|
+
try {
|
|
163
|
+
const lifted = this.liftNode(el);
|
|
164
|
+
const raw = this.tryRawHtml(lifted);
|
|
165
|
+
if (raw !== null)
|
|
166
|
+
return raw;
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (!(e instanceof Unrepresentable))
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
this.images = images;
|
|
173
|
+
this.files = files;
|
|
174
|
+
}
|
|
175
|
+
return this.fence(serializeStorage([el]));
|
|
176
|
+
}
|
|
177
|
+
tryRawHtml(el) {
|
|
178
|
+
const serialized = serializeStorage([el]);
|
|
179
|
+
if (!hasNamespacedElements([el]) &&
|
|
180
|
+
!el.name.includes(':') &&
|
|
181
|
+
CM_BLOCK_TAGS.has(el.name.toLowerCase()) &&
|
|
182
|
+
!/\n[ \t]*\n/.test(serialized) &&
|
|
183
|
+
!serialized.includes('MACRO:')) {
|
|
184
|
+
this.stats.rawHtml++;
|
|
185
|
+
return serialized;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/** Заменяет в поддереве ac:image/ac:link на текст-плейсхолдеры. */
|
|
190
|
+
liftNode(n) {
|
|
191
|
+
if (n.kind !== 'el')
|
|
192
|
+
return n;
|
|
193
|
+
if (n.name === 'ac:image')
|
|
194
|
+
return { kind: 'text', raw: this.acImageToMd(n) };
|
|
195
|
+
if (n.name === 'ac:link')
|
|
196
|
+
return { kind: 'text', raw: this.acLinkToMd(n) };
|
|
197
|
+
if (n.name.includes(':'))
|
|
198
|
+
throw new Unrepresentable();
|
|
199
|
+
return { ...n, children: n.children.map((c) => this.liftNode(c)) };
|
|
200
|
+
}
|
|
201
|
+
fence(content) {
|
|
202
|
+
this.stats.fenced++;
|
|
203
|
+
const runs = content.match(/`+/g) ?? [];
|
|
204
|
+
const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
205
|
+
return `${ticks}confluence-storage\n${content}\n${ticks}`;
|
|
206
|
+
}
|
|
207
|
+
// ── Макросы ──────────────────────────────────────────────────────────
|
|
208
|
+
macroToMd(el) {
|
|
209
|
+
const name = getAttr(el, 'ac:name') ?? '';
|
|
210
|
+
const markerMd = this.tryMacroMarker(el, name);
|
|
211
|
+
if (markerMd === null)
|
|
212
|
+
return this.fence(serializeStorage([el]));
|
|
213
|
+
this.stats.markers++;
|
|
214
|
+
return markerMd;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Пытается выразить макрос маркером. Возвращает null, если параметры
|
|
218
|
+
* не однострочны, тело не rich-text или локальная проверка (маркер →
|
|
219
|
+
* рендер → каноническое сравнение с исходником) не сошлась.
|
|
220
|
+
*/
|
|
221
|
+
tryMacroMarker(el, name) {
|
|
222
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
223
|
+
return null;
|
|
224
|
+
// Вложенный одноимённый макрос ломает парность маркеров.
|
|
225
|
+
if (this.containsMacroNamed(el.children, name))
|
|
226
|
+
return null;
|
|
227
|
+
const paramEls = [];
|
|
228
|
+
let richBody = null;
|
|
229
|
+
let plainBody = null;
|
|
230
|
+
for (const child of elements(el.children)) {
|
|
231
|
+
if (child.name === 'ac:parameter')
|
|
232
|
+
paramEls.push(child);
|
|
233
|
+
else if (child.name === 'ac:rich-text-body')
|
|
234
|
+
richBody = child;
|
|
235
|
+
else if (child.name === 'ac:plain-text-body')
|
|
236
|
+
plainBody = child;
|
|
237
|
+
else
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const extract = MACRO_EXTRACTORS[name] ?? genericExtractor;
|
|
241
|
+
const extracted = extract({ el, paramEls, richBody, plainBody });
|
|
242
|
+
if (extracted === null)
|
|
243
|
+
return null;
|
|
244
|
+
if (extracted.params.some((p) => badParam(p)))
|
|
245
|
+
return null;
|
|
246
|
+
let bodyMd = extracted.bodyMarkdown;
|
|
247
|
+
if (bodyMd === undefined && richBody !== null) {
|
|
248
|
+
const before = { images: new Set(this.images), files: new Set(this.files) };
|
|
249
|
+
try {
|
|
250
|
+
bodyMd = this.blocksToMd(richBody.children).trimEnd();
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
if (!(e instanceof Unrepresentable))
|
|
254
|
+
throw e;
|
|
255
|
+
this.images = before.images;
|
|
256
|
+
this.files = before.files;
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const builder = macro(name);
|
|
261
|
+
for (const p of extracted.params)
|
|
262
|
+
builder.param(p.name, p.value);
|
|
263
|
+
builder.body(bodyMd ?? '');
|
|
264
|
+
const markerMd = builder.toMarkdown().toString();
|
|
265
|
+
return this.verifyMacroMarker(el, markerMd) ? markerMd : null;
|
|
266
|
+
}
|
|
267
|
+
/** Маркер → render-конвейер → канонически равен исходному макросу? */
|
|
268
|
+
verifyMacroMarker(el, markerMd) {
|
|
269
|
+
try {
|
|
270
|
+
let storage = renderToStorage(markerMd, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
|
271
|
+
storage = processMacros(storage, this.registry).toString();
|
|
272
|
+
return compareStorage(serializeStorage([el]), storage).equal;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
containsMacroNamed(nodes, name) {
|
|
279
|
+
for (const n of nodes) {
|
|
280
|
+
if (n.kind !== 'el')
|
|
281
|
+
continue;
|
|
282
|
+
if (n.name === 'ac:structured-macro' && getAttr(n, 'ac:name') === name)
|
|
283
|
+
return true;
|
|
284
|
+
if (this.containsMacroNamed(n.children, name))
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
// ── Списки ───────────────────────────────────────────────────────────
|
|
290
|
+
listToMd(list) {
|
|
291
|
+
if (list.attrs.some(([k]) => !(list.name === 'ol' && k === 'start'))) {
|
|
292
|
+
throw new Unrepresentable();
|
|
293
|
+
}
|
|
294
|
+
const items = list.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
295
|
+
if (!items.every((n) => n.kind === 'el' && n.name === 'li' && n.attrs.length === 0)) {
|
|
296
|
+
throw new Unrepresentable();
|
|
297
|
+
}
|
|
298
|
+
const lis = items;
|
|
299
|
+
if (lis.length === 0)
|
|
300
|
+
throw new Unrepresentable();
|
|
301
|
+
// tight: содержимое li — инлайн (+ вложенные списки);
|
|
302
|
+
// loose: каждый абзац li обёрнут в <p>. Смешение в md не выражается.
|
|
303
|
+
const shapes = lis.map((li) => liShape(li));
|
|
304
|
+
if (shapes.some((s) => s === 'other'))
|
|
305
|
+
throw new Unrepresentable();
|
|
306
|
+
if (shapes.some((s) => s !== shapes[0]))
|
|
307
|
+
throw new Unrepresentable();
|
|
308
|
+
const loose = shapes[0] === 'loose';
|
|
309
|
+
const start = list.name === 'ol' ? Number(getAttr(list, 'start') ?? '1') : 0;
|
|
310
|
+
const lines = [];
|
|
311
|
+
lis.forEach((li, idx) => {
|
|
312
|
+
const marker = list.name === 'ol' ? `${start + idx}. ` : '- ';
|
|
313
|
+
const indent = ' '.repeat(marker.length);
|
|
314
|
+
const itemLines = this.listItemLines(li, loose);
|
|
315
|
+
lines.push(marker + itemLines[0]);
|
|
316
|
+
for (const line of itemLines.slice(1)) {
|
|
317
|
+
lines.push(line === '' ? '' : indent + line);
|
|
318
|
+
}
|
|
319
|
+
if (loose && idx < lis.length - 1)
|
|
320
|
+
lines.push('');
|
|
321
|
+
});
|
|
322
|
+
return lines.join('\n');
|
|
323
|
+
}
|
|
324
|
+
listItemLines(li, loose) {
|
|
325
|
+
const lines = [];
|
|
326
|
+
const parts = li.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
327
|
+
let inlineRun = [];
|
|
328
|
+
const flushInline = () => {
|
|
329
|
+
if (inlineRun.length === 0)
|
|
330
|
+
return;
|
|
331
|
+
const md = guardLineStart(this.inlineToMd(inlineRun).trim());
|
|
332
|
+
if (md === '')
|
|
333
|
+
throw new Unrepresentable();
|
|
334
|
+
if (lines.length > 0 && loose)
|
|
335
|
+
lines.push('');
|
|
336
|
+
lines.push(md);
|
|
337
|
+
inlineRun = [];
|
|
338
|
+
};
|
|
339
|
+
for (const part of parts) {
|
|
340
|
+
if (part.kind === 'el' && (part.name === 'ul' || part.name === 'ol')) {
|
|
341
|
+
flushInline();
|
|
342
|
+
lines.push(...this.listToMd(part).split('\n'));
|
|
343
|
+
}
|
|
344
|
+
else if (part.kind === 'el' && part.name === 'p' && part.attrs.length === 0) {
|
|
345
|
+
if (!loose)
|
|
346
|
+
throw new Unrepresentable();
|
|
347
|
+
if (lines.length > 0)
|
|
348
|
+
lines.push('');
|
|
349
|
+
const md = guardLineStart(this.inlineToMd(part.children).trim());
|
|
350
|
+
if (md === '')
|
|
351
|
+
throw new Unrepresentable();
|
|
352
|
+
lines.push(md);
|
|
353
|
+
}
|
|
354
|
+
else if (part.kind === 'el' && CM_BLOCK_TAGS.has(part.name)) {
|
|
355
|
+
throw new Unrepresentable();
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
if (loose)
|
|
359
|
+
throw new Unrepresentable();
|
|
360
|
+
inlineRun.push(part);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
flushInline();
|
|
364
|
+
if (lines.length === 0)
|
|
365
|
+
throw new Unrepresentable();
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
// ── Таблицы ──────────────────────────────────────────────────────────
|
|
369
|
+
tableToMd(table) {
|
|
370
|
+
if (table.attrs.length > 0)
|
|
371
|
+
throw new Unrepresentable();
|
|
372
|
+
const groups = elements(table.children);
|
|
373
|
+
if (table.children.some((n) => n.kind === 'text' && !/^[ \t\r\n]*$/.test(n.raw))) {
|
|
374
|
+
throw new Unrepresentable();
|
|
375
|
+
}
|
|
376
|
+
if (groups.length !== 2 || groups[0].name !== 'thead' || groups[1].name !== 'tbody') {
|
|
377
|
+
throw new Unrepresentable();
|
|
378
|
+
}
|
|
379
|
+
if (groups.some((g) => g.attrs.length > 0))
|
|
380
|
+
throw new Unrepresentable();
|
|
381
|
+
const headRows = elements(groups[0].children);
|
|
382
|
+
if (headRows.length !== 1 || headRows[0].name !== 'tr')
|
|
383
|
+
throw new Unrepresentable();
|
|
384
|
+
const headerCells = elements(headRows[0].children);
|
|
385
|
+
if (!headerCells.every((c) => c.name === 'th'))
|
|
386
|
+
throw new Unrepresentable();
|
|
387
|
+
const aligns = headerCells.map((c) => cellAlign(c));
|
|
388
|
+
const header = headerCells.map((c) => this.cellMd(c));
|
|
389
|
+
const bodyRows = [];
|
|
390
|
+
for (const tr of elements(groups[1].children)) {
|
|
391
|
+
if (tr.name !== 'tr' || tr.attrs.length > 0)
|
|
392
|
+
throw new Unrepresentable();
|
|
393
|
+
const cells = elements(tr.children);
|
|
394
|
+
if (cells.length !== headerCells.length)
|
|
395
|
+
throw new Unrepresentable();
|
|
396
|
+
cells.forEach((c, i) => {
|
|
397
|
+
if (c.name !== 'td' || cellAlign(c) !== aligns[i])
|
|
398
|
+
throw new Unrepresentable();
|
|
399
|
+
});
|
|
400
|
+
bodyRows.push(cells.map((c) => this.cellMd(c)));
|
|
401
|
+
}
|
|
402
|
+
const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
|
|
403
|
+
const row = (cells) => `| ${cells.join(' | ')} |`;
|
|
404
|
+
return [row(header), row(sep), ...bodyRows.map(row)].join('\n');
|
|
405
|
+
}
|
|
406
|
+
cellMd(cell) {
|
|
407
|
+
// Единственный <p> внутри ячейки — разворачиваем (типичная форма).
|
|
408
|
+
let content = cell.children;
|
|
409
|
+
const els = elements(content);
|
|
410
|
+
if (els.length === 1 && els[0].name === 'p' && els[0].attrs.length === 0 &&
|
|
411
|
+
content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
|
|
412
|
+
content = els[0].children;
|
|
413
|
+
}
|
|
414
|
+
const md = this.inlineToMd(content, { cell: true }).trim();
|
|
415
|
+
if (md.includes('\n'))
|
|
416
|
+
throw new Unrepresentable();
|
|
417
|
+
return md;
|
|
418
|
+
}
|
|
419
|
+
// ── Инлайн ───────────────────────────────────────────────────────────
|
|
420
|
+
/**
|
|
421
|
+
* Нормализация инлайн-последовательности перед конвертацией:
|
|
422
|
+
* краевые обычные пробелы выносятся из strong/em/s наружу, а смежные
|
|
423
|
+
* одноимённые элементы сливаются — `<strong>a</strong><strong>b</strong>`
|
|
424
|
+
* дало бы `**a****b**`, который markdown уже не распарсит.
|
|
425
|
+
*/
|
|
426
|
+
normalizeInline(nodes) {
|
|
427
|
+
const MERGEABLE = new Set(['strong', 'em', 's']);
|
|
428
|
+
const out = [];
|
|
429
|
+
for (const n of nodes) {
|
|
430
|
+
if (!(n.kind === 'el' && MERGEABLE.has(n.name) && n.attrs.length === 0)) {
|
|
431
|
+
out.push(n);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const kids = [...n.children];
|
|
435
|
+
let lead = false;
|
|
436
|
+
let trail = false;
|
|
437
|
+
const first = kids[0];
|
|
438
|
+
if (first !== undefined && first.kind === 'text') {
|
|
439
|
+
const m = /^[ \t]+/.exec(first.raw);
|
|
440
|
+
if (m) {
|
|
441
|
+
lead = true;
|
|
442
|
+
kids[0] = { kind: 'text', raw: first.raw.slice(m[0].length) };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const last = kids[kids.length - 1];
|
|
446
|
+
if (last !== undefined && last.kind === 'text') {
|
|
447
|
+
const m = /[ \t]+$/.exec(last.raw);
|
|
448
|
+
if (m) {
|
|
449
|
+
trail = true;
|
|
450
|
+
kids[kids.length - 1] = { kind: 'text', raw: last.raw.slice(0, -m[0].length) };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const cleaned = kids.filter((k) => !(k.kind === 'text' && k.raw === ''));
|
|
454
|
+
if (lead)
|
|
455
|
+
out.push({ kind: 'text', raw: ' ' });
|
|
456
|
+
if (cleaned.length > 0) {
|
|
457
|
+
const prev = out[out.length - 1];
|
|
458
|
+
if (prev !== undefined && prev.kind === 'el' && prev.name === n.name && prev.attrs.length === 0) {
|
|
459
|
+
prev.children.push(...cleaned);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
out.push({ kind: 'el', name: n.name, attrs: [], children: cleaned, selfClosing: false });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (trail)
|
|
466
|
+
out.push({ kind: 'text', raw: ' ' });
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
inlineToMd(nodes, ctx = {}) {
|
|
471
|
+
let out = '';
|
|
472
|
+
for (const n of this.normalizeInline(nodes)) {
|
|
473
|
+
if (n.kind === 'text') {
|
|
474
|
+
out += escapeMdText(n.raw, ctx);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (n.kind === 'cdata')
|
|
478
|
+
throw new Unrepresentable();
|
|
479
|
+
if (n.kind === 'comment') {
|
|
480
|
+
if (n.text.includes('MACRO:') || n.text.includes('-->'))
|
|
481
|
+
throw new Unrepresentable();
|
|
482
|
+
out += `<!--${n.text}-->`;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
out += this.inlineElementToMd(n, ctx);
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
inlineElementToMd(el, ctx) {
|
|
490
|
+
switch (el.name) {
|
|
491
|
+
case 'strong':
|
|
492
|
+
return this.wrapInline(el, '**', ctx);
|
|
493
|
+
case 'em':
|
|
494
|
+
return this.wrapInline(el, '*', ctx);
|
|
495
|
+
// <b>/<i> оставляем сырым HTML: ** рендерится в <strong>, а не в <b>.
|
|
496
|
+
case 'b':
|
|
497
|
+
case 'i':
|
|
498
|
+
return this.rawInline(el, ctx);
|
|
499
|
+
case 's':
|
|
500
|
+
return this.wrapInline(el, '~~', ctx);
|
|
501
|
+
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() === '')
|
|
506
|
+
throw new Unrepresentable();
|
|
507
|
+
const runs = text.match(/`+/g) ?? [];
|
|
508
|
+
const ticks = '`'.repeat(Math.max(1, ...runs.map((r) => r.length + 1)));
|
|
509
|
+
const pad = text.startsWith('`') || text.endsWith('`') || text.startsWith(' ') || text.endsWith(' ') ? ' ' : '';
|
|
510
|
+
return `${ticks}${pad}${text}${pad}${ticks}`;
|
|
511
|
+
}
|
|
512
|
+
case 'br':
|
|
513
|
+
return '<br/>';
|
|
514
|
+
case 'a':
|
|
515
|
+
return this.linkAnchorToMd(el, ctx);
|
|
516
|
+
case 'img': {
|
|
517
|
+
const src = getAttr(el, 'src') ?? '';
|
|
518
|
+
const alt = getAttr(el, 'alt') ?? '';
|
|
519
|
+
const other = el.attrs.filter(([k]) => k !== 'src' && k !== 'alt');
|
|
520
|
+
if (other.length === 0 && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
|
|
521
|
+
return ``;
|
|
522
|
+
}
|
|
523
|
+
return this.rawInline(el, ctx);
|
|
524
|
+
}
|
|
525
|
+
case 'ac:image':
|
|
526
|
+
return this.acImageToMd(el);
|
|
527
|
+
case 'ac:link':
|
|
528
|
+
return this.acLinkToMd(el);
|
|
529
|
+
default:
|
|
530
|
+
if (INLINE_RAW_WRAP.has(el.name))
|
|
531
|
+
return this.rawInline(el, ctx);
|
|
532
|
+
throw new Unrepresentable();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
wrapInline(el, marker, ctx) {
|
|
536
|
+
if (el.attrs.length > 0)
|
|
537
|
+
return this.rawInline(el, ctx);
|
|
538
|
+
const inner = this.inlineToMd(el.children, ctx);
|
|
539
|
+
// Краевые ПРОСТЫЕ пробелы выносим наружу — `**текст **` маркдауном не
|
|
540
|
+
// является. Юникодные пробелы ( и т.п.) выносить нельзя (изменит
|
|
541
|
+
// содержимое), а внутри маркеров они ломают flanking-правила — такой
|
|
542
|
+
// элемент отдаём сырым HTML.
|
|
543
|
+
const m = /^([ \t]*)([\s\S]*?)([ \t]*)$/.exec(inner);
|
|
544
|
+
if (!m || m[2] === '')
|
|
545
|
+
return inner;
|
|
546
|
+
const decodedEdges = decodeEntities(m[2]);
|
|
547
|
+
if (/^\s|\s$/u.test(decodedEdges))
|
|
548
|
+
return this.rawInline(el, ctx);
|
|
549
|
+
return `${m[1]}${marker}${m[2]}${marker}${m[3]}`;
|
|
550
|
+
}
|
|
551
|
+
/** Инлайн-элемент дословно: открывающий тег + инлайн-дети + закрывающий. */
|
|
552
|
+
rawInline(el, ctx) {
|
|
553
|
+
if (hasNamespacedElements([el]))
|
|
554
|
+
throw new Unrepresentable();
|
|
555
|
+
const attrs = el.attrs.map(([k, v]) => ` ${k}="${v}"`).join('');
|
|
556
|
+
if (attrs.includes('\n'))
|
|
557
|
+
throw new Unrepresentable();
|
|
558
|
+
if (el.selfClosing)
|
|
559
|
+
return `<${el.name}${attrs} />`;
|
|
560
|
+
return `<${el.name}${attrs}>${this.inlineToMd(el.children, ctx)}</${el.name}>`;
|
|
561
|
+
}
|
|
562
|
+
linkAnchorToMd(el, ctx) {
|
|
563
|
+
const href = getAttr(el, 'href') ?? '';
|
|
564
|
+
const inner = this.inlineToMd(el.children, ctx);
|
|
565
|
+
if (el.attrs.length === 1 && el.attrs[0][0] === 'href' && SAFE_URL_RE.test(href) && !/[[\]]/.test(inner)) {
|
|
566
|
+
return `[${inner}](${href})`;
|
|
567
|
+
}
|
|
568
|
+
return this.rawInline(el, ctx);
|
|
569
|
+
}
|
|
570
|
+
acImageToMd(el) {
|
|
571
|
+
const kids = elements(el.children);
|
|
572
|
+
if (kids.length !== 1)
|
|
573
|
+
throw new Unrepresentable();
|
|
574
|
+
const ref = kids[0];
|
|
575
|
+
if (ref.name === 'ri:url') {
|
|
576
|
+
if (ref.attrs.some(([k]) => k !== 'ri:value'))
|
|
577
|
+
throw new Unrepresentable();
|
|
578
|
+
const url = getAttr(ref, 'ri:value') ?? '';
|
|
579
|
+
if (el.attrs.length === 0 && SAFE_URL_RE.test(url))
|
|
580
|
+
return ``;
|
|
581
|
+
// С атрибутами или сложным URL — сырой <img>: канонизация считает
|
|
582
|
+
// <img src=… class=…> ≡ <ac:image ac:class=…><ri:url ri:value=…/>.
|
|
583
|
+
const attrs = [['src', escapeXmlAttr(url)]];
|
|
584
|
+
for (const [k] of el.attrs) {
|
|
585
|
+
if (!k.startsWith('ac:'))
|
|
586
|
+
throw new Unrepresentable();
|
|
587
|
+
const plain = k.slice(3);
|
|
588
|
+
if (plain.includes(':') || plain === 'src')
|
|
589
|
+
throw new Unrepresentable();
|
|
590
|
+
attrs.push([plain, escapeXmlAttr(getAttr(el, k) ?? '')]);
|
|
591
|
+
}
|
|
592
|
+
return serializeStorage([{ kind: 'el', name: 'img', attrs, children: [], selfClosing: true }]);
|
|
593
|
+
}
|
|
594
|
+
if (ref.name !== 'ri:attachment')
|
|
595
|
+
throw new Unrepresentable();
|
|
596
|
+
if (!ref.attrs.every(([k]) => k === 'ri:filename' || k === 'ri:version-at-save')) {
|
|
597
|
+
throw new Unrepresentable();
|
|
598
|
+
}
|
|
599
|
+
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
600
|
+
const attrs = [];
|
|
601
|
+
for (const [k] of el.attrs) {
|
|
602
|
+
if (!k.startsWith('ac:'))
|
|
603
|
+
throw new Unrepresentable();
|
|
604
|
+
attrs.push([k.slice(3), getAttr(el, k) ?? '']);
|
|
605
|
+
}
|
|
606
|
+
for (const [, v] of attrs)
|
|
607
|
+
badPlaceholderPart(v);
|
|
608
|
+
badPlaceholderPart(filename);
|
|
609
|
+
this.images.add(filename);
|
|
610
|
+
const attrStr = attrs.map(([k, v]) => `|${k}=${v}`).join('');
|
|
611
|
+
return `{{img:${filename}${attrStr}}}`;
|
|
612
|
+
}
|
|
613
|
+
acLinkToMd(el) {
|
|
614
|
+
if (el.attrs.length > 0)
|
|
615
|
+
throw new Unrepresentable();
|
|
616
|
+
const kids = elements(el.children);
|
|
617
|
+
const ref = kids[0];
|
|
618
|
+
if (ref === undefined)
|
|
619
|
+
throw new Unrepresentable();
|
|
620
|
+
let text;
|
|
621
|
+
if (kids.length === 2) {
|
|
622
|
+
const body = kids[1];
|
|
623
|
+
if (body.name !== 'ac:plain-text-link-body')
|
|
624
|
+
throw new Unrepresentable();
|
|
625
|
+
text = textContent(body.children);
|
|
626
|
+
badPlaceholderPart(text);
|
|
627
|
+
}
|
|
628
|
+
else if (kids.length > 2) {
|
|
629
|
+
throw new Unrepresentable();
|
|
630
|
+
}
|
|
631
|
+
const textAttr = text !== undefined ? `|text=${text}` : '';
|
|
632
|
+
if (ref.name === 'ri:page') {
|
|
633
|
+
if (!ref.attrs.every(([k]) => ['ri:content-title', 'ri:space-key', 'ri:version-at-save'].includes(k))) {
|
|
634
|
+
throw new Unrepresentable();
|
|
635
|
+
}
|
|
636
|
+
const title = getAttr(ref, 'ri:content-title') ?? '';
|
|
637
|
+
const space = getAttr(ref, 'ri:space-key');
|
|
638
|
+
badPlaceholderPart(title);
|
|
639
|
+
if (space !== undefined)
|
|
640
|
+
badPlaceholderPart(space);
|
|
641
|
+
const spaceAttr = space !== undefined ? `|space=${space}` : '';
|
|
642
|
+
return `{{page:${title}${spaceAttr}${textAttr}}}`;
|
|
643
|
+
}
|
|
644
|
+
if (ref.name === 'ri:attachment') {
|
|
645
|
+
if (!ref.attrs.every(([k]) => k === 'ri:filename' || k === 'ri:version-at-save')) {
|
|
646
|
+
throw new Unrepresentable();
|
|
647
|
+
}
|
|
648
|
+
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
649
|
+
badPlaceholderPart(filename);
|
|
650
|
+
this.files.add(filename);
|
|
651
|
+
return `{{file:${filename}${textAttr}}}`;
|
|
652
|
+
}
|
|
653
|
+
throw new Unrepresentable();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/** Параметр текстовый (без вложенных элементов)? Тогда name/value как есть. */
|
|
657
|
+
function textParams(paramEls) {
|
|
658
|
+
const params = [];
|
|
659
|
+
for (const p of paramEls) {
|
|
660
|
+
if (elements(p.children).length > 0)
|
|
661
|
+
return null;
|
|
662
|
+
params.push({ name: getAttr(p, 'ac:name') ?? '', value: textContent(p.children) });
|
|
663
|
+
}
|
|
664
|
+
return params;
|
|
665
|
+
}
|
|
666
|
+
/** Параметр-`<ac:link><ri:page/></ac:link>` → {page, space}. */
|
|
667
|
+
function pageRefParam(p) {
|
|
668
|
+
const link = elements(p.children);
|
|
669
|
+
if (link.length !== 1 || link[0].name !== 'ac:link' || link[0].attrs.length > 0)
|
|
670
|
+
return null;
|
|
671
|
+
const refs = elements(link[0].children);
|
|
672
|
+
if (refs.length !== 1 || refs[0].name !== 'ri:page')
|
|
673
|
+
return null;
|
|
674
|
+
if (!refs[0].attrs.every(([k]) => ['ri:content-title', 'ri:space-key', 'ri:version-at-save'].includes(k)))
|
|
675
|
+
return null;
|
|
676
|
+
const page = getAttr(refs[0], 'ri:content-title') ?? '';
|
|
677
|
+
const space = getAttr(refs[0], 'ri:space-key');
|
|
678
|
+
return space !== undefined ? { page, space } : { page };
|
|
679
|
+
}
|
|
680
|
+
const genericExtractor = (ctx) => {
|
|
681
|
+
if (ctx.plainBody !== null)
|
|
682
|
+
return null;
|
|
683
|
+
const params = textParams(ctx.paramEls);
|
|
684
|
+
if (params === null)
|
|
685
|
+
return null;
|
|
686
|
+
return { params };
|
|
687
|
+
};
|
|
688
|
+
const MACRO_EXTRACTORS = {
|
|
689
|
+
code: (ctx) => {
|
|
690
|
+
if (ctx.richBody !== null || ctx.plainBody === null)
|
|
691
|
+
return null;
|
|
692
|
+
const params = textParams(ctx.paramEls);
|
|
693
|
+
if (params === null)
|
|
694
|
+
return null;
|
|
695
|
+
const source = textContent(ctx.plainBody.children);
|
|
696
|
+
const runs = source.match(/`+/g) ?? [];
|
|
697
|
+
const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
698
|
+
return { params, bodyMarkdown: `${ticks}\n${source}\n${ticks}` };
|
|
699
|
+
},
|
|
700
|
+
anchor: (ctx) => {
|
|
701
|
+
if (ctx.richBody !== null || ctx.plainBody !== null || ctx.paramEls.length !== 1)
|
|
702
|
+
return null;
|
|
703
|
+
const p = ctx.paramEls[0];
|
|
704
|
+
if (getAttr(p, 'ac:name') !== '' || elements(p.children).length > 0)
|
|
705
|
+
return null;
|
|
706
|
+
return { params: [{ name: 'name', value: textContent(p.children) }] };
|
|
707
|
+
},
|
|
708
|
+
include: (ctx) => {
|
|
709
|
+
if (ctx.richBody !== null || ctx.plainBody !== null || ctx.paramEls.length !== 1)
|
|
710
|
+
return null;
|
|
711
|
+
const p = ctx.paramEls[0];
|
|
712
|
+
if (getAttr(p, 'ac:name') !== '')
|
|
713
|
+
return null;
|
|
714
|
+
const ref = pageRefParam(p);
|
|
715
|
+
if (ref === null)
|
|
716
|
+
return null;
|
|
717
|
+
const params = [{ name: 'page', value: ref.page }];
|
|
718
|
+
if (ref.space !== undefined)
|
|
719
|
+
params.push({ name: 'space', value: ref.space });
|
|
720
|
+
return { params };
|
|
721
|
+
},
|
|
722
|
+
'excerpt-include': (ctx) => {
|
|
723
|
+
if (ctx.richBody !== null || ctx.plainBody !== null)
|
|
724
|
+
return null;
|
|
725
|
+
const params = [];
|
|
726
|
+
for (const p of ctx.paramEls) {
|
|
727
|
+
const name = getAttr(p, 'ac:name') ?? '';
|
|
728
|
+
if (name === '') {
|
|
729
|
+
const ref = pageRefParam(p);
|
|
730
|
+
if (ref === null)
|
|
731
|
+
return null;
|
|
732
|
+
params.push({ name: 'page', value: ref.page });
|
|
733
|
+
if (ref.space !== undefined)
|
|
734
|
+
params.push({ name: 'space', value: ref.space });
|
|
735
|
+
}
|
|
736
|
+
else if (elements(p.children).length === 0) {
|
|
737
|
+
params.push({ name, value: textContent(p.children) });
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return { params };
|
|
744
|
+
},
|
|
745
|
+
'table-excerpt-include': (ctx) => {
|
|
746
|
+
if (ctx.richBody !== null || ctx.plainBody !== null)
|
|
747
|
+
return null;
|
|
748
|
+
const params = [];
|
|
749
|
+
for (const p of ctx.paramEls) {
|
|
750
|
+
const name = getAttr(p, 'ac:name') ?? '';
|
|
751
|
+
if (name === 'page') {
|
|
752
|
+
const ref = pageRefParam(p);
|
|
753
|
+
if (ref === null)
|
|
754
|
+
return null;
|
|
755
|
+
params.push({ name: 'page', value: ref.page });
|
|
756
|
+
if (ref.space !== undefined)
|
|
757
|
+
params.push({ name: 'space', value: ref.space });
|
|
758
|
+
}
|
|
759
|
+
else if (elements(p.children).length === 0) {
|
|
760
|
+
params.push({ name, value: textContent(p.children) });
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return { params };
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
/** Форма пункта списка: инлайн-содержимое (tight) или <p>-абзацы (loose). */
|
|
770
|
+
function liShape(li) {
|
|
771
|
+
let hasP = false;
|
|
772
|
+
let hasInline = false;
|
|
773
|
+
for (const n of li.children) {
|
|
774
|
+
if (n.kind === 'text') {
|
|
775
|
+
if (!/^[ \t\r\n]*$/.test(n.raw))
|
|
776
|
+
hasInline = true;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (n.kind === 'el' && n.name === 'p')
|
|
780
|
+
hasP = true;
|
|
781
|
+
else if (n.kind === 'el' && (n.name === 'ul' || n.name === 'ol'))
|
|
782
|
+
continue;
|
|
783
|
+
else
|
|
784
|
+
hasInline = true;
|
|
785
|
+
}
|
|
786
|
+
if (hasP && hasInline)
|
|
787
|
+
return 'other';
|
|
788
|
+
return hasP ? 'loose' : 'tight';
|
|
789
|
+
}
|
|
790
|
+
function badParam(p) {
|
|
791
|
+
// %-последовательности исходника декодер маркера исказил бы.
|
|
792
|
+
return /%(3D|3A|3C|3E|0A|0D|25)/i.test(p.name + p.value);
|
|
793
|
+
}
|
|
794
|
+
function badPlaceholderPart(value) {
|
|
795
|
+
if (/[|{}\n\r]/.test(value))
|
|
796
|
+
throw new Unrepresentable();
|
|
797
|
+
}
|
|
798
|
+
// ── Экранирование текста ──────────────────────────────────────────────
|
|
799
|
+
const ENTITY_RE = /&(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
|
|
800
|
+
/**
|
|
801
|
+
* Экранирует markdown-активные символы, сохраняя сущности ( и т.п.)
|
|
802
|
+
* как есть — markdown-it декодирует их при рендере.
|
|
803
|
+
*/
|
|
804
|
+
function escapeMdText(raw, ctx) {
|
|
805
|
+
const collapsed = raw.replace(/[\r\n]+/g, ' ');
|
|
806
|
+
let out = '';
|
|
807
|
+
let last = 0;
|
|
808
|
+
for (const m of collapsed.matchAll(ENTITY_RE)) {
|
|
809
|
+
out += escapePlain(collapsed.slice(last, m.index), ctx);
|
|
810
|
+
out += m[0];
|
|
811
|
+
last = m.index + m[0].length;
|
|
812
|
+
}
|
|
813
|
+
out += escapePlain(collapsed.slice(last), ctx);
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
function escapePlain(s, ctx) {
|
|
817
|
+
let esc = s.replace(/[\\`*_[\]{}~]/g, (c) => '\\' + c);
|
|
818
|
+
// Сырой U+00A0 на краю абзаца съедается trim()'ом markdown-it —
|
|
819
|
+
// в entity-форме переживает рендер (и виден при редактировании).
|
|
820
|
+
esc = esc.replace(/ /g, ' ');
|
|
821
|
+
if (ctx.cell)
|
|
822
|
+
esc = esc.replace(/\|/g, '\\|');
|
|
823
|
+
return esc;
|
|
824
|
+
}
|
|
825
|
+
/** Экранирует конструкции, значимые в начале строки (#, >, -, 1. …). */
|
|
826
|
+
function guardLineStart(md) {
|
|
827
|
+
return md.replace(/^(\s*)([#>+-]|\d+[.)])(\s|$)/, (_m, ws, ch, sp) => {
|
|
828
|
+
if (ch.length === 1)
|
|
829
|
+
return `${ws}\\${ch}${sp}`;
|
|
830
|
+
return `${ws}${ch.slice(0, -1)}\\${ch.slice(-1)}${sp}`;
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
function cellAlign(cell) {
|
|
834
|
+
if (cell.attrs.length === 0)
|
|
835
|
+
return 'none';
|
|
836
|
+
if (cell.attrs.length > 1)
|
|
837
|
+
throw new Unrepresentable();
|
|
838
|
+
const [k, v] = cell.attrs[0];
|
|
839
|
+
if (k !== 'style')
|
|
840
|
+
throw new Unrepresentable();
|
|
841
|
+
const m = /^text-align:\s*(left|right|center);?\s*$/.exec(v);
|
|
842
|
+
if (!m)
|
|
843
|
+
throw new Unrepresentable();
|
|
844
|
+
return m[1];
|
|
845
|
+
}
|