confluence-md-sync 0.8.0 → 0.8.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 +1 -1
- package/dist/export/to-markdown.d.ts +2 -0
- package/dist/export/to-markdown.js +125 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -327,7 +327,7 @@ that doesn't fit falls back to markers/fenced blocks automatically.
|
|
|
327
327
|
| Macro | Markdown |
|
|
328
328
|
| --- | --- |
|
|
329
329
|
| `info` / `note` / `warning` / `tip` | `> [!INFO] Title?` + quoted body (GitHub-style admonition) |
|
|
330
|
-
| `details` (Page Properties) | `::: properties [id=…] [hidden=true]` + md-table + `:::` |
|
|
330
|
+
| `details` (Page Properties) | `::: properties [id=…] [hidden=true]` + md-table + `:::` — complex Confluence tables are normalized to GFM on export (styling dropped, cell text verified; `stats.normalized`) |
|
|
331
331
|
| `expand` | `::: expand Title` + body + `:::` |
|
|
332
332
|
| `panel` | `::: panel title=… borderColor=…` + body + `:::` |
|
|
333
333
|
| `toc` | `{{toc}}` / `{{toc:maxLevel=3}}` |
|
|
@@ -63,6 +63,8 @@ export interface StorageToMarkdownResult {
|
|
|
63
63
|
lossy: number;
|
|
64
64
|
/** Макросы, выраженные нативным md-синтаксисом (панели, ::: …, {{…}}). */
|
|
65
65
|
native: number;
|
|
66
|
+
/** details, чья таблица нормализована в GFM (оформление потеряно, текст сверен). */
|
|
67
|
+
normalized: number;
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
@@ -79,7 +79,7 @@ class Converter {
|
|
|
79
79
|
registry;
|
|
80
80
|
images = new Set();
|
|
81
81
|
files = new Set();
|
|
82
|
-
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0 };
|
|
82
|
+
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0, normalized: 0 };
|
|
83
83
|
readable;
|
|
84
84
|
localFiles;
|
|
85
85
|
tablesAsRecords;
|
|
@@ -334,19 +334,32 @@ class Converter {
|
|
|
334
334
|
const nonWs = richBody.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
335
335
|
if (bodyEls.length !== 1 || nonWs.length !== 1 || bodyEls[0].name !== 'table')
|
|
336
336
|
return null;
|
|
337
|
-
|
|
337
|
+
const dp = [...pmap.entries()].map(([k, v]) => (oneline(v) && !/["\s]/.test(v) ? ` ${k}=${v}` : null));
|
|
338
|
+
if (dp.some((x) => x === null))
|
|
339
|
+
return null;
|
|
340
|
+
let tableMd = null;
|
|
338
341
|
try {
|
|
339
342
|
tableMd = this.tableToMd(bodyEls[0]);
|
|
340
343
|
}
|
|
341
344
|
catch (e) {
|
|
342
|
-
if (e instanceof Unrepresentable)
|
|
345
|
+
if (!(e instanceof Unrepresentable))
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
if (tableMd !== null) {
|
|
349
|
+
candidate = `::: properties${dp.join('')}\n${tableMd}\n:::`;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// «Свойства страницы» обязаны жить md-таблицей: сложную таблицу
|
|
353
|
+
// нормализуем в GFM (оформление теряется, текст сверяется рендером).
|
|
354
|
+
const norm = this.propertiesGridMd(bodyEls[0]);
|
|
355
|
+
if (norm === null)
|
|
343
356
|
return null;
|
|
344
|
-
|
|
357
|
+
const cand = `::: properties${dp.join('')}\n${norm.md}\n:::`;
|
|
358
|
+
if (!this.verifyNormalizedDetails(cand, norm.rows))
|
|
359
|
+
return null;
|
|
360
|
+
this.stats.normalized++;
|
|
361
|
+
return cand; // канонической эквивалентности нет по построению — верифицирован текст
|
|
345
362
|
}
|
|
346
|
-
const dp = [...pmap.entries()].map(([k, v]) => (oneline(v) && !/["\s]/.test(v) ? ` ${k}=${v}` : null));
|
|
347
|
-
if (dp.some((x) => x === null))
|
|
348
|
-
return null;
|
|
349
|
-
candidate = `::: properties${dp.join('')}\n${tableMd}\n:::`;
|
|
350
363
|
}
|
|
351
364
|
else if (name === 'expand' && richBody !== null) {
|
|
352
365
|
if (![...pmap.keys()].every((k) => k === 'title'))
|
|
@@ -518,6 +531,110 @@ class Converter {
|
|
|
518
531
|
return this.verifyMacroMarker(el, markerMd) ? markerMd : null;
|
|
519
532
|
}
|
|
520
533
|
/** Маркер → render-конвейер → канонически равен исходному макросу? */
|
|
534
|
+
/** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
|
|
535
|
+
* заголовок синтезируется («Поле|Значение» для двух колонок). null — не таблица. */
|
|
536
|
+
/** Убирает ac:/ri:-вставки без текста (упоминания пользователей, эмодзи):
|
|
537
|
+
* в md им нечем быть, а их присутствие роняло flatten всей ячейки. */
|
|
538
|
+
stripEmptyNamespaced(nodes) {
|
|
539
|
+
const out = [];
|
|
540
|
+
for (const n of nodes) {
|
|
541
|
+
if (n.kind === 'el') {
|
|
542
|
+
if (n.name.includes(':') && textContent(n.children).trim() === '')
|
|
543
|
+
continue;
|
|
544
|
+
out.push({ ...n, children: this.stripEmptyNamespaced(n.children) });
|
|
545
|
+
}
|
|
546
|
+
else
|
|
547
|
+
out.push(n);
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
/** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
|
|
552
|
+
* заголовок синтезируется («Поле|Значение» для двух колонок). Ячейки — readable-
|
|
553
|
+
* flatten; несовместимые вставки (напр. упоминание пользователя) — в голый текст.
|
|
554
|
+
* null — не таблица/пусто. */
|
|
555
|
+
propertiesGridMd(table) {
|
|
556
|
+
const saved = this.readable;
|
|
557
|
+
this.readable = true;
|
|
558
|
+
try {
|
|
559
|
+
const trs = [];
|
|
560
|
+
for (const child of elements(table.children)) {
|
|
561
|
+
if (['thead', 'tbody', 'tfoot'].includes(child.name)) {
|
|
562
|
+
for (const tr of elements(child.children))
|
|
563
|
+
if (tr.name === 'tr')
|
|
564
|
+
trs.push(tr);
|
|
565
|
+
}
|
|
566
|
+
else if (child.name === 'tr')
|
|
567
|
+
trs.push(child);
|
|
568
|
+
}
|
|
569
|
+
if (trs.length === 0)
|
|
570
|
+
return null;
|
|
571
|
+
const grid = trs.map((tr) => elements(tr.children)
|
|
572
|
+
.filter((c) => c.name === 'td' || c.name === 'th')
|
|
573
|
+
.map((c) => {
|
|
574
|
+
let flat;
|
|
575
|
+
try {
|
|
576
|
+
flat = this.cellFlatten(this.stripEmptyNamespaced(c.children));
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
if (!(e instanceof Unrepresentable))
|
|
580
|
+
throw e;
|
|
581
|
+
flat = textContent(c.children);
|
|
582
|
+
}
|
|
583
|
+
return flat.replace(/\s+/g, ' ').trim();
|
|
584
|
+
}));
|
|
585
|
+
const cols = Math.max(...grid.map((r) => r.length));
|
|
586
|
+
const norm = grid.map((r) => Array.from({ length: cols }, (_, i) => r[i] ?? ''));
|
|
587
|
+
const keep = Array.from({ length: cols }, (_, i) => norm.some((r) => r[i] !== ''));
|
|
588
|
+
const rows = norm.map((r) => r.filter((_, i) => keep[i])).filter((r) => r.some((c) => c !== ''));
|
|
589
|
+
const width = rows[0]?.length ?? 0;
|
|
590
|
+
if (width === 0 || rows.some((r) => r.length !== width))
|
|
591
|
+
return null;
|
|
592
|
+
const esc = (s) => s.replace(/\|/g, '\\|');
|
|
593
|
+
const header = width === 2 ? ['Поле', 'Значение'] : rows[0].map(() => ' ');
|
|
594
|
+
const line = (cells) => `| ${cells.map(esc).join(' | ')} |`;
|
|
595
|
+
const md = [line(header), `| ${header.map(() => '---').join(' | ')} |`, ...rows.map(line)].join('\n');
|
|
596
|
+
return { md, rows };
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
this.readable = saved;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** Нормализованные details: рендерим кандидата и сверяем ТЕКСТ ячеек с исходным. */
|
|
603
|
+
verifyNormalizedDetails(candidate, rows) {
|
|
604
|
+
try {
|
|
605
|
+
let storage = renderToStorage(candidate, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
|
606
|
+
storage = processMacros(storage, this.registry).toString();
|
|
607
|
+
let renderedTable = null;
|
|
608
|
+
const walk = (ns) => {
|
|
609
|
+
for (const n of elements(ns)) {
|
|
610
|
+
if (renderedTable)
|
|
611
|
+
return;
|
|
612
|
+
if (n.name === 'table') {
|
|
613
|
+
renderedTable = n;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
walk(n.children);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
walk(parseStorage(storage));
|
|
620
|
+
if (!renderedTable)
|
|
621
|
+
return false;
|
|
622
|
+
const backNorm = this.propertiesGridMd(renderedTable);
|
|
623
|
+
if (backNorm === null)
|
|
624
|
+
return false;
|
|
625
|
+
const back = backNorm.rows;
|
|
626
|
+
const width = rows[0]?.length ?? 0;
|
|
627
|
+
const headerIsSynth = back.length > 0 &&
|
|
628
|
+
(width === 2 ? back[0][0] === 'Поле' && back[0][1] === 'Значение' : back[0].every((c) => c === ''));
|
|
629
|
+
const body = headerIsSynth ? back.slice(1) : back;
|
|
630
|
+
if (body.length !== rows.length)
|
|
631
|
+
return false;
|
|
632
|
+
return body.every((r, i) => r.length === rows[i].length && r.every((c, j) => c === rows[i][j]));
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
521
638
|
verifyMacroMarker(el, markerMd) {
|
|
522
639
|
try {
|
|
523
640
|
let storage = renderToStorage(markerMd, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
package/package.json
CHANGED