confluence-md-sync 0.1.1 → 0.2.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
@@ -95,6 +95,49 @@ const { storage } = await publishPage({ pageId, markdownPath, dryRun: true }, cf
95
95
  await publishPage({ pageId, markdown: '# Generated\n\ntext' }, cfg);
96
96
  ```
97
97
 
98
+ ## BPMN diagrams out of the box
99
+
100
+ Pass a `.bpmn` file as an image — it is rendered to PNG at publish time
101
+ (headless Chromium via [bpmn-to-image](https://npmjs.com/package/bpmn-to-image),
102
+ an optional peer dependency):
103
+
104
+ ```bash
105
+ npm install -D bpmn-to-image
106
+ ```
107
+
108
+ > bpmn-to-image pins puppeteer 21, whose bundled Chromium fails to launch on
109
+ > recent OSes ("socket hang up"). Force a current puppeteer in your
110
+ > `package.json`:
111
+ >
112
+ > ```json
113
+ > "overrides": { "puppeteer": "^24.0.0" }
114
+ > ```
115
+
116
+ ```markdown
117
+ Процесс выпуска релиза:
118
+
119
+ {{img:release-flow.bpmn}}
120
+ ```
121
+
122
+ ```ts
123
+ await publishPage({
124
+ pageId: '123456789',
125
+ markdownPath: 'docs/process.md',
126
+ images: ['docs/release-flow.bpmn'], // converted to release-flow.png automatically
127
+ bpmnOutDir: 'build', // optional; default: temp dir per run
128
+ }, cfg);
129
+ ```
130
+
131
+ Chromium PNG output is not byte-stable between runs, so dedup is pinned to
132
+ the SHA-256 of the *source* `.bpmn` via a `.src-sha256` sidecar — unchanged
133
+ diagrams never create new attachment versions. Batch pre-conversion is also
134
+ available:
135
+
136
+ ```ts
137
+ import { convertBpmnFolder } from 'confluence-md-sync';
138
+ await convertBpmnFolder({ srcDir: 'docs/diagrams', outDir: 'build' });
139
+ ```
140
+
98
141
  ## Read pages and tables
99
142
 
100
143
  ```ts
@@ -0,0 +1,46 @@
1
+ /**
2
+ * BPMN → image conversion (out-of-the-box BPMN support).
3
+ *
4
+ * Тяжёлые зависимости (`bpmn-to-image` + puppeteer/Chromium) — опциональный
5
+ * peer dependency: импортируются динамически только когда конвертация
6
+ * реально запрошена. Ядро библиотеки остаётся лёгким.
7
+ *
8
+ * npm install -D bpmn-to-image
9
+ */
10
+ export type BpmnImageFormat = 'png' | 'svg' | 'pdf';
11
+ export declare const BPMN_FILE_RE: RegExp;
12
+ export declare function isBpmnFile(path: string): boolean;
13
+ /** `p1.bpmn` → `p1.png` (имя выходного файла для формата). */
14
+ export declare function bpmnOutputName(file: string, format?: BpmnImageFormat): string;
15
+ export interface BpmnConversion {
16
+ input: string;
17
+ output: string;
18
+ }
19
+ /**
20
+ * Конвертирует BPMN-файлы в изображения по явному списку input → output.
21
+ *
22
+ * Рядом с каждым результатом пишется sidecar `<output>.src-sha256` с SHA-256
23
+ * исходного BPMN — AttachmentService использует его как dedup-tag вместо SHA
24
+ * результата (PNG от Chromium байт-нестабилен между запусками, BPMN —
25
+ * стабилен). Так Confluence не плодит лишние версии аттача, когда диаграмма
26
+ * не менялась.
27
+ */
28
+ export declare function convertBpmn(conversions: BpmnConversion[]): Promise<void>;
29
+ export interface ConvertBpmnFolderOptions {
30
+ /** Папка-источник с `*.bpmn`. */
31
+ srcDir: string;
32
+ /** Папка-приёмник для сгенерированных изображений. Будет создана, если её нет. */
33
+ outDir: string;
34
+ /** Формат выхода. Дефолт — `png`. */
35
+ format?: BpmnImageFormat;
36
+ /** Регулярка для фильтра входов. Дефолт — `/\.bpmn$/i`. */
37
+ pattern?: RegExp;
38
+ }
39
+ /**
40
+ * Конвертирует все BPMN-файлы из `srcDir` в изображения в `outDir`.
41
+ * Файлы получают то же базовое имя с расширением формата (`p1.bpmn` → `p1.png`).
42
+ *
43
+ * Сгенерированные файлы — артефакты сборки: коммитить их не нужно.
44
+ * В Confluence они улетают как аттачи на этапе публикации.
45
+ */
46
+ export declare function convertBpmnFolder(opts: ConvertBpmnFolderOptions): Promise<BpmnConversion[]>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * BPMN → image conversion (out-of-the-box BPMN support).
3
+ *
4
+ * Тяжёлые зависимости (`bpmn-to-image` + puppeteer/Chromium) — опциональный
5
+ * peer dependency: импортируются динамически только когда конвертация
6
+ * реально запрошена. Ядро библиотеки остаётся лёгким.
7
+ *
8
+ * npm install -D bpmn-to-image
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { basename, dirname, extname, join } from 'node:path';
13
+ import { SRC_SHA_SIDECAR_SUFFIX } from '../attachments/attachment.js';
14
+ export const BPMN_FILE_RE = /\.bpmn$/i;
15
+ export function isBpmnFile(path) {
16
+ return BPMN_FILE_RE.test(path);
17
+ }
18
+ /** `p1.bpmn` → `p1.png` (имя выходного файла для формата). */
19
+ export function bpmnOutputName(file, format = 'png') {
20
+ return `${basename(file, extname(file))}.${format}`;
21
+ }
22
+ let puppeteerPatched = false;
23
+ /**
24
+ * bpmn-to-image вызывает puppeteer.launch() без флагов. В CI Chromium
25
+ * запускается из-под root и без --no-sandbox просто не стартует. Патчим
26
+ * глобальный puppeteer.launch один раз — best-effort: если puppeteer
27
+ * не резолвится из нашего контекста, bpmn-to-image разберётся сам.
28
+ */
29
+ async function patchPuppeteerForCI() {
30
+ if (puppeteerPatched)
31
+ return;
32
+ puppeteerPatched = true;
33
+ try {
34
+ const { default: puppeteer } = await import('puppeteer');
35
+ const origLaunch = puppeteer.launch.bind(puppeteer);
36
+ puppeteer.launch = ((options = {}) => {
37
+ const existing = options?.args ?? [];
38
+ const patched = [
39
+ ...existing,
40
+ '--no-sandbox',
41
+ '--disable-setuid-sandbox',
42
+ '--disable-dev-shm-usage',
43
+ ];
44
+ return origLaunch({ ...options, args: patched });
45
+ });
46
+ }
47
+ catch {
48
+ // puppeteer не установлен отдельно — положимся на bpmn-to-image
49
+ }
50
+ }
51
+ async function loadConverter() {
52
+ try {
53
+ const mod = await import('bpmn-to-image');
54
+ await patchPuppeteerForCI();
55
+ return mod;
56
+ }
57
+ catch (err) {
58
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
59
+ throw new Error("BPMN conversion requires the optional peer dependency 'bpmn-to-image'. " +
60
+ 'Install it with: npm install -D bpmn-to-image');
61
+ }
62
+ throw err;
63
+ }
64
+ }
65
+ /**
66
+ * Конвертирует BPMN-файлы в изображения по явному списку input → output.
67
+ *
68
+ * Рядом с каждым результатом пишется sidecar `<output>.src-sha256` с SHA-256
69
+ * исходного BPMN — AttachmentService использует его как dedup-tag вместо SHA
70
+ * результата (PNG от Chromium байт-нестабилен между запусками, BPMN —
71
+ * стабилен). Так Confluence не плодит лишние версии аттача, когда диаграмма
72
+ * не менялась.
73
+ */
74
+ export async function convertBpmn(conversions) {
75
+ if (conversions.length === 0)
76
+ return;
77
+ const { convertAll } = await loadConverter();
78
+ for (const c of conversions) {
79
+ mkdirSync(dirname(c.output), { recursive: true });
80
+ }
81
+ console.log(`[bpmn] converting ${conversions.length} diagram(s)`);
82
+ await convertAll(conversions.map((c) => ({ input: c.input, outputs: [c.output] })));
83
+ for (const c of conversions) {
84
+ const sha = createHash('sha256').update(readFileSync(c.input)).digest('hex');
85
+ writeFileSync(c.output + SRC_SHA_SIDECAR_SUFFIX, sha);
86
+ }
87
+ }
88
+ /**
89
+ * Конвертирует все BPMN-файлы из `srcDir` в изображения в `outDir`.
90
+ * Файлы получают то же базовое имя с расширением формата (`p1.bpmn` → `p1.png`).
91
+ *
92
+ * Сгенерированные файлы — артефакты сборки: коммитить их не нужно.
93
+ * В Confluence они улетают как аттачи на этапе публикации.
94
+ */
95
+ export async function convertBpmnFolder(opts) {
96
+ const format = opts.format ?? 'png';
97
+ const pattern = opts.pattern ?? BPMN_FILE_RE;
98
+ const files = readdirSync(opts.srcDir).filter((f) => pattern.test(f));
99
+ if (files.length === 0) {
100
+ console.log(`[bpmn] no files matching ${pattern} in ${opts.srcDir}`);
101
+ return [];
102
+ }
103
+ const conversions = files.map((f) => ({
104
+ input: join(opts.srcDir, f),
105
+ output: join(opts.outDir, bpmnOutputName(f, format)),
106
+ }));
107
+ await convertBpmn(conversions);
108
+ return conversions;
109
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export { ConfluenceClient, ConfluenceApiError, type ConfluencePage, type ConfluencePageStorage, type ConfluenceAttachment, type ConfluenceLabel, type AttachmentVersionData, type CreatePageOptions, } from './client/client.js';
2
2
  export { authHeader, loadConfigFromEnv, type ConfluenceAuthType, type ConfluenceConfig, type LoadConfigOptions, } from './client/config.js';
3
3
  export { Markdown } from './markdown/markdown.js';
4
- export { renderToStorage, extractPlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, } from './markdown/render.js';
4
+ export { renderToStorage, extractPlaceholders, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, } from './markdown/render.js';
5
5
  export { validateMarkdown, MarkdownValidationError, type ValidateOptions } from './markdown/validate.js';
6
6
  export * from './macros/index.js';
7
+ export { convertBpmn, convertBpmnFolder, isBpmnFile, bpmnOutputName, BPMN_FILE_RE, type BpmnConversion, type BpmnImageFormat, type ConvertBpmnFolderOptions, } from './bpmn/convert.js';
7
8
  export { fileSha256, HASH_TAG_PREFIX } from './attachments/hash.js';
8
9
  export { Attachment, AttachmentService, toAttachmentVersion, SRC_SHA_SIDECAR_SUFFIX, type AttachmentVersion, type EnsuredAttachment, } from './attachments/attachment.js';
9
10
  export { Page, Table } from './pages/page.js';
package/dist/index.js CHANGED
@@ -3,10 +3,12 @@ export { ConfluenceClient, ConfluenceApiError, } from './client/client.js';
3
3
  export { authHeader, loadConfigFromEnv, } from './client/config.js';
4
4
  // Markdown
5
5
  export { Markdown } from './markdown/markdown.js';
6
- export { renderToStorage, extractPlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
6
+ export { renderToStorage, extractPlaceholders, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
7
7
  export { validateMarkdown, MarkdownValidationError } from './markdown/validate.js';
8
8
  // Macros (pluggable)
9
9
  export * from './macros/index.js';
10
+ // BPMN (optional peer dep 'bpmn-to-image' is loaded lazily on use)
11
+ export { convertBpmn, convertBpmnFolder, isBpmnFile, bpmnOutputName, BPMN_FILE_RE, } from './bpmn/convert.js';
10
12
  // Attachments
11
13
  export { fileSha256, HASH_TAG_PREFIX } from './attachments/hash.js';
12
14
  export { Attachment, AttachmentService, toAttachmentVersion, SRC_SHA_SIDECAR_SUFFIX, } from './attachments/attachment.js';
@@ -4,6 +4,12 @@ export interface ExtractedPlaceholders {
4
4
  files: string[];
5
5
  }
6
6
  export declare function extractPlaceholders(markdown: string): ExtractedPlaceholders;
7
+ /**
8
+ * Переименовывает `{{img:...}}`-плейсхолдеры по карте старое→новое имя.
9
+ * Используется BPMN-конвейером: автор пишет `{{img:p1.bpmn}}`, публикация
10
+ * подменяет на `{{img:p1.png}}` после конвертации диаграммы.
11
+ */
12
+ export declare function renameImagePlaceholders(markdown: string, renames: Map<string, string>): string;
7
13
  export interface AttachmentUrls {
8
14
  /** filename → абсолютный URL аттача, полученный из Confluence после аплоада. */
9
15
  images: Map<string, string>;
@@ -34,6 +34,19 @@ function escapeXmlAttr(s) {
34
34
  }
35
35
  });
36
36
  }
37
+ /**
38
+ * Переименовывает `{{img:...}}`-плейсхолдеры по карте старое→новое имя.
39
+ * Используется BPMN-конвейером: автор пишет `{{img:p1.bpmn}}`, публикация
40
+ * подменяет на `{{img:p1.png}}` после конвертации диаграммы.
41
+ */
42
+ export function renameImagePlaceholders(markdown, renames) {
43
+ if (renames.size === 0)
44
+ return markdown;
45
+ return markdown.replace(/\{\{img:([^}]+)\}\}/g, (full, rawName) => {
46
+ const renamed = renames.get(rawName.trim());
47
+ return renamed ? `{{img:${renamed}}}` : full;
48
+ });
49
+ }
37
50
  export class MissingAttachmentUrlError extends Error {
38
51
  constructor(message) {
39
52
  super(message);
@@ -37,6 +37,12 @@ export interface PublishPageOptions {
37
37
  versionMessage?: string;
38
38
  /** Свой реестр макросов (default: встроенные core + table-filter). */
39
39
  registry?: MacroRegistry;
40
+ /**
41
+ * Каталог для PNG, сгенерированных из `*.bpmn` в images[].
42
+ * Default: временный каталог на каждый запуск — дедуп аттачей всё равно
43
+ * идёт по SHA-256 исходного BPMN через sidecar, лишних версий не будет.
44
+ */
45
+ bpmnOutDir?: string;
40
46
  /**
41
47
  * Ключ content property для хранения content-hash.
42
48
  * Default: 'confluence-md-sync-content-hash'.
@@ -1,8 +1,11 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { mkdtempSync, readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
3
5
  import { ConfluenceClient } from '../client/client.js';
4
6
  import { AttachmentService } from '../attachments/attachment.js';
5
- import { renderToStorage } from '../markdown/render.js';
7
+ import { bpmnOutputName, convertBpmn, isBpmnFile } from '../bpmn/convert.js';
8
+ import { renameImagePlaceholders, renderToStorage } from '../markdown/render.js';
6
9
  import { validateMarkdown } from '../markdown/validate.js';
7
10
  import { processMacros } from '../macros/registry.js';
8
11
  import { defaultMacroRegistry } from '../macros/index.js';
@@ -60,11 +63,29 @@ export async function publishPage(opts, cfg) {
60
63
  else {
61
64
  throw new Error('publishPage: either markdownPath or markdown must be provided');
62
65
  }
63
- const images = opts.images ?? [];
66
+ let images = opts.images ?? [];
64
67
  const files = opts.files ?? [];
65
68
  const tables = opts.tables ?? [];
66
69
  const registry = opts.registry ?? defaultMacroRegistry;
67
70
  const hashKey = opts.hashPropertyKey ?? DEFAULT_HASH_PROPERTY_KEY;
71
+ // 0. BPMN из коробки: *.bpmn в images[] конвертируются в PNG, а
72
+ // {{img:p1.bpmn}} в markdown подменяется на {{img:p1.png}} ДО валидации.
73
+ // Сама конвертация (puppeteer) — после валидации, чтобы падать быстро.
74
+ const bpmnConversions = [];
75
+ const bpmnInputs = images.filter(isBpmnFile);
76
+ if (bpmnInputs.length > 0) {
77
+ const outDir = opts.bpmnOutDir ?? mkdtempSync(join(tmpdir(), 'confluence-md-sync-bpmn-'));
78
+ const renames = new Map();
79
+ const outputByInput = new Map();
80
+ for (const input of bpmnInputs) {
81
+ const output = join(outDir, bpmnOutputName(input));
82
+ bpmnConversions.push({ input, output });
83
+ outputByInput.set(input, output);
84
+ renames.set(basename(input), basename(output));
85
+ }
86
+ images = images.map((p) => outputByInput.get(p) ?? p);
87
+ markdown = renameImagePlaceholders(markdown, renames);
88
+ }
68
89
  validateMarkdown({
69
90
  markdown,
70
91
  imagePaths: images,
@@ -77,6 +98,11 @@ export async function publishPage(opts, cfg) {
77
98
  const tableMarkdown = table.markdown instanceof Markdown ? table.markdown.toString() : table.markdown;
78
99
  markdown = markdown.replace(new RegExp(`\\{\\{table:${escapeRegex(table.name)}\\}\\}`, 'g'), () => tableMarkdown);
79
100
  }
101
+ // Конвертация диаграмм — до аплоада; в dry-run пропускается (аттачи всё
102
+ // равно не загружаются, URL подставляются фиктивные).
103
+ if (bpmnConversions.length > 0 && !opts.dryRun) {
104
+ await convertBpmn(bpmnConversions);
105
+ }
80
106
  const client = new ConfluenceClient(cfg);
81
107
  const attachmentSvc = new AttachmentService(client);
82
108
  const { pageId, created } = await resolvePage(client, opts);
@@ -89,9 +115,9 @@ export async function publishPage(opts, cfg) {
89
115
  // В dry-run не трогаем Confluence — подставляем фиктивные URL, чтобы
90
116
  // рендер и валидация плейсхолдеров отработали полностью.
91
117
  for (const p of images)
92
- urls.images.set(basenameOf(p), `dry-run://img/${basenameOf(p)}`);
118
+ urls.images.set(basename(p), `dry-run://img/${basename(p)}`);
93
119
  for (const p of files)
94
- urls.files.set(basenameOf(p), `dry-run://file/${basenameOf(p)}`);
120
+ urls.files.set(basename(p), `dry-run://file/${basename(p)}`);
95
121
  }
96
122
  else {
97
123
  for (const p of images) {
@@ -155,10 +181,6 @@ export async function publishPage(opts, cfg) {
155
181
  console.log(`[publish] ${pageId} "${title}" → v${nextVersion} (hash ${newHash.slice(0, 12)})`);
156
182
  return { pageId, title, version: nextVersion, attachments, updated: true, created, storage };
157
183
  }
158
- function basenameOf(p) {
159
- const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
160
- return idx === -1 ? p : p.slice(idx + 1);
161
- }
162
184
  function escapeRegex(s) {
163
185
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
164
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -63,7 +63,20 @@
63
63
  "devDependencies": {
64
64
  "@types/markdown-it": "^14.1.2",
65
65
  "@types/node": "^20.14.0",
66
+ "bpmn-to-image": "^0.7.0",
67
+ "puppeteer": "^24.43.1",
66
68
  "typescript": "^5.6.0",
67
69
  "vitest": "^3.0.0"
70
+ },
71
+ "peerDependencies": {
72
+ "bpmn-to-image": ">=0.7.0"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "bpmn-to-image": {
76
+ "optional": true
77
+ }
78
+ },
79
+ "overrides": {
80
+ "puppeteer": "$puppeteer"
68
81
  }
69
82
  }