confluence-md-sync 0.3.0 → 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 CHANGED
@@ -28,9 +28,9 @@ npm install -g confluence-md-sync # as a CLI: `confluence-md-sync …`
28
28
  - **Flexible sources** — images/files accept relative paths, absolute paths
29
29
  or `http(s)` URLs; URLs on your Confluence host are fetched with the
30
30
  configured token.
31
- - **Loss-free round-trip** — export a page to Markdown and publish it back;
32
- what Markdown can't express is kept verbatim, verified by canonical
33
- storage comparison.
31
+ - **Export & round-trip** — pull a page into Markdown and publish it back
32
+ loss-free (verified by canonical storage comparison), or export a
33
+ `readable`, pure-Markdown version for humans.
34
34
 
35
35
  ## Configuration
36
36
 
@@ -171,23 +171,28 @@ import { convertBpmnFolder } from 'confluence-md-sync';
171
171
  await convertBpmnFolder({ srcDir: 'docs/diagrams', outDir: 'build' });
172
172
  ```
173
173
 
174
- ## Export a page back to Markdown (round-trip)
174
+ ## Export a page back to Markdown
175
175
 
176
- Pull an existing page by ID and turn its storage format into Markdown
177
- without losing markup. The conversion is **loss-free by construction**: a
178
- three-tier policy renders each node as clean Markdown where possible,
179
- macro markers where a macro round-trips, and verbatim storage (raw HTML,
180
- or a ` ```confluence-storage ` fence) as a fallback. Attachments and page
181
- links become `{{img:...}}` / `{{file:...}}` / `{{page:...}}` placeholders.
176
+ Pull an existing page by ID and turn its storage format into Markdown.
177
+ Attachments and page links become `{{img:...}}` / `{{file:...}}` /
178
+ `{{page:...}}` placeholders. Two modes trade fidelity against readability:
179
+
180
+ | Mode | For | What it does |
181
+ | --- | --- | --- |
182
+ | `faithful` *(default)* | round-trip, editing then re-publishing | **Loss-free by construction.** Clean Markdown → macro markers → verbatim storage (raw HTML, or a ` ```confluence-storage ` fence) as a fallback. Perfect fidelity, but the raw-HTML blocks (complex tables, wrappers) render poorly in some Markdown viewers. |
183
+ | `readable` | reading, diffing, docs you won't publish back | **Clean Markdown, no raw HTML.** Complex tables are flattened to GFM (merged cells → filled grid, block cells → `• …` joined by `<br>`), styled spans/wrappers are unwrapped, entities decoded. Keeps the content; **drops** colours, exact merge geometry, wrappers — *not* round-trippable. |
182
184
 
183
185
  ```ts
184
186
  import { exportPage } from 'confluence-md-sync';
185
187
 
186
188
  const { markdownPath, images, downloaded } = await exportPage(
187
189
  '123456789',
188
- { outDir: 'exported' }, // writes exported/page.md + exported/attachments/
190
+ { outDir: 'exported' }, // faithful; writes page.md + attachments/
189
191
  cfg,
190
192
  );
193
+
194
+ // Readable variant for humans:
195
+ await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
191
196
  ```
192
197
 
193
198
  Round-trip means *edit the Markdown, then publish it straight back*:
@@ -220,8 +225,9 @@ normalised away (Confluence itself rewrites these on every save). Use
220
225
  From the CLI:
221
226
 
222
227
  ```bash
223
- confluence-md-sync export 123456789 --out-dir exported
224
- confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
228
+ confluence-md-sync export 123456789 --out page.md # faithful
229
+ confluence-md-sync export 123456789 --out page.md --readable # clean Markdown
230
+ confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
225
231
  ```
226
232
 
227
233
  ## Read pages and tables
@@ -321,7 +327,8 @@ export CONFLUENCE_BASE_URL='https://confluence.example.com'
321
327
  export CONFLUENCE_TOKEN='<your-PAT>'
322
328
 
323
329
  # Download a page to an exact .md path (add --no-attachments for the md only)
324
- confluence-md-sync export 123456789 --out ./page.md
330
+ confluence-md-sync export 123456789 --out ./page.md # faithful
331
+ confluence-md-sync export 123456789 --out ./page.md --readable # clean Markdown
325
332
 
326
333
  confluence-md-sync publish docs/page.md --page-id 123456789 \
327
334
  --image build/flow.png --file build/data.csv --label docs
package/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ const HELP = `confluence-md-sync — Markdown ⇄ Confluence
21
21
 
22
22
  Usage:
23
23
  confluence-md-sync publish <markdown-file> [options]
24
- confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--no-attachments]
24
+ confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--readable] [--no-attachments]
25
25
  confluence-md-sync roundtrip <page-id> [--show-markdown]
