confluence-md-sync 0.8.0 → 0.8.2
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 +154 -11
- package/dist/publish/notice.d.ts +1 -0
- package/dist/publish/notice.js +1 -1
- 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. */
|
|
@@ -25,6 +25,7 @@ import { processMacros } from '../macros/registry.js';
|
|
|
25
25
|
import { defaultMacroRegistry } from '../macros/index.js';
|
|
26
26
|
import { renderToStorage } from '../markdown/render.js';
|
|
27
27
|
import { compareStorage } from './canonical.js';
|
|
28
|
+
import { NOTICE_MACRO_ID } from '../publish/notice.js';
|
|
28
29
|
import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
|
|
29
30
|
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
30
31
|
export function storageToMarkdown(storage, opts = {}) {
|
|
@@ -79,7 +80,7 @@ class Converter {
|
|
|
79
80
|
registry;
|
|
80
81
|
images = new Set();
|
|
81
82
|
files = new Set();
|
|
82
|
-
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0 };
|
|
83
|
+
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0, normalized: 0 };
|
|
83
84
|
readable;
|
|
84
85
|
localFiles;
|
|
85
86
|
tablesAsRecords;
|
|
@@ -283,7 +284,14 @@ class Converter {
|
|
|
283
284
|
}
|
|
284
285
|
// p / td / th / li / caption и прочие «инлайн-контейнеры» → инлайн.
|
|
285
286
|
// multiline: это свободный поток (не ячейка) — <br> станет переносом.
|
|
286
|
-
|
|
287
|
+
let inline = '';
|
|
288
|
+
try {
|
|
289
|
+
inline = this.inlineToMd(el.children, { multiline: true }).trim();
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
if (!(e instanceof Unrepresentable))
|
|
293
|
+
throw e; /* → голый текст ниже */
|
|
294
|
+
}
|
|
287
295
|
if (inline !== '')
|
|
288
296
|
return inline.split('\n').map((l) => guardLineStart(l)).join('\n');
|
|
289
297
|
// Совсем ничего не вышло — голый текст (может быть пустым).
|
|
@@ -301,8 +309,6 @@ class Converter {
|
|
|
301
309
|
anchor: 'anchor', detailssummary: 'properties-report',
|
|
302
310
|
};
|
|
303
311
|
tryNativeMd(el, name) {
|
|
304
|
-
if (this.readable)
|
|
305
|
-
return null; // readable-путь остаётся прежним
|
|
306
312
|
const parts = this.macroParts(el);
|
|
307
313
|
if (parts === null)
|
|
308
314
|
return null;
|
|
@@ -334,19 +340,32 @@ class Converter {
|
|
|
334
340
|
const nonWs = richBody.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
335
341
|
if (bodyEls.length !== 1 || nonWs.length !== 1 || bodyEls[0].name !== 'table')
|
|
336
342
|
return null;
|
|
337
|
-
|
|
343
|
+
const dp = [...pmap.entries()].map(([k, v]) => (oneline(v) && !/["\s]/.test(v) ? ` ${k}=${v}` : null));
|
|
344
|
+
if (dp.some((x) => x === null))
|
|
345
|
+
return null;
|
|
346
|
+
let tableMd = null;
|
|
338
347
|
try {
|
|
339
348
|
tableMd = this.tableToMd(bodyEls[0]);
|
|
340
349
|
}
|
|
341
350
|
catch (e) {
|
|
342
|
-
if (e instanceof Unrepresentable)
|
|
351
|
+
if (!(e instanceof Unrepresentable))
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
if (tableMd !== null) {
|
|
355
|
+
candidate = `::: properties${dp.join('')}\n${tableMd}\n:::`;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// «Свойства страницы» обязаны жить md-таблицей: сложную таблицу
|
|
359
|
+
// нормализуем в GFM (оформление теряется, текст сверяется рендером).
|
|
360
|
+
const norm = this.propertiesGridMd(bodyEls[0]);
|
|
361
|
+
if (norm === null)
|
|
343
362
|
return null;
|
|
344
|
-
|
|
363
|
+
const cand = `::: properties${dp.join('')}\n${norm.md}\n:::`;
|
|
364
|
+
if (!this.readable && !this.verifyNormalizedDetails(cand, norm.rows))
|
|
365
|
+
return null;
|
|
366
|
+
this.stats.normalized++;
|
|
367
|
+
return cand; // канонической эквивалентности нет по построению — верифицирован текст
|
|
345
368
|
}
|
|
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
369
|
}
|
|
351
370
|
else if (name === 'expand' && richBody !== null) {
|
|
352
371
|
if (![...pmap.keys()].every((k) => k === 'title'))
|
|
@@ -389,6 +408,8 @@ class Converter {
|
|
|
389
408
|
}
|
|
390
409
|
if (candidate === null)
|
|
391
410
|
return null;
|
|
411
|
+
if (this.readable)
|
|
412
|
+
return candidate; // readable: без канонической верификации (режим и так lossy)
|
|
392
413
|
return this.verifyMacroMarker(el, candidate) ? candidate : null;
|
|
393
414
|
}
|
|
394
415
|
/** Разбирает macro-элемент на параметры и тела; null — посторонние дети. */
|
|
@@ -425,6 +446,9 @@ class Converter {
|
|
|
425
446
|
}
|
|
426
447
|
}
|
|
427
448
|
macroToMd(el) {
|
|
449
|
+
// Баннер managedNotice — артефакт публикации, в md-источник не попадает никогда.
|
|
450
|
+
if (getAttr(el, 'ac:macro-id') === NOTICE_MACRO_ID)
|
|
451
|
+
return '';
|
|
428
452
|
const name = getAttr(el, 'ac:name') ?? '';
|
|
429
453
|
const nativeMd = this.tryNativeMd(el, name);
|
|
430
454
|
if (nativeMd !== null) {
|
|
@@ -518,6 +542,110 @@ class Converter {
|
|
|
518
542
|
return this.verifyMacroMarker(el, markerMd) ? markerMd : null;
|
|
519
543
|
}
|
|
520
544
|
/** Маркер → render-конвейер → канонически равен исходному макросу? */
|
|
545
|
+
/** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
|
|
546
|
+
* заголовок синтезируется («Поле|Значение» для двух колонок). null — не таблица. */
|
|
547
|
+
/** Убирает ac:/ri:-вставки без текста (упоминания пользователей, эмодзи):
|
|
548
|
+
* в md им нечем быть, а их присутствие роняло flatten всей ячейки. */
|
|
549
|
+
stripEmptyNamespaced(nodes) {
|
|
550
|
+
const out = [];
|
|
551
|
+
for (const n of nodes) {
|
|
552
|
+
if (n.kind === 'el') {
|
|
553
|
+
if (n.name.includes(':') && textContent(n.children).trim() === '')
|
|
554
|
+
continue;
|
|
555
|
+
out.push({ ...n, children: this.stripEmptyNamespaced(n.children) });
|
|
556
|
+
}
|
|
557
|
+
else
|
|
558
|
+
out.push(n);
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
/** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
|
|
563
|
+
* заголовок синтезируется («Поле|Значение» для двух колонок). Ячейки — readable-
|
|
564
|
+
* flatten; несовместимые вставки (напр. упоминание пользователя) — в голый текст.
|
|
565
|
+
* null — не таблица/пусто. */
|
|
566
|
+
propertiesGridMd(table) {
|
|
567
|
+
const saved = this.readable;
|
|
568
|
+
this.readable = true;
|
|
569
|
+
try {
|
|
570
|
+
const trs = [];
|
|
571
|
+
for (const child of elements(table.children)) {
|
|
572
|
+
if (['thead', 'tbody', 'tfoot'].includes(child.name)) {
|
|
573
|
+
for (const tr of elements(child.children))
|
|
574
|
+
if (tr.name === 'tr')
|
|
575
|
+
trs.push(tr);
|
|
576
|
+
}
|
|
577
|
+
else if (child.name === 'tr')
|
|
578
|
+
trs.push(child);
|
|
579
|
+
}
|
|
580
|
+
if (trs.length === 0)
|
|
581
|
+
return null;
|
|
582
|
+
const grid = trs.map((tr) => elements(tr.children)
|
|
583
|
+
.filter((c) => c.name === 'td' || c.name === 'th')
|
|
584
|
+
.map((c) => {
|
|
585
|
+
let flat;
|
|
586
|
+
try {
|
|
587
|
+
flat = this.cellFlatten(this.stripEmptyNamespaced(c.children));
|
|
588
|
+
}
|
|
589
|
+
catch (e) {
|
|
590
|
+
if (!(e instanceof Unrepresentable))
|
|
591
|
+
throw e;
|
|
592
|
+
flat = textContent(c.children);
|
|
593
|
+
}
|
|
594
|
+
return flat.replace(/\s+/g, ' ').trim();
|
|
595
|
+
}));
|
|
596
|
+
const cols = Math.max(...grid.map((r) => r.length));
|
|
597
|
+
const norm = grid.map((r) => Array.from({ length: cols }, (_, i) => r[i] ?? ''));
|
|
598
|
+
const keep = Array.from({ length: cols }, (_, i) => norm.some((r) => r[i] !== ''));
|
|
599
|
+
const rows = norm.map((r) => r.filter((_, i) => keep[i])).filter((r) => r.some((c) => c !== ''));
|
|
600
|
+
const width = rows[0]?.length ?? 0;
|
|
601
|
+
if (width === 0 || rows.some((r) => r.length !== width))
|
|
602
|
+
return null;
|
|
603
|
+
const esc = (s) => s.replace(/\|/g, '\\|');
|
|
604
|
+
const header = width === 2 ? ['Поле', 'Значение'] : rows[0].map(() => ' ');
|
|
605
|
+
const line = (cells) => `| ${cells.map(esc).join(' | ')} |`;
|
|
606
|
+
const md = [line(header), `| ${header.map(() => '---').join(' | ')} |`, ...rows.map(line)].join('\n');
|
|
607
|
+
return { md, rows };
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
this.readable = saved;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** Нормализованные details: рендерим кандидата и сверяем ТЕКСТ ячеек с исходным. */
|
|
614
|
+
verifyNormalizedDetails(candidate, rows) {
|
|
615
|
+
try {
|
|
616
|
+
let storage = renderToStorage(candidate, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
|
617
|
+
storage = processMacros(storage, this.registry).toString();
|
|
618
|
+
let renderedTable = null;
|
|
619
|
+
const walk = (ns) => {
|
|
620
|
+
for (const n of elements(ns)) {
|
|
621
|
+
if (renderedTable)
|
|
622
|
+
return;
|
|
623
|
+
if (n.name === 'table') {
|
|
624
|
+
renderedTable = n;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
walk(n.children);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
walk(parseStorage(storage));
|
|
631
|
+
if (!renderedTable)
|
|
632
|
+
return false;
|
|
633
|
+
const backNorm = this.propertiesGridMd(renderedTable);
|
|
634
|
+
if (backNorm === null)
|
|
635
|
+
return false;
|
|
636
|
+
const back = backNorm.rows;
|
|
637
|
+
const width = rows[0]?.length ?? 0;
|
|
638
|
+
const headerIsSynth = back.length > 0 &&
|
|
639
|
+
(width === 2 ? back[0][0] === 'Поле' && back[0][1] === 'Значение' : back[0].every((c) => c === ''));
|
|
640
|
+
const body = headerIsSynth ? back.slice(1) : back;
|
|
641
|
+
if (body.length !== rows.length)
|
|
642
|
+
return false;
|
|
643
|
+
return body.every((r, i) => r.length === rows[i].length && r.every((c, j) => c === rows[i][j]));
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
521
649
|
verifyMacroMarker(el, markerMd) {
|
|
522
650
|
try {
|
|
523
651
|
let storage = renderToStorage(markerMd, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
|
@@ -1109,6 +1237,21 @@ class Converter {
|
|
|
1109
1237
|
return `{{img:${filename}${attrStr}}}`;
|
|
1110
1238
|
}
|
|
1111
1239
|
acLinkToMd(el) {
|
|
1240
|
+
// readable безотказен: непредставимая ссылка (user-mention, ссылка без цели,
|
|
1241
|
+
// card-appearance) деградирует до своего текста.
|
|
1242
|
+
if (this.readable) {
|
|
1243
|
+
try {
|
|
1244
|
+
return this.acLinkStrict(el);
|
|
1245
|
+
}
|
|
1246
|
+
catch (e) {
|
|
1247
|
+
if (!(e instanceof Unrepresentable))
|
|
1248
|
+
throw e;
|
|
1249
|
+
return escapeMdText(textContent(el.children), {}, true).trim();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return this.acLinkStrict(el);
|
|
1253
|
+
}
|
|
1254
|
+
acLinkStrict(el) {
|
|
1112
1255
|
if (el.attrs.length > 0)
|
|
1113
1256
|
throw new Unrepresentable();
|
|
1114
1257
|
const kids = elements(el.children);
|
package/dist/publish/notice.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface ManagedNoticeOptions {
|
|
|
28
28
|
}
|
|
29
29
|
export declare const DEFAULT_MANAGED_NOTICE_TEXT: string;
|
|
30
30
|
export declare const DEFAULT_MANAGED_NOTICE_LINK_TEXT = "docs-studio";
|
|
31
|
+
export declare const NOTICE_MACRO_ID = "0f0e0d0c-0b0a-4009-8008-000000000001";
|
|
31
32
|
/** Строит storage-разметку баннера (одиночный `<ac:structured-macro>`). */
|
|
32
33
|
export declare function buildManagedNotice(opts: ManagedNoticeOptions): string;
|
|
33
34
|
/** Дописывает баннер в начало (top) или конец (bottom) storage-контента. */
|
package/dist/publish/notice.js
CHANGED
|
@@ -12,7 +12,7 @@ export const DEFAULT_MANAGED_NOTICE_LINK_TEXT = 'docs-studio';
|
|
|
12
12
|
// Фиксированный ac:macro-id: баннер обязан давать БАЙТ-В-БАЙТ одинаковый
|
|
13
13
|
// storage при каждой публикации. Со случайным id (generateMacroId) content-
|
|
14
14
|
// hash менялся бы каждый раз → страница вечно считалась бы изменённой.
|
|
15
|
-
const NOTICE_MACRO_ID = '0f0e0d0c-0b0a-4009-8008-000000000001';
|
|
15
|
+
export const NOTICE_MACRO_ID = '0f0e0d0c-0b0a-4009-8008-000000000001';
|
|
16
16
|
/** Собирает `<a href>`-ссылку на источник. */
|
|
17
17
|
function noticeLink(opts) {
|
|
18
18
|
const text = opts.linkText ?? DEFAULT_MANAGED_NOTICE_LINK_TEXT;
|
package/package.json
CHANGED