confluence-md-sync 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/dist/attachments/attachment.d.ts +39 -0
  4. package/dist/attachments/attachment.js +83 -0
  5. package/dist/attachments/hash.d.ts +2 -0
  6. package/dist/attachments/hash.js +6 -0
  7. package/dist/cli.d.ts +13 -0
  8. package/dist/cli.js +85 -0
  9. package/dist/client/client.d.ts +128 -0
  10. package/dist/client/client.js +311 -0
  11. package/dist/client/config.d.ts +42 -0
  12. package/dist/client/config.js +54 -0
  13. package/dist/csv.d.ts +18 -0
  14. package/dist/csv.js +86 -0
  15. package/dist/index.d.ts +13 -0
  16. package/dist/index.js +20 -0
  17. package/dist/macros/builder.d.ts +30 -0
  18. package/dist/macros/builder.js +58 -0
  19. package/dist/macros/index.d.ts +44 -0
  20. package/dist/macros/index.js +50 -0
  21. package/dist/macros/plugins/core.d.ts +49 -0
  22. package/dist/macros/plugins/core.js +214 -0
  23. package/dist/macros/plugins/table-filter.d.ts +35 -0
  24. package/dist/macros/plugins/table-filter.js +155 -0
  25. package/dist/macros/registry.d.ts +29 -0
  26. package/dist/macros/registry.js +111 -0
  27. package/dist/macros/types.d.ts +38 -0
  28. package/dist/macros/types.js +15 -0
  29. package/dist/macros/xml.d.ts +28 -0
  30. package/dist/macros/xml.js +52 -0
  31. package/dist/markdown/markdown.d.ts +36 -0
  32. package/dist/markdown/markdown.js +78 -0
  33. package/dist/markdown/render.d.ts +35 -0
  34. package/dist/markdown/render.js +80 -0
  35. package/dist/markdown/validate.d.ts +11 -0
  36. package/dist/markdown/validate.js +60 -0
  37. package/dist/pages/page.d.ts +104 -0
  38. package/dist/pages/page.js +234 -0
  39. package/dist/pages/tables.d.ts +72 -0
  40. package/dist/pages/tables.js +163 -0
  41. package/dist/publish/publish.d.ts +67 -0
  42. package/dist/publish/publish.js +164 -0
  43. package/dist/publish/runner.d.ts +36 -0
  44. package/dist/publish/runner.js +41 -0
  45. package/dist/wrapper.d.ts +21 -0
  46. package/dist/wrapper.js +36 -0
  47. package/package.json +69 -0