26
26
 
27
27
  publish options:
@@ -43,6 +43,9 @@ export options:
43
43
  if any, go to attachments/ next to it)
44
44
  --out-dir <dir> Output directory (default: ./<page-id>); writes page.md
45
45
  and attachments/
46
+ --readable Prefer clean Markdown over fidelity: no raw HTML blocks,
47
+ complex tables flattened to GFM. Not round-trippable —
48
+ loses styling/exact cell merges, keeps the content
46
49
  --no-attachments Do not download referenced attachments
47
50
 
48
51
  roundtrip options:
@@ -70,6 +73,7 @@ async function main() {
70
73
  'dry-run': { type: 'boolean' },
71
74
  out: { type: 'string' },
72
75
  'out-dir': { type: 'string' },
76
+ readable: { type: 'boolean' },
73
77
  'no-attachments': { type: 'boolean' },
74
78
  'show-markdown': { type: 'boolean' },
75
79
  help: { type: 'boolean' },
@@ -107,9 +111,13 @@ async function main() {
107
111
  outFile: values.out,
108
112
  outDir: values['out-dir'],
109
113
  downloadAttachments: !values['no-attachments'],
114
+ mode: values.readable ? 'readable' : 'faithful',
110
115
  }, cfg);
116
+ const tail = values.readable
117
+ ? `${result.stats.lossy} block(s) simplified`
118
+ : `${result.stats.fenced} raw storage block(s)`;
111
119
  console.log(`[cli] exported page ${result.pageId} "${result.title}" v${result.version} → ${result.markdownPath}` +
112
- ` (${result.downloaded.size} attachment(s), ${result.stats.fenced} raw storage block(s))`);
120
+ ` (${result.downloaded.size} attachment(s), ${tail})`);
113
121
  return;
114
122
  }
