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,311 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { authHeader } from './config.js';
4
+ export class ConfluenceApiError extends Error {
5
+ status;
6
+ statusText;
7
+ detail;
8
+ constructor(message, status, statusText, detail) {
9
+ super(message);
10
+ this.status = status;
11
+ this.statusText = statusText;
12
+ this.detail = detail;
13
+ this.name = 'ConfluenceApiError';
14
+ }
15
+ }
16
+ export class ConfluenceClient {
17
+ auth;
18
+ baseUrl;
19
+ constructor(cfg) {
20
+ if (!cfg.baseUrl)
21
+ throw new Error('ConfluenceClient: baseUrl is required');
22
+ if (!cfg.token)
23
+ throw new Error('ConfluenceClient: token is required');
24
+ this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
25
+ this.auth = authHeader(cfg);
26
+ }
27
+ /** Превращает относительный путь из API (`/download/...`) в абсолютный URL. */
28
+ absoluteUrl(path) {
29
+ if (/^https?:\/\//i.test(path))
30
+ return path;
31
+ return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
32
+ }
33
+ url(path) {
34
+ return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
35
+ }
36
+ headers(extra = {}) {
37
+ return {
38
+ Authorization: this.auth,
39
+ Accept: 'application/json',
40
+ ...extra,
41
+ };
42
+ }
43
+ async parseError(res, label) {
44
+ let detail = '';
45
+ try {
46
+ detail = await res.text();
47
+ }
48
+ catch {
49
+ detail = '<no body>';
50
+ }
51
+ throw new ConfluenceApiError(`${label}: ${res.status} ${res.statusText} — ${detail.slice(0, 500)}`, res.status, res.statusText, detail);
52
+ }
53
+ // ── Pages ────────────────────────────────────────────────────────────
54
+ async getPage(pageId) {
55
+ const res = await fetch(this.url(`/rest/api/content/${pageId}?expand=version`), { headers: this.headers() });
56
+ if (!res.ok)
57
+ await this.parseError(res, `getPage(${pageId})`);
58
+ return (await res.json());
59
+ }
60
+ async getPageStorage(pageId) {
61
+ const res = await fetch(this.url(`/rest/api/content/${pageId}?expand=body.storage,version`), { headers: this.headers() });
62
+ if (!res.ok)
63
+ await this.parseError(res, `getPageStorage(${pageId})`);
64
+ const data = (await res.json());
65
+ return {
66
+ title: data.title ?? '',
67
+ storage: data.body?.storage?.value ?? '',
68
+ version: data.version?.number ?? 0,
69
+ };
70
+ }
71
+ /** Finds a page by space key and exact title. Returns null if not found. */
72
+ async getPageByTitle(spaceKey, title) {
73
+ const params = new URLSearchParams({ spaceKey, title, expand: 'version' });
74
+ const res = await fetch(this.url(`/rest/api/content?${params}`), {
75
+ headers: this.headers(),
76
+ });
77
+ if (!res.ok)
78
+ await this.parseError(res, `getPageByTitle(${spaceKey}, ${title})`);
79
+ const data = (await res.json());
80
+ return data.results?.[0] ?? null;
81
+ }
82
+ async createPage(opts) {
83
+ const body = {
84
+ type: 'page',
85
+ title: opts.title,
86
+ space: { key: opts.spaceKey },
87
+ body: {
88
+ storage: { value: opts.storage ?? '', representation: 'storage' },
89
+ },
90
+ };
91
+ if (opts.parentId)
92
+ body.ancestors = [{ id: opts.parentId }];
93
+ const res = await fetch(this.url('/rest/api/content'), {
94
+ method: 'POST',
95
+ headers: this.headers({ 'Content-Type': 'application/json' }),
96
+ body: JSON.stringify(body),
97
+ });
98
+ if (!res.ok)
99
+ await this.parseError(res, `createPage(${opts.spaceKey}/${opts.title})`);
100
+ return (await res.json());
101
+ }
102
+ async updatePage(pageId, body) {
103
+ const version = { number: body.version };
104
+ if (body.versionMessage)
105
+ version.message = body.versionMessage;
106
+ const res = await fetch(this.url(`/rest/api/content/${pageId}`), {
107
+ method: 'PUT',
108
+ headers: this.headers({ 'Content-Type': 'application/json' }),
109
+ body: JSON.stringify({
110
+ id: pageId,
111
+ type: 'page',
112
+ title: body.title,
113
+ version,
114
+ body: {
115
+ storage: { value: body.storage, representation: 'storage' },
116
+ },
117
+ }),
118
+ });
119
+ if (!res.ok)
120
+ await this.parseError(res, `updatePage(${pageId})`);
121
+ }
122
+ async deletePage(pageId) {
123
+ const res = await fetch(this.url(`/rest/api/content/${pageId}`), {
124
+ method: 'DELETE',
125
+ headers: this.headers(),
126
+ });
127
+ if (!res.ok)
128
+ await this.parseError(res, `deletePage(${pageId})`);
129
+ }
130
+ /** Direct child pages of a page. */
131
+ async getChildPages(pageId, limit = 200) {
132
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/child/page?limit=${limit}&expand=version`), { headers: this.headers() });
133
+ if (!res.ok)
134
+ await this.parseError(res, `getChildPages(${pageId})`);
135
+ const data = (await res.json());
136
+ return data.results ?? [];
137
+ }
138
+ /** CQL content search (e.g. `space = DOCS and type = page and title ~ "Report*"`). */
139
+ async search(cql, limit = 50) {
140
+ const params = new URLSearchParams({ cql, limit: String(limit), expand: 'version' });
141
+ const res = await fetch(this.url(`/rest/api/content/search?${params}`), {
142
+ headers: this.headers(),
143
+ });
144
+ if (!res.ok)
145
+ await this.parseError(res, `search(${cql})`);
146
+ const data = (await res.json());
147
+ return data.results ?? [];
148
+ }
149
+ // ── Labels ───────────────────────────────────────────────────────────
150
+ async getLabels(pageId) {
151
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/label`), {
152
+ headers: this.headers(),
153
+ });
154
+ if (!res.ok)
155
+ await this.parseError(res, `getLabels(${pageId})`);
156
+ const data = (await res.json());
157
+ return data.results ?? [];
158
+ }
159
+ async addLabels(pageId, labels) {
160
+ if (labels.length === 0)
161
+ return;
162
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/label`), {
163
+ method: 'POST',
164
+ headers: this.headers({ 'Content-Type': 'application/json' }),
165
+ body: JSON.stringify(labels.map((name) => ({ prefix: 'global', name }))),
166
+ });
167
+ if (!res.ok)
168
+ await this.parseError(res, `addLabels(${pageId})`);
169
+ }
170
+ async removeLabel(pageId, label) {
171
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/label/${encodeURIComponent(label)}`), { method: 'DELETE', headers: this.headers() });
172
+ if (!res.ok)
173
+ await this.parseError(res, `removeLabel(${pageId}, ${label})`);
174
+ }
175
+ // ── Content properties ───────────────────────────────────────────────
176
+ /**
177
+ * Content Property — произвольные key/value на странице вне body.
178
+ * Используется для content-hash (см. publish.ts): попытка хранить
179
+ * hash в body как HTML-comment не пережила нормализацию storage Confluence.
180
+ * Возвращает null, если property с таким ключом нет.
181
+ */
182
+ async getContentProperty(pageId, key) {
183
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/property/${encodeURIComponent(key)}`), { headers: this.headers() });
184
+ if (res.status === 404)
185
+ return null;
186
+ if (!res.ok)
187
+ await this.parseError(res, `getContentProperty(${pageId}, ${key})`);
188
+ const data = (await res.json());
189
+ return { value: data.value, version: data.version?.number ?? 1 };
190
+ }
191
+ /**
192
+ * Создаёт (если version null) или обновляет content property.
193
+ * При update Confluence требует следующий version-номер.
194
+ */
195
+ async setContentProperty(pageId, key, value, currentVersion) {
196
+ const isUpdate = currentVersion !== null;
197
+ const url = isUpdate
198
+ ? this.url(`/rest/api/content/${pageId}/property/${encodeURIComponent(key)}`)
199
+ : this.url(`/rest/api/content/${pageId}/property`);
200
+ const body = { key, value };
201
+ if (isUpdate)
202
+ body.version = { number: currentVersion + 1 };
203
+ const res = await fetch(url, {
204
+ method: isUpdate ? 'PUT' : 'POST',
205
+ headers: this.headers({ 'Content-Type': 'application/json' }),
206
+ body: JSON.stringify(body),
207
+ });
208
+ if (!res.ok)
209
+ await this.parseError(res, `setContentProperty(${pageId}, ${key})`);
210
+ }
211
+ // ── Attachments ──────────────────────────────────────────────────────
212
+ async listAttachments(pageId, filename) {
213
+ // expand=version нужен для дедупа по version.message (sha256-тегу).
214
+ // _links возвращается по умолчанию, явный expand не требуется.
215
+ const params = new URLSearchParams({ expand: 'version', limit: '200' });
216
+ if (filename)
217
+ params.set('filename', filename);
218
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/child/attachment?${params}`), { headers: this.headers() });
219
+ if (!res.ok)
220
+ await this.parseError(res, `listAttachments(${pageId})`);
221
+ const data = (await res.json());
222
+ return data.results ?? [];
223
+ }
224
+ async createAttachment(pageId, filePath, comment) {
225
+ const filename = basename(filePath);
226
+ const buf = readFileSync(filePath);
227
+ const form = new FormData();
228
+ form.append('file', new Blob([buf]), filename);
229
+ form.append('minorEdit', 'true');
230
+ if (comment)
231
+ form.append('comment', comment);
232
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/child/attachment`), {
233
+ method: 'POST',
234
+ headers: this.headers({ 'X-Atlassian-Token': 'nocheck' }),
235
+ body: form,
236
+ });
237
+ if (!res.ok)
238
+ await this.parseError(res, `createAttachment(${filename})`);
239
+ const data = (await res.json());
240
+ return 'results' in data ? data.results[0] : data;
241
+ }
242
+ /**
243
+ * Скачивает содержимое аттача по download-пути из `_links.download`
244
+ * (или абсолютному URL). Возвращает сырые байты — текстовая
245
+ * декодировка лежит на caller'е.
246
+ */
247
+ async downloadAttachment(downloadPath) {
248
+ const res = await fetch(this.absoluteUrl(downloadPath), {
249
+ headers: { Authorization: this.auth },
250
+ });
251
+ if (!res.ok)
252
+ await this.parseError(res, `downloadAttachment(${downloadPath})`);
253
+ return Buffer.from(await res.arrayBuffer());
254
+ }
255
+ async updateAttachmentData(pageId, attachmentId, filePath, comment) {
256
+ const filename = basename(filePath);
257
+ const buf = readFileSync(filePath);
258
+ const form = new FormData();
259
+ form.append('file', new Blob([buf]), filename);
260
+ form.append('minorEdit', 'true');
261
+ if (comment)
262
+ form.append('comment', comment);
263
+ const res = await fetch(this.url(`/rest/api/content/${pageId}/child/attachment/${attachmentId}/data`), {
264
+ method: 'POST',
265
+ headers: this.headers({ 'X-Atlassian-Token': 'nocheck' }),
266
+ body: form,
267
+ });
268
+ if (!res.ok)
269
+ await this.parseError(res, `updateAttachmentData(${filename})`);
270
+ return (await res.json());
271
+ }
272
+ /**
273
+ * Список версий аттача через Confluence Files API (Data Center only).
274
+ * Структура ответа гибкая — номер версии нормализуется потребителем
275
+ * (поле versionNumber / version / number).
276
+ */
277
+ async getAttachmentVersions(attachmentId) {
278
+ const res = await fetch(this.url(`/rest/files/1.0/files/${attachmentId}/versions`), { headers: this.headers() });
279
+ if (!res.ok)
280
+ await this.parseError(res, `getAttachmentVersions(${attachmentId})`);
281
+ const data = (await res.json());
282
+ return Array.isArray(data) ? data : (data.versions ?? []);
283
+ }
284
+ /**
285
+ * Удаляет конкретную версию аттача (Data Center only). REST-аналога нет,
286
+ * идём через legacy-action; XSRF обходим заголовком X-Atlassian-Token:
287
+ * no-check (тот же приём работает для createAttachment).
288
+ */
289
+ async removeAttachmentVersion(pageId, fileName, version) {
290
+ const params = new URLSearchParams({ pageId, fileName, version: String(version) });
291
+ const res = await fetch(this.url(`/json/removeattachmentversion.action?${params}`), {
292
+ method: 'POST',
293
+ headers: this.headers({
294
+ 'X-Atlassian-Token': 'no-check',
295
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
296
+ }),
297
+ body: '',
298
+ });
299
+ if (!res.ok)
300
+ await this.parseError(res, `removeAttachmentVersion(${fileName} v${version})`);
301
+ }
302
+ /** Удаляет аттач целиком (все версии). */
303
+ async deleteAttachment(attachmentId) {
304
+ const res = await fetch(this.url(`/rest/api/content/${attachmentId}`), {
305
+ method: 'DELETE',
306
+ headers: this.headers(),
307
+ });
308
+ if (!res.ok)
309
+ await this.parseError(res, `deleteAttachment(${attachmentId})`);
310
+ }
311
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Configuration and authentication for the Confluence REST client.
3
+ *
4
+ * Supports both deployment flavours:
5
+ * - Data Center / Server: Personal Access Token via `Authorization: Bearer`.
6
+ * - Cloud: email + API token via `Authorization: Basic`.
7
+ */
8
+ export type ConfluenceAuthType = 'bearer' | 'basic';
9
+ export interface ConfluenceConfig {
10
+ /** Base URL of the Confluence instance, e.g. `https://confluence.example.com` or `https://your.atlassian.net/wiki`. */
11
+ baseUrl: string;
12
+ /** Personal Access Token (Data Center) or API token (Cloud). */
13
+ token: string;
14
+ /**
15
+ * Username / e-mail. Required for `basic` auth (Cloud); for `bearer`
16
+ * it is optional and used only for logging.
17
+ */
18
+ username?: string;
19
+ /**
20
+ * Authentication scheme. Defaults to `bearer` (Data Center PAT).
21
+ * Set to `basic` for Confluence Cloud (username = Atlassian account e-mail).
22
+ */
23
+ authType?: ConfluenceAuthType;
24
+ }
25
+ /** Builds the `Authorization` header value for the given config. */
26
+ export declare function authHeader(cfg: ConfluenceConfig): string;
27
+ export interface LoadConfigOptions {
28
+ /**
29
+ * Extra env var names to try for the token, before the standard ones.
30
+ * Useful for legacy CI setups with non-standard variable names.
31
+ */
32
+ tokenEnvVars?: string[];
33
+ }
34
+ /**
35
+ * Loads Confluence config from environment variables:
36
+ *
37
+ * - `CONFLUENCE_BASE_URL` (required)
38
+ * - `CONFLUENCE_TOKEN` or `CONFLUENCE_PAT` (required)
39
+ * - `CONFLUENCE_USERNAME` (optional; required for basic auth)
40
+ * - `CONFLUENCE_AUTH_TYPE` (`bearer` | `basic`, optional, default `bearer`)
41
+ */
42
+ export declare function loadConfigFromEnv(opts?: LoadConfigOptions): ConfluenceConfig;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Configuration and authentication for the Confluence REST client.
3
+ *
4
+ * Supports both deployment flavours:
5
+ * - Data Center / Server: Personal Access Token via `Authorization: Bearer`.
6
+ * - Cloud: email + API token via `Authorization: Basic`.
7
+ */
8
+ /** Builds the `Authorization` header value for the given config. */
9
+ export function authHeader(cfg) {
10
+ const authType = cfg.authType ?? 'bearer';
11
+ if (authType === 'basic') {
12
+ if (!cfg.username) {
13
+ throw new Error("ConfluenceConfig: username is required for authType 'basic'");
14
+ }
15
+ return `Basic ${Buffer.from(`${cfg.username}:${cfg.token}`).toString('base64')}`;
16
+ }
17
+ // Confluence DC принимает Personal Access Token через Bearer-схему.
18
+ // Basic с username:PAT тоже формально поддерживается, но требует точного
19
+ // совпадения логина (LDAP/AD), что капризно — Bearer этим не страдает.
20
+ return `Bearer ${cfg.token}`;
21
+ }
22
+ /**
23
+ * Loads Confluence config from environment variables:
24
+ *
25
+ * - `CONFLUENCE_BASE_URL` (required)
26
+ * - `CONFLUENCE_TOKEN` or `CONFLUENCE_PAT` (required)
27
+ * - `CONFLUENCE_USERNAME` (optional; required for basic auth)
28
+ * - `CONFLUENCE_AUTH_TYPE` (`bearer` | `basic`, optional, default `bearer`)
29
+ */
30
+ export function loadConfigFromEnv(opts = {}) {
31
+ const tokenVars = [...(opts.tokenEnvVars ?? []), 'CONFLUENCE_TOKEN', 'CONFLUENCE_PAT'];
32
+ let token;
33
+ for (const name of tokenVars) {
34
+ token = process.env[name];
35
+ if (token)
36
+ break;
37
+ }
38
+ if (!token) {
39
+ throw new Error(`Required env var for Confluence token is not set (tried: ${tokenVars.join(', ')})`);
40
+ }
41
+ const baseUrl = process.env.CONFLUENCE_BASE_URL;
42
+ if (!baseUrl)
43
+ throw new Error("Required env var 'CONFLUENCE_BASE_URL' is not set");
44
+ const authType = process.env.CONFLUENCE_AUTH_TYPE;
45
+ if (authType && authType !== 'bearer' && authType !== 'basic') {
46
+ throw new Error(`CONFLUENCE_AUTH_TYPE must be 'bearer' or 'basic', got '${authType}'`);
47
+ }
48
+ return {
49
+ baseUrl,
50
+ token,
51
+ username: process.env.CONFLUENCE_USERNAME,
52
+ authType,
53
+ };
54
+ }
package/dist/csv.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Авто-декод текстового файла: UTF-8/UTF-16 BOM → соответствующая UTF;
3
+ * без BOM пробуем strict UTF-8 (fatal: true), если падает на невалидной
4
+ * последовательности — fallback на Windows-1251 (типичная кодировка
5
+ * для русских CSV из Excel/1С).
6
+ */
7
+ export declare function decodeText(buf: Buffer): string;
8
+ /**
9
+ * Парсит CSV (RFC 4180): кавычки, экранирование `""`, CRLF/LF. Разделитель
10
+ * определяется по первой строке — если `;` больше `,`, то `;`, иначе `,`.
11
+ */
12
+ export declare function readCsv(text: string): string[][];
13
+ /**
14
+ * Пишет CSV с `;`-разделителем (русский Excel), CRLF, кавычками вокруг полей
15
+ * с `;` / `"` / переносом и экранированием `"` → `""`. BOM в начале — чтобы
16
+ * Excel сразу распознал UTF-8 кириллицу.
17
+ */
18
+ export declare function writeCsv(path: string, rows: string[][]): void;
package/dist/csv.js ADDED
@@ -0,0 +1,86 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ /**
3
+ * Авто-декод текстового файла: UTF-8/UTF-16 BOM → соответствующая UTF;
4
+ * без BOM пробуем strict UTF-8 (fatal: true), если падает на невалидной
5
+ * последовательности — fallback на Windows-1251 (типичная кодировка
6
+ * для русских CSV из Excel/1С).
7
+ */
8
+ export function decodeText(buf) {
9
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
10
+ return new TextDecoder('utf-8').decode(buf.subarray(3));
11
+ }
12
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
13
+ return new TextDecoder('utf-16le').decode(buf.subarray(2));
14
+ }
15
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
16
+ return new TextDecoder('utf-16be').decode(buf.subarray(2));
17
+ }
18
+ try {
19
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
20
+ }
21
+ catch {
22
+ return new TextDecoder('windows-1251').decode(buf);
23
+ }
24
+ }
25
+ /**
26
+ * Парсит CSV (RFC 4180): кавычки, экранирование `""`, CRLF/LF. Разделитель
27
+ * определяется по первой строке — если `;` больше `,`, то `;`, иначе `,`.
28
+ */
29
+ export function readCsv(text) {
30
+ const firstLine = text.split(/\r?\n/, 1)[0] ?? '';
31
+ const sep = (firstLine.match(/;/g) ?? []).length > (firstLine.match(/,/g) ?? []).length ? ';' : ',';
32
+ const rows = [];
33
+ let cur = [];
34
+ let field = '';
35
+ let inQuotes = false;
36
+ for (let i = 0; i < text.length; i++) {
37
+ const c = text[i];
38
+ if (inQuotes) {
39
+ if (c === '"' && text[i + 1] === '"') {
40
+ field += '"';
41
+ i++;
42
+ continue;
43
+ }
44
+ if (c === '"') {
45
+ inQuotes = false;
46
+ continue;
47
+ }
48
+ field += c;
49
+ continue;
50
+ }
51
+ if (c === '"') {
52
+ inQuotes = true;
53
+ continue;
54
+ }
55
+ if (c === sep) {
56
+ cur.push(field);
57
+ field = '';
58
+ continue;
59
+ }
60
+ if (c === '\r')
61
+ continue;
62
+ if (c === '\n') {
63
+ cur.push(field);
64
+ rows.push(cur);
65
+ cur = [];
66
+ field = '';
67
+ continue;
68
+ }
69
+ field += c;
70
+ }
71
+ if (field !== '' || cur.length > 0) {
72
+ cur.push(field);
73
+ rows.push(cur);
74
+ }
75
+ return rows;
76
+ }
77
+ /**
78
+ * Пишет CSV с `;`-разделителем (русский Excel), CRLF, кавычками вокруг полей
79
+ * с `;` / `"` / переносом и экранированием `"` → `""`. BOM в начале — чтобы
80
+ * Excel сразу распознал UTF-8 кириллицу.
81
+ */
82
+ export function writeCsv(path, rows) {
83
+ const esc = (s) => (/[";\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
84
+ const text = '' + rows.map((r) => r.map((c) => esc(c ?? '')).join(';')).join('\r\n') + '\r\n';
85
+ writeFileSync(path, text, 'utf-8');
86
+ }
@@ -0,0 +1,13 @@
1
+ export { ConfluenceClient, ConfluenceApiError, type ConfluencePage, type ConfluencePageStorage, type ConfluenceAttachment, type ConfluenceLabel, type AttachmentVersionData, type CreatePageOptions, } from './client/client.js';
2
+ export { authHeader, loadConfigFromEnv, type ConfluenceAuthType, type ConfluenceConfig, type LoadConfigOptions, } from './client/config.js';
3
+ export { Markdown } from './markdown/markdown.js';
4
+ export { renderToStorage, extractPlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, } from './markdown/render.js';
5
+ export { validateMarkdown, MarkdownValidationError, type ValidateOptions } from './markdown/validate.js';
6
+ export * from './macros/index.js';
7
+ export { fileSha256, HASH_TAG_PREFIX } from './attachments/hash.js';
8
+ export { Attachment, AttachmentService, toAttachmentVersion, SRC_SHA_SIDECAR_SUFFIX, type AttachmentVersion, type EnsuredAttachment, } from './attachments/attachment.js';
9
+ export { Page, Table } from './pages/page.js';
10
+ export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, type ColumnAlign, type TableColumn, } from './pages/tables.js';
11
+ export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, type PublishPageOptions, type PublishPageResult, type TableData, } from './publish/publish.js';
12
+ export { runPublish, type Here, type Build, type PublishPlan, type RunPublishOptions, } from './publish/runner.js';
13
+ export { confluence, ConfluenceWrapper } from './wrapper.js';
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // Client
2
+ export { ConfluenceClient, ConfluenceApiError, } from './client/client.js';
3
+ export { authHeader, loadConfigFromEnv, } from './client/config.js';
4
+ // Markdown
5
+ export { Markdown } from './markdown/markdown.js';
6
+ export { renderToStorage, extractPlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
7
+ export { validateMarkdown, MarkdownValidationError } from './markdown/validate.js';
8
+ // Macros (pluggable)
9
+ export * from './macros/index.js';
10
+ // Attachments
11
+ export { fileSha256, HASH_TAG_PREFIX } from './attachments/hash.js';
12
+ export { Attachment, AttachmentService, toAttachmentVersion, SRC_SHA_SIDECAR_SUFFIX, } from './attachments/attachment.js';
13
+ // Pages & tables
14
+ export { Page, Table } from './pages/page.js';
15
+ export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, } from './pages/tables.js';
16
+ // Publish
17
+ export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, } from './publish/publish.js';
18
+ export { runPublish, } from './publish/runner.js';
19
+ // Facade
20
+ export { confluence, ConfluenceWrapper } from './wrapper.js';
@@ -0,0 +1,30 @@
1
+ import { Markdown } from '../markdown/markdown.js';
2
+ /**
3
+ * Построитель макроса для использования в markdown.
4
+ *
5
+ * @example
6
+ * const md = macro('table-excerpt')
7
+ * .param('name', 'jira_fact')
8
+ * .param('hide', 'true')
9
+ * .body(tableMarkdown)
10
+ * .toMarkdown();
11
+ */
12
+ export declare class MacroBuilder {
13
+ private macroName;
14
+ private macroParams;
15
+ private bodyContent;
16
+ constructor(macroName: string);
17
+ param(name: string, value: string): this;
18
+ withParams(obj: Record<string, string | undefined>): this;
19
+ body(content: Markdown | string): this;
20
+ toMarkdown(): Markdown;
21
+ }
22
+ /**
23
+ * Создаёт построитель макроса.
24
+ *
25
+ * @example
26
+ * macro('table-excerpt').param('name', 'data').body(md).toMarkdown()
27
+ */
28
+ export declare function macro(name: string): MacroBuilder;
29
+ export declare function escapeParamValue(s: string): string;
30
+ export declare function unescapeParamValue(s: string): string;
@@ -0,0 +1,58 @@
1
+ import { Markdown } from '../markdown/markdown.js';
2
+ /**
3
+ * Построитель макроса для использования в markdown.
4
+ *
5
+ * @example
6
+ * const md = macro('table-excerpt')
7
+ * .param('name', 'jira_fact')
8
+ * .param('hide', 'true')
9
+ * .body(tableMarkdown)
10
+ * .toMarkdown();
11
+ */
12
+ export class MacroBuilder {
13
+ macroName;
14
+ macroParams = [];
15
+ bodyContent = '';
16
+ constructor(macroName) {
17
+ this.macroName = macroName;
18
+ }
19
+ param(name, value) {
20
+ this.macroParams.push({ name, value });
21
+ return this;
22
+ }
23
+ withParams(obj) {
24
+ for (const [k, v] of Object.entries(obj)) {
25
+ if (v !== undefined)
26
+ this.param(k, v);
27
+ }
28
+ return this;
29
+ }
30
+ body(content) {
31
+ this.bodyContent = content instanceof Markdown ? content.toString() : content;
32
+ return this;
33
+ }
34
+ toMarkdown() {
35
+ const paramStr = this.macroParams.length > 0
36
+ ? ':' + this.macroParams.map((p) => `${escapeParamValue(p.name)}=${escapeParamValue(p.value)}`).join(':')
37
+ : '';
38
+ const content = `<!-- MACRO:start:${this.macroName}${paramStr} -->\n` +
39
+ this.bodyContent +
40
+ `\n<!-- MACRO:end:${this.macroName} -->`;
41
+ return new Markdown(content);
42
+ }
43
+ }
44
+ /**
45
+ * Создаёт построитель макроса.
46
+ *
47
+ * @example
48
+ * macro('table-excerpt').param('name', 'data').body(md).toMarkdown()
49
+ */
50
+ export function macro(name) {
51
+ return new MacroBuilder(name);
52
+ }
53
+ export function escapeParamValue(s) {
54
+ return s.replace(/[=:]/g, (c) => (c === '=' ? '%3D' : '%3A'));
55
+ }
56
+ export function unescapeParamValue(s) {
57
+ return s.replace(/%3D/g, '=').replace(/%3A/g, ':');
58
+ }