confluence-md-sync 0.4.0 → 0.5.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
@@ -180,19 +180,29 @@ 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);
202
+
203
+ // Self-contained (local <img>/links) with wide tables unfolded to records:
204
+ await exportPage('123456789',
205
+ { outDir: 'doc', attachments: 'local', tables: 'records' }, cfg);
196
206
  ```
197
207
 
198
208
  Round-trip means *edit the Markdown, then publish it straight back*:
@@ -222,12 +232,28 @@ schema-version, attribute/parameter order and entity vs literal forms are
222
232
  normalised away (Confluence itself rewrites these on every save). Use
223
233
  `compareStorage(a, b)` directly to diff two storage fragments.
224
234
 
225
- From the CLI:
235
+ ### Which export variant?
236
+
237
+ Attachments download to `attachments/` next to the `.md` in every variant
238
+ (pass `--no-attachments` to skip). Flags compose; `--local` and `--records`
239
+ imply `--readable`.
240
+
241
+ | Command | Use it when | Output |
242
+ | --- | --- | --- |
243
+ | `export <id> --out page.md` | you'll **edit and publish it back** | Faithful, round-trippable; raw HTML kept for what Markdown can't express. |
244
+ | `export <id> --out page.md --readable` | **reading / diffing** in a Markdown viewer | Clean Markdown, no raw HTML; complex tables → GFM. Not round-trippable. |
245
+ | `export <id> --out-dir doc --local` | a **self-contained folder to view offline** | Like `--readable`, but images become `<img src="attachments/…">` (sizes kept) and files become `[name](attachments/…)` links pointing at the downloaded files. |
246
+ | `export <id> --out page.md --records` | tables are **too wide** to read as a grid | Every table row is unfolded into a `**Header:** value` record (records split by `---`). Best for tables that have a header row. |
247
+ | `export <id> --out-dir doc --local --records` | an **offline, wide-table-friendly** read | Both of the above together. |
248
+ | `roundtrip <id>` | **checking** a page converts with no loss (CI) | Writes nothing; exits `2` if faithful conversion would lose markup. |
226
249
 
227
250
  ```bash
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
251
+ confluence-md-sync export 123456789 --out page.md # faithful (edit → publish back)
252
+ confluence-md-sync export 123456789 --out page.md --readable # clean Markdown to read
253
+ confluence-md-sync export 123456789 --out-dir doc --local # self-contained: page.md + attachments/
254
+ confluence-md-sync export 123456789 --out page.md --records # wide tables → records
255
+ confluence-md-sync export 123456789 --out-dir doc --local --records # offline + records
256
+ confluence-md-sync roundtrip 123456789 --show-markdown # verify, exit 2 on loss
231
257
  ```
232
258
 
233
259
  ## Read pages and tables
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>] [--readable] [--no-attachments]
24
+ confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--readable] [--local] [--records] [--no-attachments]
25
25
  confluence-md-sync roundtrip <page-id> [--show-markdown]
26
26
 
27
27
  publish options:
@@ -46,6 +46,12 @@ export options:
46
46
  --readable Prefer clean Markdown over fidelity: no raw HTML blocks,
47
47
  complex tables flattened to GFM. Not round-trippable —
48
48
  loses styling/exact cell merges, keeps the content
49
+ --local Reference downloaded attachments as local files:
50
+ images as HTML <img src="attachments/…">, other files
51
+ as [name](attachments/…) links. Implies --readable
52
+ --records Unfold every table row into a "**Header:** value"
53
+ record (records split by ---). For wide tables that
54
+ don't fit as GFM. Implies --readable
49
55
  --no-attachments Do not download referenced attachments
50
56
 
51
57
  roundtrip options:
@@ -74,6 +80,8 @@ async function main() {
74
80
  out: { type: 'string' },
75
81
  'out-dir': { type: 'string' },
76
82
  readable: { type: 'boolean' },
83
+ local: { type: 'boolean' },
84
+ records: { type: 'boolean' },
77
85
  'no-attachments': { type: 'boolean' },
78
86
  'show-markdown': { type: 'boolean' },
79
87
  help: { type: 'boolean' },
@@ -107,13 +115,17 @@ async function main() {
107
115
  if (command === 'export') {
108
116
  if (!arg)
109
117
  throw new Error('export: page id is required');
118
+ // --local и --records подразумевают readable-обработку текста.
119
+ const readable = values.readable || values.local || values.records;
110
120
  const result = await exportPage(arg, {
111
121
  outFile: values.out,
112
122
  outDir: values['out-dir'],
113
123
  downloadAttachments: !values['no-attachments'],
114
- mode: values.readable ? 'readable' : 'faithful',
124
+ mode: readable ? 'readable' : 'faithful',
125
+ attachments: values.local ? 'local' : 'placeholder',
126
+ tables: values.records ? 'records' : 'auto',
115
127
  }, cfg);
116
- const tail = values.readable
128
+ const tail = readable
117
129
  ? `${result.stats.lossy} block(s) simplified`
118
130
  : `${result.stats.fenced} raw storage block(s)`;
119
131
  console.log(`[cli] exported page ${result.pageId} "${result.title}" v${result.version} → ${result.markdownPath}` +
@@ -23,6 +23,13 @@ export interface ExportPageOptions {
23
23
  * 'readable' — чистый Markdown ценой оформления (round-trip не гарантирован).
24
24
  */
25
25
  mode?: 'faithful' | 'readable';
26
+ /**
27
+ * 'placeholder' (default) — {{img:}}/{{file:}}; 'local' — картинки как
28
+ * HTML `<img src="attachments/…">`, файлы как md-ссылки. Подразумевает readable.
29
+ */
30
+ attachments?: 'placeholder' | 'local';
31
+ /** 'auto' (default) или 'records' — строки таблиц как записи. Подразумевает readable. */
32
+ tables?: 'auto' | 'records';
26
33
  registry?: MacroRegistry;
27
34
  }
28
35
  export interface ExportPageResult extends StorageToMarkdownResult {
@@ -9,7 +9,12 @@ 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, mode: opts.mode });
12
+ const converted = storageToMarkdown(page.storage, {
13
+ registry: opts.registry,
14
+ mode: opts.mode,
15
+ attachments: opts.attachments,
16
+ tables: opts.tables,
17
+ });
13
18
  const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
14
19
  mkdirSync(dirname(markdownPath), { recursive: true });
15
20
  writeFileSync(markdownPath, converted.markdown);
@@ -28,6 +28,24 @@ export interface StorageToMarkdownOptions {
28
28
  * 'readable' — чистый Markdown ценой оформления, без round-trip.
29
29
  */
30
30
  mode?: 'faithful' | 'readable';
31
+ /**
32
+ * Как ссылаться на аттачи:
33
+ * - 'placeholder' (default) — {{img:name}} / {{file:name}} (для publish);
34
+ * - 'local' — картинки как HTML `<img src="attachments/name">` (с
35
+ * сохранением размеров), файлы как md-ссылки `[name](attachments/name)`.
36
+ * Годится для самодостаточного просмотра рядом со скачанными файлами.
37
+ * Включение 'local' подразумевает readable-обработку текста.
38
+ */
39
+ attachments?: 'placeholder' | 'local';
40
+ /**
41
+ * Как выводить таблицы:
42
+ * - 'auto' (default) — GFM (readable) / round-trip (faithful);
43
+ * - 'records' — каждую строку разворачивать в запись
44
+ * «**Заголовок:** значение», записи через `---`. Подразумевает readable.
45
+ */
46
+ tables?: 'auto' | 'records';
47
+ /** Префикс пути к локальным аттачам для attachments:'local'. Default: 'attachments/'. */
48
+ localPrefix?: string;
31
49
  }
32
50
  export interface StorageToMarkdownResult {
33
51
  markdown: string;
@@ -28,7 +28,17 @@ import { compareStorage } from './canonical.js';
28
28
  import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
29
29
  /** Конвертирует storage-фрагмент страницы в Markdown. */
30
30
  export function storageToMarkdown(storage, opts = {}) {
31
- const conv = new Converter(opts.registry ?? defaultMacroRegistry, opts.mode === 'readable');
31
+ const local = opts.attachments === 'local';
32
+ const records = opts.tables === 'records';
33
+ // 'local' и 'records' — заведомо не round-trip, поэтому включают readable-
34
+ // обработку текста (сущности, спаны, переносы), даже без mode:'readable'.
35
+ const readable = opts.mode === 'readable' || local || records;
36
+ const conv = new Converter(opts.registry ?? defaultMacroRegistry, {
37
+ readable,
38
+ localFiles: local,
39
+ tablesAsRecords: records,
40
+ localPrefix: opts.localPrefix ?? 'attachments/',
41
+ });
32
42
  const markdown = conv.blocksToMd(parseStorage(storage));
33
43
  const attachmentRefs = new Set([...conv.images, ...conv.files]);
34
44
  for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
@@ -67,13 +77,19 @@ const UNWRAP_BLOCK = new Set([
67
77
  const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
68
78
  class Converter {
69
79
  registry;
70
- readable;
71
80
  images = new Set();
72
81
  files = new Set();
73
82
  stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
74
- constructor(registry, readable = false) {
83
+ readable;
84
+ localFiles;
85
+ tablesAsRecords;
86
+ localPrefix;
87
+ constructor(registry, opts) {
75
88
  this.registry = registry;
76
- this.readable = readable;
89
+ this.readable = opts.readable;
90
+ this.localFiles = opts.localFiles;
91
+ this.tablesAsRecords = opts.tablesAsRecords;
92
+ this.localPrefix = opts.localPrefix;
77
93
  }
78
94
  // ── Блочный уровень ──────────────────────────────────────────────────
79
95
  blocksToMd(nodes) {
@@ -104,7 +120,11 @@ class Converter {
104
120
  const h = /^h([1-6])$/.exec(el.name);
105
121
  try {
106
122
  if (h && el.attrs.length === 0) {
107
- const inline = this.inlineToMd(el.children);
123
+ // Заголовок однострочен по определению — в readable схлопываем
124
+ // возможные переводы строк (из <br>) в пробел.
125
+ let inline = this.inlineToMd(el.children);
126
+ if (this.readable)
127
+ inline = inline.replace(/\s*\n\s*/g, ' ');
108
128
  if (inline.includes('\n') || inline.trim() === '')
109
129
  throw new Unrepresentable();
110
130
  return '#'.repeat(Number(h[1])) + ' ' + guardLineStart(inline.trim());
@@ -122,11 +142,15 @@ class Converter {
122
142
  return '---';
123
143
  if (el.name === 'ul' || el.name === 'ol')
124
144
  return this.listToMd(el);
125
- // Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
126
- // отклоняет: faithful сырой HTML, readable → readableFallback
127
- // (lossy++ и разворот в GFM через readableTable).
128
- if (el.name === 'table')
145
+ if (el.name === 'table') {
146
+ // tables:'records' любую таблицу разворачиваем в записи.
147
+ if (this.tablesAsRecords)
148
+ return this.recordsTable(el);
149
+ // Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
150
+ // отклоняет: faithful → сырой HTML, readable → readableFallback
151
+ // (lossy++ и разворот в GFM через readableTable).
129
152
  return this.tableToMd(el);
153
+ }
130
154
  if (el.name === 'ac:structured-macro')
131
155
  return this.macroToMd(el);
132
156
  if (el.name === 'ac:image' || el.name === 'ac:link') {
@@ -147,7 +171,10 @@ class Converter {
147
171
  if (children.length === 0)
148
172
  return this.fallbackBlock(el);
149
173
  try {
150
- const inline = this.inlineToMd(children).trim();
174
+ // multiline: в readable-абзаце <br> становится настоящим переводом
175
+ // строки (см. case 'br'). guardLineStart применяем к КАЖДОЙ строке —
176
+ // после переноса тоже нельзя случайно начать список/заголовок.
177
+ const inline = this.inlineToMd(children, { multiline: true }).trim();
151
178
  if (inline === '')
152
179
  return this.fallbackBlock(el);
153
180
  // Абзац, начинающийся с блочного HTML-тега, markdown-it превратит в
@@ -155,7 +182,7 @@ class Converter {
155
182
  const m = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(inline);
156
183
  if (m && CM_BLOCK_TAGS.has(m[1].toLowerCase()))
157
184
  return this.fallbackBlock(el);
158
- return guardLineStart(inline);
185
+ return inline.split('\n').map((line) => guardLineStart(line)).join('\n');
159
186
  }
160
187
  catch (e) {
161
188
  if (!(e instanceof Unrepresentable))
@@ -255,9 +282,10 @@ class Converter {
255
282
  .join('\n');
256
283
  }
257
284
  // p / td / th / li / caption и прочие «инлайн-контейнеры» → инлайн.
258
- const inline = this.inlineToMd(el.children).trim();
285
+ // multiline: это свободный поток (не ячейка) — <br> станет переносом.
286
+ const inline = this.inlineToMd(el.children, { multiline: true }).trim();
259
287
  if (inline !== '')
260
- return guardLineStart(inline);
288
+ return inline.split('\n').map((l) => guardLineStart(l)).join('\n');
261
289
  // Совсем ничего не вышло — голый текст (может быть пустым).
262
290
  return escapeMdText(textContent(el.children), {}, this.readable).trim();
263
291
  }
@@ -497,7 +525,7 @@ class Converter {
497
525
  content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
498
526
  content = els[0].children;
499
527
  }
500
- const md = this.inlineToMd(content, { cell: false }).trim();
528
+ const md = this.inlineToMd(content, { cell: true }).trim();
501
529
  if (md.includes('\n'))
502
530
  throw new Unrepresentable();
503
531
  // Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
@@ -506,12 +534,12 @@ class Converter {
506
534
  }
507
535
  // ── Readable-таблицы: любая таблица → GFM ─────────────────────────────
508
536
  /**
509
- * Разворачивает произвольную таблицу (colspan/rowspan, блочные ячейки,
510
- * несколько header-строк) в GFM. Объединения превращаются в плотную
511
- * сетку: содержимое — в верхней-левой клетке диапазона, остальные клетки
512
- * пустые. Первая строка сетки становится шапкой GFM.
537
+ * Строит плотную сетку из произвольной таблицы (colspan/rowspan, блочные
538
+ * ячейки, несколько header-строк). Объединения раскрываются: содержимое
539
+ * в верхней-левой клетке диапазона, остальные клетки пустые. Содержимое
540
+ * ячейки flatten-строка БЕЗ экранирования пайпов (его делает вызывающий).
513
541
  */
514
- readableTable(table) {
542
+ buildTableGrid(table) {
515
543
  const trs = [];
516
544
  let caption = '';
517
545
  for (const child of elements(table.children)) {
@@ -527,7 +555,6 @@ class Converter {
527
555
  }
528
556
  if (trs.length === 0)
529
557
  throw new Unrepresentable();
530
- // Плотная сетка с учётом colspan/rowspan.
531
558
  const grid = [];
532
559
  const aligns = [];
533
560
  trs.forEach((tr, rowIdx) => {
@@ -541,10 +568,7 @@ class Converter {
541
568
  col++;
542
569
  const colspan = Math.max(1, Number(getAttr(cell, 'colspan') ?? '1') || 1);
543
570
  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, '\\|');
571
+ const content = tidyCell(this.cellFlatten(cell.children));
548
572
  for (let r = 0; r < rowspan; r++) {
549
573
  for (let c = 0; c < colspan; c++) {
550
574
  const rr = rowIdx + r;
@@ -572,12 +596,45 @@ class Converter {
572
596
  }
573
597
  while (aligns.length < width)
574
598
  aligns.push('none');
599
+ return { grid, aligns, caption };
600
+ }
601
+ /** Разворачивает любую таблицу в GFM (сетка с заполнением объединений). */
602
+ readableTable(table) {
603
+ const { grid, aligns, caption } = this.buildTableGrid(table);
604
+ const esc = (s) => s.replace(/\|/g, '\\|');
575
605
  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)];
606
+ const rowMd = (cells) => `| ${cells.map(esc).join(' | ')} |`;
607
+ const lines = [rowMd(grid[0]), `| ${sep.join(' | ')} |`, ...grid.slice(1).map(rowMd)];
578
608
  const table_ = lines.join('\n');
579
609
  return caption !== '' ? `**${caption}**\n\n${table_}` : table_;
580
610
  }
611
+ /**
612
+ * tables:'records' — каждую строку тела таблицы разворачивает в запись
613
+ * «**Заголовок:** значение» (пустые ячейки и колонки-филлеры пропускаются),
614
+ * записи разделяются `---`. Заголовки берутся из первой строки сетки.
615
+ */
616
+ recordsTable(table) {
617
+ this.stats.lossy++;
618
+ const { grid, caption } = this.buildTableGrid(table);
619
+ const header = grid[0];
620
+ const records = [];
621
+ for (const row of grid.slice(1)) {
622
+ const lines = [];
623
+ row.forEach((val, i) => {
624
+ const key = (header[i] ?? '').trim();
625
+ // В записи <br> из ячейки → «; » (одна строка, без HTML).
626
+ const value = val.replace(/<br>/g, '; ').trim();
627
+ if (key !== '' && value !== '')
628
+ lines.push(`**${key}:** ${value}`);
629
+ });
630
+ if (lines.length > 0)
631
+ records.push(lines.join('\n'));
632
+ }
633
+ if (records.length === 0)
634
+ throw new Unrepresentable();
635
+ const body = records.join('\n\n---\n\n');
636
+ return caption !== '' ? `**${caption}**\n\n${body}` : body;
637
+ }
581
638
  /**
582
639
  * Сплющивает содержимое ячейки в одну строку: абзацы и пункты списков
583
640
  * разделяются `<br>` (единственный HTML, идиоматичный для GFM-ячеек),
@@ -590,7 +647,7 @@ class Converter {
590
647
  const flush = () => {
591
648
  if (inlineRun.length === 0)
592
649
  return;
593
- const s = this.inlineToMd(inlineRun, { cell: false }).replace(/\s+/g, ' ').trim();
650
+ const s = this.inlineToMd(inlineRun, { cell: true }).replace(/\s+/g, ' ').trim();
594
651
  if (s !== '')
595
652
  blocks.push(s);
596
653
  inlineRun = [];
@@ -602,7 +659,7 @@ class Converter {
602
659
  }
603
660
  else if (n.kind === 'el' && n.name === 'p') {
604
661
  flush();
605
- const s = this.inlineToMd(n.children, { cell: false }).replace(/\s+/g, ' ').trim();
662
+ const s = this.inlineToMd(n.children, { cell: true }).replace(/\s+/g, ' ').trim();
606
663
  if (s !== '')
607
664
  blocks.push(s);
608
665
  }
@@ -735,6 +792,11 @@ class Converter {
735
792
  return `${ticks}${pad}${text}${pad}${ticks}`;
736
793
  }
737
794
  case 'br':
795
+ // readable: в свободном потоке (абзац/цитата) — настоящий перенос
796
+ // строки; в ячейке GFM-таблицы перенос невозможен (сломал бы строку
797
+ // таблицы) — там остаётся <br>. faithful — всегда <br/> (round-trip).
798
+ if (this.readable)
799
+ return ctx.multiline ? '\n' : ctx.cell ? '<br>' : '<br/>';
738
800
  return '<br/>';
739
801
  case 'a':
740
802
  return this.linkAnchorToMd(el, ctx);
@@ -807,6 +869,30 @@ class Converter {
807
869
  if (kids.length !== 1)
808
870
  throw new Unrepresentable();
809
871
  const ref = kids[0];
872
+ // attachments:'local' — картинка как HTML <img> (сохраняет размеры).
873
+ // ri:attachment → локальный путь; ri:url → внешний URL как есть.
874
+ if (this.localFiles && (ref.name === 'ri:attachment' || ref.name === 'ri:url')) {
875
+ let src;
876
+ let alt = '';
877
+ if (ref.name === 'ri:attachment') {
878
+ const filename = getAttr(ref, 'ri:filename') ?? '';
879
+ this.images.add(filename);
880
+ src = this.localPrefix + filename;
881
+ alt = filename;
882
+ }
883
+ else {
884
+ src = getAttr(ref, 'ri:value') ?? '';
885
+ }
886
+ const attrs = [['src', escapeXmlAttr(src)]];
887
+ for (const [k] of el.attrs) {
888
+ const plain = k.startsWith('ac:') ? k.slice(3) : k;
889
+ if (plain === 'height' || plain === 'width') {
890
+ attrs.push([plain, escapeXmlAttr(getAttr(el, k) ?? '')]);
891
+ }
892
+ }
893
+ attrs.push(['alt', escapeXmlAttr(alt)]);
894
+ return serializeStorage([{ kind: 'el', name: 'img', attrs, children: [], selfClosing: true }]);
895
+ }
810
896
  if (ref.name === 'ri:url') {
811
897
  if (ref.attrs.some(([k]) => k !== 'ri:value'))
812
898
  throw new Unrepresentable();
@@ -881,8 +967,15 @@ class Converter {
881
967
  throw new Unrepresentable();
882
968
  }
883
969
  const filename = getAttr(ref, 'ri:filename') ?? '';
884
- badPlaceholderPart(filename);
885
970
  this.files.add(filename);
971
+ // attachments:'local' — md-ссылка на локальный файл вместо {{file:}}.
972
+ if (this.localFiles) {
973
+ const label = (text ?? filename).replace(/[[\]]/g, '\\$&');
974
+ const path = this.localPrefix + filename;
975
+ const target = /[()\s]/.test(path) ? `<${path}>` : path;
976
+ return `[${label}](${target})`;
977
+ }
978
+ badPlaceholderPart(filename);
886
979
  return `{{file:${filename}${textAttr}}}`;
887
980
  }
888
981
  throw new Unrepresentable();
@@ -1059,21 +1152,13 @@ function escapeMdText(raw, ctx, readable = false) {
1059
1152
  out += escapePlain(collapsed.slice(last), ctx, readable);
1060
1153
  return out;
1061
1154
  }
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;
1155
+ function escapePlain(s, _ctx, readable = false) {
1156
+ const esc = s.replace(/[\\`*_[\]{}~]/g, (c) => '\\' + c);
1157
+ // Пайпы здесь НЕ трогаем — они экранируются один раз на границе ячейки
1158
+ // (cellMd / readableTable), иначе плейсхолдеры {{img:…|…}} двоились бы.
1159
+ // readable: неразрывный пробел → обычный; faithful: → entity (переживает
1160
+ // trim() markdown-it на краю абзаца).
1161
+ return esc.replace(/\u00A0/g, readable ? ' ' : '&nbsp;');
1077
1162
  }
1078
1163
  /** Экранирует конструкции, значимые в начале строки (#, >, -, 1. …). */
1079
1164
  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.5.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",