confluence-md-sync 0.8.10 → 0.9.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.
@@ -20,6 +20,7 @@ export interface ConfluenceAttachment {
20
20
  };
21
21
  extensions?: {
22
22
  fileSize?: number;
23
+ mediaType?: string;
23
24
  };
24
25
  /**
25
26
  * `_links.download` — относительный путь скачивания, который Confluence
@@ -1,6 +1,11 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
3
  import { authHeader } from './config.js';
4
+ function attachmentMime(name, bytes) {
5
+ if (/^\s*(?:<\?xml[^>]*>\s*)?<(mxfile|mxGraphModel)\b/.test(bytes.subarray(0, 4096).toString('utf8')))
6
+ return 'application/vnd.jgraph.mxfile';
7
+ return /\.png$/i.test(name) ? 'image/png' : 'application/octet-stream';
8
+ }
4
9
  export class ConfluenceApiError extends Error {
5
10
  status;
6
11
  statusText;
@@ -230,7 +235,7 @@ export class ConfluenceClient {
230
235
  const filename = basename(filePath);
231
236
  const buf = readFileSync(filePath);
232
237
  const form = new FormData();
233
- form.append('file', new Blob([buf]), filename);
238
+ form.append('file', new Blob([buf], { type: attachmentMime(filename, buf) }), filename);
234
239
  form.append('minorEdit', 'true');
235
240
  if (comment)
236
241
  form.append('comment', comment);
@@ -261,7 +266,7 @@ export class ConfluenceClient {
261
266
  const filename = basename(filePath);
262
267
  const buf = readFileSync(filePath);
263
268
  const form = new FormData();
264
- form.append('file', new Blob([buf]), filename);
269
+ form.append('file', new Blob([buf], { type: attachmentMime(filename, buf) }), filename);
265
270
  form.append('minorEdit', 'true');
266
271
  if (comment)
267
272
  form.append('comment', comment);
@@ -6,10 +6,12 @@ import { mkdirSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { ConfluenceClient } from '../client/client.js';
8
8
  import { storageToMarkdown } from './to-markdown.js';
9
+ import { drawioReferences, drawioAttachmentNames } from '../markdown/drawio.js';
9
10
  export async function exportPage(pageId, opts, cfg) {
10
11
  const client = new ConfluenceClient(cfg);
11
12
  const page = await client.getPageStorage(pageId);
12
13
  const converted = storageToMarkdown(page.storage, {
14
+ drawioNames: drawioAttachmentNames(await client.listAttachments(pageId)),
13
15
  registry: opts.registry,
14
16
  mode: opts.mode,
15
17
  attachments: opts.attachments,
@@ -23,13 +25,17 @@ export async function exportPage(pageId, opts, cfg) {
23
25
  const dir = join(dirname(markdownPath), 'attachments');
24
26
  mkdirSync(dir, { recursive: true });
25
27
  for (const name of converted.attachmentRefs) {
26
- const [att] = await client.listAttachments(pageId, name);
28
+ const diagram = drawioReferences(converted.markdown).find(d => d.name === name || d.preview === name);
29
+ const sourcePage = diagram?.pageId || pageId;
30
+ const [att] = await client.listAttachments(sourcePage, name);
27
31
  if (att === undefined) {
28
32
  console.warn(`[export] attachment '${name}' referenced by the page but not found`);
29
33
  continue;
30
34
  }
31
- const link = att._links?.download ?? `/download/attachments/${pageId}/${encodeURIComponent(name)}`;
32
- const data = await client.downloadAttachment(link);
35
+ const link = new URL(att._links?.download ?? `/download/attachments/${sourcePage}/${encodeURIComponent(name)}`, cfg.baseUrl);
36
+ if (diagram?.name === name && diagram.revision)
37
+ link.searchParams.set('version', diagram.revision);
38
+ const data = await client.downloadAttachment(link.href);
33
39
  const path = join(dir, name);
34
40
  writeFileSync(path, data);
35
41
  downloaded.set(name, path);
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import { type MacroRegistry } from '../macros/registry.js';
23
23
  export interface StorageToMarkdownOptions {
24
+ /** Known XML attachments; needed for image-mode diagrams without a .drawio suffix. */
25
+ drawioNames?: string[];
24
26
  /** Реестр для проверки маркеров макросов (default: встроенный). */
25
27
  registry?: MacroRegistry;
