confluence-md-sync 0.1.1 → 0.2.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 +60 -3
- package/dist/bpmn/convert.d.ts +46 -0
- package/dist/bpmn/convert.js +109 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -1
- package/dist/markdown/render.d.ts +6 -0
- package/dist/markdown/render.js +13 -0
- package/dist/publish/publish.d.ts +6 -0
- package/dist/publish/publish.js +62 -19
- package/package.json +14 -1
package/README.md
CHANGED
|
@@ -48,10 +48,11 @@ Markdown + three placeholder kinds:
|
|
|
48
48
|
```markdown
|
|
49
49
|
# Отчёт за месяц
|
|
50
50
|
|
|
51
|
-
{{img:
|
|
51
|
+
{{img:chart.png}} <!-- inline image from images[] -->
|
|
52
|
+
{{img:flow.bpmn}} <!-- BPMN diagram — rendered to PNG automatically -->
|
|
52
53
|
Исходник: {{file:data.csv}} <!-- attachment link from files[] -->
|
|
53
54
|
|
|
54
|
-
{{table:summary}}
|
|
55
|
+
{{table:summary}} <!-- table injected from tables[] -->
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
```ts
|
|
@@ -66,7 +67,10 @@ const summary = renderMarkdownTable(rows, [
|
|
|
66
67
|
await publishPage({
|
|
67
68
|
pageId: '123456789',
|
|
68
69
|
markdownPath: 'docs/report.md',
|
|
69
|
-
images: [
|
|
70
|
+
images: [
|
|
71
|
+
'build/chart.png',
|
|
72
|
+
'docs/flow.bpmn', // converted to flow.png on the fly (see BPMN section)
|
|
73
|
+
],
|
|
70
74
|
files: ['build/data.csv'],
|
|
71
75
|
tables: [{
|
|
72
76
|
name: 'summary',
|
|
@@ -95,6 +99,59 @@ const { storage } = await publishPage({ pageId, markdownPath, dryRun: true }, cf
|
|
|
95
99
|
await publishPage({ pageId, markdown: '# Generated\n\ntext' }, cfg);
|
|
96
100
|
```
|
|
97
101
|
|
|
102
|
+
## BPMN diagrams out of the box
|
|
103
|
+
|
|
104
|
+
Pass a `.bpmn` file as an image — it is rendered to PNG at publish time
|
|
105
|
+
(headless Chromium via [bpmn-to-image](https://npmjs.com/package/bpmn-to-image),
|
|
106
|
+
an optional peer dependency).
|
|
107
|
+
|
|
108
|
+
Setup — install the converter **and** force a current puppeteer:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm install -D bpmn-to-image puppeteer
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```jsonc
|
|
115
|
+
// package.json — required: bpmn-to-image pins puppeteer 21, whose bundled
|
|
116
|
+
// Chromium fails to launch on recent OSes ("socket hang up"). The override
|
|
117
|
+
// must reference the direct dependency ("$puppeteer"), otherwise `npm ci`
|
|
118
|
+
// fails with "Override for puppeteer conflicts with direct dependency".
|
|
119
|
+
{
|
|
120
|
+
"devDependencies": {
|
|
121
|
+
"bpmn-to-image": "^0.7.0",
|
|
122
|
+
"puppeteer": "^24.0.0"
|
|
123
|
+
},
|
|
124
|
+
"overrides": {
|
|
125
|
+
"puppeteer": "$puppeteer"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```markdown
|
|
131
|
+
Процесс выпуска релиза:
|
|
132
|
+
|
|
133
|
+
{{img:release-flow.bpmn}}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
await publishPage({
|
|
138
|
+
pageId: '123456789',
|
|
139
|
+
markdownPath: 'docs/process.md',
|
|
140
|
+
images: ['docs/release-flow.bpmn'], // converted to release-flow.png automatically
|
|
141
|
+
bpmnOutDir: 'build', // optional; default: temp dir per run
|
|
142
|
+
}, cfg);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Chromium PNG output is not byte-stable between runs, so dedup is pinned to
|
|
146
|
+
the SHA-256 of the *source* `.bpmn` via a `.src-sha256` sidecar — unchanged
|
|
147
|
+
diagrams never create new attachment versions. Batch pre-conversion is also
|
|
148
|
+
available:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { convertBpmnFolder } from 'confluence-md-sync';
|
|
152
|
+
await convertBpmnFolder({ srcDir: 'docs/diagrams', outDir: 'build' });
|
|
153
|
+
```
|
|
154
|
+
|
|
98
155
|
## Read pages and tables
|
|
99
156
|
|
|
100
157
|
```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>;
|
package/dist/markdown/render.js
CHANGED
|
@@ -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'.
|
package/dist/publish/publish.js
CHANGED
|
@@ -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 {
|
|
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';
|
|
@@ -17,14 +20,26 @@ import { Markdown } from '../markdown/markdown.js';
|
|
|
17
20
|
export const DEFAULT_HASH_PROPERTY_KEY = 'confluence-md-sync-content-hash';
|
|
18
21
|
export function computeContentHash(storage) {
|
|
19
22
|
// Перед хешированием вычищаем query (?version=N&modificationDate=…) из
|
|
20
|
-
// attachment download-URL'
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// base-URL без query.
|
|
23
|
+
// attachment download-URL'ов — защита для storage, писанного схемой 1
|
|
24
|
+
// (см. HASH_SCHEME): там в body лежали URL с пином версии. Начиная со
|
|
25
|
+
// схемы 2 в body подставляются канонические URL без query, и replace —
|
|
26
|
+
// no-op.
|
|
25
27
|
const canonical = storage.replace(/(\/download\/attachments\/[^"?\s]+)\?[^"\s]*/g, '$1');
|
|
26
28
|
return createHash('sha256').update(canonical, 'utf-8').digest('hex');
|
|
27
29
|
}
|
|
30
|
+
// Схема записи download-URL в body. Схема 1 подставляла URL из
|
|
31
|
+
// _links.download как есть — с ?version=N&modificationDate=…; при ребампе
|
|
32
|
+
// аттача без изменения текста страница оставалась UNCHANGED и продолжала
|
|
33
|
+
// отдавать старую, пиненную версию картинки. Схема 2 подставляет
|
|
34
|
+
// канонический URL без query — Confluence по нему отдаёт последнюю версию
|
|
35
|
+
// аттача, и обновление диаграммы видно без переписывания body. Property со
|
|
36
|
+
// схемой ≠ текущей (в т.ч. без поля scheme) считается устаревшей — страница
|
|
37
|
+
// один раз переписывается каноническими URL.
|
|
38
|
+
const HASH_SCHEME = 2;
|
|
39
|
+
/** Канонический download-URL аттача: без query (?version=N&…). */
|
|
40
|
+
function canonicalDownloadUrl(url) {
|
|
41
|
+
return url.split('?')[0];
|
|
42
|
+
}
|
|
28
43
|
async function resolvePage(client, opts) {
|
|
29
44
|
if (opts.pageId)
|
|
30
45
|
return { pageId: opts.pageId, created: false };
|
|
@@ -60,11 +75,29 @@ export async function publishPage(opts, cfg) {
|
|
|
60
75
|
else {
|
|
61
76
|
throw new Error('publishPage: either markdownPath or markdown must be provided');
|
|
62
77
|
}
|
|
63
|
-
|
|
78
|
+
let images = opts.images ?? [];
|
|
64
79
|
const files = opts.files ?? [];
|
|
65
80
|
const tables = opts.tables ?? [];
|
|
66
81
|
const registry = opts.registry ?? defaultMacroRegistry;
|
|
67
82
|
const hashKey = opts.hashPropertyKey ?? DEFAULT_HASH_PROPERTY_KEY;
|
|
83
|
+
// 0. BPMN из коробки: *.bpmn в images[] конвертируются в PNG, а
|
|
84
|
+
// {{img:p1.bpmn}} в markdown подменяется на {{img:p1.png}} ДО валидации.
|
|
85
|
+
// Сама конвертация (puppeteer) — после валидации, чтобы падать быстро.
|
|
86
|
+
const bpmnConversions = [];
|
|
87
|
+
const bpmnInputs = images.filter(isBpmnFile);
|
|
88
|
+
if (bpmnInputs.length > 0) {
|
|
89
|
+
const outDir = opts.bpmnOutDir ?? mkdtempSync(join(tmpdir(), 'confluence-md-sync-bpmn-'));
|
|
90
|
+
const renames = new Map();
|
|
91
|
+
const outputByInput = new Map();
|
|
92
|
+
for (const input of bpmnInputs) {
|
|
93
|
+
const output = join(outDir, bpmnOutputName(input));
|
|
94
|
+
bpmnConversions.push({ input, output });
|
|
95
|
+
outputByInput.set(input, output);
|
|
96
|
+
renames.set(basename(input), basename(output));
|
|
97
|
+
}
|
|
98
|
+
images = images.map((p) => outputByInput.get(p) ?? p);
|
|
99
|
+
markdown = renameImagePlaceholders(markdown, renames);
|
|
100
|
+
}
|
|
68
101
|
validateMarkdown({
|
|
69
102
|
markdown,
|
|
70
103
|
imagePaths: images,
|
|
@@ -77,6 +110,11 @@ export async function publishPage(opts, cfg) {
|
|
|
77
110
|
const tableMarkdown = table.markdown instanceof Markdown ? table.markdown.toString() : table.markdown;
|
|
78
111
|
markdown = markdown.replace(new RegExp(`\\{\\{table:${escapeRegex(table.name)}\\}\\}`, 'g'), () => tableMarkdown);
|
|
79
112
|
}
|
|
113
|
+
// Конвертация диаграмм — до аплоада; в dry-run пропускается (аттачи всё
|
|
114
|
+
// равно не загружаются, URL подставляются фиктивные).
|
|
115
|
+
if (bpmnConversions.length > 0 && !opts.dryRun) {
|
|
116
|
+
await convertBpmn(bpmnConversions);
|
|
117
|
+
}
|
|
80
118
|
const client = new ConfluenceClient(cfg);
|
|
81
119
|
const attachmentSvc = new AttachmentService(client);
|
|
82
120
|
const { pageId, created } = await resolvePage(client, opts);
|
|
@@ -89,20 +127,23 @@ export async function publishPage(opts, cfg) {
|
|
|
89
127
|
// В dry-run не трогаем Confluence — подставляем фиктивные URL, чтобы
|
|
90
128
|
// рендер и валидация плейсхолдеров отработали полностью.
|
|
91
129
|
for (const p of images)
|
|
92
|
-
urls.images.set(
|
|
130
|
+
urls.images.set(basename(p), `dry-run://img/${basename(p)}`);
|
|
93
131
|
for (const p of files)
|
|
94
|
-
urls.files.set(
|
|
132
|
+
urls.files.set(basename(p), `dry-run://file/${basename(p)}`);
|
|
95
133
|
}
|
|
96
134
|
else {
|
|
135
|
+
// В body уходит канонический URL без query (см. HASH_SCHEME) — страница
|
|
136
|
+
// всегда отдаёт последнюю версию аттача. Полный URL с версией остаётся
|
|
137
|
+
// в результате для caller'а.
|
|
97
138
|
for (const p of images) {
|
|
98
139
|
const r = await attachmentSvc.ensure(pageId, p);
|
|
99
|
-
urls.images.set(r.filename, r.downloadUrl);
|
|
140
|
+
urls.images.set(r.filename, canonicalDownloadUrl(r.downloadUrl));
|
|
100
141
|
attachments.push({ filename: r.filename, id: r.id, reused: r.reused, url: r.downloadUrl });
|
|
101
142
|
console.log(`[attachment] ${r.filename}: ${r.reused ? 'reused' : 'uploaded'} (id=${r.id})`);
|
|
102
143
|
}
|
|
103
144
|
for (const p of files) {
|
|
104
145
|
const r = await attachmentSvc.ensure(pageId, p);
|
|
105
|
-
urls.files.set(r.filename, r.downloadUrl);
|
|
146
|
+
urls.files.set(r.filename, canonicalDownloadUrl(r.downloadUrl));
|
|
106
147
|
attachments.push({ filename: r.filename, id: r.id, reused: r.reused, url: r.downloadUrl });
|
|
107
148
|
console.log(`[attachment] ${r.filename}: ${r.reused ? 'reused' : 'uploaded'} (id=${r.id})`);
|
|
108
149
|
}
|
|
@@ -130,8 +171,14 @@ export async function publishPage(opts, cfg) {
|
|
|
130
171
|
client.getContentProperty(pageId, hashKey),
|
|
131
172
|
]);
|
|
132
173
|
const title = opts.title ?? existing.title;
|
|
133
|
-
|
|
134
|
-
|
|
174
|
+
// Property, писанная другой схемой (или до появления scheme), не считается
|
|
175
|
+
// совпадением: body мог быть записан с пином версий аттачей — его нужно
|
|
176
|
+
// один раз переписать каноническими URL.
|
|
177
|
+
const propValue = hashProp && typeof hashProp.value === 'object' && hashProp.value !== null
|
|
178
|
+
? hashProp.value
|
|
179
|
+
: null;
|
|
180
|
+
const existingHash = propValue && propValue.scheme === HASH_SCHEME
|
|
181
|
+
? (propValue.hash ?? null)
|
|
135
182
|
: null;
|
|
136
183
|
if (existingHash === newHash && title === existing.title) {
|
|
137
184
|
console.log(`[publish] ${pageId} "${title}" → UNCHANGED (hash ${newHash.slice(0, 12)}, v${existing.version})`);
|
|
@@ -149,16 +196,12 @@ export async function publishPage(opts, cfg) {
|
|
|
149
196
|
});
|
|
150
197
|
// 5. Запись/обновление content property с новым hash. Делаем ПОСЛЕ
|
|
151
198
|
// updatePage чтобы при сбое publish hash не «опередил» реальное содержимое.
|
|
152
|
-
await client.setContentProperty(pageId, hashKey, { hash: newHash }, hashProp ? hashProp.version : null);
|
|
199
|
+
await client.setContentProperty(pageId, hashKey, { hash: newHash, scheme: HASH_SCHEME }, hashProp ? hashProp.version : null);
|
|
153
200
|
if (opts.labels?.length)
|
|
154
201
|
await client.addLabels(pageId, opts.labels);
|
|
155
202
|
console.log(`[publish] ${pageId} "${title}" → v${nextVersion} (hash ${newHash.slice(0, 12)})`);
|
|
156
203
|
return { pageId, title, version: nextVersion, attachments, updated: true, created, storage };
|
|
157
204
|
}
|
|
158
|
-
function basenameOf(p) {
|
|
159
|
-
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
|
160
|
-
return idx === -1 ? p : p.slice(idx + 1);
|
|
161
|
-
}
|
|
162
205
|
function escapeRegex(s) {
|
|
163
206
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
164
207
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "confluence-md-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -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
|
}
|