confluence-md-sync 0.4.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
@@ -180,18 +180,24 @@ Attachments and page links become `{{img:...}}` / `{{file:...}}` /
180
180
  | Mode | For | What it does |
181
181
  | --- | --- | --- |
182
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. |
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.
184
189
 
185
190
  ```ts
186
191
  import { exportPage } from 'confluence-md-sync';
187
192
 
188
193
  const { markdownPath, images, downloaded } = await exportPage(
189
194
  '123456789',
190
- { outDir: 'exported' }, // faithful; writes page.md + attachments/
195
+ { outDir: 'exported' }, // faithful; exported/page.md + exported/attachments/
191
196
  cfg,
192
197
  );
198
+ console.log(downloaded); // Map<filename, localPath> of saved attachments
193
199
 
194
- // Readable variant for humans:
200
+ // Readable variant for humans (writes page.md + ./attachments/ next to it):
195
201
  await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
196
202
  ```
197
203
 
@@ -225,6 +231,7 @@ normalised away (Confluence itself rewrites these on every save). Use
225
231
  From the CLI:
226
232
 
227
233
  ```bash
234
+ # attachments download to ./attachments/ next to the .md (omit --no-attachments)
228
235
  confluence-md-sync export 123456789 --out page.md # faithful
229
236
  confluence-md-sync export 123456789 --out page.md --readable # clean Markdown
230
237
  confluence-md-sync roundtrip 123456789 --show-markdown # exit 2 on loss
@@ -104,7 +104,11 @@ class Converter {
104
104
  const h = /^h([1-6])$/.exec(el.name);
105
105
  try {
106
106
  if (h && el.attrs.length === 0) {
107
- 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, ' ');
108
112
  if (inline.includes('\n') || inline.trim() === '')
109
113
  throw new Unrepresentable();
110
114
  return '#'.repeat(Number(h[1])) + ' ' + guardLineStart(inline.trim());
@@ -147,7 +151,10 @@ class Converter {
147
151
  if (children.length === 0)
148
152
  return this.fallbackBlock(el);
149
153
  try {
150
- const inline = this.inlineToMd(children).trim();
154
+ // multiline: в readable-абзаце <br> становится настоящим переводом
155
+ // строки (см. case 'br'). guardLineStart применяем к КАЖДОЙ строке —
156
+ // после переноса тоже нельзя случайно начать список/заголовок.
157
+ const inline = this.inlineToMd(children, { multiline: true }).trim();
151
158
  if (inline === '')
152
159
  return this.fallbackBlock(el);
153
160
  // Абзац, начинающийся с блочного HTML-тега, markdown-it превратит в
@@ -155,7 +162,7 @@ class Converter {
155
162
  const m = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(inline);
156
163
  if (m && CM_BLOCK_TAGS.has(m[1].toLowerCase()))
157
164
  return this.fallbackBlock(el);
158
- return guardLineStart(inline);
165
+ return inline.split('\n').map((line) => guardLineStart(line)).join('\n');
159
166
  }
160
167
  catch (e) {
161
168
  if (!(e instanceof Unrepresentable))
@@ -255,9 +262,10 @@ class Converter {
255
262
  .join('\n');
256
263
  }
257
264
  // p / td / th / li / caption и прочие «инлайн-контейнеры» → инлайн.
258
- const inline = this.inlineToMd(el.children).trim();
265
+ // multiline: это свободный поток (не ячейка) — <br> станет переносом.
266
+ const inline = this.inlineToMd(el.children, { multiline: true }).trim();
259
267
  if (inline !== '')
260
- return guardLineStart(inline);
268
+ return inline.split('\n').map((l) => guardLineStart(l)).join('\n');
261
269
  // Совсем ничего не вышло — голый текст (может быть пустым).
262
270
  return escapeMdText(textContent(el.children), {}, this.readable).trim();
263
271
  }
@@ -497,7 +505,7 @@ class Converter {
497
505
  content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
498
506
  content = els[0].children;
499
507
  }
500
- const md = this.inlineToMd(content, { cell: false }).trim();
508
+ const md = this.inlineToMd(content, { cell: true }).trim();
501
509
  if (md.includes('\n'))
502
510
  throw new Unrepresentable();
503
511
  // Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
@@ -590,7 +598,7 @@ class Converter {
590
598
  const flush = () => {
591
599
  if (inlineRun.length === 0)
592
600
  return;
593
- const s = this.inlineToMd(inlineRun, { cell: false }).replace(/\s+/g, ' ').trim();
601
+ const s = this.inlineToMd(inlineRun, { cell: true }).replace(/\s+/g, ' ').trim();
594
602
  if (s !== '')
595
603
  blocks.push(s);
596
604
  inlineRun = [];
@@ -602,7 +610,7 @@ class Converter {
602
610
  }
603
611
  else if (n.kind === 'el' && n.name === 'p') {
604
612
  flush();
605
- const s = this.inlineToMd(n.children, { cell: false }).replace(/\s+/g, ' ').trim();
613
+ const s = this.inlineToMd(n.children, { cell: true }).replace(/\s+/g, ' ').trim();
606
614
  if (s !== '')
607
615
  blocks.push(s);
608
616
  }
@@ -735,6 +743,11 @@ class Converter {
735
743
  return `${ticks}${pad}${text}${pad}${ticks}`;
736
744
  }
737
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/>';
738
751
  return '<br/>';
739
752
  case 'a':
740
753
  return this.linkAnchorToMd(el, ctx);
@@ -1059,21 +1072,13 @@ function escapeMdText(raw, ctx, readable = false) {
1059
1072
  out += escapePlain(collapsed.slice(last), ctx, readable);
1060
1073
  return out;
1061
1074
  }
1062
- function escapePlain(s, ctx, readable = false) {
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
- }
1071
- // Сырой U+00A0 на краю абзаца съедается trim()'ом markdown-it —
1072
- // в entity-форме переживает рендер (и виден при редактировании).
1073
- esc = esc.replace(/\u00A0/g, '&nbsp;');
1074
- if (ctx.cell)
1075
- esc = esc.replace(/\|/g, '\\|');
1076
- 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;');
1077
1082
  }
1078
1083
  /** Экранирует конструкции, значимые в начале строки (#, >, -, 1. …). */
1079
1084
  function guardLineStart(md) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.4.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",