26
28
  /**
@@ -39,6 +39,7 @@ export function storageToMarkdown(storage, opts = {}) {
39
39
  localFiles: local,
40
40
  tablesAsRecords: records,
41
41
  localPrefix: opts.localPrefix ?? 'attachments/',
42
+ drawioNames: opts.drawioNames ?? [],
42
43
  });
43
44
  const markdown = conv.blocksToMd(parseStorage(storage));
44
45
  const attachmentRefs = new Set([...conv.images, ...conv.files]);
@@ -78,6 +79,7 @@ const UNWRAP_BLOCK = new Set([
78
79
  const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
79
80
  class Converter {
80
81
  registry;
82
+ drawioNames;
81
83
  images = new Set();
82
84
  files = new Set();
83
85
  stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0, normalized: 0 };
@@ -87,6 +89,7 @@ class Converter {
87
89
  localPrefix;
88
90
  constructor(registry, opts) {
89
91
  this.registry = registry;
92
+ this.drawioNames = new Set(opts.drawioNames);
90
93
  this.readable = opts.readable;
91
94
  this.localFiles = opts.localFiles;
92
95
  this.tablesAsRecords = opts.tablesAsRecords;
@@ -347,6 +350,22 @@ class Converter {
347
350
  const pmap = new Map(params.map((p) => [p.name, p.value]));
348
351
  const oneline = (v) => v !== undefined && !/[\n|{}]/.test(v) && !v.includes('}}');
349
352
  let candidate = null;
353
+ if ((name === 'drawio' || name === 'inc-drawio') && richBody === null) {
354
+ const filename = pmap.get('diagramName');
355
+ if (!filename || !oneline(filename) || /[/\\]/.test(filename))
356
+ return null;
357
+ if (params.some(p => !oneline(p.value) || /[|{}\n=]/.test(p.name)))
358
+ return null;
359
+ const attrs = params.filter(p => p.name !== 'diagramName').map(p => `|${p.name}=${p.value}`).join('');
360
+ candidate = `{{drawio:${filename}${attrs}}}`;
361
+ if (name === 'inc-drawio')
362
+ this.stats.normalized++;
363
+ else if (!this.readable && !this.verifyMacroMarker(el, candidate))
364
+ return null;
365
+ this.files.add(filename);
366
+ this.images.add(`${filename}.png`);
367
+ return candidate;
368
+ }
350
369
  const tag = Converter.NATIVE_ADMONITION_TAG[name];
351
370
  if (tag && richBody !== null) {
352
371
  // Панель: параметры — только title.
@@ -1216,6 +1235,18 @@ class Converter {
1216
1235
  if (kids.length !== 1)
1217
1236
  throw new Unrepresentable();
1218
1237
  const ref = kids[0];
1238
+ const filename = getAttr(ref, 'ri:filename') ?? '';
1239
+ const stem = filename.replace(/\.png$/i, '');
1240
+ const drawioName = getAttr(el, 'ac:alt')?.match(/^drawio:(.+)$/)?.[1]
1241
+ ?? (stem !== filename && (this.drawioNames.has(stem) || /\.drawio$/i.test(stem)) ? stem : undefined);
1242
+ if (drawioName && ref.name === 'ri:attachment' && !/[{}|\n/\\]/.test(drawioName)) {
1243
+ const preview = getAttr(ref, 'ri:filename');
1244
+ if (preview && !/[{}|\n/\\]/.test(preview)) {
1245
+ this.files.add(drawioName);
1246
+ this.images.add(preview);
1247
+ return `{{drawio:${drawioName}|format=image${preview !== `${drawioName}.png` ? `|preview=${preview}` : ''}}}`;
1248
+ }
1249
+ }
1219
1250
  // attachments:'local' — картинка как HTML <img> (сохраняет размеры).
1220
1251
  // ri:attachment → локальный путь; ri:url → внешний URL как есть.
1221
1252
  if (this.localFiles && (ref.name === 'ri:attachment' || ref.name === 'ri:url')) {
@@ -1264,7 +1295,6 @@ class Converter {
1264
1295
  if (!ref.attrs.every(([k]) => k === 'ri:filename' || k === 'ri:version-at-save')) {
1265
1296
  throw new Unrepresentable();
1266
1297
  }
1267
- const filename = getAttr(ref, 'ri:filename') ?? '';
1268
1298
  const attrs = [];
1269
1299
  for (const [k] of el.attrs) {
1270
1300
  if (!k.startsWith('ac:'))
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 { drawioReferences, drawioAttachmentNames, type DrawioReference } from './markdown/drawio.js';
6
7
  export { nativeToMarkers, nativeMacroList, NATIVE_ADMONITIONS, NATIVE_DIRECTIVES, NATIVE_PLACEHOLDERS, } from './markdown/native.js';
7
8
  export * from './macros/index.js';
8
9
  export { convertBpmn, convertBpmnFolder, isBpmnFile, bpmnOutputName, BPMN_FILE_RE, type BpmnConversion, type BpmnImageFormat, type ConvertBpmnFolderOptions, } from './bpmn/convert.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 { drawioReferences, drawioAttachmentNames } from './markdown/drawio.js';
8
9
  export { nativeToMarkers, nativeMacroList, NATIVE_ADMONITIONS, NATIVE_DIRECTIVES, NATIVE_PLACEHOLDERS, } from './markdown/native.js';
9
10
  // Macros (pluggable)
10
11
  export * from './macros/index.js';
@@ -6,6 +6,7 @@ export { coreMacrosPlugin, extractPlainText } from './plugins/core.js';
6
6
  export { tableFilterPlugin, TABLE_FILTER_DEFAULTS } from './plugins/table-filter.js';
7
7
  export { csvTablePlugin, CSV_TABLE_DEFAULTS } from './plugins/csv-table.js';
8
8
  export { portfolioForJiraPlanPlugin } from './plugins/portfolio-for-jira-plan.js';
9
+ export { drawioPlugin } from './plugins/drawio.js';
9
10
  import { MacroRegistry } from './registry.js';
10
11
  import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePage, jiraIssue, panel, status, toc } from './plugins/core.js';
11
12
  import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
@@ -48,4 +49,5 @@ export declare const macros: {
48
49
  tableJoiner: typeof tableJoiner;
49
50
  csvTable: typeof csvTable;
50
51
  portfolioForJiraPlan: typeof portfolioForJiraPlan;
52
+ drawio: (name: string, opts?: Record<string, string | undefined>) => import("../index.js").Markdown;
51
53
  };
@@ -6,6 +6,7 @@ export { coreMacrosPlugin, extractPlainText } from './plugins/core.js';
6
6
  export { tableFilterPlugin, TABLE_FILTER_DEFAULTS } from './plugins/table-filter.js';
7
7
  export { csvTablePlugin, CSV_TABLE_DEFAULTS } from './plugins/csv-table.js';
8
8
  export { portfolioForJiraPlanPlugin } from './plugins/portfolio-for-jira-plan.js';
9
+ export { drawioPlugin } from './plugins/drawio.js';
9
10
  import { MacroRegistry } from './registry.js';
10
11
  import { coreMacrosPlugin } from './plugins/core.js';
11
12
  import { tableFilterPlugin } from './plugins/table-filter.js';
@@ -15,13 +16,15 @@ import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePa
15
16
  import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
16
17
  import { csvTable } from './plugins/csv-table.js';
17
18
  import { portfolioForJiraPlan } from './plugins/portfolio-for-jira-plan.js';
19
+ import { drawio, drawioPlugin } from './plugins/drawio.js';
18
20
  /** Creates a registry pre-loaded with all built-in plugins. */
