confluence-md-sync 0.7.0 → 0.8.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 +25 -0
- package/dist/export/to-markdown.d.ts +4 -0
- package/dist/export/to-markdown.js +257 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/markdown/native.d.ts +47 -0
- package/dist/markdown/native.js +232 -0
- package/dist/markdown/render.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -318,6 +318,31 @@ const rows = readCsv(decodeText(await page.getAttachment('data.csv')));
|
|
|
318
318
|
|
|
319
319
|
## Macros
|
|
320
320
|
|
|
321
|
+
### Native Markdown syntax (0.8.0+)
|
|
322
|
+
|
|
323
|
+
Popular macros are written (and exported back) as plain Markdown — no comment
|
|
324
|
+
markers, no raw XHTML. Round-trip is verified canonically per macro; anything
|
|
325
|
+
that doesn't fit falls back to markers/fenced blocks automatically.
|
|
326
|
+
|
|
327
|
+
| Macro | Markdown |
|
|
328
|
+
| --- | --- |
|
|
329
|
+
| `info` / `note` / `warning` / `tip` | `> [!INFO] Title?` + quoted body (GitHub-style admonition) |
|
|
330
|
+
| `details` (Page Properties) | `::: properties [id=…] [hidden=true]` + md-table + `:::` — complex Confluence tables are normalized to GFM on export (styling dropped, cell text verified; `stats.normalized`) |
|
|
331
|
+
| `expand` | `::: expand Title` + body + `:::` |
|
|
332
|
+
| `panel` | `::: panel title=… borderColor=…` + body + `:::` |
|
|
333
|
+
| `toc` | `{{toc}}` / `{{toc:maxLevel=3}}` |
|
|
334
|
+
| `children` | `{{children}}` / `{{children:depth=2}}` |
|
|
335
|
+
| `jira` | `{{jira:KEY-1}}` / `{{jira:jql=project = X\|maximumIssues=20}}` |
|
|
336
|
+
| `status` | `{{status:Текст\|colour=Green\|subtle=true}}` |
|
|
337
|
+
| `anchor` | `{{anchor:name}}` |
|
|
338
|
+
| `detailssummary` (Page Properties Report) | `{{properties-report:cql=…\|firstcolumn=…}}` |
|
|
339
|
+
|
|
340
|
+
Inside fenced code blocks the syntax is left untouched. The machine-readable
|
|
341
|
+
list is exported as `nativeMacroList()`; the md→markers pass is
|
|
342
|
+
`nativeToMarkers()` (runs automatically inside `renderToStorage`).
|
|
343
|
+
|
|
344
|
+
### Marker builders
|
|
345
|
+
|
|
321
346
|
Builders return `Markdown` with comment markers; markers become
|
|
322
347
|
`<ac:structured-macro>` after rendering (nested macros resolve inner-first):
|
|
323
348
|
|
|
@@ -61,6 +61,10 @@ export interface StorageToMarkdownResult {
|
|
|
61
61
|
rawHtml: number;
|
|
62
62
|
/** readable-режим: сколько узлов конвертировано с потерей оформления. */
|
|
63
63
|
lossy: number;
|
|
64
|
+
/** Макросы, выраженные нативным md-синтаксисом (панели, ::: …, {{…}}). */
|
|
65
|
+
native: number;
|
|
66
|
+
/** details, чья таблица нормализована в GFM (оформление потеряно, текст сверен). */
|
|
67
|
+
normalized: number;
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
@@ -79,7 +79,7 @@ class Converter {
|
|
|
79
79
|
registry;
|
|
80
80
|
images = new Set();
|
|
81
81
|
files = new Set();
|
|
82
|
-
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
|
|
82
|
+
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0, normalized: 0 };
|
|
83
83
|
readable;
|
|
84
84
|
localFiles;
|
|
85
85
|
tablesAsRecords;
|
|
@@ -290,8 +290,160 @@ class Converter {
|
|
|
290
290
|
return escapeMdText(textContent(el.children), {}, this.readable).trim();
|
|
291
291
|
}
|
|
292
292
|
// ── Макросы ──────────────────────────────────────────────────────────
|
|
293
|
+
// ── Нативный md-синтаксис (native.ts): панели, ::: properties/expand/panel,
|
|
294
|
+
// {{toc}}/{{children}}/{{jira}}/{{status}}/{{anchor}}/{{properties-report}}.
|
|
295
|
+
// Каждый кандидат проверяется рендером и канонической сверкой (как маркеры).
|
|
296
|
+
static NATIVE_ADMONITION_TAG = {
|
|
297
|
+
info: 'INFO', note: 'NOTE', warning: 'WARNING', tip: 'TIP',
|
|
298
|
+
};
|
|
299
|
+
static NATIVE_PLACEHOLDER_NAME = {
|
|
300
|
+
toc: 'toc', children: 'children', jira: 'jira', status: 'status',
|
|
301
|
+
anchor: 'anchor', detailssummary: 'properties-report',
|
|
302
|
+
};
|
|
303
|
+
tryNativeMd(el, name) {
|
|
304
|
+
if (this.readable)
|
|
305
|
+
return null; // readable-путь остаётся прежним
|
|
306
|
+
const parts = this.macroParts(el);
|
|
307
|
+
if (parts === null)
|
|
308
|
+
return null;
|
|
309
|
+
const { params, richBody, plainBody } = parts;
|
|
310
|
+
if (plainBody !== null)
|
|
311
|
+
return null;
|
|
312
|
+
const pmap = new Map(params.map((p) => [p.name, p.value]));
|
|
313
|
+
const oneline = (v) => v !== undefined && !/[\n|{}]/.test(v) && !v.includes('}}');
|
|
314
|
+
let candidate = null;
|
|
315
|
+
const tag = Converter.NATIVE_ADMONITION_TAG[name];
|
|
316
|
+
if (tag && richBody !== null) {
|
|
317
|
+
// Панель: параметры — только title.
|
|
318
|
+
if (![...pmap.keys()].every((k) => k === 'title'))
|
|
319
|
+
return null;
|
|
320
|
+
const title = pmap.get('title');
|
|
321
|
+
if (title !== undefined && (!oneline(title) || /\[|\]/.test(title)))
|
|
322
|
+
return null;
|
|
323
|
+
const bodyMd = this.bodyMd(richBody);
|
|
324
|
+
if (bodyMd === null || bodyMd.trim() === '')
|
|
325
|
+
return null;
|
|
326
|
+
const quoted = bodyMd.split('\n').map((l) => (l === '' ? '>' : `> ${l}`)).join('\n');
|
|
327
|
+
candidate = `> [!${tag}]${title ? ' ' + title : ''}\n${quoted}`;
|
|
328
|
+
}
|
|
329
|
+
else if (name === 'details' && richBody !== null) {
|
|
330
|
+
// «Свойства страницы»: параметры id/hidden, тело — простая md-таблица.
|
|
331
|
+
if (![...pmap.keys()].every((k) => k === 'id' || k === 'hidden'))
|
|
332
|
+
return null;
|
|
333
|
+
const bodyEls = elements(richBody.children);
|
|
334
|
+
const nonWs = richBody.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
|
|
335
|
+
if (bodyEls.length !== 1 || nonWs.length !== 1 || bodyEls[0].name !== 'table')
|
|
336
|
+
return null;
|
|
337
|
+
const dp = [...pmap.entries()].map(([k, v]) => (oneline(v) && !/["\s]/.test(v) ? ` ${k}=${v}` : null));
|
|
338
|
+
if (dp.some((x) => x === null))
|
|
339
|
+
return null;
|
|
340
|
+
let tableMd = null;
|
|
341
|
+
try {
|
|
342
|
+
tableMd = this.tableToMd(bodyEls[0]);
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
if (!(e instanceof Unrepresentable))
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
if (tableMd !== null) {
|
|
349
|
+
candidate = `::: properties${dp.join('')}\n${tableMd}\n:::`;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// «Свойства страницы» обязаны жить md-таблицей: сложную таблицу
|
|
353
|
+
// нормализуем в GFM (оформление теряется, текст сверяется рендером).
|
|
354
|
+
const norm = this.propertiesGridMd(bodyEls[0]);
|
|
355
|
+
if (norm === null)
|
|
356
|
+
return null;
|
|
357
|
+
const cand = `::: properties${dp.join('')}\n${norm.md}\n:::`;
|
|
358
|
+
if (!this.verifyNormalizedDetails(cand, norm.rows))
|
|
359
|
+
return null;
|
|
360
|
+
this.stats.normalized++;
|
|
361
|
+
return cand; // канонической эквивалентности нет по построению — верифицирован текст
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else if (name === 'expand' && richBody !== null) {
|
|
365
|
+
if (![...pmap.keys()].every((k) => k === 'title'))
|
|
366
|
+
return null;
|
|
367
|
+
const title = pmap.get('title');
|
|
368
|
+
if (title !== undefined && (!oneline(title) || /=|"/.test(title)))
|
|
369
|
+
return null;
|
|
370
|
+
const bodyMd = this.bodyMd(richBody);
|
|
371
|
+
if (bodyMd === null)
|
|
372
|
+
return null;
|
|
373
|
+
candidate = `::: expand${title ? ' ' + title : ''}\n${bodyMd}\n:::`;
|
|
374
|
+
}
|
|
375
|
+
else if (Converter.NATIVE_PLACEHOLDER_NAME[name] && richBody === null) {
|
|
376
|
+
// Bodyless-плейсхолдеры. Части значений с | или }} не выразить — маркер.
|
|
377
|
+
const ph = Converter.NATIVE_PLACEHOLDER_NAME[name];
|
|
378
|
+
const attrs = [];
|
|
379
|
+
let head = '';
|
|
380
|
+
for (const p of params) {
|
|
381
|
+
if (!oneline(p.value) || p.value.includes('|'))
|
|
382
|
+
return null;
|
|
383
|
+
if (name === 'jira' && p.name === 'key' && head === '') {
|
|
384
|
+
head = p.value;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (name === 'status' && p.name === 'title' && head === '') {
|
|
388
|
+
head = p.value;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (name === 'anchor' && (p.name === 'name' || p.name === '') && head === '') {
|
|
392
|
+
head = p.value;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const attrName = name === 'jira' && p.name === 'jqlQuery' ? 'jql' : p.name;
|
|
396
|
+
if (!/^[A-Za-z-]+$/.test(attrName))
|
|
397
|
+
return null;
|
|
398
|
+
attrs.push(`${attrName}=${p.value}`);
|
|
399
|
+
}
|
|
400
|
+
const inner = [head, ...attrs].filter((s, i) => s !== '' || i > 0).join('|');
|
|
401
|
+
candidate = inner === '' ? `{{${ph}}}` : `{{${ph}:${inner}}}`;
|
|
402
|
+
}
|
|
403
|
+
if (candidate === null)
|
|
404
|
+
return null;
|
|
405
|
+
return this.verifyMacroMarker(el, candidate) ? candidate : null;
|
|
406
|
+
}
|
|
407
|
+
/** Разбирает macro-элемент на параметры и тела; null — посторонние дети. */
|
|
408
|
+
macroParts(el) {
|
|
409
|
+
const params = [];
|
|
410
|
+
let richBody = null;
|
|
411
|
+
let plainBody = null;
|
|
412
|
+
for (const child of elements(el.children)) {
|
|
413
|
+
if (child.name === 'ac:parameter') {
|
|
414
|
+
const pname = getAttr(child, 'ac:name') ?? '';
|
|
415
|
+
params.push({ name: pname, value: decodeEntities(textContent(child.children)) });
|
|
416
|
+
}
|
|
417
|
+
else if (child.name === 'ac:rich-text-body')
|
|
418
|
+
richBody = child;
|
|
419
|
+
else if (child.name === 'ac:plain-text-body')
|
|
420
|
+
plainBody = child;
|
|
421
|
+
else
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
return { params, richBody, plainBody };
|
|
425
|
+
}
|
|
426
|
+
/** Тело макроса → md; null, если не легло без потерь. */
|
|
427
|
+
bodyMd(richBody) {
|
|
428
|
+
const before = { images: new Set(this.images), files: new Set(this.files) };
|
|
429
|
+
try {
|
|
430
|
+
return this.blocksToMd(richBody.children).trimEnd();
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
if (!(e instanceof Unrepresentable))
|
|
434
|
+
throw e;
|
|
435
|
+
this.images = before.images;
|
|
436
|
+
this.files = before.files;
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
293
440
|
macroToMd(el) {
|
|
294
441
|
const name = getAttr(el, 'ac:name') ?? '';
|
|
442
|
+
const nativeMd = this.tryNativeMd(el, name);
|
|
443
|
+
if (nativeMd !== null) {
|
|
444
|
+
this.stats.native++;
|
|
445
|
+
return nativeMd;
|
|
446
|
+
}
|
|
295
447
|
const markerMd = this.tryMacroMarker(el, name);
|
|
296
448
|
if (markerMd !== null) {
|
|
297
449
|
this.stats.markers++;
|
|
@@ -379,6 +531,110 @@ class Converter {
|
|
|
379
531
|
return this.verifyMacroMarker(el, markerMd) ? markerMd : null;
|
|
380
532
|
}
|
|
381
533
|
/** Маркер → render-конвейер → канонически равен исходному макросу? */
|
|
534
|
+
/** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
|
|
535
|
+
* заголовок синтезируется («Поле|Значение» для двух колонок). null — не таблица. */
|
|
536
|
+
/** Убирает ac:/ri:-вставки без текста (упоминания пользователей, эмодзи):
|
|
537
|
+
* в md им нечем быть, а их присутствие роняло flatten всей ячейки. */
|
|
538
|
+
stripEmptyNamespaced(nodes) {
|
|
539
|
+
const out = [];
|
|
540
|
+
for (const n of nodes) {
|
|
541
|
+
if (n.kind === 'el') {
|
|
542
|
+
if (n.name.includes(':') && textContent(n.children).trim() === '')
|
|
543
|
+
continue;
|
|
544
|
+
out.push({ ...n, children: this.stripEmptyNamespaced(n.children) });
|
|
545
|
+
}
|
|
546
|
+
else
|
|
547
|
+
out.push(n);
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
/** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
|
|
552
|
+
* заголовок синтезируется («Поле|Значение» для двух колонок). Ячейки — readable-
|
|
553
|
+
* flatten; несовместимые вставки (напр. упоминание пользователя) — в голый текст.
|
|
554
|
+
* null — не таблица/пусто. */
|
|
555
|
+
propertiesGridMd(table) {
|
|
556
|
+
const saved = this.readable;
|
|
557
|
+
this.readable = true;
|
|
558
|
+
try {
|
|
559
|
+
const trs = [];
|
|
560
|
+
for (const child of elements(table.children)) {
|
|
561
|
+
if (['thead', 'tbody', 'tfoot'].includes(child.name)) {
|
|
562
|
+
for (const tr of elements(child.children))
|
|
563
|
+
if (tr.name === 'tr')
|
|
564
|
+
trs.push(tr);
|
|
565
|
+
}
|
|
566
|
+
else if (child.name === 'tr')
|
|
567
|
+
trs.push(child);
|
|
568
|
+
}
|
|
569
|
+
if (trs.length === 0)
|
|
570
|
+
return null;
|
|
571
|
+
const grid = trs.map((tr) => elements(tr.children)
|
|
572
|
+
.filter((c) => c.name === 'td' || c.name === 'th')
|
|
573
|
+
.map((c) => {
|
|
574
|
+
let flat;
|
|
575
|
+
try {
|
|
576
|
+
flat = this.cellFlatten(this.stripEmptyNamespaced(c.children));
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
if (!(e instanceof Unrepresentable))
|
|
580
|
+
throw e;
|
|
581
|
+
flat = textContent(c.children);
|
|
582
|
+
}
|
|
583
|
+
return flat.replace(/\s+/g, ' ').trim();
|
|
584
|
+
}));
|
|
585
|
+
const cols = Math.max(...grid.map((r) => r.length));
|
|
586
|
+
const norm = grid.map((r) => Array.from({ length: cols }, (_, i) => r[i] ?? ''));
|
|
587
|
+
const keep = Array.from({ length: cols }, (_, i) => norm.some((r) => r[i] !== ''));
|
|
588
|
+
const rows = norm.map((r) => r.filter((_, i) => keep[i])).filter((r) => r.some((c) => c !== ''));
|
|
589
|
+
const width = rows[0]?.length ?? 0;
|
|
590
|
+
if (width === 0 || rows.some((r) => r.length !== width))
|
|
591
|
+
return null;
|
|
592
|
+
const esc = (s) => s.replace(/\|/g, '\\|');
|
|
593
|
+
const header = width === 2 ? ['Поле', 'Значение'] : rows[0].map(() => ' ');
|
|
594
|
+
const line = (cells) => `| ${cells.map(esc).join(' | ')} |`;
|
|
595
|
+
const md = [line(header), `| ${header.map(() => '---').join(' | ')} |`, ...rows.map(line)].join('\n');
|
|
596
|
+
return { md, rows };
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
this.readable = saved;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** Нормализованные details: рендерим кандидата и сверяем ТЕКСТ ячеек с исходным. */
|
|
603
|
+
verifyNormalizedDetails(candidate, rows) {
|
|
604
|
+
try {
|
|
605
|
+
let storage = renderToStorage(candidate, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
|
606
|
+
storage = processMacros(storage, this.registry).toString();
|
|
607
|
+
let renderedTable = null;
|
|
608
|
+
const walk = (ns) => {
|
|
609
|
+
for (const n of elements(ns)) {
|
|
610
|
+
if (renderedTable)
|
|
611
|
+
return;
|
|
612
|
+
if (n.name === 'table') {
|
|
613
|
+
renderedTable = n;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
walk(n.children);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
walk(parseStorage(storage));
|
|
620
|
+
if (!renderedTable)
|
|
621
|
+
return false;
|
|
622
|
+
const backNorm = this.propertiesGridMd(renderedTable);
|
|
623
|
+
if (backNorm === null)
|
|
624
|
+
return false;
|
|
625
|
+
const back = backNorm.rows;
|
|
626
|
+
const width = rows[0]?.length ?? 0;
|
|
627
|
+
const headerIsSynth = back.length > 0 &&
|
|
628
|
+
(width === 2 ? back[0][0] === 'Поле' && back[0][1] === 'Значение' : back[0].every((c) => c === ''));
|
|
629
|
+
const body = headerIsSynth ? back.slice(1) : back;
|
|
630
|
+
if (body.length !== rows.length)
|
|
631
|
+
return false;
|
|
632
|
+
return body.every((r, i) => r.length === rows[i].length && r.every((c, j) => c === rows[i][j]));
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
382
638
|
verifyMacroMarker(el, markerMd) {
|
|
383
639
|
try {
|
|
384
640
|
let storage = renderToStorage(markerMd, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { authHeader, loadConfigFromEnv, type ConfluenceAuthType, type Confluence
|
|
|
3
3
|
export { Markdown } from './markdown/markdown.js';
|
|
4
4
|
export { renderToStorage, extractPlaceholders, parsePlaceholder, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, type PlaceholderRef, type RenderStorageOptions, } from './markdown/render.js';
|
|
5
5
|
export { validateMarkdown, MarkdownValidationError, type ValidateOptions } from './markdown/validate.js';
|
|
6
|
+
export { nativeToMarkers, nativeMacroList, NATIVE_ADMONITIONS, NATIVE_DIRECTIVES, NATIVE_PLACEHOLDERS, } from './markdown/native.js';
|
|
6
7
|
export * from './macros/index.js';
|
|
7
8
|
export { convertBpmn, convertBpmnFolder, isBpmnFile, bpmnOutputName, BPMN_FILE_RE, type BpmnConversion, type BpmnImageFormat, type ConvertBpmnFolderOptions, } from './bpmn/convert.js';
|
|
8
9
|
export { fileSha256, HASH_TAG_PREFIX } from './attachments/hash.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { authHeader, loadConfigFromEnv, } from './client/config.js';
|
|
|
5
5
|
export { Markdown } from './markdown/markdown.js';
|
|
6
6
|
export { renderToStorage, extractPlaceholders, parsePlaceholder, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
|
|
7
7
|
export { validateMarkdown, MarkdownValidationError } from './markdown/validate.js';
|
|
8
|
+
export { nativeToMarkers, nativeMacroList, NATIVE_ADMONITIONS, NATIVE_DIRECTIVES, NATIVE_PLACEHOLDERS, } from './markdown/native.js';
|
|
8
9
|
// Macros (pluggable)
|
|
9
10
|
export * from './macros/index.js';
|
|
10
11
|
// BPMN (optional peer dep 'bpmn-to-image' is loaded lazily on use)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Нативный Markdown-синтаксис для популярных макросов Confluence.
|
|
3
|
+
* Сахар над маркерами <!-- MACRO:start/end -->: перед рендером native-
|
|
4
|
+
* конструкции переписываются в маркеры (nativeToMarkers), а экспорт
|
|
5
|
+
* (storage → md) эмитит их обратно с канонической верификацией.
|
|
6
|
+
*
|
|
7
|
+
* Перечень (см. README, раздел «Нативные макросы»):
|
|
8
|
+
*
|
|
9
|
+
* Панели (admonition в стиле GitHub):
|
|
10
|
+
* > [!INFO] Заголовок → info (заголовок опционален)
|
|
11
|
+
* > [!NOTE] / [!WARNING] / [!TIP] → note / warning / tip
|
|
12
|
+
* > текст панели — обычные строки цитаты после тега.
|
|
13
|
+
*
|
|
14
|
+
* Блоки-директивы (fenced ::: … :::):
|
|
15
|
+
* ::: properties [id=x] [hidden=true] → details («Свойства страницы»;
|
|
16
|
+
* тело — обычная md-таблица) работает с отчётом detailssummary
|
|
17
|
+
* ::: expand Заголовок → expand (разворачиваемый блок)
|
|
18
|
+
* ::: panel title=… borderColor=… → panel
|
|
19
|
+
*
|
|
20
|
+
* Строчные плейсхолдеры (семейство {{img:}}/{{page:}}):
|
|
21
|
+
* {{toc}} / {{toc:maxLevel=3}} → toc (оглавление)
|
|
22
|
+
* {{children}} / {{children:depth=2}} → children (дочерние страницы)
|
|
23
|
+
* {{jira:DR-123}} → jira (карточка задачи)
|
|
24
|
+
* {{jira:jql=project = DR|maximumIssues=20}} → jira (выгрузка по JQL)
|
|
25
|
+
* {{status:Готово|colour=Green}} → status (лейбл)
|
|
26
|
+
* {{anchor:имя}} → anchor (якорь)
|
|
27
|
+
* {{properties-report:cql=label = "x"}} → detailssummary (отчёт по
|
|
28
|
+
* свойствам страниц)
|
|
29
|
+
*
|
|
30
|
+
* Внутри fenced-код-блоков (``` / ~~~) синтаксис не интерпретируется.
|
|
31
|
+
*/
|
|
32
|
+
/** Панели: тег admonition → имя макроса. */
|
|
33
|
+
export declare const NATIVE_ADMONITIONS: Record<string, string>;
|
|
34
|
+
/** Блоки-директивы: имя директивы → имя макроса. */
|
|
35
|
+
export declare const NATIVE_DIRECTIVES: Record<string, string>;
|
|
36
|
+
/** Строчные плейсхолдеры: имя → имя макроса. */
|
|
37
|
+
export declare const NATIVE_PLACEHOLDERS: Record<string, string>;
|
|
38
|
+
/** Полный перечень макросов с нативной md-разметкой (для документации/UI). */
|
|
39
|
+
export declare function nativeMacroList(): {
|
|
40
|
+
macro: string;
|
|
41
|
+
syntax: string;
|
|
42
|
+
}[];
|
|
43
|
+
/**
|
|
44
|
+
* Переписывает нативные конструкции в маркеры макросов. Идемпотентна для
|
|
45
|
+
* текста без нативного синтаксиса; содержимое fenced-код-блоков не трогает.
|
|
46
|
+
*/
|
|
47
|
+
export declare function nativeToMarkers(src: string): string;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Нативный Markdown-синтаксис для популярных макросов Confluence.
|
|
3
|
+
* Сахар над маркерами <!-- MACRO:start/end -->: перед рендером native-
|
|
4
|
+
* конструкции переписываются в маркеры (nativeToMarkers), а экспорт
|
|
5
|
+
* (storage → md) эмитит их обратно с канонической верификацией.
|
|
6
|
+
*
|
|
7
|
+
* Перечень (см. README, раздел «Нативные макросы»):
|
|
8
|
+
*
|
|
9
|
+
* Панели (admonition в стиле GitHub):
|
|
10
|
+
* > [!INFO] Заголовок → info (заголовок опционален)
|
|
11
|
+
* > [!NOTE] / [!WARNING] / [!TIP] → note / warning / tip
|
|
12
|
+
* > текст панели — обычные строки цитаты после тега.
|
|
13
|
+
*
|
|
14
|
+
* Блоки-директивы (fenced ::: … :::):
|
|
15
|
+
* ::: properties [id=x] [hidden=true] → details («Свойства страницы»;
|
|
16
|
+
* тело — обычная md-таблица) работает с отчётом detailssummary
|
|
17
|
+
* ::: expand Заголовок → expand (разворачиваемый блок)
|
|
18
|
+
* ::: panel title=… borderColor=… → panel
|
|
19
|
+
*
|
|
20
|
+
* Строчные плейсхолдеры (семейство {{img:}}/{{page:}}):
|
|
21
|
+
* {{toc}} / {{toc:maxLevel=3}} → toc (оглавление)
|
|
22
|
+
* {{children}} / {{children:depth=2}} → children (дочерние страницы)
|
|
23
|
+
* {{jira:DR-123}} → jira (карточка задачи)
|
|
24
|
+
* {{jira:jql=project = DR|maximumIssues=20}} → jira (выгрузка по JQL)
|
|
25
|
+
* {{status:Готово|colour=Green}} → status (лейбл)
|
|
26
|
+
* {{anchor:имя}} → anchor (якорь)
|
|
27
|
+
* {{properties-report:cql=label = "x"}} → detailssummary (отчёт по
|
|
28
|
+
* свойствам страниц)
|
|
29
|
+
*
|
|
30
|
+
* Внутри fenced-код-блоков (``` / ~~~) синтаксис не интерпретируется.
|
|
31
|
+
*/
|
|
32
|
+
import { escapeParamValue } from '../macros/builder.js';
|
|
33
|
+
/** Панели: тег admonition → имя макроса. */
|
|
34
|
+
export const NATIVE_ADMONITIONS = {
|
|
35
|
+
INFO: 'info',
|
|
36
|
+
NOTE: 'note',
|
|
37
|
+
WARNING: 'warning',
|
|
38
|
+
TIP: 'tip',
|
|
39
|
+
};
|
|
40
|
+
/** Блоки-директивы: имя директивы → имя макроса. */
|
|
41
|
+
export const NATIVE_DIRECTIVES = {
|
|
42
|
+
properties: 'details',
|
|
43
|
+
expand: 'expand',
|
|
44
|
+
panel: 'panel',
|
|
45
|
+
};
|
|
46
|
+
/** Строчные плейсхолдеры: имя → имя макроса. */
|
|
47
|
+
export const NATIVE_PLACEHOLDERS = {
|
|
48
|
+
toc: 'toc',
|
|
49
|
+
children: 'children',
|
|
50
|
+
jira: 'jira',
|
|
51
|
+
status: 'status',
|
|
52
|
+
anchor: 'anchor',
|
|
53
|
+
'properties-report': 'detailssummary',
|
|
54
|
+
};
|
|
55
|
+
/** Полный перечень макросов с нативной md-разметкой (для документации/UI). */
|
|
56
|
+
export function nativeMacroList() {
|
|
57
|
+
return [
|
|
58
|
+
{ macro: 'info', syntax: '> [!INFO] Заголовок?' },
|
|
59
|
+
{ macro: 'note', syntax: '> [!NOTE] Заголовок?' },
|
|
60
|
+
{ macro: 'warning', syntax: '> [!WARNING] Заголовок?' },
|
|
61
|
+
{ macro: 'tip', syntax: '> [!TIP] Заголовок?' },
|
|
62
|
+
{ macro: 'details', syntax: '::: properties [id=…] [hidden=true] … :::' },
|
|
63
|
+
{ macro: 'expand', syntax: '::: expand Заголовок … :::' },
|
|
64
|
+
{ macro: 'panel', syntax: '::: panel title=… … :::' },
|
|
65
|
+
{ macro: 'toc', syntax: '{{toc}} | {{toc:maxLevel=3}}' },
|
|
66
|
+
{ macro: 'children', syntax: '{{children}} | {{children:depth=2}}' },
|
|
67
|
+
{ macro: 'jira', syntax: '{{jira:KEY-1}} | {{jira:jql=…|maximumIssues=20}}' },
|
|
68
|
+
{ macro: 'status', syntax: '{{status:Текст|colour=Green|subtle=true}}' },
|
|
69
|
+
{ macro: 'anchor', syntax: '{{anchor:имя}}' },
|
|
70
|
+
{ macro: 'detailssummary', syntax: '{{properties-report:cql=…|firstcolumn=…}}' },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
const ADMONITION_FIRST_RE = /^>\s*\[!([A-Za-z]+)\]\s*(.*)$/;
|
|
74
|
+
const DIRECTIVE_OPEN_RE = /^:::\s+([a-z-]+)(?:\s+(.*?))?\s*$/;
|
|
75
|
+
const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
|
|
76
|
+
const PLACEHOLDER_INLINE_RE = /\{\{(toc|children|jira|status|anchor|properties-report)(?::((?:[^{}]|\{[^{])*?))?\}\}/g;
|
|
77
|
+
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
|
78
|
+
/** Компактная пара маркеров без тела — для строчного контекста. */
|
|
79
|
+
function inlineMarker(macroName, params) {
|
|
80
|
+
const paramStr = params.length
|
|
81
|
+
? ':' + params.map((p) => `${escapeParamValue(p.name)}=${escapeParamValue(p.value)}`).join(':')
|
|
82
|
+
: '';
|
|
83
|
+
return `<!-- MACRO:start:${macroName}${paramStr} --><!-- MACRO:end:${macroName} -->`;
|
|
84
|
+
}
|
|
85
|
+
function blockMarker(macroName, params, body) {
|
|
86
|
+
const paramStr = params.length
|
|
87
|
+
? ':' + params.map((p) => `${escapeParamValue(p.name)}=${escapeParamValue(p.value)}`).join(':')
|
|
88
|
+
: '';
|
|
89
|
+
return `<!-- MACRO:start:${macroName}${paramStr} -->\n${body}\n<!-- MACRO:end:${macroName} -->`;
|
|
90
|
+
}
|
|
91
|
+
/** `a|b=c|d=e` → первый сегмент + пары; поведение как у parsePlaceholder. */
|
|
92
|
+
function splitAttrs(raw) {
|
|
93
|
+
const parts = raw.split('|');
|
|
94
|
+
const attrs = [];
|
|
95
|
+
for (const part of parts.slice(1)) {
|
|
96
|
+
const eq = part.indexOf('=');
|
|
97
|
+
if (eq === -1)
|
|
98
|
+
attrs.push({ name: part.trim(), value: '' });
|
|
99
|
+
else
|
|
100
|
+
attrs.push({ name: part.slice(0, eq).trim(), value: part.slice(eq + 1).trim() });
|
|
101
|
+
}
|
|
102
|
+
return { head: parts[0].trim(), attrs };
|
|
103
|
+
}
|
|
104
|
+
/** Параметры плейсхолдера → параметры макроса Confluence. */
|
|
105
|
+
function placeholderParams(kind, raw) {
|
|
106
|
+
if (raw === undefined || raw.trim() === '')
|
|
107
|
+
return [];
|
|
108
|
+
const { head, attrs } = splitAttrs(raw);
|
|
109
|
+
const params = [];
|
|
110
|
+
const headIsPair = head.includes('=');
|
|
111
|
+
if (headIsPair) {
|
|
112
|
+
const eq = head.indexOf('=');
|
|
113
|
+
attrs.unshift({ name: head.slice(0, eq).trim(), value: head.slice(eq + 1).trim() });
|
|
114
|
+
}
|
|
115
|
+
switch (kind) {
|
|
116
|
+
case 'jira':
|
|
117
|
+
if (!headIsPair && head !== '')
|
|
118
|
+
params.push({ name: 'key', value: head });
|
|
119
|
+
break;
|
|
120
|
+
case 'status':
|
|
121
|
+
if (!headIsPair && head !== '')
|
|
122
|
+
params.push({ name: 'title', value: head });
|
|
123
|
+
break;
|
|
124
|
+
case 'anchor':
|
|
125
|
+
if (!headIsPair && head !== '')
|
|
126
|
+
params.push({ name: 'name', value: head });
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
// toc/children/properties-report: только k=v-атрибуты.
|
|
130
|
+
if (!headIsPair && head !== '')
|
|
131
|
+
params.push({ name: head, value: '' });
|
|
132
|
+
}
|
|
133
|
+
for (const a of attrs) {
|
|
134
|
+
// jira: короткое `jql=` — алиас родного jqlQuery.
|
|
135
|
+
if (kind === 'jira' && a.name === 'jql')
|
|
136
|
+
params.push({ name: 'jqlQuery', value: a.value });
|
|
137
|
+
else
|
|
138
|
+
params.push(a);
|
|
139
|
+
}
|
|
140
|
+
return params;
|
|
141
|
+
}
|
|
142
|
+
function replaceInline(line) {
|
|
143
|
+
return line.replace(PLACEHOLDER_INLINE_RE, (_full, kind, raw) => inlineMarker(NATIVE_PLACEHOLDERS[kind], placeholderParams(kind, raw)));
|
|
144
|
+
}
|
|
145
|
+
/** Параметры директивы: токены `k=v` (значение можно в кавычках) + свободный текст → title. */
|
|
146
|
+
function directiveParams(name, rest) {
|
|
147
|
+
const params = [];
|
|
148
|
+
if (!rest)
|
|
149
|
+
return params;
|
|
150
|
+
const free = [];
|
|
151
|
+
const re = /([A-Za-z-]+)=("([^"]*)"|\S+)|(\S+)/g;
|
|
152
|
+
for (const m of rest.matchAll(re)) {
|
|
153
|
+
if (m[1])
|
|
154
|
+
params.push({ name: m[1], value: m[3] ?? m[2] });
|
|
155
|
+
else
|
|
156
|
+
free.push(m[4]);
|
|
157
|
+
}
|
|
158
|
+
if (free.length && name === 'expand')
|
|
159
|
+
params.unshift({ name: 'title', value: free.join(' ') });
|
|
160
|
+
return params;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Переписывает нативные конструкции в маркеры макросов. Идемпотентна для
|
|
164
|
+
* текста без нативного синтаксиса; содержимое fenced-код-блоков не трогает.
|
|
165
|
+
*/
|
|
166
|
+
export function nativeToMarkers(src) {
|
|
167
|
+
const lines = src.split('\n');
|
|
168
|
+
const out = [];
|
|
169
|
+
let fence = null;
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
const fm = FENCE_RE.exec(line);
|
|
173
|
+
if (fm) {
|
|
174
|
+
if (fence === null)
|
|
175
|
+
fence = fm[1][0];
|
|
176
|
+
else if (fm[1][0] === fence)
|
|
177
|
+
fence = null;
|
|
178
|
+
out.push(line);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (fence !== null) {
|
|
182
|
+
out.push(line);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// ── Панели: > [!TYPE] Заголовок? ─────────────────────────────────────
|
|
186
|
+
const am = ADMONITION_FIRST_RE.exec(line);
|
|
187
|
+
const macroName = am ? NATIVE_ADMONITIONS[am[1].toUpperCase()] : undefined;
|
|
188
|
+
if (am && macroName) {
|
|
189
|
+
const body = [];
|
|
190
|
+
let j = i + 1;
|
|
191
|
+
for (; j < lines.length && /^>( |$)/.test(lines[j]); j++) {
|
|
192
|
+
body.push(lines[j].replace(/^> ?/, ''));
|
|
193
|
+
}
|
|
194
|
+
const params = am[2].trim() ? [{ name: 'title', value: am[2].trim() }] : [];
|
|
195
|
+
out.push(blockMarker(macroName, params, nativeToMarkers(body.join('\n'))));
|
|
196
|
+
i = j - 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// ── Директивы: ::: name … / ::: ──────────────────────────────────────
|
|
200
|
+
const dm = DIRECTIVE_OPEN_RE.exec(line);
|
|
201
|
+
const dirMacro = dm ? NATIVE_DIRECTIVES[dm[1]] : undefined;
|
|
202
|
+
if (dm && dirMacro) {
|
|
203
|
+
const body = [];
|
|
204
|
+
let j = i + 1;
|
|
205
|
+
let innerFence = null;
|
|
206
|
+
let closed = false;
|
|
207
|
+
for (; j < lines.length; j++) {
|
|
208
|
+
const bl = lines[j];
|
|
209
|
+
const bfm = FENCE_RE.exec(bl);
|
|
210
|
+
if (bfm) {
|
|
211
|
+
if (innerFence === null)
|
|
212
|
+
innerFence = bfm[1][0];
|
|
213
|
+
else if (bfm[1][0] === innerFence)
|
|
214
|
+
innerFence = null;
|
|
215
|
+
}
|
|
216
|
+
else if (innerFence === null && DIRECTIVE_CLOSE_RE.test(bl)) {
|
|
217
|
+
closed = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
body.push(bl);
|
|
221
|
+
}
|
|
222
|
+
if (closed) {
|
|
223
|
+
out.push(blockMarker(dirMacro, directiveParams(dm[1], dm[2]), nativeToMarkers(body.join('\n'))));
|
|
224
|
+
i = j;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
// Незакрытая директива — оставляем как текст.
|
|
228
|
+
}
|
|
229
|
+
out.push(replaceInline(line));
|
|
230
|
+
}
|
|
231
|
+
return out.join('\n');
|
|
232
|
+
}
|
package/dist/markdown/render.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import MarkdownIt from 'markdown-it';
|
|
2
|
+
import { nativeToMarkers } from './native.js';
|
|
2
3
|
// xhtmlOut: true — Confluence storage format = XHTML, void-элементы
|
|
3
4
|
// (<hr/>, <br/>, <img/>) обязаны быть самозакрывающимися.
|
|
4
5
|
// html: true — разрешить HTML (нужно для <!-- MACRO:... --> комментариев)
|
|
@@ -106,6 +107,9 @@ export class MissingAttachmentUrlError extends Error {
|
|
|
106
107
|
* посимвольно.
|
|
107
108
|
*/
|
|
108
109
|
export function renderToStorage(markdown, urls, opts = {}) {
|
|
110
|
+
// Нативный md-синтаксис макросов (панели, ::: properties, {{toc}} …) —
|
|
111
|
+
// сахар над маркерами: переписываем до markdown-it (см. native.ts).
|
|
112
|
+
markdown = nativeToMarkers(markdown);
|
|
109
113
|
const renderer = opts.linkify === false ? mdNoLinkify : md;
|
|
110
114
|
let html = renderer.render(markdown);
|
|
111
115
|
// ```confluence-storage — транспорт для дословного XHTML (ac:/ri:-теги
|
package/package.json
CHANGED