confluence-md-sync 0.12.0 → 0.13.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.
@@ -357,6 +357,7 @@ class Converter {
357
357
  toc: 'toc', children: 'children', jira: 'jira', status: 'status',
358
358
  anchor: 'anchor', detailssummary: 'properties-report',
359
359
  'portfolio-for-jira-plan': 'portfolio-for-jira-plan',
360
+ 'table-excerpt-include': 'table-excerpt-include',
360
361
  };
361
362
  tryNativeMd(el, name) {
362
363
  const parts = this.macroParts(el);
@@ -368,6 +369,23 @@ class Converter {
368
369
  const pmap = new Map(params.map((p) => [p.name, p.value]));
369
370
  const oneline = (v) => v !== undefined && !/[\n|{}]/.test(v) && !v.includes('}}');
370
371
  let candidate = null;
372
+ if (['table-filter', 'table-excerpt', 'table-joiner'].includes(name) && richBody !== null) {
373
+ if (elements(el.children).filter(p => p.name === 'ac:parameter').some(p => elements(p.children).length))
374
+ return null;
375
+ if (params.some(p => !/^[\w-]+$/.test(p.name)))
376
+ return null;
377
+ const bodyMd = this.bodyMd(richBody);
378
+ if (bodyMd === null)
379
+ return null;
380
+ candidate = `::: ${name}${params.map(p => ` ${p.name}=${JSON.stringify(p.value)}`).join('')}\n${bodyMd}\n:::`;
381
+ if (this.verifyMacroMarker(el, candidate))
382
+ return candidate;
383
+ if (this.verifyTableMacro(el, candidate)) {
384
+ this.stats.normalized++;
385
+ return candidate;
386
+ }
387
+ return null;
388
+ }
371
389
  if ((name === 'drawio' || name === 'inc-drawio') && richBody === null) {
372
390
  const filename = pmap.get('diagramName');
373
391
  if (!filename || !oneline(filename) || /[/\\]/.test(filename))
@@ -449,7 +467,11 @@ class Converter {
449
467
  const ph = Converter.NATIVE_PLACEHOLDER_NAME[name];
450
468
  const attrs = [];
451
469
  let head = '';
452
- for (const p of params) {
470
+ const nativeParams = name === 'table-excerpt-include'
471
+ ? MACRO_EXTRACTORS[name]({ el, paramEls: elements(el.children).filter(p => p.name === 'ac:parameter'), richBody, plainBody })?.params : params;
472
+ if (!nativeParams)
473
+ return null;
474
+ for (const p of nativeParams) {
453
475
  const value = /[|{}\n\r]/.test(p.value) || /^"/.test(p.value) ? JSON.stringify(p.value) : p.value;
454
476
  if (name === 'jira' && p.name === 'key' && head === '') {
455
477
  head = p.value;
@@ -464,7 +486,7 @@ class Converter {
464
486
  continue;
465
487
  }
466
488
  const attrName = name === 'jira' && p.name === 'jqlQuery' ? 'jql' : p.name;
467
- if (!/^[A-Za-z-]+$/.test(attrName))
489
+ if (!/^[A-Za-z_][\w-]*$/.test(attrName))
468
490
  return null;
469
491
  attrs.push(`${attrName}=${value}`);
470
492
  }
@@ -733,6 +755,36 @@ class Converter {
733
755
  return false;
734
756
  }
735
757
  }
758
+ /** Accept editable GFM tables only when the surrounding macros/parameters and normalized cells match. */
759
+ verifyTableMacro(el, markdown) {
760
+ const saved = this.readable, stats = { ...this.stats };
761
+ try {
762
+ this.readable = true;
763
+ const render = (md) => processMacros(renderToStorage(md, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false }), this.registry).toString();
764
+ const normalize = (nodes) => nodes.flatMap(node => {
765
+ if (node.kind !== 'el')
766
+ return [node];
767
+ if (node.name === 'table') {
768
+ // Nested block macros require their own faithful conversion; never flatten their semantics.
769
+ const hasMacro = (ns) => ns.some(n => n.kind === 'el' && (n.name === 'ac:structured-macro' || hasMacro(n.children)));
770
+ if (hasMacro(node.children))
771
+ throw new Unrepresentable();
772
+ return parseStorage(render(this.readableTable(node)));
773
+ }
774
+ return [{ ...node, children: normalize(node.children) }];
775
+ });
776
+ return compareStorage(serializeStorage(normalize([el])), serializeStorage(normalize(parseStorage(render(markdown))))).equal;
777
+ }
778
+ catch (error) {
779
+ if (!(error instanceof Unrepresentable))
780
+ throw error;
781
+ return false;
782
+ }
783
+ finally {
784
+ this.readable = saved;
785
+ Object.assign(this.stats, stats);
786
+ }
787
+ }
736
788
  containsMacroNamed(nodes, name) {
737
789
  for (const n of nodes) {
738
790
  if (n.kind !== 'el')
@@ -1424,6 +1476,12 @@ class Converter {
1424
1476
  throw new Unrepresentable();
1425
1477
  }
1426
1478
  const textAttr = text !== undefined ? `|text=${text}` : '';
1479
+ if (ref.name === 'ri:user') {
1480
+ const identity = ref.attrs.find(([key]) => ['ri:userkey', 'ri:username', 'ri:account-id'].includes(key));
1481
+ if (!identity || ref.attrs.length !== 1)
1482
+ throw new Unrepresentable();
1483
+ return placeholder('user', decodeEntities(identity[1]), [['type', identity[0].slice(3)], ...(text === undefined ? [] : [['text', text]])]);
1484
+ }
1427
1485
  if (ref.name === 'ri:page') {
1428
1486
  if (!ref.attrs.every(([k]) => ['ri:content-title', 'ri:space-key', 'ri:version-at-save'].includes(k))) {
1429
1487
  throw new Unrepresentable();
@@ -63,13 +63,7 @@ export const tableFilterPlugin = {
63
63
  // Параметры: name — название выборки; hide — скрывать ли содержимое.
64
64
  name: 'table-excerpt',
65
65
  render: (ctx) => {
66
- const map = paramMap(ctx.params);
67
- const params = [];
68
- if (map.hide === 'true')
69
- params.push({ name: 'hide', value: 'true' });
70
- if (map.name)
71
- params.push({ name: 'name', value: map.name });
72
- return structuredMacro('table-excerpt', ctx.macroId, { params, richBody: ctx.body });
66
+ return structuredMacro('table-excerpt', ctx.macroId, { params: ctx.params, richBody: ctx.body });
73
67
  },
74
68
  },
75
69
  {
@@ -43,6 +43,9 @@ export const NATIVE_DIRECTIVES = {
43
43
  properties: 'details',
44
44
  expand: 'expand',
45
45
  panel: 'panel',
46
+ 'table-filter': 'table-filter',
47
+ 'table-excerpt': 'table-excerpt',
48
+ 'table-joiner': 'table-joiner',
46
49
  };
47
50
  /** Строчные плейсхолдеры: имя → имя макроса. */
48
51
  export const NATIVE_PLACEHOLDERS = {
@@ -56,6 +59,7 @@ export const NATIVE_PLACEHOLDERS = {
56
59
  anchor: 'anchor',
57
60
  'properties-report': 'detailssummary',
58
61
  'portfolio-for-jira-plan': 'portfolio-for-jira-plan',
62
+ 'table-excerpt-include': 'table-excerpt-include',
59
63
  };
60
64
  /** Полный перечень макросов с нативной md-разметкой (для документации/UI). */
61
65
  export function nativeMacroList() {
@@ -70,6 +74,8 @@ export function nativeMacroList() {
70
74
  { macro: 'details', syntax: '::: properties [id=…] [hidden=true] … :::' },
71
75
  { macro: 'expand', syntax: '::: expand Заголовок … :::' },
72
76
  { macro: 'panel', syntax: '::: panel title=… … :::' },
77
+ ...['table-filter', 'table-excerpt', 'table-joiner'].map(macro => ({ macro, syntax: `::: ${macro} name="…" … :::` })),
78
+ { macro: 'table-excerpt-include', syntax: '{{table-excerpt-include:name=…|page=…|space=…}}' },
73
79
  { macro: 'toc', syntax: '{{toc}} | {{toc:maxLevel=3}}' },
74
80
  { macro: 'children', syntax: '{{children}} | {{children:depth=2}}' },
75
81
  { macro: 'jira', syntax: '{{jira:KEY-1}} | {{jira:jql=…|maximumIssues=20}}' },
@@ -84,7 +84,7 @@ export function renderToStorage(markdown, urls, opts = {}) {
84
84
  while (markdown.includes(prefix))
85
85
  prefix += 'X';
86
86
  markdown = replaceInlineSyntax(markdown, ref => {
87
- if (!['img', 'file', 'page', 'note'].includes(ref.id) && !(ref.id in NATIVE_PLACEHOLDERS))
87
+ if (!['img', 'file', 'page', 'user', 'note'].includes(ref.id) && !(ref.id in NATIVE_PLACEHOLDERS))
88
88
  return;
89
89
  const line = markdown.slice(markdown.lastIndexOf('\n', ref.from - 1) + 1, ref.from);
90
90
  const cell = line.trimStart().startsWith('|');
@@ -96,6 +96,13 @@ export function renderToStorage(markdown, urls, opts = {}) {
96
96
  let html = renderer.render(markdown);
97
97
  const renderPlaceholder = (kind, body) => {
98
98
  const { name, attrs } = parsePlaceholder(body);
99
+ if (kind === 'user') {
100
+ const map = Object.fromEntries(attrs), type = map.type || 'userkey';
101
+ if (!['userkey', 'username', 'account-id'].includes(type))
102
+ throw new Error('Unknown Confluence user identity type');
103
+ const body = map.text === undefined ? '' : `<ac:plain-text-link-body><![CDATA[${map.text.replace(/]]>/g, ']]]]><![CDATA[>')}]]></ac:plain-text-link-body>`;
104
+ return `<ac:link><ri:user ri:${type}="${escapeXmlAttr(name)}" />${body}</ac:link>`;
105
+ }
99
106
  if (kind === 'page') {
100
107
  return renderPageLink(name, attrs);
101
108
  }
@@ -127,7 +134,7 @@ export function renderToStorage(markdown, urls, opts = {}) {
127
134
  const body = renderToStorage(note.text.replace(/\r?\n/g, '\n\n'), urls, opts);
128
135
  return renderInlineNote(body.trim(), note.attrs);
129
136
  }
130
- return ['img', 'file', 'page'].includes(ref.id) ? renderPlaceholder(ref.id, ref.body) : nativeToMarkers(ref.raw);
137
+ return ['img', 'file', 'page', 'user'].includes(ref.id) ? renderPlaceholder(ref.id, ref.body) : nativeToMarkers(ref.raw);
131
138
  });
132
139
  html = html.replace(/<p>((?:\s*<!-- MACRO:start:[^>]* -->\s*<!-- MACRO:end:[^>]* -->\s*)+)<\/p>/g, '$1');
133
140
  // ```confluence-storage — транспорт для дословного XHTML (ac:/ri:-теги
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",