19
21
  export function createDefaultRegistry() {
20
22
  return new MacroRegistry()
21
23
  .use(coreMacrosPlugin)
22
24
  .use(tableFilterPlugin)
23
25
  .use(csvTablePlugin)
24
- .use(portfolioForJiraPlanPlugin);
26
+ .use(portfolioForJiraPlanPlugin)
27
+ .use(drawioPlugin);
25
28
  }
26
29
  /**
27
30
  * Глобальный реестр по умолчанию (core + table-filter). Используется,
@@ -60,4 +63,5 @@ export const macros = {
60
63
  tableJoiner,
61
64
  csvTable,
62
65
  portfolioForJiraPlan,
66
+ drawio,
63
67
  };
@@ -0,0 +1,6 @@
1
+ import { type MacroPlugin } from '../types.js';
2
+ /** XML keeps its attachment name, including extensionless Confluence diagrams. */
3
+ export declare const drawioPlugin: MacroPlugin;
4
+ export declare const drawio: (name: string, opts?: Record<string, string | undefined>) => import("../../index.js").Markdown;
5
+ /** Published attachments belong to the target page and use its latest version. */
6
+ export declare function unpinDrawioStorage(storage: string): string;
@@ -0,0 +1,24 @@
1
+ import { macro } from '../builder.js';
2
+ import { paramMap } from '../types.js';
3
+ import { escapeXmlAttr, structuredMacro } from '../xml.js';
4
+ /** XML keeps its attachment name, including extensionless Confluence diagrams. */
5
+ export const drawioPlugin = {
6
+ name: 'drawio',
7
+ macros: [{ name: 'drawio', render(ctx) {
8
+ const params = paramMap(ctx.params);
9
+ const name = params.diagramName;
10
+ if (!name)
11
+ throw new Error('drawio: diagramName is required');
12
+ if (params.format === 'image') {
13
+ return `<ac:image><ri:attachment ri:filename="${escapeXmlAttr(params.preview || `${name}.png`)}" /></ac:image>`;
14
+ }
15
+ if (params.format && params.format !== 'macro')
16
+ throw new Error('drawio: format must be macro or image');
17
+ return structuredMacro('drawio', ctx.macroId, { params: ctx.params.filter(p => p.name !== 'format' && p.name !== 'preview').sort((a, b) => a.name.localeCompare(b.name)) });
18
+ } }],
19
+ };
20
+ export const drawio = (name, opts = {}) => macro('drawio').param('diagramName', name).withParams(opts).toMarkdown();
21
+ /** Published attachments belong to the target page and use its latest version. */
22
+ export function unpinDrawioStorage(storage) {
23
+ return storage.replace(/<ac:structured-macro\b[^>]*ac:name="drawio"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, block => block.replace(/<ac:parameter ac:name="(?:revision|pageId)">[\s\S]*?<\/ac:parameter>/g, ''));
24
+ }
@@ -0,0 +1,15 @@
1
+ export interface DrawioReference {
2
+ name: string;
3
+ preview: string;
4
+ format: 'macro' | 'image';
5
+ revision?: string;
6
+ pageId?: string;
7
+ }
8
+ export declare function drawioAttachmentNames(attachments: {
9
+ title: string;
10
+ extensions?: {
11
+ mediaType?: string;
12
+ };
13
+ }[]): string[];
14
+ /** Skip code examples; ordinary and table-cell references remain attachments. */
15
+ export declare function drawioReferences(markdown: string): DrawioReference[];
@@ -0,0 +1,28 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import { parsePlaceholder } from './render.js';
3
+ export function drawioAttachmentNames(attachments) {
4
+ return attachments.filter(a => a.extensions?.mediaType === 'application/vnd.jgraph.mxfile' || /\.drawio$/i.test(a.title)).map(a => a.title);
5
+ }
6
+ /** Skip code examples; ordinary and table-cell references remain attachments. */
7
+ export function drawioReferences(markdown) {
8
+ const refs = [];
9
+ const tokens = new MarkdownIt().parse(markdown, {});
10
+ for (const token of tokens)
11
+ for (const child of token.children ?? []) {
12
+ if (child.type !== 'text')
13
+ continue;
14
+ for (const match of child.content.matchAll(/\{\{drawio:([^}]+)\}\}/g)) {
15
+ const { name, attrs } = parsePlaceholder(match[1]);
16
+ const opts = Object.fromEntries(attrs);
17
+ if (!name || /[/\\]/.test(name) || name === '.' || name === '..')
18
+ throw new Error('drawio: invalid attachment filename');
19
+ if (opts.format && !['macro', 'image'].includes(opts.format))
20
+ throw new Error('drawio: format must be macro or image');
21
+ const preview = opts.preview || `${name}.png`;
22
+ if (/[/\\]/.test(preview) || preview === '.' || preview === '..')
23
+ throw new Error('drawio: invalid preview filename');
24
+ refs.push({ name, preview, format: opts.format === 'image' ? 'image' : 'macro', revision: opts.revision, pageId: opts.pageId });
25
+ }
26
+ }
27
+ return refs;
28
+ }
@@ -45,6 +45,7 @@ export const NATIVE_DIRECTIVES = {
45
45
  };
