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.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/dist/attachments/attachment.d.ts +39 -0
- package/dist/attachments/attachment.js +83 -0
- package/dist/attachments/hash.d.ts +2 -0
- package/dist/attachments/hash.js +6 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +85 -0
- package/dist/client/client.d.ts +128 -0
- package/dist/client/client.js +311 -0
- package/dist/client/config.d.ts +42 -0
- package/dist/client/config.js +54 -0
- package/dist/csv.d.ts +18 -0
- package/dist/csv.js +86 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +20 -0
- package/dist/macros/builder.d.ts +30 -0
- package/dist/macros/builder.js +58 -0
- package/dist/macros/index.d.ts +44 -0
- package/dist/macros/index.js +50 -0
- package/dist/macros/plugins/core.d.ts +49 -0
- package/dist/macros/plugins/core.js +214 -0
- package/dist/macros/plugins/table-filter.d.ts +35 -0
- package/dist/macros/plugins/table-filter.js +155 -0
- package/dist/macros/registry.d.ts +29 -0
- package/dist/macros/registry.js +111 -0
- package/dist/macros/types.d.ts +38 -0
- package/dist/macros/types.js +15 -0
- package/dist/macros/xml.d.ts +28 -0
- package/dist/macros/xml.js +52 -0
- package/dist/markdown/markdown.d.ts +36 -0
- package/dist/markdown/markdown.js +78 -0
- package/dist/markdown/render.d.ts +35 -0
- package/dist/markdown/render.js +80 -0
- package/dist/markdown/validate.d.ts +11 -0
- package/dist/markdown/validate.js +60 -0
- package/dist/pages/page.d.ts +104 -0
- package/dist/pages/page.js +234 -0
- package/dist/pages/tables.d.ts +72 -0
- package/dist/pages/tables.js +163 -0
- package/dist/publish/publish.d.ts +67 -0
- package/dist/publish/publish.js +164 -0
- package/dist/publish/runner.d.ts +36 -0
- package/dist/publish/runner.js +41 -0
- package/dist/wrapper.d.ts +21 -0
- package/dist/wrapper.js +36 -0
- package/package.json +69 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Markdown } from '../markdown/markdown.js';
|
|
2
|
+
import { unescapeParamValue } from './builder.js';
|
|
3
|
+
import { generateMacroId } from './xml.js';
|
|
4
|
+
/**
|
|
5
|
+
* Реестр макросов. Пустой при создании — наполняется плагинами через
|
|
6
|
+
* {@link use} или точечной регистрацией через {@link register}.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const registry = new MacroRegistry()
|
|
10
|
+
* .use(coreMacrosPlugin)
|
|
11
|
+
* .use(tableFilterPlugin)
|
|
12
|
+
* .register('my-macro', (ctx) => `<ac:structured-macro ...>`);
|
|
13
|
+
*/
|
|
14
|
+
export class MacroRegistry {
|
|
15
|
+
renderers = new Map();
|
|
16
|
+
pluginNames = [];
|
|
17
|
+
/** Registers all macros of a plugin. Later registrations win on name clash. */
|
|
18
|
+
use(plugin) {
|
|
19
|
+
for (const def of plugin.macros) {
|
|
20
|
+
this.renderers.set(def.name, def.render);
|
|
21
|
+
}
|
|
22
|
+
this.pluginNames.push(plugin.name);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
register(name, renderer) {
|
|
26
|
+
this.renderers.set(name, renderer);
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
has(name) {
|
|
30
|
+
return this.renderers.has(name);
|
|
31
|
+
}
|
|
32
|
+
getRenderer(name) {
|
|
33
|
+
return this.renderers.get(name);
|
|
34
|
+
}
|
|
35
|
+
getAllRenderers() {
|
|
36
|
+
return this.renderers;
|
|
37
|
+
}
|
|
38
|
+
/** Names of plugins registered via {@link use} (for diagnostics). */
|
|
39
|
+
get plugins() {
|
|
40
|
+
return [...this.pluginNames];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Преобразует маркеры макросов в XHTML storage format.
|
|
45
|
+
* Обрабатывает вложенные макросы снизу вверх (от inner к outer).
|
|
46
|
+
*/
|
|
47
|
+
export function processMacros(storage, registry) {
|
|
48
|
+
let content = storage instanceof Markdown ? storage.toString() : storage;
|
|
49
|
+
// Повторяем, пока есть макросы (для обработки вложения)
|
|
50
|
+
let modified = true;
|
|
51
|
+
let iterations = 0;
|
|
52
|
+
const maxIterations = 100;
|
|
53
|
+
while (modified && iterations < maxIterations) {
|
|
54
|
+
modified = false;
|
|
55
|
+
iterations++;
|
|
56
|
+
for (const [macroName, renderer] of registry.getAllRenderers()) {
|
|
57
|
+
const result = processMacroType(content, macroName, renderer);
|
|
58
|
+
if (result !== content) {
|
|
59
|
+
content = result;
|
|
60
|
+
modified = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return new Markdown(content);
|
|
66
|
+
}
|
|
67
|
+
function processMacroType(storage, macroName, renderer) {
|
|
68
|
+
// `:[^\n]*?` — lazy до конца строки маркера. Раньше тут было `[^-]*`,
|
|
69
|
+
// что ломалось на дефис в имени параметра (например cell-width=) или
|
|
70
|
+
// в значении: маркер целиком не матчился и макрос не рендерился.
|
|
71
|
+
const startMarkerRe = new RegExp(`<!-- MACRO:start:${escapeRegex(macroName)}(:[^\\n]*?)? -->`, 'g');
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = startMarkerRe.exec(storage)) !== null) {
|
|
74
|
+
const paramStr = match[1] ?? '';
|
|
75
|
+
const params = parseParams(paramStr);
|
|
76
|
+
const startIdx = match.index;
|
|
77
|
+
const endMarker = `<!-- MACRO:end:${macroName} -->`;
|
|
78
|
+
const endIdx = storage.indexOf(endMarker, startIdx);
|
|
79
|
+
if (endIdx === -1) {
|
|
80
|
+
console.warn(`[macro] start marker without end for macro '${macroName}' at position ${startIdx}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const bodyStart = startIdx + match[0].length;
|
|
84
|
+
const body = storage.substring(bodyStart, endIdx).trim();
|
|
85
|
+
const macroId = generateMacroId();
|
|
86
|
+
const macroXhtml = renderer({ params, body, macroId });
|
|
87
|
+
const before = storage.substring(0, startIdx);
|
|
88
|
+
const after = storage.substring(endIdx + endMarker.length);
|
|
89
|
+
storage = before + macroXhtml + after;
|
|
90
|
+
// Сбрасываем поиск для повторной обработки
|
|
91
|
+
startMarkerRe.lastIndex = 0;
|
|
92
|
+
}
|
|
93
|
+
return storage;
|
|
94
|
+
}
|
|
95
|
+
function parseParams(paramStr) {
|
|
96
|
+
if (!paramStr.trim())
|
|
97
|
+
return [];
|
|
98
|
+
return paramStr
|
|
99
|
+
.split(':')
|
|
100
|
+
.filter((p) => p.trim())
|
|
101
|
+
.map((pair) => {
|
|
102
|
+
const [name, value] = pair.split('=');
|
|
103
|
+
return {
|
|
104
|
+
name: unescapeParamValue(name?.trim() ?? ''),
|
|
105
|
+
value: unescapeParamValue(value?.trim() ?? ''),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function escapeRegex(s) {
|
|
110
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types of the pluggable macro system.
|
|
3
|
+
*
|
|
4
|
+
* Макросы в markdown представлены как комментарии:
|
|
5
|
+
* <!-- MACRO:start:name:param1=value1:param2=value2 -->
|
|
6
|
+
* markdown content / nested macros
|
|
7
|
+
* <!-- MACRO:end:name -->
|
|
8
|
+
*
|
|
9
|
+
* После рендера в storage format эти маркеры преобразуются в
|
|
10
|
+
* <ac:structured-macro> с соответствующими параметрами.
|
|
11
|
+
*/
|
|
12
|
+
export interface MacroParam {
|
|
13
|
+
name: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
export interface RendererContext {
|
|
17
|
+
params: MacroParam[];
|
|
18
|
+
/** Macro body already rendered to XHTML (may contain nested rendered macros). */
|
|
19
|
+
body: string;
|
|
20
|
+
/** Generated UUID for ac:macro-id. */
|
|
21
|
+
macroId: string;
|
|
22
|
+
}
|
|
23
|
+
/** Renders a macro marker into Confluence storage-format XHTML. */
|
|
24
|
+
export type MacroRenderer = (ctx: RendererContext) => string;
|
|
25
|
+
export interface MacroDefinition {
|
|
26
|
+
name: string;
|
|
27
|
+
render: MacroRenderer;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A plugin is a named collection of macro definitions. Register it on a
|
|
31
|
+
* {@link MacroRegistry} via `registry.use(plugin)`.
|
|
32
|
+
*/
|
|
33
|
+
export interface MacroPlugin {
|
|
34
|
+
name: string;
|
|
35
|
+
macros: MacroDefinition[];
|
|
36
|
+
}
|
|
37
|
+
/** Convenience: turn `params` array into a name→value map. */
|
|
38
|
+
export declare function paramMap(params: MacroParam[]): Record<string, string>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types of the pluggable macro system.
|
|
3
|
+
*
|
|
4
|
+
* Макросы в markdown представлены как комментарии:
|
|
5
|
+
* <!-- MACRO:start:name:param1=value1:param2=value2 -->
|
|
6
|
+
* markdown content / nested macros
|
|
7
|
+
* <!-- MACRO:end:name -->
|
|
8
|
+
*
|
|
9
|
+
* После рендера в storage format эти маркеры преобразуются в
|
|
10
|
+
* <ac:structured-macro> с соответствующими параметрами.
|
|
11
|
+
*/
|
|
12
|
+
/** Convenience: turn `params` array into a name→value map. */
|
|
13
|
+
export function paramMap(params) {
|
|
14
|
+
return Object.fromEntries(params.map((p) => [p.name, p.value]));
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** XML helpers shared by macro renderers. */
|
|
2
|
+
import type { MacroParam } from './types.js';
|
|
3
|
+
/** Экранирует строку для XML-атрибутов и текстовых узлов. */
|
|
4
|
+
export declare function escapeXmlAttr(s: string): string;
|
|
5
|
+
/** Генерирует UUID для ac:macro-id. */
|
|
6
|
+
export declare function generateMacroId(): string;
|
|
7
|
+
export interface StructuredMacroOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Macro parameters. `undefined` values are skipped. A key rendered as
|
|
10
|
+
* empty string (`''`) produces `<ac:parameter ac:name="">` — some
|
|
11
|
+
* built-in macros (anchor, include) use the unnamed parameter.
|
|
12
|
+
* Values are escaped unless wrapped in {@link rawValue}.
|
|
13
|
+
*/
|
|
14
|
+
params?: Array<MacroParam & {
|
|
15
|
+
raw?: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
/** Rich text body (already-rendered XHTML). */
|
|
18
|
+
richBody?: string;
|
|
19
|
+
/** Plain text body — wrapped in CDATA (for `code`, `noformat`, etc.). */
|
|
20
|
+
plainBody?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Assembles an `<ac:structured-macro>` element. Takes care of parameter
|
|
24
|
+
* escaping, rich vs plain bodies and CDATA safety.
|
|
25
|
+
*/
|
|
26
|
+
export declare function structuredMacro(name: string, macroId: string, opts?: StructuredMacroOptions): string;
|
|
27
|
+
/** Builds an `<ac:link><ri:page .../></ac:link>` value for page-reference params. */
|
|
28
|
+
export declare function pageLinkValue(title: string, spaceKey?: string): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** XML helpers shared by macro renderers. */
|
|
2
|
+
/** Экранирует строку для XML-атрибутов и текстовых узлов. */
|
|
3
|
+
export function escapeXmlAttr(s) {
|
|
4
|
+
return s.replace(/[<>&"']/g, (c) => {
|
|
5
|
+
switch (c) {
|
|
6
|
+
case '<': return '<';
|
|
7
|
+
case '>': return '>';
|
|
8
|
+
case '&': return '&';
|
|
9
|
+
case '"': return '"';
|
|
10
|
+
case "'": return ''';
|
|
11
|
+
default: return c;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Генерирует UUID для ac:macro-id. */
|
|
16
|
+
export function generateMacroId() {
|
|
17
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
18
|
+
const r = (Math.random() * 16) | 0;
|
|
19
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
20
|
+
return v.toString(16);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Assembles an `<ac:structured-macro>` element. Takes care of parameter
|
|
25
|
+
* escaping, rich vs plain bodies and CDATA safety.
|
|
26
|
+
*/
|
|
27
|
+
export function structuredMacro(name, macroId, opts = {}) {
|
|
28
|
+
const params = (opts.params ?? [])
|
|
29
|
+
.map((p) => {
|
|
30
|
+
const value = p.raw ? p.value : escapeXmlAttr(p.value);
|
|
31
|
+
return `<ac:parameter ac:name="${escapeXmlAttr(p.name)}">${value}</ac:parameter>`;
|
|
32
|
+
})
|
|
33
|
+
.join('');
|
|
34
|
+
let body = '';
|
|
35
|
+
if (opts.plainBody !== undefined) {
|
|
36
|
+
// `]]>` внутри CDATA недопустим — разрезаем на соседние CDATA-секции.
|
|
37
|
+
const safe = opts.plainBody.replace(/\]\]>/g, ']]]]><![CDATA[>');
|
|
38
|
+
body = `<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body>`;
|
|
39
|
+
}
|
|
40
|
+
else if (opts.richBody !== undefined) {
|
|
41
|
+
body = `<ac:rich-text-body>${opts.richBody}</ac:rich-text-body>`;
|
|
42
|
+
}
|
|
43
|
+
return (`<ac:structured-macro ac:name="${escapeXmlAttr(name)}" ac:schema-version="1" ac:macro-id="${escapeXmlAttr(macroId)}">` +
|
|
44
|
+
params +
|
|
45
|
+
body +
|
|
46
|
+
`</ac:structured-macro>`);
|
|
47
|
+
}
|
|
48
|
+
/** Builds an `<ac:link><ri:page .../></ac:link>` value for page-reference params. */
|
|
49
|
+
export function pageLinkValue(title, spaceKey) {
|
|
50
|
+
const space = spaceKey ? ` ri:space-key="${escapeXmlAttr(spaceKey)}"` : '';
|
|
51
|
+
return `<ac:link><ri:page ri:content-title="${escapeXmlAttr(title)}"${space}/></ac:link>`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Типизированное представление Markdown контента с валидацией.
|
|
3
|
+
*
|
|
4
|
+
* Вместо обычных строк используется класс Markdown для:
|
|
5
|
+
* - Явности типа в сигнатурах функций
|
|
6
|
+
* - Валидации содержимого (незакрытые макросы, неверные плейсхолдеры)
|
|
7
|
+
* - Удобства преобразований и композиции
|
|
8
|
+
*/
|
|
9
|
+
export declare class Markdown {
|
|
10
|
+
private readonly content;
|
|
11
|
+
constructor(content: string);
|
|
12
|
+
/**
|
|
13
|
+
* Валидирует markdown контент.
|
|
14
|
+
*
|
|
15
|
+
* Проверяет:
|
|
16
|
+
* - Закрытость всех макросов (MACRO:start/end)
|
|
17
|
+
* - Корректность плейсхолдеров таблиц
|
|
18
|
+
*/
|
|
19
|
+
private validate;
|
|
20
|
+
private validateMacros;
|
|
21
|
+
private validateTablePlaceholders;
|
|
22
|
+
/** Возвращает строковое представление. */
|
|
23
|
+
toString(): string;
|
|
24
|
+
/** Возвращает длину контента. */
|
|
25
|
+
get length(): number;
|
|
26
|
+
/** Проверяет, содержит ли контент таблицы. */
|
|
27
|
+
hasTables(): boolean;
|
|
28
|
+
/** Извлекает все имена плейсхолдеров таблиц. */
|
|
29
|
+
getTableNames(): string[];
|
|
30
|
+
/** Создаёт Markdown из строки. */
|
|
31
|
+
static from(content: string): Markdown;
|
|
32
|
+
/** Объединяет несколько Markdown в один. */
|
|
33
|
+
static concat(...parts: (Markdown | string)[]): Markdown;
|
|
34
|
+
/** Пустой Markdown. */
|
|
35
|
+
static empty(): Markdown;
|
|
36
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Типизированное представление Markdown контента с валидацией.
|
|
3
|
+
*
|
|
4
|
+
* Вместо обычных строк используется класс Markdown для:
|
|
5
|
+
* - Явности типа в сигнатурах функций
|
|
6
|
+
* - Валидации содержимого (незакрытые макросы, неверные плейсхолдеры)
|
|
7
|
+
* - Удобства преобразований и композиции
|
|
8
|
+
*/
|
|
9
|
+
export class Markdown {
|
|
10
|
+
content;
|
|
11
|
+
constructor(content) {
|
|
12
|
+
this.validate(content);
|
|
13
|
+
this.content = content;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Валидирует markdown контент.
|
|
17
|
+
*
|
|
18
|
+
* Проверяет:
|
|
19
|
+
* - Закрытость всех макросов (MACRO:start/end)
|
|
20
|
+
* - Корректность плейсхолдеров таблиц
|
|
21
|
+
*/
|
|
22
|
+
validate(content) {
|
|
23
|
+
this.validateMacros(content);
|
|
24
|
+
this.validateTablePlaceholders(content);
|
|
25
|
+
}
|
|
26
|
+
validateMacros(content) {
|
|
27
|
+
const startMarkers = (content.match(/<!-- MACRO:start:/g) || []).length;
|
|
28
|
+
const endMarkers = (content.match(/<!-- MACRO:end:/g) || []).length;
|
|
29
|
+
if (startMarkers !== endMarkers) {
|
|
30
|
+
throw new Error(`Markdown validation failed: mismatched macro markers (start: ${startMarkers}, end: ${endMarkers})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
validateTablePlaceholders(content) {
|
|
34
|
+
const placeholders = Array.from(content.matchAll(/\{\{table:([a-z0-9_-]+)\}\}/gi));
|
|
35
|
+
if (placeholders.length === 0)
|
|
36
|
+
return;
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const [, name] of placeholders) {
|
|
39
|
+
if (seen.has(name.toLowerCase())) {
|
|
40
|
+
throw new Error(`Markdown validation failed: duplicate table placeholder '{{table:${name}}}'`);
|
|
41
|
+
}
|
|
42
|
+
seen.add(name.toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Возвращает строковое представление. */
|
|
46
|
+
toString() {
|
|
47
|
+
return this.content;
|
|
48
|
+
}
|
|
49
|
+
/** Возвращает длину контента. */
|
|
50
|
+
get length() {
|
|
51
|
+
return this.content.length;
|
|
52
|
+
}
|
|
53
|
+
/** Проверяет, содержит ли контент таблицы. */
|
|
54
|
+
hasTables() {
|
|
55
|
+
return /\{\{table:/.test(this.content);
|
|
56
|
+
}
|
|
57
|
+
/** Извлекает все имена плейсхолдеров таблиц. */
|
|
58
|
+
getTableNames() {
|
|
59
|
+
const names = new Set();
|
|
60
|
+
for (const [, name] of this.content.matchAll(/\{\{table:([a-z0-9_-]+)\}\}/gi)) {
|
|
61
|
+
names.add(name.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
return Array.from(names);
|
|
64
|
+
}
|
|
65
|
+
/** Создаёт Markdown из строки. */
|
|
66
|
+
static from(content) {
|
|
67
|
+
return new Markdown(content);
|
|
68
|
+
}
|
|
69
|
+
/** Объединяет несколько Markdown в один. */
|
|
70
|
+
static concat(...parts) {
|
|
71
|
+
const content = parts.map((p) => (p instanceof Markdown ? p.toString() : p)).join('');
|
|
72
|
+
return new Markdown(content);
|
|
73
|
+
}
|
|
74
|
+
/** Пустой Markdown. */
|
|
75
|
+
static empty() {
|
|
76
|
+
return new Markdown('');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const PLACEHOLDER_RE: RegExp;
|
|
2
|
+
export interface ExtractedPlaceholders {
|
|
3
|
+
images: string[];
|
|
4
|
+
files: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function extractPlaceholders(markdown: string): ExtractedPlaceholders;
|
|
7
|
+
export interface AttachmentUrls {
|
|
8
|
+
/** filename → абсолютный URL аттача, полученный из Confluence после аплоада. */
|
|
9
|
+
images: Map<string, string>;
|
|
10
|
+
files: Map<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export declare class MissingAttachmentUrlError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Превращает Markdown в Confluence storage format.
|
|
17
|
+
*
|
|
18
|
+
* Логика двушаговая:
|
|
19
|
+
* 1) markdown-it рендерит сам Markdown (без знания про плейсхолдеры).
|
|
20
|
+
* `{{img:foo}}` и `{{file:foo}}` для markdown-it — обычный текст
|
|
21
|
+
* без спецсимволов, доходят до выхода нетронутыми (могут быть
|
|
22
|
+
* обёрнуты в <p>...</p>, что нас устраивает).
|
|
23
|
+
* 2) В готовом HTML регуляркой подставляем `<img src=URL/>` и
|
|
24
|
+
* `<a href=URL>filename</a>` на места плейсхолдеров. URL обязан
|
|
25
|
+
* существовать для каждого плейсхолдера — иначе
|
|
26
|
+
* MissingAttachmentUrlError.
|
|
27
|
+
*
|
|
28
|
+
* Раньше использовалась схема через sentinel-токены (` CFLIMG_0 `):
|
|
29
|
+
* markdown-it трогал окружающие пробелы при упаковке в <p>, sentinel
|
|
30
|
+
* терял пробелы, итоговый .split() не находил совпадения и в Confluence
|
|
31
|
+
* вместо картинки попадал кусок «CFLIMG_0». Прямая регекс-замена по
|
|
32
|
+
* HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
|
|
33
|
+
* посимвольно.
|
|
34
|
+
*/
|
|
35
|
+
export declare function renderToStorage(markdown: string, urls: AttachmentUrls): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
// xhtmlOut: true — Confluence storage format = XHTML, void-элементы
|
|
3
|
+
// (<hr/>, <br/>, <img/>) обязаны быть самозакрывающимися.
|
|
4
|
+
// html: true — разрешить HTML (нужно для <!-- MACRO:... --> комментариев)
|
|
5
|
+
const md = new MarkdownIt({
|
|
6
|
+
html: true,
|
|
7
|
+
linkify: true,
|
|
8
|
+
typographer: false,
|
|
9
|
+
breaks: false,
|
|
10
|
+
xhtmlOut: true,
|
|
11
|
+
});
|
|
12
|
+
export const PLACEHOLDER_RE = /\{\{(img|file):([^}]+)\}\}/g;
|
|
13
|
+
export function extractPlaceholders(markdown) {
|
|
14
|
+
const images = new Set();
|
|
15
|
+
const files = new Set();
|
|
16
|
+
for (const m of markdown.matchAll(PLACEHOLDER_RE)) {
|
|
17
|
+
const name = m[2].trim();
|
|
18
|
+
if (m[1] === 'img')
|
|
19
|
+
images.add(name);
|
|
20
|
+
else
|
|
21
|
+
files.add(name);
|
|
22
|
+
}
|
|
23
|
+
return { images: [...images], files: [...files] };
|
|
24
|
+
}
|
|
25
|
+
function escapeXmlAttr(s) {
|
|
26
|
+
return s.replace(/[<>&"']/g, (c) => {
|
|
27
|
+
switch (c) {
|
|
28
|
+
case '<': return '<';
|
|
29
|
+
case '>': return '>';
|
|
30
|
+
case '&': return '&';
|
|
31
|
+
case '"': return '"';
|
|
32
|
+
case "'": return ''';
|
|
33
|
+
default: return c;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export class MissingAttachmentUrlError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'MissingAttachmentUrlError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Превращает Markdown в Confluence storage format.
|
|
45
|
+
*
|
|
46
|
+
* Логика двушаговая:
|
|
47
|
+
* 1) markdown-it рендерит сам Markdown (без знания про плейсхолдеры).
|
|
48
|
+
* `{{img:foo}}` и `{{file:foo}}` для markdown-it — обычный текст
|
|
49
|
+
* без спецсимволов, доходят до выхода нетронутыми (могут быть
|
|
50
|
+
* обёрнуты в <p>...</p>, что нас устраивает).
|
|
51
|
+
* 2) В готовом HTML регуляркой подставляем `<img src=URL/>` и
|
|
52
|
+
* `<a href=URL>filename</a>` на места плейсхолдеров. URL обязан
|
|
53
|
+
* существовать для каждого плейсхолдера — иначе
|
|
54
|
+
* MissingAttachmentUrlError.
|
|
55
|
+
*
|
|
56
|
+
* Раньше использовалась схема через sentinel-токены (` CFLIMG_0 `):
|
|
57
|
+
* markdown-it трогал окружающие пробелы при упаковке в <p>, sentinel
|
|
58
|
+
* терял пробелы, итоговый .split() не находил совпадения и в Confluence
|
|
59
|
+
* вместо картинки попадал кусок «CFLIMG_0». Прямая регекс-замена по
|
|
60
|
+
* HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
|
|
61
|
+
* посимвольно.
|
|
62
|
+
*/
|
|
63
|
+
export function renderToStorage(markdown, urls) {
|
|
64
|
+
const html = md.render(markdown);
|
|
65
|
+
return html.replace(PLACEHOLDER_RE, (_full, kind, rawName) => {
|
|
66
|
+
const name = String(rawName).trim();
|
|
67
|
+
if (kind === 'img') {
|
|
68
|
+
const url = urls.images.get(name);
|
|
69
|
+
if (!url) {
|
|
70
|
+
throw new MissingAttachmentUrlError(`No uploaded URL for image '${name}' — was it included in publishPage().images and successfully uploaded?`);
|
|
71
|
+
}
|
|
72
|
+
return `<img src="${escapeXmlAttr(url)}" alt="${escapeXmlAttr(name)}" />`;
|
|
73
|
+
}
|
|
74
|
+
const url = urls.files.get(name);
|
|
75
|
+
if (!url) {
|
|
76
|
+
throw new MissingAttachmentUrlError(`No uploaded URL for file '${name}' — was it included in publishPage().files and successfully uploaded?`);
|
|
77
|
+
}
|
|
78
|
+
return `<a href="${escapeXmlAttr(url)}">${escapeXmlAttr(name)}</a>`;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class MarkdownValidationError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export interface ValidateOptions {
|
|
5
|
+
markdown: string;
|
|
6
|
+
imagePaths: string[];
|
|
7
|
+
filePaths: string[];
|
|
8
|
+
tableNames?: string[];
|
|
9
|
+
sourceLabel?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function validateMarkdown(opts: ValidateOptions): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { extractPlaceholders } from './render.js';
|
|
3
|
+
export class MarkdownValidationError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'MarkdownValidationError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function validateMarkdown(opts) {
|
|
10
|
+
const { images, files } = extractPlaceholders(opts.markdown);
|
|
11
|
+
const tables = extractTablePlaceholders(opts.markdown);
|
|
12
|
+
const providedImages = new Set(opts.imagePaths.map((p) => basename(p)));
|
|
13
|
+
const providedFiles = new Set(opts.filePaths.map((p) => basename(p)));
|
|
14
|
+
const providedTables = new Set(opts.tableNames ?? []);
|
|
15
|
+
const label = opts.sourceLabel ? `${opts.sourceLabel}: ` : '';
|
|
16
|
+
const errors = [];
|
|
17
|
+
for (const name of images) {
|
|
18
|
+
if (!providedImages.has(name)) {
|
|
19
|
+
errors.push(`${label}image placeholder {{img:${name}}} has no matching file in images[]`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (const name of files) {
|
|
23
|
+
if (!providedFiles.has(name)) {
|
|
24
|
+
errors.push(`${label}file placeholder {{file:${name}}} has no matching file in files[]`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const name of tables) {
|
|
28
|
+
if (!providedTables.has(name)) {
|
|
29
|
+
errors.push(`${label}table placeholder {{table:${name}}} has no matching table in tables[]`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const referencedImages = new Set(images);
|
|
33
|
+
const referencedFiles = new Set(files);
|
|
34
|
+
const referencedTables = new Set(tables);
|
|
35
|
+
for (const name of providedImages) {
|
|
36
|
+
if (!referencedImages.has(name)) {
|
|
37
|
+
console.warn(`[validate] ${label}image '${name}' provided but not referenced by any {{img:...}} placeholder`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const name of providedFiles) {
|
|
41
|
+
if (!referencedFiles.has(name)) {
|
|
42
|
+
console.warn(`[validate] ${label}file '${name}' provided but not referenced by any {{file:...}} placeholder`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const name of providedTables) {
|
|
46
|
+
if (!referencedTables.has(name)) {
|
|
47
|
+
console.warn(`[validate] ${label}table '${name}' provided but not referenced by any {{table:...}} placeholder`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (errors.length > 0) {
|
|
51
|
+
throw new MarkdownValidationError(`Markdown validation failed:\n - ${errors.join('\n - ')}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function extractTablePlaceholders(markdown) {
|
|
55
|
+
const tables = new Set();
|
|
56
|
+
for (const m of markdown.matchAll(/\{\{table:([a-z0-9_-]+)\}\}/gi)) {
|
|
57
|
+
tables.add(m[1].trim());
|
|
58
|
+
}
|
|
59
|
+
return [...tables];
|
|
60
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Объектная модель для работы со страницами и таблицами Confluence.
|
|
3
|
+
*/
|
|
4
|
+
import type { ConfluenceClient, ConfluenceLabel, ConfluencePage } from '../client/client.js';
|
|
5
|
+
import { Attachment } from '../attachments/attachment.js';
|
|
6
|
+
import { Markdown } from '../markdown/markdown.js';
|
|
7
|
+
/**
|
|
8
|
+
* Представляет страницу Confluence.
|
|
9
|
+
*/
|
|
10
|
+
export declare class Page {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
private readonly storage;
|
|
14
|
+
private readonly client;
|
|
15
|
+
constructor(id: string, title: string, storage: string, client: ConfluenceClient);
|
|
16
|
+
/** Сырой storage-контент страницы. */
|
|
17
|
+
getStorage(): string;
|
|
18
|
+
/**
|
|
19
|
+
* Скачивает аттач страницы по имени файла. Возвращает сырые байты.
|
|
20
|
+
* Текстовая декодировка — через {@link getAttachmentText}.
|
|
21
|
+
*/
|
|
22
|
+
getAttachment(filename: string): Promise<Buffer>;
|
|
23
|
+
/** Скачивает аттач и декодирует как текст (по умолчанию utf-8). */
|
|
24
|
+
getAttachmentText(filename: string, encoding?: BufferEncoding): Promise<string>;
|
|
25
|
+
/** Список аттачей страницы с историей версий (versions заполнены). */
|
|
26
|
+
getAttachments(): Promise<Attachment[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Удаляет у всех аттачей страницы старые версии, оставляя только последнюю
|
|
29
|
+
* (max по номеру). Полезно для регулярно перезаписываемых файлов (CSV),
|
|
30
|
+
* которые иначе копят десятки версий. Возвращает число удалённых версий.
|
|
31
|
+
*/
|
|
32
|
+
removeOldAttachmentVersions(): Promise<number>;
|
|
33
|
+
/** Лейблы страницы. */
|
|
34
|
+
getLabels(): Promise<ConfluenceLabel[]>;
|
|
35
|
+
/** Добавляет лейблы (идемпотентно). */
|
|
36
|
+
addLabels(labels: string[]): Promise<void>;
|
|
37
|
+
/** Дочерние страницы. */
|
|
38
|
+
getChildren(): Promise<ConfluencePage[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Извлекает таблицу со страницы.
|
|
41
|
+
*
|
|
42
|
+
* @param target порядковый номер таблицы (0 = первая) или имя
|
|
43
|
+
* table-excerpt-макроса, обёрнутого вокруг таблицы (параметр `name`).
|
|
44
|
+
* @throws если таблица не найдена
|
|
45
|
+
*/
|
|
46
|
+
getTable(target?: number | string): Table;
|
|
47
|
+
/**
|
|
48
|
+
* Извлекает все таблицы со страницы.
|
|
49
|
+
*/
|
|
50
|
+
getTables(): Table[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Представляет таблицу Confluence (извлечённую из HTML storage format).
|
|
54
|
+
*
|
|
55
|
+
* Предоставляет методы для преобразования таблицы в разные форматы:
|
|
56
|
+
* - `toCells()` — массив строк и ячеек
|
|
57
|
+
* - `toAny()` — массив объектов с заголовками как ключи
|
|
58
|
+
* - `toType<T>()` — массив типизированных объектов с маппером
|
|
59
|
+
*/
|
|
60
|
+
export declare class Table {
|
|
61
|
+
private readonly cells;
|
|
62
|
+
private readonly headers;
|
|
63
|
+
constructor(tableHtml: string[][]);
|
|
64
|
+
/**
|
|
65
|
+
* Возвращает сырой массив ячеек таблицы.
|
|
66
|
+
*/
|
|
67
|
+
toCells(): string[][];
|
|
68
|
+
/**
|
|
69
|
+
* Возвращает строки таблицы (без заголовков) как массив объектов.
|
|
70
|
+
*
|
|
71
|
+
* Ключи объектов — заголовки колонок.
|
|
72
|
+
* Если есть дублирующиеся заголовки, к имени добавляется порядковый номер.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* table.toAny() → [
|
|
76
|
+
* { 'Таб.№': '14800145', 'ФИО': 'Иванов И.И.', ... },
|
|
77
|
+
* { 'Таб.№': '14800146', 'ФИО': 'Петров П.П.', ... },
|
|
78
|
+
* ]
|
|
79
|
+
*/
|
|
80
|
+
toAny(): Record<string, string>[];
|
|
81
|
+
/**
|
|
82
|
+
* Преобразует таблицу в массив типизированных объектов.
|
|
83
|
+
*
|
|
84
|
+
* @param mapper функция, которая заполняет поля результирующего объекта на основе строки таблицы
|
|
85
|
+
* @returns массив объектов типа T, заполненный маппером
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* const employees = table.toType<Employee>((row, emp) => {
|
|
89
|
+
* emp.tabNumber = row['Таб.№'];
|
|
90
|
+
* emp.fio = row['ФИО'];
|
|
91
|
+
* });
|
|
92
|
+
*/
|
|
93
|
+
toType<T>(mapper: (row: Record<string, string>, result: T) => void): T[];
|
|
94
|
+
/** Возвращает количество строк (включая заголовок). */
|
|
95
|
+
get rowCount(): number;
|
|
96
|
+
/** Возвращает количество колонок. */
|
|
97
|
+
get columnCount(): number;
|
|
98
|
+
/** Возвращает заголовки колонок. */
|
|
99
|
+
getHeaders(): string[];
|
|
100
|
+
/** Возвращает таблицу в Markdown формате для использования с макросами. */
|
|
101
|
+
toMarkdown(): Markdown;
|
|
102
|
+
/** Строит карту заголовков с обработкой дубликатов. */
|
|
103
|
+
private buildHeaderMap;
|
|
104
|
+
}
|