confluence-md-sync 0.8.10 → 0.10.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.
@@ -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;
@@ -333,6 +336,7 @@ class Converter {
333
336
  info: 'INFO', note: 'NOTE', warning: 'WARNING', tip: 'TIP',
334
337
  };
335
338
  static NATIVE_PLACEHOLDER_NAME = {
339
+ search: 'search',
336
340
  toc: 'toc', children: 'children', jira: 'jira', status: 'status',
337
341
  anchor: 'anchor', detailssummary: 'properties-report',
338
342
  'portfolio-for-jira-plan': 'portfolio-for-jira-plan',
@@ -347,6 +351,22 @@ class Converter {
347
351
  const pmap = new Map(params.map((p) => [p.name, p.value]));
348
352
  const oneline = (v) => v !== undefined && !/[\n|{}]/.test(v) && !v.includes('}}');
349
353
  let candidate = null;
354
+ if ((name === 'drawio' || name === 'inc-drawio') && richBody === null) {
355
+ const filename = pmap.get('diagramName');
356
+ if (!filename || !oneline(filename) || /[/\\]/.test(filename))
357
+ return null;
358
+ if (params.some(p => !oneline(p.value) || /[|{}\n=]/.test(p.name)))
359
+ return null;
360
+ const attrs = params.filter(p => p.name !== 'diagramName').map(p => `|${p.name}=${p.value}`).join('');
361
+ candidate = `{{drawio:${filename}${attrs}}}`;
362
+ if (name === 'inc-drawio')
363
+ this.stats.normalized++;
364
+ else if (!this.readable && !this.verifyMacroMarker(el, candidate))
365
+ return null;
366
+ this.files.add(filename);
367
+ this.images.add(`${filename}.png`);
368
+ return candidate;
369
+ }
350
370
  const tag = Converter.NATIVE_ADMONITION_TAG[name];
351
371
  if (tag && richBody !== null) {
352
372
  // Панель: параметры — только title.
@@ -413,8 +433,7 @@ class Converter {
413
433
  const attrs = [];
414
434
  let head = '';
415
435
  for (const p of params) {
416
- if (!oneline(p.value) || p.value.includes('|'))
417
- return null;
436
+ const value = /[|{}\n\r]/.test(p.value) || /^"/.test(p.value) ? JSON.stringify(p.value) : p.value;
418
437
  if (name === 'jira' && p.name === 'key' && head === '') {
419
438
  head = p.value;
420
439
  continue;
@@ -430,7 +449,7 @@ class Converter {
430
449
  const attrName = name === 'jira' && p.name === 'jqlQuery' ? 'jql' : p.name;
431
450
  if (!/^[A-Za-z-]+$/.test(attrName))
432
451
  return null;
433
- attrs.push(`${attrName}=${p.value}`);
452
+ attrs.push(`${attrName}=${value}`);
434
453
  }
435
454
  const inner = [head, ...attrs].filter((s, i) => s !== '' || i > 0).join('|');
436
455
  candidate = inner === '' ? `{{${ph}}}` : `{{${ph}:${inner}}}`;
@@ -449,7 +468,9 @@ class Converter {
449
468
  for (const child of elements(el.children)) {
450
469
  if (child.name === 'ac:parameter') {
451
470
  const pname = getAttr(child, 'ac:name') ?? '';
452
- params.push({ name: pname, value: decodeEntities(textContent(child.children)) });
471
+ const space = getAttr(el, 'ac:name') === 'search' && pname === 'spacekey'
472
+ ? elements(child.children).find(n => n.name === 'ri:space') : undefined;
473
+ params.push({ name: pname, value: space ? getAttr(space, 'ri:space-key') ?? '' : decodeEntities(textContent(child.children)) });
453
474
  }
454
475
  else if (child.name === 'ac:rich-text-body')
455
476
  richBody = child;
@@ -1216,6 +1237,18 @@ class Converter {
1216
1237
  if (kids.length !== 1)
1217
1238
  throw new Unrepresentable();
1218
1239
  const ref = kids[0];
1240
+ const filename = getAttr(ref, 'ri:filename') ?? '';
1241
+ const stem = filename.replace(/\.png$/i, '');
1242
+ const drawioName = getAttr(el, 'ac:alt')?.match(/^drawio:(.+)$/)?.[1]
1243
+ ?? (stem !== filename && (this.drawioNames.has(stem) || /\.drawio$/i.test(stem)) ? stem : undefined);
1244
+ if (drawioName && ref.name === 'ri:attachment' && !/[{}|\n/\\]/.test(drawioName)) {
1245
+ const preview = getAttr(ref, 'ri:filename');
1246
+ if (preview && !/[{}|\n/\\]/.test(preview)) {
1247
+ this.files.add(drawioName);
1248
+ this.images.add(preview);
1249
+ return `{{drawio:${drawioName}|format=image${preview !== `${drawioName}.png` ? `|preview=${preview}` : ''}}}`;
1250
+ }
1251
+ }
1219
1252
  // attachments:'local' — картинка как HTML <img> (сохраняет размеры).
1220
1253
  // ri:attachment → локальный путь; ri:url → внешний URL как есть.
1221
1254
  if (this.localFiles && (ref.name === 'ri:attachment' || ref.name === 'ri:url')) {
@@ -1264,7 +1297,6 @@ class Converter {
1264
1297
  if (!ref.attrs.every(([k]) => k === 'ri:filename' || k === 'ri:version-at-save')) {
1265
1298
  throw new Unrepresentable();
1266
1299
  }
1267
- const filename = getAttr(ref, 'ri:filename') ?? '';
1268
1300
  const attrs = [];
1269
1301
  for (const [k] of el.attrs) {
1270
1302
  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,8 @@ 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';
10
+ export { searchPlugin } from './plugins/search.js';
9
11
  import { MacroRegistry } from './registry.js';
10
12
  import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePage, jiraIssue, panel, status, toc } from './plugins/core.js';
11
13
  import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
@@ -48,4 +50,6 @@ export declare const macros: {
48
50
  tableJoiner: typeof tableJoiner;
49
51
  csvTable: typeof csvTable;
50
52
  portfolioForJiraPlan: typeof portfolioForJiraPlan;
53
+ drawio: (name: string, opts?: Record<string, string | undefined>) => import("../index.js").Markdown;
54
+ search: (query: string, options?: Record<string, string | undefined>) => import("../index.js").Markdown;
51
55
  };
@@ -6,6 +6,8 @@ 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';
10
+ export { searchPlugin } from './plugins/search.js';
9
11
  import { MacroRegistry } from './registry.js';
10
12
  import { coreMacrosPlugin } from './plugins/core.js';
11
13
  import { tableFilterPlugin } from './plugins/table-filter.js';
@@ -15,13 +17,17 @@ import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePa
15
17
  import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
16
18
  import { csvTable } from './plugins/csv-table.js';
17
19
  import { portfolioForJiraPlan } from './plugins/portfolio-for-jira-plan.js';
20
+ import { drawio, drawioPlugin } from './plugins/drawio.js';
21
+ import { search, searchPlugin } from './plugins/search.js';
18
22
  /** Creates a registry pre-loaded with all built-in plugins. */
19
23
  export function createDefaultRegistry() {
20
24
  return new MacroRegistry()
21
25
  .use(coreMacrosPlugin)
22
26
  .use(tableFilterPlugin)
23
27
  .use(csvTablePlugin)
24
- .use(portfolioForJiraPlanPlugin);
28
+ .use(portfolioForJiraPlanPlugin)
29
+ .use(drawioPlugin)
30
+ .use(searchPlugin);
25
31
  }
26
32
  /**
27
33
  * Глобальный реестр по умолчанию (core + table-filter). Используется,
@@ -60,4 +66,6 @@ export const macros = {
60
66
  tableJoiner,
61
67
  csvTable,
62
68
  portfolioForJiraPlan,
69
+ drawio,
70
+ search,
63
71
  };
@@ -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,4 @@
1
+ import type { MacroPlugin } from '../types.js';
2
+ /** Search Results uses a space reference, not a text parameter, in storage. */
3
+ export declare const searchPlugin: MacroPlugin;
4
+ export declare const search: (query: string, options?: Record<string, string | undefined>) => import("../../index.js").Markdown;
@@ -0,0 +1,12 @@
1
+ import { macro } from '../builder.js';
2
+ import { escapeXmlAttr, structuredMacro } from '../xml.js';
3
+ /** Search Results uses a space reference, not a text parameter, in storage. */
4
+ export const searchPlugin = {
5
+ name: 'search',
6
+ macros: [{ name: 'search', render: ctx => structuredMacro('search', ctx.macroId, {
7
+ params: [...ctx.params].sort((a, b) => a.name.localeCompare(b.name)).map(p => p.name === 'spacekey' && p.value
8
+ ? { ...p, value: `<ri:space ri:space-key="${escapeXmlAttr(p.value)}" />`, raw: true }
9
+ : p),
10
+ }) }],
11
+ };
12
+ export const search = (query, options = {}) => macro('search').param('query', query).withParams(options).toMarkdown();
@@ -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,8 @@ export const NATIVE_DIRECTIVES = {
45
45
  };
46
46
  /** Строчные плейсхолдеры: имя → имя макроса. */
47
47
  export const NATIVE_PLACEHOLDERS = {
48
+ search: 'search',
49
+ drawio: 'drawio',
48
50
  toc: 'toc',
49
51
  children: 'children',
50
52
  jira: 'jira',
@@ -56,6 +58,8 @@ export const NATIVE_PLACEHOLDERS = {
56
58
  /** Полный перечень макросов с нативной md-разметкой (для документации/UI). */
57
59
  export function nativeMacroList() {
58
60
  return [
61
+ { macro: 'search', syntax: '{{search:query=…|spacekey=…|type=page|maxLimit=10}}' },
62
+ { macro: 'drawio', syntax: '{{drawio:diagram.drawio|format=macro}} | {{drawio:diagram.drawio|format=image}}' },
59
63
  { macro: 'info', syntax: '> [!INFO] Заголовок?' },
60
64
  { macro: 'note', syntax: '> [!NOTE] Заголовок?' },
61
65
  { macro: 'warning', syntax: '> [!WARNING] Заголовок?' },
@@ -75,7 +79,7 @@ export function nativeMacroList() {
75
79
  const ADMONITION_FIRST_RE = /^>\s*\[!([A-Za-z]+)\]\s*(.*)$/;
76
80
  const DIRECTIVE_OPEN_RE = /^:::\s+([a-z-]+)(?:\s+(.*?))?\s*$/;
77
81
  const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
78
- const PLACEHOLDER_INLINE_RE = /\{\{(toc|children|jira|status|anchor|properties-report|portfolio-for-jira-plan)(?::((?:[^{}]|\{[^{])*?))?\}\}/g;
82
+ const PLACEHOLDER_INLINE_RE = /\{\{(search|drawio|toc|children|jira|status|anchor|properties-report|portfolio-for-jira-plan)(?::((?:"(?:\\.|[^"\\])*"|[^{}"]|\{[^{])*?))?\}\}/g;
79
83
  const FENCE_RE = /^\s*(`{3,}|~{3,})/;
80
84
  /** Компактная пара маркеров без тела — для строчного контекста. */
81
85
  function inlineMarker(macroName, params) {
@@ -92,29 +96,53 @@ function blockMarker(macroName, params, body) {
92
96
  }
93
97
  /** `a|b=c|d=e` → первый сегмент + пары; поведение как у parsePlaceholder. */
94
98
  function splitAttrs(raw) {
95
- const parts = raw.split('|');
99
+ const parts = raw.match(/(?:"(?:\\.|[^"\\])*"|[^|"])+/g) ?? [];
100
+ const value = (text) => {
101
+ const trimmed = text.trim();
102
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
103
+ try {
104
+ return JSON.parse(trimmed);
105
+ }
106
+ catch { /* Unquoted legacy values remain literal. */ }
107
+ }
108
+ return trimmed;
109
+ };
96
110
  const attrs = [];
97
111
  for (const part of parts.slice(1)) {
98
112
  const eq = part.indexOf('=');
99
113
  if (eq === -1)
100
114
  attrs.push({ name: part.trim(), value: '' });
101
115
  else
102
- attrs.push({ name: part.slice(0, eq).trim(), value: part.slice(eq + 1).trim() });
116
+ attrs.push({ name: part.slice(0, eq).trim(), value: value(part.slice(eq + 1)) });
103
117
  }
104
- return { head: parts[0].trim(), attrs };
118
+ return { head: parts[0]?.trim() ?? '', attrs };
105
119
  }
106
120
  /** Параметры плейсхолдера → параметры макроса Confluence. */
107
121
  function placeholderParams(kind, raw) {
108
122
  if (raw === undefined || raw.trim() === '')
109
123
  return [];
110
124
  const { head, attrs } = splitAttrs(raw);
125
+ if (kind === 'drawio')
126
+ return [{ name: 'diagramName', value: head }, ...attrs];
111
127
  const params = [];
112
128
  const headIsPair = head.includes('=');
113
129
  if (headIsPair) {
114
130
  const eq = head.indexOf('=');
115
- attrs.unshift({ name: head.slice(0, eq).trim(), value: head.slice(eq + 1).trim() });
131
+ const v = head.slice(eq + 1).trim();
132
+ let decoded = v;
133
+ if (v.startsWith('"') && v.endsWith('"')) {
134
+ try {
135
+ decoded = JSON.parse(v);
136
+ }
137
+ catch { /* Legacy unquoted values remain literal. */ }
138
+ }
139
+ attrs.unshift({ name: head.slice(0, eq).trim(), value: decoded });
116
140
  }
117
141
  switch (kind) {
142
+ case 'search':
143
+ if (!headIsPair && head !== '')
144
+ params.push({ name: 'query', value: head });
145
+ break;
118
146
  case 'jira':
119
147
  if (!headIsPair && head !== '')
120
148
  params.push({ name: 'key', value: head });
@@ -150,12 +178,20 @@ function directiveParams(name, rest) {
150
178
  if (!rest)
151
179
  return params;
152
180
  const free = [];
153
- const re = /([A-Za-z-]+)=("([^"]*)"|\S+)|(\S+)/g;
181
+ const re = /([A-Za-z_][\w-]*)=("(?:\\.|[^"\\])*"|\S+)|(\S+)/g;
154
182
  for (const m of rest.matchAll(re)) {
155
- if (m[1])
156
- params.push({ name: m[1], value: m[3] ?? m[2] });
183
+ if (m[1]) {
184
+ let value = m[2];
185
+ if (value.startsWith('"') && value.endsWith('"')) {
186
+ try {
187
+ value = JSON.parse(value);
188
+ }
189
+ catch { /* Preserve malformed legacy values as text. */ }
190
+ }
191
+ params.push({ name: m[1], value });
192
+ }
157
193
  else
158
- free.push(m[4]);
194
+ free.push(m[3]);
159
195
  }
160
196
  if (free.length && name === 'expand')
161
197
  params.unshift({ name: 'title', value: free.join(' ') });
@@ -206,6 +242,7 @@ export function nativeToMarkers(src) {
206
242
  let j = i + 1;
207
243
  let innerFence = null;
208
244
  let closed = false;
245
+ let depth = 1;
209
246
  for (; j < lines.length; j++) {
210
247
  const bl = lines[j];
211
248
  const bfm = FENCE_RE.exec(bl);
@@ -215,9 +252,14 @@ export function nativeToMarkers(src) {
215
252
  else if (bfm[1][0] === innerFence)
216
253
  innerFence = null;
217
254
  }
218
- else if (innerFence === null && DIRECTIVE_CLOSE_RE.test(bl)) {
219
- closed = true;
220
- break;
255
+ else if (innerFence === null) {
256
+ const nested = DIRECTIVE_OPEN_RE.exec(bl);
257
+ if (nested && NATIVE_DIRECTIVES[nested[1]])
258
+ depth++;
259
+ else if (DIRECTIVE_CLOSE_RE.test(bl) && --depth === 0) {
260
+ closed = true;
261
+ break;
262
+ }
221
263
  }
222
264
  body.push(bl);
223
265
  }
@@ -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.10.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",