confluence-md-sync 0.3.0 → 0.4.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 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,34 @@ 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, and `<br>` in paragraphs becomes a real line break (inside table cells it stays `<br>` — GFM cells can't hold newlines). Keeps the content; **drops** colours, exact merge geometry, wrappers — *not* round-trippable. |
184
+
185
+ Images and attachments are downloaded by default (pass
186
+ `downloadAttachments: false` to skip). They land in `attachments/` next to
187
+ the Markdown, and the page body references them via `{{img:name}}` /
188
+ `{{file:name}}` — in both modes.
182
189
 
183
190
  ```ts
184
191
  import { exportPage } from 'confluence-md-sync';
185
192
 
186
193
  const { markdownPath, images, downloaded } = await exportPage(
187
194
  '123456789',
188
- { outDir: 'exported' }, // writes exported/page.md + exported/attachments/
195
+ { outDir: 'exported' }, // faithful; exported/page.md + exported/attachments/
189
196
  cfg,
190
197
  );
198
+ console.log(downloaded); // Map<filename, localPath> of saved attachments
199
+
200
+ // Readable variant for humans (writes page.md + ./attachments/ next to it):
201
+ await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
191
202
  ```
192
203
 
193
204
  Round-trip means *edit the Markdown, then publish it straight back*:
@@ -220,8 +231,10 @@ normalised away (Confluence itself rewrites these on every save). Use
220
231
  From the CLI:
221
232
 
222
233
  ```bash
223
- confluence-md-sync export 123456789 --out-dir exported
224
- confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
234
+ # attachments download to ./attachments/ next to the .md (omit --no-attachments)
235
+ confluence-md-sync export 123456789 --out page.md # faithful
236
+ confluence-md-sync export 123456789 --out page.md --readable # clean Markdown
237
+ confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
225
238
  ```
226
239
 
227
240
  ## Read pages and tables
@@ -321,7 +334,8 @@ export CONFLUENCE_BASE_URL='https://confluence.example.com'
321
334
  export CONFLUENCE_TOKEN='<your-PAT>'
322
335
 
323
336
  # Download a page to an exact .md path (add --no-attachments for the md only)
324
- confluence-md-sync export 123456789 --out ./page.md
337
+ confluence-md-sync export 123456789 --out ./page.md # faithful
338
+ confluence-md-sync export 123456789 --out ./page.md --readable # clean Markdown
325
339
 
326
340
  confluence-md-sync publish docs/page.md --page-id 123456789 \
327
341
  --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) {
@@ -90,7 +104,11 @@ class Converter {
90
104
  const h = /^h([1-6])$/.exec(el.name);
91
105
  try {
92
106
  if (h && el.attrs.length === 0) {
93
- const inline = this.inlineToMd(el.children);
107
+ // Заголовок однострочен по определению — в readable схлопываем
108
+ // возможные переводы строк (из <br>) в пробел.
109
+ let inline = this.inlineToMd(el.children);
110
+ if (this.readable)
111
+ inline = inline.replace(/\s*\n\s*/g, ' ');
94
112
  if (inline.includes('\n') || inline.trim() === '')
95
113
  throw new Unrepresentable();
96
114
  return '#'.repeat(Number(h[1])) + ' ' + guardLineStart(inline.trim());
@@ -108,6 +126,9 @@ class Converter {
108
126
  return '---';
109
127
  if (el.name === 'ul' || el.name === 'ol')
110
128
  return this.listToMd(el);
129
+ // Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
130
+ // отклоняет: faithful → сырой HTML, readable → readableFallback
131
+ // (lossy++ и разворот в GFM через readableTable).
111
132
  if (el.name === 'table')
112
133
  return this.tableToMd(el);
113
134
  if (el.name === 'ac:structured-macro')
@@ -130,7 +151,10 @@ class Converter {
130
151
  if (children.length === 0)
131
152
  return this.fallbackBlock(el);
132
153
  try {
133
- const inline = this.inlineToMd(children).trim();
154
+ // multiline: в readable-абзаце <br> становится настоящим переводом
155
+ // строки (см. case 'br'). guardLineStart применяем к КАЖДОЙ строке —
156
+ // после переноса тоже нельзя случайно начать список/заголовок.
157
+ const inline = this.inlineToMd(children, { multiline: true }).trim();
134
158
  if (inline === '')
135
159
  return this.fallbackBlock(el);
136
160
  // Абзац, начинающийся с блочного HTML-тега, markdown-it превратит в
@@ -138,7 +162,7 @@ class Converter {
138
162
  const m = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(inline);
139
163
  if (m && CM_BLOCK_TAGS.has(m[1].toLowerCase()))
140
164
  return this.fallbackBlock(el);
141
- return guardLineStart(inline);
165
+ return inline.split('\n').map((line) => guardLineStart(line)).join('\n');
142
166
  }
143
167
  catch (e) {
144
168
  if (!(e instanceof Unrepresentable))
@@ -153,6 +177,8 @@ class Converter {
153
177
  * а рендер вернёт плейсхолдерам исходную ac:-форму.
154
178
  */
155
179
  fallbackBlock(el) {
180
+ if (this.readable)
181
+ return this.readableFallback(el);
156
182
  const direct = this.tryRawHtml(el);
157
183
  if (direct !== null)
158
184
  return direct;
@@ -204,14 +230,82 @@ class Converter {
204
230
  const ticks = '`'.repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
205
231
  return `${ticks}confluence-storage\n${content}\n${ticks}`;
206
232
  }
233
+ // ── Readable-режим: lossy-конвертация в чистый Markdown ────────────────
234
+ /**
235
+ * Fallback readable-режима: НИКОГДА не выдаёт сырой HTML-блок. Таблицы
236
+ * разворачивает в GFM, контейнеры-обёртки — в блоки, blockquote — в `>`,
237
+ * остальное — в инлайн/текст. Content сохраняется, оформление теряется.
238
+ */
239
+ readableFallback(el) {
240
+ this.stats.lossy++;
241
+ const name = el.name.toLowerCase();
242
+ if (el.name === 'table') {
243
+ try {
244
+ return this.readableTable(el);
245
+ }
246
+ catch (e) {
247
+ if (!(e instanceof Unrepresentable))
248
+ throw e;
249
+ }
250
+ }
251
+ if (UNWRAP_BLOCK.has(name)) {
252
+ const inner = this.blocksToMd(el.children).trimEnd();
253
+ if (inner !== '')
254
+ return inner;
255
+ return '';
256
+ }
257
+ if (name === 'blockquote') {
258
+ const inner = this.blocksToMd(el.children).trimEnd();
259
+ return inner
260
+ .split('\n')
261
+ .map((line) => (line === '' ? '>' : `> ${line}`))
262
+ .join('\n');
263
+ }
264
+ // p / td / th / li / caption и прочие «инлайн-контейнеры» → инлайн.
265
+ // multiline: это свободный поток (не ячейка) — <br> станет переносом.
266
+ const inline = this.inlineToMd(el.children, { multiline: true }).trim();
267
+ if (inline !== '')
268
+ return inline.split('\n').map((l) => guardLineStart(l)).join('\n');
269
+ // Совсем ничего не вышло — голый текст (может быть пустым).
270
+ return escapeMdText(textContent(el.children), {}, this.readable).trim();
271
+ }
207
272
  // ── Макросы ──────────────────────────────────────────────────────────
208
273
  macroToMd(el) {
209
274
  const name = getAttr(el, 'ac:name') ?? '';
210
275
  const markerMd = this.tryMacroMarker(el, name);
211
- if (markerMd === null)
212
- return this.fence(serializeStorage([el]));
213
- this.stats.markers++;
214
- return markerMd;
276
+ if (markerMd !== null) {
277
+ this.stats.markers++;
278
+ return markerMd;
279
+ }
280
+ if (this.readable)
281
+ return this.readableMacro(el);
282
+ return this.fence(serializeStorage([el]));
283
+ }
284
+ /**
285
+ * Readable-режим для макроса, который не лёг в маркер: сохраняем
286
+ * содержимое, теряем «обёртку» макроса. rich-text-body → блоки;
287
+ * plain-text-body → код-fence (обычный ``` — чистый Markdown);
288
+ * иначе — заголовок-подпись, чтобы место макроса не исчезло бесследно.
289
+ */
290
+ readableMacro(el) {
291
+ this.stats.lossy++;
292
+ const name = getAttr(el, 'ac:name') ?? 'macro';
293
+ for (const child of elements(el.children)) {
294
+ if (child.name === 'ac:rich-text-body') {
295
+ const inner = this.blocksToMd(child.children).trimEnd();
296
+ if (inner !== '')
297
+ return inner;
298
+ }
299
+ if (child.name === 'ac:plain-text-body') {
300
+ const text = textContent(child.children);
301
+ if (text.trim() !== '') {
302
+ const ticks = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((r) => r.length + 1)));
303
+ return `${ticks}\n${text}\n${ticks}`;
304
+ }
305
+ }
306
+ }
307
+ // Bodyless-макрос (toc, children, …) — оставляем видимый след.
308
+ return `_[macro: ${name}]_`;
215
309
  }
216
310
  /**
217
311
  * Пытается выразить макрос маркером. Возвращает null, если параметры
@@ -414,7 +508,137 @@ class Converter {
414
508
  const md = this.inlineToMd(content, { cell: true }).trim();
415
509
  if (md.includes('\n'))
416
510
  throw new Unrepresentable();
417
- return md;
511
+ // Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
512
+ // плейсхолдеры {{img:…|…}} (которые минуют инлайн-экранирование).
513
+ return md.replace(/\|/g, '\\|');
514
+ }
515
+ // ── Readable-таблицы: любая таблица → GFM ─────────────────────────────
516
+ /**
517
+ * Разворачивает произвольную таблицу (colspan/rowspan, блочные ячейки,
518
+ * несколько header-строк) в GFM. Объединения превращаются в плотную
519
+ * сетку: содержимое — в верхней-левой клетке диапазона, остальные клетки
520
+ * пустые. Первая строка сетки становится шапкой GFM.
521
+ */
522
+ readableTable(table) {
523
+ const trs = [];
524
+ let caption = '';
525
+ for (const child of elements(table.children)) {
526
+ if (child.name === 'caption')
527
+ caption = this.inlineToMd(child.children).trim();
528
+ else if (['thead', 'tbody', 'tfoot'].includes(child.name)) {
529
+ for (const tr of elements(child.children))
530
+ if (tr.name === 'tr')
531
+ trs.push(tr);
532
+ }
533
+ else if (child.name === 'tr')
534
+ trs.push(child);
535
+ }
536
+ if (trs.length === 0)
537
+ throw new Unrepresentable();
538
+ // Плотная сетка с учётом colspan/rowspan.
539
+ const grid = [];
540
+ const aligns = [];
541
+ trs.forEach((tr, rowIdx) => {
542
+ if (!grid[rowIdx])
543
+ grid[rowIdx] = [];
544
+ let col = 0;
545
+ for (const cell of elements(tr.children)) {
546
+ if (cell.name !== 'td' && cell.name !== 'th')
547
+ continue;
548
+ while (grid[rowIdx][col] !== undefined)
549
+ col++;
550
+ const colspan = Math.max(1, Number(getAttr(cell, 'colspan') ?? '1') || 1);
551
+ const rowspan = Math.max(1, Number(getAttr(cell, 'rowspan') ?? '1') || 1);
552
+ // Экранируем пайпы ОДИН раз здесь — над всем содержимым ячейки
553
+ // (текст + плейсхолдеры {{img:…|…}} + буллеты), поэтому cellFlatten
554
+ // сам их не трогает (inline с cell:false).
555
+ const content = tidyCell(this.cellFlatten(cell.children)).replace(/\|/g, '\\|');
556
+ for (let r = 0; r < rowspan; r++) {
557
+ for (let c = 0; c < colspan; c++) {
558
+ const rr = rowIdx + r;
559
+ const cc = col + c;
560
+ if (!grid[rr])
561
+ grid[rr] = [];
562
+ grid[rr][cc] = r === 0 && c === 0 ? content : '';
563
+ }
564
+ }
565
+ if (rowIdx === 0) {
566
+ const a = readableAlign(cell);
567
+ for (let c = 0; c < colspan; c++)
568
+ aligns[col + c] = a;
569
+ }
570
+ col += colspan;
571
+ }
572
+ });
573
+ const width = Math.max(...grid.map((r) => r.length));
574
+ if (width === 0)
575
+ throw new Unrepresentable();
576
+ for (const r of grid) {
577
+ for (let c = 0; c < width; c++)
578
+ if (r[c] === undefined)
579
+ r[c] = '';
580
+ }
581
+ while (aligns.length < width)
582
+ aligns.push('none');
583
+ const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
584
+ const rowMd = (cells) => `| ${cells.join(' | ')} |`;
585
+ const lines = [rowMd(grid[0]), rowMd(sep), ...grid.slice(1).map(rowMd)];
586
+ const table_ = lines.join('\n');
587
+ return caption !== '' ? `**${caption}**\n\n${table_}` : table_;
588
+ }
589
+ /**
590
+ * Сплющивает содержимое ячейки в одну строку: абзацы и пункты списков
591
+ * разделяются `<br>` (единственный HTML, идиоматичный для GFM-ячеек),
592
+ * пункты помечаются `• `. Инлайн-разметка (жирный, ссылки, плейсхолдеры)
593
+ * сохраняется.
594
+ */
595
+ cellFlatten(nodes) {
596
+ const blocks = [];
597
+ let inlineRun = [];
598
+ const flush = () => {
599
+ if (inlineRun.length === 0)
600
+ return;
601
+ const s = this.inlineToMd(inlineRun, { cell: true }).replace(/\s+/g, ' ').trim();
602
+ if (s !== '')
603
+ blocks.push(s);
604
+ inlineRun = [];
605
+ };
606
+ for (const n of nodes) {
607
+ if (n.kind === 'el' && (n.name === 'ul' || n.name === 'ol')) {
608
+ flush();
609
+ blocks.push(this.flattenList(n));
610
+ }
611
+ else if (n.kind === 'el' && n.name === 'p') {
612
+ flush();
613
+ const s = this.inlineToMd(n.children, { cell: true }).replace(/\s+/g, ' ').trim();
614
+ if (s !== '')
615
+ blocks.push(s);
616
+ }
617
+ else if (n.kind === 'el' && (UNWRAP_BLOCK.has(n.name.toLowerCase()) || n.name === 'blockquote')) {
618
+ flush();
619
+ const s = this.cellFlatten(n.children);
620
+ if (s !== '')
621
+ blocks.push(s);
622
+ }
623
+ else if (n.kind === 'el' && n.name === 'table') {
624
+ flush();
625
+ const s = this.cellFlatten(collectCellText(n));
626
+ if (s !== '')
627
+ blocks.push(s);
628
+ }
629
+ else {
630
+ inlineRun.push(n);
631
+ }
632
+ }
633
+ flush();
634
+ return blocks.filter((b) => b !== '').join('<br>');
635
+ }
636
+ flattenList(list) {
637
+ const items = elements(list.children).filter((li) => li.name === 'li');
638
+ return items
639
+ .map((li) => '• ' + this.cellFlatten(li.children))
640
+ .filter((s) => s !== '• ')
641
+ .join('<br>');
418
642
  }
419
643
  // ── Инлайн ───────────────────────────────────────────────────────────
420
644
  /**
@@ -471,12 +695,19 @@ class Converter {
471
695
  let out = '';
472
696
  for (const n of this.normalizeInline(nodes)) {
473
697
  if (n.kind === 'text') {
474
- out += escapeMdText(n.raw, ctx);
698
+ out += escapeMdText(n.raw, ctx, this.readable);
475
699
  continue;
476
700
  }
477
- if (n.kind === 'cdata')
701
+ if (n.kind === 'cdata') {
702
+ if (this.readable) {
703
+ out += escapeMdText(n.text, ctx, true);
704
+ continue;
705
+ }
478
706
  throw new Unrepresentable();
707
+ }
479
708
  if (n.kind === 'comment') {
709
+ if (this.readable)
710
+ continue; // невидимый комментарий — отбрасываем
480
711
  if (n.text.includes('MACRO:') || n.text.includes('-->'))
481
712
  throw new Unrepresentable();
482
713
  out += `<!--${n.text}-->`;
@@ -492,24 +723,31 @@ class Converter {
492
723
  return this.wrapInline(el, '**', ctx);
493
724
  case 'em':
494
725
  return this.wrapInline(el, '*', ctx);
495
- // <b>/<i> оставляем сырым HTML: ** рендерится в <strong>, а не в <b>.
726
+ // <b>/<i>: faithful сырой HTML (** рендерится в <strong>, а не <b>);
727
+ // readable — маппим в **/* (потеря точного тега приемлема).
496
728
  case 'b':
729
+ return this.readable ? this.wrapInline(el, '**', ctx) : this.rawInline(el, ctx);
497
730
  case 'i':
498
- return this.rawInline(el, ctx);
731
+ return this.readable ? this.wrapInline(el, '*', ctx) : this.rawInline(el, ctx);
499
732
  case 's':
500
733
  return this.wrapInline(el, '~~', ctx);
501
734
  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() === '')
735
+ if (el.attrs.length > 0 && !this.readable)
506
736
  throw new Unrepresentable();
737
+ const text = textContent(el.children).replace(/\s*\n\s*/g, ' ');
738
+ if (text.trim() === '')
739
+ return this.readable ? '' : (() => { throw new Unrepresentable(); })();
507
740
  const runs = text.match(/`+/g) ?? [];
508
741
  const ticks = '`'.repeat(Math.max(1, ...runs.map((r) => r.length + 1)));
509
742
  const pad = text.startsWith('`') || text.endsWith('`') || text.startsWith(' ') || text.endsWith(' ') ? ' ' : '';
510
743
  return `${ticks}${pad}${text}${pad}${ticks}`;
511
744
  }
512
745
  case 'br':
746
+ // readable: в свободном потоке (абзац/цитата) — настоящий перенос
747
+ // строки; в ячейке GFM-таблицы перенос невозможен (сломал бы строку
748
+ // таблицы) — там остаётся <br>. faithful — всегда <br/> (round-trip).
749
+ if (this.readable)
750
+ return ctx.multiline ? '\n' : ctx.cell ? '<br>' : '<br/>';
513
751
  return '<br/>';
514
752
  case 'a':
515
753
  return this.linkAnchorToMd(el, ctx);
@@ -517,35 +755,42 @@ class Converter {
517
755
  const src = getAttr(el, 'src') ?? '';
518
756
  const alt = getAttr(el, 'alt') ?? '';
519
757
  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})`;
758
+ if ((other.length === 0 || this.readable) && SAFE_URL_RE.test(src) && !/[[\]()]/.test(alt)) {
759
+ return `![${alt}](${src})`; // readable: лишние атрибуты (class…) отбрасываются
522
760
  }
523
- return this.rawInline(el, ctx);
761
+ return this.readable ? escapeMdText(alt, ctx, true) : this.rawInline(el, ctx);
524
762
  }
525
763
  case 'ac:image':
526
764
  return this.acImageToMd(el);
527
765
  case 'ac:link':
528
766
  return this.acLinkToMd(el);
529
767
  default:
768
+ // readable: любой не-ac инлайн-контейнер (span, u, sub, font, …)
769
+ // разворачиваем — тег и стили теряем, содержимое оставляем.
770
+ if (this.readable && !el.name.includes(':'))
771
+ return this.inlineToMd(el.children, ctx);
772
+ if (this.readable)
773
+ return escapeMdText(textContent(el.children), ctx, true);
530
774
  if (INLINE_RAW_WRAP.has(el.name))
531
775
  return this.rawInline(el, ctx);
532
776
  throw new Unrepresentable();
533
777
  }
534
778
  }
535
779
  wrapInline(el, marker, ctx) {
536
- if (el.attrs.length > 0)
780
+ if (el.attrs.length > 0 && !this.readable)
537
781
  return this.rawInline(el, ctx);
538
782
  const inner = this.inlineToMd(el.children, ctx);
539
783
  // Краевые ПРОСТЫЕ пробелы выносим наружу — `**текст **` маркдауном не
540
784
  // является. Юникодные пробелы (&nbsp; и т.п.) выносить нельзя (изменит
541
785
  // содержимое), а внутри маркеров они ломают flanking-правила — такой
542
- // элемент отдаём сырым HTML.
786
+ // элемент отдаём сырым HTML (faithful) либо просто оставляем как есть
787
+ // без обёртки (readable).
543
788
  const m = /^([ \t]*)([\s\S]*?)([ \t]*)$/.exec(inner);
544
789
  if (!m || m[2] === '')
545
790
  return inner;
546
791
  const decodedEdges = decodeEntities(m[2]);
547
792
  if (/^\s|\s$/u.test(decodedEdges))
548
- return this.rawInline(el, ctx);
793
+ return this.readable ? inner : this.rawInline(el, ctx);
549
794
  return `${m[1]}${marker}${m[2]}${marker}${m[3]}`;
550
795
  }
551
796
  /** Инлайн-элемент дословно: открывающий тег + инлайн-дети + закрывающий. */
@@ -562,9 +807,12 @@ class Converter {
562
807
  linkAnchorToMd(el, ctx) {
563
808
  const href = getAttr(el, 'href') ?? '';
564
809
  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})`;
810
+ const onlyHref = el.attrs.length === 1 && el.attrs[0][0] === 'href';
811
+ if ((onlyHref || this.readable) && SAFE_URL_RE.test(href) && !/[[\]]/.test(inner)) {
812
+ return `[${inner}](${href})`; // readable: доп. атрибуты ссылки отбрасываются
567
813
  }
814
+ if (this.readable)
815
+ return inner; // ссылку не выразить в MD — оставляем текст
568
816
  return this.rawInline(el, ctx);
569
817
  }
570
818
  acImageToMd(el) {
@@ -801,26 +1049,36 @@ const ENTITY_RE = /&(?:#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
801
1049
  * Экранирует markdown-активные символы, сохраняя сущности (&nbsp; и т.п.)
802
1050
  * как есть — markdown-it декодирует их при рендере.
803
1051
  */
804
- function escapeMdText(raw, ctx) {
1052
+ function escapeMdText(raw, ctx, readable = false) {
805
1053
  const collapsed = raw.replace(/[\r\n]+/g, ' ');
806
1054
  let out = '';
807
1055
  let last = 0;
808
1056
  for (const m of collapsed.matchAll(ENTITY_RE)) {
809
- out += escapePlain(collapsed.slice(last, m.index), ctx);
810
- out += m[0];
1057
+ out += escapePlain(collapsed.slice(last, m.index), ctx, readable);
1058
+ if (readable) {
1059
+ // readable: декодируем сущность в символ (&quot;→", &mdash;→—, nbsp→
1060
+ // пробел). `<`, `>`, `&` оставляем сущностями — их «живой» символ
1061
+ // мог бы создать случайный HTML/сущность. Нераспознанное — как есть.
1062
+ const dec = decodeEntities(m[0]);
1063
+ out += dec === m[0] || dec === '<' || dec === '>' || dec === '&'
1064
+ ? m[0]
1065
+ : escapePlain(dec, ctx, readable);
1066
+ }
1067
+ else {
1068
+ out += m[0];
1069
+ }
811
1070
  last = m.index + m[0].length;
812
1071
  }
813
- out += escapePlain(collapsed.slice(last), ctx);
1072
+ out += escapePlain(collapsed.slice(last), ctx, readable);
814
1073
  return out;
815
1074
  }
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, '&nbsp;');
821
- if (ctx.cell)
822
- esc = esc.replace(/\|/g, '\\|');
823
- return esc;
1075
+ function escapePlain(s, _ctx, readable = false) {
1076
+ const esc = s.replace(/[\\`*_[\]{}~]/g, (c) => '\\' + c);
1077
+ // Пайпы здесь НЕ трогаем они экранируются один раз на границе ячейки
1078
+ // (cellMd / readableTable), иначе плейсхолдеры {{img:…|…}} двоились бы.
1079
+ // readable: неразрывный пробел → обычный; faithful: → entity (переживает
1080
+ // trim() markdown-it на краю абзаца).
1081
+ return esc.replace(/\u00A0/g, readable ? ' ' : '&nbsp;');
824
1082
  }
825
1083
  /** Экранирует конструкции, значимые в начале строки (#, >, -, 1. …). */
826
1084
  function guardLineStart(md) {
@@ -843,3 +1101,37 @@ function cellAlign(cell) {
843
1101
  throw new Unrepresentable();
844
1102
  return m[1];
845
1103
  }
1104
+ /** Как cellAlign, но не бросает: любой нераспознанный стиль → 'none'. */
1105
+ function readableAlign(cell) {
1106
+ const style = getAttr(cell, 'style');
1107
+ const m = style ? /text-align:\s*(left|right|center)/.exec(style) : null;
1108
+ return m ? m[1] : 'none';
1109
+ }
1110
+ /**
1111
+ * Причёсывает содержимое GFM-ячейки: нормализует `<br/>`→`<br>`, схлопывает
1112
+ * подряд идущие переводы строк и срезает их по краям — чтобы «пустая»
1113
+ * ячейка (в исходнике `<p><br/></p>`) стала действительно пустой.
1114
+ */
1115
+ function tidyCell(s) {
1116
+ return s
1117
+ .replace(/<br\s*\/?>/gi, '<br>')
1118
+ .replace(/(?:\s*<br>\s*)+/g, '<br>')
1119
+ .replace(/^<br>|<br>$/g, '')
1120
+ .trim();
1121
+ }
1122
+ /** Разворачивает вложенную в ячейку таблицу в плоский список её ячеек. */
1123
+ function collectCellText(table) {
1124
+ const out = [];
1125
+ const walk = (nodes) => {
1126
+ for (const n of nodes) {
1127
+ if (n.kind === 'el' && (n.name === 'td' || n.name === 'th')) {
1128
+ out.push({ kind: 'el', name: 'p', attrs: [], children: n.children, selfClosing: false });
1129
+ }
1130
+ else if (n.kind === 'el') {
1131
+ walk(n.children);
1132
+ }
1133
+ }
1134
+ };
1135
+ walk(table.children);
1136
+ return out;
1137
+ }
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.1",
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",