46
46
  /** Строчные плейсхолдеры: имя → имя макроса. */
47
47
  export const NATIVE_PLACEHOLDERS = {
48
+ drawio: 'drawio',
48
49
  toc: 'toc',
49
50
  children: 'children',
50
51
  jira: 'jira',
@@ -56,6 +57,7 @@ export const NATIVE_PLACEHOLDERS = {
56
57
  /** Полный перечень макросов с нативной md-разметкой (для документации/UI). */
57
58
  export function nativeMacroList() {
58
59
  return [
60
+ { macro: 'drawio', syntax: '{{drawio:diagram.drawio|format=macro}} | {{drawio:diagram.drawio|format=image}}' },
59
61
  { macro: 'info', syntax: '> [!INFO] Заголовок?' },
60
62
  { macro: 'note', syntax: '> [!NOTE] Заголовок?' },
61
63
  { macro: 'warning', syntax: '> [!WARNING] Заголовок?' },
@@ -75,7 +77,7 @@ export function nativeMacroList() {
75
77
  const ADMONITION_FIRST_RE = /^>\s*\[!([A-Za-z]+)\]\s*(.*)$/;
76
78
  const DIRECTIVE_OPEN_RE = /^:::\s+([a-z-]+)(?:\s+(.*?))?\s*$/;
77
79
  const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
78
- const PLACEHOLDER_INLINE_RE = /\{\{(toc|children|jira|status|anchor|properties-report|portfolio-for-jira-plan)(?::((?:[^{}]|\{[^{])*?))?\}\}/g;
80
+ const PLACEHOLDER_INLINE_RE = /\{\{(drawio|toc|children|jira|status|anchor|properties-report|portfolio-for-jira-plan)(?::((?:[^{}]|\{[^{])*?))?\}\}/g;
79
81
  const FENCE_RE = /^\s*(`{3,}|~{3,})/;
80
82
  /** Компактная пара маркеров без тела — для строчного контекста. */
81
83
  function inlineMarker(macroName, params) {
@@ -108,6 +110,8 @@ function placeholderParams(kind, raw) {
108
110
  if (raw === undefined || raw.trim() === '')
109
111
  return [];
110
112
  const { head, attrs } = splitAttrs(raw);
113
+ if (kind === 'drawio')
114
+ return [{ name: 'diagramName', value: head }, ...attrs];
111
115
  const params = [];
112
116
  const headIsPair = head.includes('=');
113
117
  if (headIsPair) {
@@ -206,6 +210,7 @@ export function nativeToMarkers(src) {
206
210
  let j = i + 1;
207
211
  let innerFence = null;
208
212
  let closed = false;
213
+ let depth = 1;
209
214
  for (; j < lines.length; j++) {
210
215
  const bl = lines[j];
211
216
  const bfm = FENCE_RE.exec(bl);
@@ -215,9 +220,14 @@ export function nativeToMarkers(src) {
215
220
  else if (bfm[1][0] === innerFence)
216
221
  innerFence = null;
217
222
  }
218
- else if (innerFence === null && DIRECTIVE_CLOSE_RE.test(bl)) {
219
- closed = true;
220
- break;
223
+ else if (innerFence === null) {
224
+ const nested = DIRECTIVE_OPEN_RE.exec(bl);
225
+ if (nested && NATIVE_DIRECTIVES[nested[1]])
226
+ depth++;
227
+ else if (DIRECTIVE_CLOSE_RE.test(bl) && --depth === 0) {
228
+ closed = true;
229
+ break;
230
+ }
221
231
  }
222
232
  body.push(bl);
223
233
  }
@@ -1,5 +1,6 @@
1
1
  import { basename } from 'node:path';
2
2
  import { extractPlaceholders } from './render.js';
3
+ import { drawioReferences } from './drawio.js';
3
4
  export class MarkdownValidationError extends Error {
4
5
  constructor(message) {
5
6
  super(message);
@@ -8,6 +9,10 @@ export class MarkdownValidationError extends Error {
8
9
  }
9
10
  export function validateMarkdown(opts) {
10
11
  const { images, files } = extractPlaceholders(opts.markdown);
12
+ for (const diagram of drawioReferences(opts.markdown)) {
13
+ files.push(diagram.name);
14
+ images.push(diagram.preview);
15
+ }
11
16
  const tables = extractTablePlaceholders(opts.markdown);
12
17
  const providedImages = new Set(opts.imagePaths.map((p) => basename(p)));
13
18
  const providedFiles = new Set(opts.filePaths.map((p) => basename(p)));
@@ -12,6 +12,7 @@ import { applyManagedNotice } from './notice.js';
12
12
  import { processMacros } from '../macros/registry.js';
13
13
  import { defaultMacroRegistry } from '../macros/index.js';
14
14
  import { Markdown } from '../markdown/markdown.js';
15
+ import { unpinDrawioStorage } from '../macros/plugins/drawio.js';
15
16
  // Content property с SHA-256 от рендеренного storage. Хранится отдельно
16
17
  // от body через /rest/api/content/{id}/property/{key}, поэтому:
17
18
  // - не попадает в HTML страницы (пользователь не видит);
@@ -200,6 +201,7 @@ export async function publishPage(opts, cfg) {
200
201
  let storage = renderToStorage(markdown, urls, opts.render ?? {});
201
202
  // 2.5. Преобразование маркеров макросов в XHTML.
202
203
  storage = processMacros(storage, registry).toString();
204
+ storage = unpinDrawioStorage(storage);
203
205
  // 2.6. Баннер «страница управляется извне». Вставляется здесь, в storage —
204
206
  // не в markdown — поэтому источник/round-trip его не содержат. Баннер
205
207
  // детерминирован (фиксированный macro-id), значит попадает в content-hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.8.10",
3
+ "version": "0.9.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",