115
123
  if (command === 'roundtrip') {
@@ -18,6 +18,11 @@ export interface ExportPageOptions {
18
18
  outDir?: string;
19
19
  /** Скачивать ли аттачи, на которые ссылается страница. Default: true. */
20
20
  downloadAttachments?: boolean;
21
+ /**
22
+ * 'faithful' (default) — round-trippable, с сырым HTML в fallback;
23
+ * 'readable' — чистый Markdown ценой оформления (round-trip не гарантирован).
24
+ */
25
+ mode?: 'faithful' | 'readable';
21
26
  registry?: MacroRegistry;
22
27
  }
23
28
  export interface ExportPageResult extends StorageToMarkdownResult {
@@ -9,7 +9,7 @@ import { storageToMarkdown } from './to-markdown.js';
9
9
  export async function exportPage(pageId, opts, cfg) {
10
10
  const client = new ConfluenceClient(cfg);
11
11
  const page = await client.getPageStorage(pageId);
12
- const converted = storageToMarkdown(page.storage, { registry: opts.registry });
12
+ const converted = storageToMarkdown(page.storage, { registry: opts.registry, mode: opts.mode });
13
13
  const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
14
14
  mkdirSync(dirname(markdownPath), { recursive: true });
15
15
  writeFileSync(markdownPath, converted.markdown);
@@ -1,23 +1,33 @@
1
1
  /**
2
- * Конвертация Confluence storage → Markdown с гарантией round-trip.
2
+ * Конвертация Confluence storage → Markdown. Два режима:
3
3
  *
4
- * Трёхуровневая политика на каждый узел:
4
+ * FAITHFUL (default) гарантия round-trip. Трёхуровневая политика:
5
5
  * 1. чистый Markdown — заголовки, абзацы, списки, простые таблицы,
6
6
  * ссылки, картинки-аттачи ({{img:...}}), page-ссылки ({{page:...}});
7
7
  * 2. маркеры макросов <!-- MACRO:start/end --> — для макросов, чей
8
8
  * рендер восстанавливает исходный XHTML (проверяется на месте:
9
9
  * маркер прогоняется через render-конвейер и сравнивается канонически);
10
- * 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML
11
- * (markdown-it пропускает его насквозь), с ними — fenced-блоком
12
- * ```confluence-storage (разворачивается обратно при рендере).
10
+ * 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML,
11
+ * с ними — fenced-блоком ```confluence-storage.
12
+ * Потери исключены по построению; но (3) даёт сырой HTML, который многие
13
+ * md-редакторы показывают уродливо.
13
14
  *
14
- * Потери по построению исключены: всё, что не легло в (1)-(2), уезжает
15
- * в (3) дословно.
15
+ * READABLE чистый Markdown ценой оформления. Round-trip НЕ гарантируется:
16
+ * теряются цвета/стили спанов, div-обёртки, точная геометрия объединённых
17
+ * ячеек; сохраняется смысловая нагрузка (текст, структура). Сложные
18
+ * таблицы разворачиваются в GFM (colspan/rowspan → сетка с заполнением,
19
+ * блочное содержимое ячеек — во flatten через <br>). Сырой HTML-блок не
20
+ * выдаётся никогда.
16
21
  */
17
22
  import { type MacroRegistry } from '../macros/registry.js';
18
23
  export interface StorageToMarkdownOptions {
19
24
  /** Реестр для проверки маркеров макросов (default: встроенный). */
20
25
  registry?: MacroRegistry;
26
+ /**
27
+ * 'faithful' (default) — round-trippable, но с сырым HTML в fallback;
28
+ * 'readable' — чистый Markdown ценой оформления, без round-trip.
29
+ */
30
+ mode?: 'faithful' | 'readable';
21
31
  }
22
32
  export interface StorageToMarkdownResult {
23
33
  markdown: string;
@@ -31,6 +41,8 @@ export interface StorageToMarkdownResult {
31
41
  markers: number;
32
42
  fenced: number;
33
43
  rawHtml: number;
44
+ /** readable-режим: сколько узлов конвертировано с потерей оформления. */
45
+ lossy: number;
34
46
  };
35
47
  }
36
48
  /** Конвертирует storage-фрагмент страницы в Markdown. */
@@ -1,18 +1,23 @@
1
1
  /**
2
- * Конвертация Confluence storage → Markdown с гарантией round-trip.
2
+ * Конвертация Confluence storage → Markdown. Два режима:
3
3
  *
4
- * Трёхуровневая политика на каждый узел:
4
+ * FAITHFUL (default) гарантия round-trip. Трёхуровневая политика:
5
5
  * 1. чистый Markdown — заголовки, абзацы, списки, простые таблицы,
6
6
  * ссылки, картинки-аттачи ({{img:...}}), page-ссылки ({{page:...}});
7
7
  * 2. маркеры макросов <!-- MACRO:start/end --> — для макросов, чей
8
8
  * рендер восстанавливает исходный XHTML (проверяется на месте:
9
9
  * маркер прогоняется через render-конвейер и сравнивается канонически);
10
- * 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML
11
- * (markdown-it пропускает его насквозь), с ними — fenced-блоком
12
- * ```confluence-storage (разворачивается обратно при рендере).
10
+ * 3. дословный XHTML — «как есть»: без ac:/ri:-тегов — сырым HTML,
11
+ * с ними — fenced-блоком ```confluence-storage.
12
+ * Потери исключены по построению; но (3) даёт сырой HTML, который многие
13
+ * md-редакторы показывают уродливо.
13
14
  *
14
- * Потери по построению исключены: всё, что не легло в (1)-(2), уезжает
15
- * в (3) дословно.
15
+ * READABLE чистый Markdown ценой оформления. Round-trip НЕ гарантируется:
16
+ * теряются цвета/стили спанов, div-обёртки, точная геометрия объединённых
17
+ * ячеек; сохраняется смысловая нагрузка (текст, структура). Сложные
18
+ * таблицы разворачиваются в GFM (colspan/rowspan → сетка с заполнением,
19
+ * блочное содержимое ячеек — во flatten через <br>). Сырой HTML-блок не
20
+ * выдаётся никогда.
16
21
  */
17
22
  import { macro } from '../macros/builder.js';
18
23
  import { escapeXmlAttr } from '../macros/xml.js';
@@ -23,7 +28,7 @@ import { compareStorage } from './canonical.js';
23
28
  import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
24
29
  /** Конвертирует storage-фрагмент страницы в Markdown. */
25
30
  export function storageToMarkdown(storage, opts = {}) {
26
- const conv = new Converter(opts.registry ?? defaultMacroRegistry);
31
+ const conv = new Converter(opts.registry ?? defaultMacroRegistry, opts.mode === 'readable');
27
32
  const markdown = conv.blocksToMd(parseStorage(storage));
28
33
  const attachmentRefs = new Set([...conv.images, ...conv.files]);
29
34
  for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
@@ -52,14 +57,23 @@ const CM_BLOCK_TAGS = new Set([
52
57
  'pre', 'script', 'style', 'textarea',
53
58
  ]);
54
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
+ ]);
55
67
  const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
56
68
  class Converter {
57
69
  registry;
70
+ readable;
58
71
  images = new Set();
59
72
  files = new Set();
60
- stats = { markers: 0, fenced: 0, rawHtml: 0 };
61
- constructor(registry) {
73
+ stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
74
+ constructor(registry, readable = false) {
62
75
  this.registry = registry;
76
+ this.readable = readable;
63
77
  }
64
78
  // ── Блочный уровень ──────────────────────────────────────────────────
65
79
  blocksToMd(nodes) {
@@ -108,6 +122,9 @@ class Converter {
108
122
  return '---';
109
123
  if (el.name === 'ul' || el.name === 'ol')
110
124
  return this.listToMd(el);
125
+ // Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
126
+ // отклоняет: faithful → сырой HTML, readable → readableFallback
127
+ // (lossy++ и разворот в GFM через readableTable).
111
128
  if (el.name === 'table')
112
129
  return this.tableToMd(el);
113
130
  if (el.name === 'ac:structured-macro')
@@ -153,6 +170,8 @@ class Converter {
153
170
  * а рендер вернёт плейсхолдерам исходную ac:-форму.
154
171
  */
155
172
  fallbackBlock(el) {
173
+ if (this.readable)
174
+ return this.readableFallback(el);
156
175
  const direct = this.tryRawHtml(el);
157
176
  if (direct !== null)
158
177
  return direct;
@@ -204,14 +223,81 @@ class Converter {
204
223
  const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
205
224
  return `${ticks}confluence-storage\n${content}\n${ticks}`;
206
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
+ }
207
264
  // ── Макросы ──────────────────────────────────────────────────────────
208
265
  macroToMd(el) {
209
266
  const name = getAttr(el, 'ac:name') ?? '';
210
267
  const markerMd = this.tryMacroMarker(el, name);
211
- if (markerMd === null)
212
- return this.fence(serializeStorage([el]));
213
- this.stats.markers++;
214
- return markerMd;
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}]_`;
215
301
  }
216
302
  /**
217
303
  * Пытается выразить макрос маркером. Возвращает null, если параметры
@@ -411,10 +497,140 @@ class Converter {
411
497
  content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
412
498
  content = els[0].children;
413
499
  }
414
- const md = this.inlineToMd(content, { cell: true }).trim();
500
+ const md = this.inlineToMd(content, { cell: false }).trim();
415
501
  if (md.includes('\n'))
416
502
  throw new Unrepresentable();
417
- return md;
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>');
418
634
  }
419
635
  // ── Инлайн ───────────────────────────────────────────────────────────
420
636
  /**
@@ -471,12 +687,19 @@ class Converter {
471
687
  let out = '';
472
688
  for (const n of this.normalizeInline(nodes)) {
473
689
  if (n.kind === 'text') {
474
- out += escapeMdText(n.raw, ctx);
690
+ out += escapeMdText(n.raw, ctx, this.readable);
475
691
  continue;
476
692
  }
477
- if (n.kind === 'cdata')
693
+ if (n.kind === 'cdata') {
694
+ if (this.readable) {
695
+ out += escapeMdText(n.text, ctx, true);
696
+ continue;
697
+ }
478
698
  throw new Unrepresentable();
699
+ }
479
700
  if (n.kind === 'comment') {
701
+ if (this.readable)
702
+ continue; // невидимый комментарий — отбрасываем
480
703
  if (n.text.includes('MACRO:') || n.text.includes('-->'))
481
704
  throw new Unrepresentable();
482
705
  out += `<!--${n.text}-->`;
@@ -492,18 +715,20 @@ class Converter {
492
715
  return this.wrapInline(el, '**', ctx);
493
716
  case 'em':
494
717
  return this.wrapInline(el, '*', ctx);
495
- // <b>/<i> оставляем сырым HTML: ** рендерится в <strong>, а не в <b>.
718
+ // <b>/<i>: faithful сырой HTML (** рендерится в <strong>, а не <b>);
719
+ // readable — маппим в **/* (потеря точного тега приемлема).
496
720
  case 'b':
721
+ return this.readable ? this.wrapInline(el, '**', ctx) : this.rawInline(el, ctx);
497
722
  case 'i':
498
- return this.rawInline(el, ctx);
723
+ return this.readable ? this.wrapInline(el, '*', ctx) : this.rawInline(el, ctx);
499
724
  case 's':
500
725
  return this.wrapInline(el, '~~', ctx);
501
726
  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() === '')
727
+ if (el.attrs.length > 0 && !this.readable)
506
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(); })();
507
732
  const runs = text.match(/`+/g) ?? [];
508
733
  const ticks = '`'.repeat(Math.max(1, ...runs.map((r) => r.length + 1)));
509
734
  const pad = text.startsWith('`') || text.endsWith('`') || text.startsWith(' ') || text.endsWith(' ') ? ' ' : '';
@@ -517,35 +742,42 @@ class Converter {
517
742
  const src = getAttr(el, 'src') ?? '';
518
743
  const alt = getAttr(el, 'alt') ?? '';
519
744
  const other = el.attrs.filter(([k]) => k !== 'src' && k !== 'alt');
520
- if (other.length === 0 && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
521
- return `![${alt}](${src})`;
745
+ if ((other.length === 0 || this.readable) && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
746
+ return `![${alt}](${src})`; // readable: лишние атрибуты (class…) отбрасываются
522
747
  }
523
- return this.rawInline(el, ctx);
748
+ return this.readable ? escapeMdText(alt, ctx, true) : this.rawInline(el, ctx);
524
749
  }
525
750
  case 'ac:image':
526
751
  return this.acImageToMd(el);
527
752
  case 'ac:link':
528
753
  return this.acLinkToMd(el);
529
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);
530
761
  if (INLINE_RAW_WRAP.has(el.name))
531
762
  return this.rawInline(el, ctx);
532
763
  throw new Unrepresentable();
533
764
  }
534
765
  }
535
766
  wrapInline(el, marker, ctx) {
536
- if (el.attrs.length > 0)
767
+ if (el.attrs.length > 0 && !this.readable)
537
768
  return this.rawInline(el, ctx);
538
769
  const inner = this.inlineToMd(el.children, ctx);
539
770
  // Краевые ПРОСТЫЕ пробелы выносим наружу — `**текст **` маркдауном не
540
771
  // является. Юникодные пробелы (&nbsp; и т.п.) выносить нельзя (изменит
541
772
  // содержимое), а внутри маркеров они ломают flanking-правила — такой
542
- // элемент отдаём сырым HTML.
773
+ // элемент отдаём сырым HTML (faithful) либо просто оставляем как есть
774
+ // без обёртки (readable).
543
775
  const m = /^([ \t]*)([\s\S]*?)([ \t]*)$/.exec(inner);
544
776
  if (!m || m[2] === '')
545
777
  return inner;
546
778
  const decodedEdges = decodeEntities(m[2]);
547
779
  if (/^\s|\s$/u.test(decodedEdges))
548
- return this.rawInline(el, ctx);
780
+ return this.readable ? inner : this.rawInline(el, ctx);
549
781
  return `${m[1]}${marker}${m[2]}${marker}${m[3]}`;
550
782
  }
551
783
  /** Инлайн-элемент дословно: открывающий тег + инлайн-дети + закрывающий. */
@@ -562,9 +794,12 @@ class Converter {
562
794
  linkAnchorToMd(el, ctx) {
563
795
  const href = getAttr(el, 'href') ?? '';
564
796
  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})`;
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: доп. атрибуты ссылки отбрасываются
567
800
  }
801
+ if (this.readable)
802
+ return inner; // ссылку не выразить в MD — оставляем текст
568
803
  return this.rawInline(el, ctx);
569
804
  }
570
805
  acImageToMd(el) {
@@ -801,23 +1036,41 @@ const ENTITY_RE = /&(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
801
1036
  * Экранирует markdown-активные символы, сохраняя сущности (&nbsp; и т.п.)
802
1037
  * как есть — markdown-it декодирует их при рендере.
803
1038
  */
804
- function escapeMdText(raw, ctx) {
1039
+ function escapeMdText(raw, ctx, readable = false) {
805
1040
  const collapsed = raw.replace(/[\r\n]+/g, ' ');
806
1041
  let out = '';
807
1042
  let last = 0;
808
1043
  for (const m of collapsed.matchAll(ENTITY_RE)) {
809
- out += escapePlain(collapsed.slice(last, m.index), ctx);
810
- out += m[0];
1044
+ out += escapePlain(collapsed.slice(last, m.index), ctx, readable);
1045
+ if (readable) {
1046
+ // readable: декодируем сущность в символ (&quot;→", &mdash;→—, 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
+ }
811
1057
  last = m.index + m[0].length;
812
1058
  }
813
- out += escapePlain(collapsed.slice(last), ctx);
1059
+ out += escapePlain(collapsed.slice(last), ctx, readable);
814
1060
  return out;
815
1061
  }
816
- function escapePlain(s, ctx) {
1062
+ function escapePlain(s, ctx, readable = false) {
817
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
+ }
818
1071
  // Сырой U+00A0 на краю абзаца съедается trim()'ом markdown-it —
819
1072
  // в entity-форме переживает рендер (и виден при редактировании).
820
- esc = esc.replace(/ /g, '&nbsp;');
1073
+ esc = esc.replace(/\u00A0/g, '&nbsp;');
821
1074
  if (ctx.cell)
822
1075
  esc = esc.replace(/\|/g, '\\|');
823
1076
  return esc;
@@ -843,3 +1096,37 @@ function cellAlign(cell) {
843
1096
  throw new Unrepresentable();
844
1097
  return m[1];
845
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",