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 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
- const inline = this.inlineToMd(el.children, { multiline: true }).trim();
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
- let tableMd;
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
- throw e;
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);
@@ -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-контента. */
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Publish Markdown to Confluence (Data Center & Cloud): idempotent page sync, attachment dedup, tables and a pluggable macro system",
5
5
  "keywords": [
6
6
  "confluence",