@@ -0,0 +1,164 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { ConfluenceClient } from '../client/client.js';
4
+ import { AttachmentService } from '../attachments/attachment.js';
5
+ import { renderToStorage } from '../markdown/render.js';
6
+ import { validateMarkdown } from '../markdown/validate.js';
7
+ import { processMacros } from '../macros/registry.js';
8
+ import { defaultMacroRegistry } from '../macros/index.js';
9
+ import { Markdown } from '../markdown/markdown.js';
10
+ // Content property с SHA-256 от рендеренного storage. Хранится отдельно
11
+ // от body через /rest/api/content/{id}/property/{key}, поэтому:
12
+ // - не попадает в HTML страницы (пользователь не видит);
13
+ // - переживает любую нормализацию storage Confluence (HTML-comment не
14
+ // переживал — был первый подход, не сработал).
15
+ // При повторной публикации того же контента hash совпадёт → updatePage
16
+ // не вызываем → версия страницы не растёт, история не пухнет.
17
+ export const DEFAULT_HASH_PROPERTY_KEY = 'confluence-md-sync-content-hash';
18
+ export function computeContentHash(storage) {
19
+ // Перед хешированием вычищаем query (?version=N&modificationDate=…) из
20
+ // attachment download-URL'ов. Иначе любой ребамп аттача (новая версия
21
+ // от не-детерминированной генерации картинок → новый SHA → новый
22
+ // version N в URL) ломал бы hash страницы, хотя тело логически
23
+ // не изменилось — Confluence сам отдаст последнюю версию аттача по
24
+ // base-URL без query.
25
+ const canonical = storage.replace(/(\/download\/attachments\/[^"?\s]+)\?[^"\s]*/g, '$1');
26
+ return createHash('sha256').update(canonical, 'utf-8').digest('hex');
27
+ }
28
+ async function resolvePage(client, opts) {
29
+ if (opts.pageId)
30
+ return { pageId: opts.pageId, created: false };
31
+ if (!opts.spaceKey || !opts.title) {
32
+ throw new Error('publishPage: either pageId or spaceKey + title must be provided');
33
+ }
34
+ const existing = await client.getPageByTitle(opts.spaceKey, opts.title);
35
+ if (existing)
36
+ return { pageId: existing.id, created: false };
37
+ if (opts.createIfMissing === false) {
38
+ throw new Error(`publishPage: page '${opts.title}' not found in space '${opts.spaceKey}' and createIfMissing is false`);
39
+ }
40
+ if (opts.dryRun) {
41
+ console.log(`[publish] dry-run: would create page '${opts.title}' in space '${opts.spaceKey}'`);
42
+ return { pageId: 'dry-run', created: true };
43
+ }
44
+ const page = await client.createPage({
45
+ spaceKey: opts.spaceKey,
46
+ title: opts.title,
47
+ parentId: opts.parentPageId,
48
+ });
49
+ console.log(`[publish] created page ${page.id} "${opts.title}" in space ${opts.spaceKey}`);
50
+ return { pageId: page.id, created: true };
51
+ }
52
+ export async function publishPage(opts, cfg) {
53
+ let markdown;
54
+ if (opts.markdownPath) {
55
+ markdown = readFileSync(opts.markdownPath, 'utf-8');
56
+ }
57
+ else if (opts.markdown !== undefined) {
58
+ markdown = opts.markdown instanceof Markdown ? opts.markdown.toString() : opts.markdown;
59
+ }
60
+ else {
61
+ throw new Error('publishPage: either markdownPath or markdown must be provided');
62
+ }
63
+ const images = opts.images ?? [];
64
+ const files = opts.files ?? [];
65
+ const tables = opts.tables ?? [];
66
+ const registry = opts.registry ?? defaultMacroRegistry;
67
+ const hashKey = opts.hashPropertyKey ?? DEFAULT_HASH_PROPERTY_KEY;
68
+ validateMarkdown({
69
+ markdown,
70
+ imagePaths: images,
71
+ filePaths: files,
72
+ tableNames: tables.map((t) => t.name),
73
+ sourceLabel: opts.markdownPath,
74
+ });
75
+ // Подставляем таблицы в markdown (уже могут быть обёрнуты в макросы).
76
+ for (const table of tables) {
77
+ const tableMarkdown = table.markdown instanceof Markdown ? table.markdown.toString() : table.markdown;
78
+ markdown = markdown.replace(new RegExp(`\\{\\{table:${escapeRegex(table.name)}\\}\\}`, 'g'), () => tableMarkdown);
79
+ }
80
+ const client = new ConfluenceClient(cfg);
81
+ const attachmentSvc = new AttachmentService(client);
82
+ const { pageId, created } = await resolvePage(client, opts);
83
+ // 1. Загрузка аттачей (или переиспользование по SHA-256). Собираем
84
+ // отдельные карты «имя → URL» для картинок и файлов — рендер потом
85
+ // подставит эти URL в <img src=…> и <a href=…> вместо плейсхолдеров.
86
+ const urls = { images: new Map(), files: new Map() };
87
+ const attachments = [];
88
+ if (opts.dryRun) {
89
+ // В dry-run не трогаем Confluence — подставляем фиктивные URL, чтобы
90
+ // рендер и валидация плейсхолдеров отработали полностью.
91
+ for (const p of images)
92
+ urls.images.set(basenameOf(p), `dry-run://img/${basenameOf(p)}`);
93
+ for (const p of files)
94
+ urls.files.set(basenameOf(p), `dry-run://file/${basenameOf(p)}`);
95
+ }
96
+ else {
97
+ for (const p of images) {
98
+ const r = await attachmentSvc.ensure(pageId, p);
99
+ urls.images.set(r.filename, r.downloadUrl);
100
+ attachments.push({ filename: r.filename, id: r.id, reused: r.reused, url: r.downloadUrl });
101
+ console.log(`[attachment] ${r.filename}: ${r.reused ? 'reused' : 'uploaded'} (id=${r.id})`);
102
+ }
103
+ for (const p of files) {
104
+ const r = await attachmentSvc.ensure(pageId, p);
105
+ urls.files.set(r.filename, r.downloadUrl);
106
+ attachments.push({ filename: r.filename, id: r.id, reused: r.reused, url: r.downloadUrl });
107
+ console.log(`[attachment] ${r.filename}: ${r.reused ? 'reused' : 'uploaded'} (id=${r.id})`);
108
+ }
109
+ }
110
+ // 2. Рендер MD → storage format с подстановкой полученных URL-ов.
111
+ let storage = renderToStorage(markdown, urls);
112
+ // 2.5. Преобразование маркеров макросов в XHTML.
113
+ storage = processMacros(storage, registry).toString();
114
+ if (opts.dryRun) {
115
+ console.log(`[publish] dry-run: page ${pageId} rendered OK (${storage.length} bytes of storage)`);
116
+ return {
117
+ pageId,
118
+ title: opts.title ?? '',
119
+ version: 0,
120
+ attachments,
121
+ updated: false,
122
+ created,
123
+ storage,
124
+ };
125
+ }
126
+ // 3. Content-hash check. Hash хранится в content property, не в body.
127
+ const newHash = computeContentHash(storage);
128
+ const [existing, hashProp] = await Promise.all([
129
+ client.getPageStorage(pageId),
130
+ client.getContentProperty(pageId, hashKey),
131
+ ]);
132
+ const title = opts.title ?? existing.title;
133
+ const existingHash = hashProp && typeof hashProp.value === 'object' && hashProp.value !== null
134
+ ? hashProp.value.hash ?? null
135
+ : null;
136
+ if (existingHash === newHash && title === existing.title) {
137
+ console.log(`[publish] ${pageId} "${title}" → UNCHANGED (hash ${newHash.slice(0, 12)}, v${existing.version})`);
138
+ if (opts.labels?.length)
139
+ await client.addLabels(pageId, opts.labels);
140
+ return { pageId, title, version: existing.version, attachments, updated: false, created, storage };
141
+ }
142
+ // 4. Обновление страницы — только после успешного аплоада всех аттачей.
143
+ const nextVersion = existing.version + 1;
144
+ await client.updatePage(pageId, {
145
+ title,
146
+ version: nextVersion,
147
+ storage,
148
+ versionMessage: opts.versionMessage,
149
+ });
150
+ // 5. Запись/обновление content property с новым hash. Делаем ПОСЛЕ
151
+ // updatePage чтобы при сбое publish hash не «опередил» реальное содержимое.
152
+ await client.setContentProperty(pageId, hashKey, { hash: newHash }, hashProp ? hashProp.version : null);
153
+ if (opts.labels?.length)
154
+ await client.addLabels(pageId, opts.labels);
155
+ console.log(`[publish] ${pageId} "${title}" → v${nextVersion} (hash ${newHash.slice(0, 12)})`);
156
+ return { pageId, title, version: nextVersion, attachments, updated: true, created, storage };
157
+ }
158
+ function basenameOf(p) {
159
+ const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
160
+ return idx === -1 ? p : p.slice(idx + 1);
161
+ }
162
+ function escapeRegex(s) {
163
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
164
+ }
@@ -0,0 +1,36 @@
1
+ import { type PublishPageOptions, type PublishPageResult } from './publish.js';
2
+ import { type ConfluenceConfig, type LoadConfigOptions } from '../client/config.js';
3
+ export type Here = (relativePath: string) => string;
4
+ export type Build = (relativePath: string) => string;
5
+ export type PublishPlan = PublishPageOptions[] | ((here: Here, build: Build) => PublishPageOptions[] | Promise<PublishPageOptions[]>);
6
+ export interface RunPublishOptions {
7
+ /** Готовый конфиг вместо загрузки из env. */
8
+ config?: ConfluenceConfig;
9
+ /** Опции загрузки конфига из env (кастомные имена переменных токена). */
10
+ env?: LoadConfigOptions;
11
+ /**
12
+ * Каталог для сгенерированных артефактов (PNG и т.п.), доступный через
13
+ * `build(rel)`. Default: `<baseDir>/../../build/<basename(baseDir)>` —
14
+ * соответствует компоновке репо `<repo>/docs/<set>` + `<repo>/build/<set>`.
15
+ */
16
+ buildDir?: string;
17
+ }
18
+ /**
19
+ * Минимальная точка входа для публикующего скрипта (`docs/<set>/publish.ts`).
20
+ *
21
+ * await runPublish(import.meta.dirname, (here, build) => [
22
+ * {
23
+ * pageId: '...',
24
+ * markdownPath: here('overview.md'),
25
+ * images: [build('p1.png')], // сгенерировано отдельным шагом в build-каталоге
26
+ * },
27
+ * ]);
28
+ *
29
+ * - `here(rel)` — пути относительно папки запускающего модуля.
30
+ * - `build(rel)` — пути относительно build-каталога (см. RunPublishOptions.buildDir).
31
+ *
32
+ * Конфиг Confluence загружается из env (CONFLUENCE_BASE_URL, CONFLUENCE_TOKEN,
33
+ * CONFLUENCE_USERNAME, CONFLUENCE_AUTH_TYPE). При любой ошибке пишет одну
34
+ * строку в stderr и завершает процесс с кодом 1 — Confluence не трогается.
35
+ */
36
+ export declare function runPublish(baseDir: string, plan: PublishPlan, opts?: RunPublishOptions): Promise<PublishPageResult[]>;
@@ -0,0 +1,41 @@
1
+ import { basename, join } from 'node:path';
2
+ import { publishPage } from './publish.js';
3
+ import { loadConfigFromEnv } from '../client/config.js';
4
+ /**
5
+ * Минимальная точка входа для публикующего скрипта (`docs/<set>/publish.ts`).
6
+ *
7
+ * await runPublish(import.meta.dirname, (here, build) => [
8
+ * {
9
+ * pageId: '...',
10
+ * markdownPath: here('overview.md'),
11
+ * images: [build('p1.png')], // сгенерировано отдельным шагом в build-каталоге
12
+ * },
13
+ * ]);
14
+ *
15
+ * - `here(rel)` — пути относительно папки запускающего модуля.
16
+ * - `build(rel)` — пути относительно build-каталога (см. RunPublishOptions.buildDir).
17
+ *
18
+ * Конфиг Confluence загружается из env (CONFLUENCE_BASE_URL, CONFLUENCE_TOKEN,
19
+ * CONFLUENCE_USERNAME, CONFLUENCE_AUTH_TYPE). При любой ошибке пишет одну
20
+ * строку в stderr и завершает процесс с кодом 1 — Confluence не трогается.
21
+ */
22
+ export async function runPublish(baseDir, plan, opts = {}) {
23
+ const here = (p) => join(baseDir, p);
24
+ const folderName = basename(baseDir);
25
+ const buildBase = opts.buildDir ?? join(baseDir, '..', '..', 'build', folderName);
26
+ const build = (p) => join(buildBase, p);
27
+ try {
28
+ const cfg = opts.config ?? loadConfigFromEnv(opts.env);
29
+ const pages = typeof plan === 'function' ? await plan(here, build) : plan;
30
+ const results = [];
31
+ for (const page of pages) {
32
+ results.push(await publishPage(page, cfg));
33
+ }
34
+ return results;
35
+ }
36
+ catch (err) {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ console.error(`[publish] failed: ${msg}`);
39
+ process.exit(1);
40
+ }
41
+ }
@@ -0,0 +1,21 @@
1
+ import type { ConfluenceConfig } from './client/config.js';
2
+ import { ConfluenceClient } from './client/client.js';
3
+ import { AttachmentService } from './attachments/attachment.js';
4
+ import { Page } from './pages/page.js';
5
+ import { type PublishPageOptions, type PublishPageResult } from './publish/publish.js';
6
+ /**
7
+ * Высокоуровневый фасад: один объект на конфиг, отдаёт клиент, сервис
8
+ * аттачей, объектную модель страниц и публикацию.
9
+ */
10
+ export declare class ConfluenceWrapper {
11
+ private readonly cfg;
12
+ readonly client: ConfluenceClient;
13
+ readonly attachments: AttachmentService;
14
+ constructor(cfg: ConfluenceConfig);
15
+ readPage(pageId: string): Promise<Page>;
16
+ /** Находит страницу по space + title и возвращает объектную модель. */
17
+ findPage(spaceKey: string, title: string): Promise<Page | null>;
18
+ /** Публикует markdown на страницу (см. {@link publishPage}). */
19
+ publish(opts: PublishPageOptions): Promise<PublishPageResult>;
20
+ }
21
+ export declare function confluence(cfg: ConfluenceConfig): ConfluenceWrapper;
@@ -0,0 +1,36 @@
1
+ import { ConfluenceClient } from './client/client.js';
2
+ import { AttachmentService } from './attachments/attachment.js';
3
+ import { Page } from './pages/page.js';
4
+ import { publishPage } from './publish/publish.js';
5
+ /**
6
+ * Высокоуровневый фасад: один объект на конфиг, отдаёт клиент, сервис
7
+ * аттачей, объектную модель страниц и публикацию.
8
+ */
9
+ export class ConfluenceWrapper {
10
+ cfg;
11
+ client;
12
+ attachments;
13
+ constructor(cfg) {
14
+ this.cfg = cfg;
15
+ this.client = new ConfluenceClient(cfg);
16
+ this.attachments = new AttachmentService(this.client);
17
+ }
18
+ async readPage(pageId) {
19
+ const { title, storage } = await this.client.getPageStorage(pageId);
20
+ return new Page(pageId, title, storage, this.client);
21
+ }
22
+ /** Находит страницу по space + title и возвращает объектную модель. */
23
+ async findPage(spaceKey, title) {
24
+ const found = await this.client.getPageByTitle(spaceKey, title);
25
+ if (!found)
26
+ return null;
27
+ return this.readPage(found.id);
28
+ }
29
+ /** Публикует markdown на страницу (см. {@link publishPage}). */
30
+ async publish(opts) {
31
+ return publishPage(opts, this.cfg);
32
+ }
33
+ }
34
+ export function confluence(cfg) {
35
+ return new ConfluenceWrapper(cfg);
36
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "confluence-md-sync",
3
+ "version": "0.1.1",
4
+ "description": "Publish Markdown to Confluence (Data Center & Cloud): idempotent page sync, attachment dedup, tables and a pluggable macro system",
5
+ "keywords": [
6
+ "confluence",
7
+ "markdown",
8
+ "sync",
9
+ "publish",
10
+ "atlassian",
11
+ "docs-as-code",
12
+ "storage-format",
13
+ "macros"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "hexstyle",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/hexstyle/confluence-md-sync.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/hexstyle/confluence-md-sync/issues"
23
+ },
24
+ "homepage": "https://github.com/hexstyle/confluence-md-sync#readme",
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=20.11"
28
+ },
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ },
36
+ "./csv": {
37
+ "types": "./dist/csv.d.ts",
38
+ "import": "./dist/csv.js"
39
+ },
40
+ "./macros": {
41
+ "types": "./dist/macros/index.d.ts",
42
+ "import": "./dist/macros/index.js"
43
+ }
44
+ },
45
+ "bin": {
46
+ "confluence-md-sync": "./dist/cli.js"
47
+ },
48
+ "files": [
49
+ "dist",
50
+ "README.md",
51
+ "LICENSE"
52
+ ],
53
+ "scripts": {
54
+ "build": "tsc -p tsconfig.build.json",
55
+ "typecheck": "tsc --noEmit",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest",
58
+ "prepublishOnly": "npm run build"
59
+ },
60
+ "dependencies": {
61
+ "markdown-it": "^14.1.0"
62
+ },
63
+ "devDependencies": {
64
+ "@types/markdown-it": "^14.1.2",
65
+ "@types/node": "^20.14.0",
66
+ "typescript": "^5.6.0",
67
+ "vitest": "^3.0.0"
68
+ }
69
+ }