confluence-md-sync 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -21
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +76 -28
- package/dist/export/canonical.d.ts +38 -0
- package/dist/export/canonical.js +272 -0
- package/dist/export/export-page.d.ts +31 -0
- package/dist/export/export-page.js +41 -0
- package/dist/export/roundtrip.d.ts +30 -0
- package/dist/export/roundtrip.js +32 -0
- package/dist/export/to-markdown.d.ts +37 -0
- package/dist/export/to-markdown.js +845 -0
- package/dist/export/xhtml.d.ts +52 -0
- package/dist/export/xhtml.js +230 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +8 -1
- package/dist/macros/builder.d.ts +7 -0
- package/dist/macros/builder.js +23 -2
- package/dist/macros/index.d.ts +2 -1
- package/dist/macros/index.js +2 -1
- package/dist/macros/plugins/core.js +3 -0
- package/dist/macros/plugins/table-filter.d.ts +8 -0
- package/dist/macros/plugins/table-filter.js +35 -3
- package/dist/macros/registry.d.ts +11 -0
- package/dist/macros/registry.js +45 -1
- package/dist/markdown/render.d.ts +30 -1
- package/dist/markdown/render.js +83 -13
- package/dist/publish/publish.d.ts +17 -0
- package/dist/publish/publish.js +74 -14
- package/dist/publish/remote.d.ts +25 -0
- package/dist/publish/remote.js +52 -0
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Мини-парсер и сериализатор storage-формата Confluence.
|
|
3
|
+
*
|
|
4
|
+
* Storage — well-formed XML-фрагмент (XHTML + пространства имён ac:/ri:),
|
|
5
|
+
* но с HTML-сущностями ( и т.п.), которые «настоящий» XML-парсер не
|
|
6
|
+
* переварит. Поэтому свой парсер: сущности в тексте и атрибутах НЕ
|
|
7
|
+
* декодируются (хранятся как в исходнике — это даёт побайтовую
|
|
8
|
+
* сериализацию обратно), а декодирование по требованию делает
|
|
9
|
+
* {@link decodeEntities}.
|
|
10
|
+
*/
|
|
11
|
+
export interface XElement {
|
|
12
|
+
kind: 'el';
|
|
13
|
+
name: string;
|
|
14
|
+
/** Пары [имя, raw-значение] в исходном порядке; значения не декодированы. */
|
|
15
|
+
attrs: Array<[string, string]>;
|
|
16
|
+
children: XNode[];
|
|
17
|
+
selfClosing: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface XText {
|
|
20
|
+
kind: 'text';
|
|
21
|
+
/** Текст как в исходнике, сущности не декодированы. */
|
|
22
|
+
raw: string;
|
|
23
|
+
}
|
|
24
|
+
export interface XCdata {
|
|
25
|
+
kind: 'cdata';
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
export interface XComment {
|
|
29
|
+
kind: 'comment';
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
export type XNode = XElement | XText | XCdata | XComment;
|
|
33
|
+
export declare class StorageParseError extends Error {
|
|
34
|
+
readonly position: number;
|
|
35
|
+
constructor(message: string, position: number);
|
|
36
|
+
}
|
|
37
|
+
/** Парсит storage-фрагмент в список узлов. Бросает StorageParseError. */
|
|
38
|
+
export declare function parseStorage(input: string): XNode[];
|
|
39
|
+
/** Сериализует узлы обратно в storage. Для нетронутого дерева — побайтово. */
|
|
40
|
+
export declare function serializeStorage(nodes: XNode[]): string;
|
|
41
|
+
/** Декодирует HTML/XML-сущности (именованные из словаря + числовые). */
|
|
42
|
+
export declare function decodeEntities(raw: string): string;
|
|
43
|
+
/** Значение атрибута элемента (декодированное) или undefined. */
|
|
44
|
+
export declare function getAttr(el: XElement, name: string): string | undefined;
|
|
45
|
+
/** Текстовое содержимое поддерева (сущности декодированы, теги отброшены). */
|
|
46
|
+
export declare function textContent(nodes: XNode[]): string;
|
|
47
|
+
/** true, если в поддереве есть элементы с namespace-префиксом (ac:, ri:, …). */
|
|
48
|
+
export declare function hasNamespacedElements(nodes: XNode[]): boolean;
|
|
49
|
+
/** Только элементы среди узлов. */
|
|
50
|
+
export declare function elements(nodes: XNode[]): XElement[];
|
|
51
|
+
/** Экранирует текст для XML (используется при генерации новых узлов). */
|
|
52
|
+
export declare function escapeXmlText(s: string): string;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Мини-парсер и сериализатор storage-формата Confluence.
|
|
3
|
+
*
|
|
4
|
+
* Storage — well-formed XML-фрагмент (XHTML + пространства имён ac:/ri:),
|
|
5
|
+
* но с HTML-сущностями ( и т.п.), которые «настоящий» XML-парсер не
|
|
6
|
+
* переварит. Поэтому свой парсер: сущности в тексте и атрибутах НЕ
|
|
7
|
+
* декодируются (хранятся как в исходнике — это даёт побайтовую
|
|
8
|
+
* сериализацию обратно), а декодирование по требованию делает
|
|
9
|
+
* {@link decodeEntities}.
|
|
10
|
+
*/
|
|
11
|
+
export class StorageParseError extends Error {
|
|
12
|
+
position;
|
|
13
|
+
constructor(message, position) {
|
|
14
|
+
super(`${message} (at offset ${position})`);
|
|
15
|
+
this.position = position;
|
|
16
|
+
this.name = 'StorageParseError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// XHTML-«пустые» элементы: на случай невалидного <br> без самозакрытия.
|
|
20
|
+
const VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'col', 'input', 'meta', 'link']);
|
|
21
|
+
const NAME_RE = /[A-Za-z_][A-Za-z0-9._:-]*/y;
|
|
22
|
+
/** Парсит storage-фрагмент в список узлов. Бросает StorageParseError. */
|
|
23
|
+
export function parseStorage(input) {
|
|
24
|
+
const root = [];
|
|
25
|
+
const stack = [];
|
|
26
|
+
let current = root;
|
|
27
|
+
let i = 0;
|
|
28
|
+
const pushText = (from, to) => {
|
|
29
|
+
if (to > from)
|
|
30
|
+
current.push({ kind: 'text', raw: input.slice(from, to) });
|
|
31
|
+
};
|
|
32
|
+
let textStart = 0;
|
|
33
|
+
while (i < input.length) {
|
|
34
|
+
const lt = input.indexOf('<', i);
|
|
35
|
+
if (lt === -1)
|
|
36
|
+
break;
|
|
37
|
+
pushText(textStart, lt);
|
|
38
|
+
if (input.startsWith('<![CDATA[', lt)) {
|
|
39
|
+
const end = input.indexOf(']]>', lt + 9);
|
|
40
|
+
if (end === -1)
|
|
41
|
+
throw new StorageParseError('unterminated CDATA', lt);
|
|
42
|
+
current.push({ kind: 'cdata', text: input.slice(lt + 9, end) });
|
|
43
|
+
i = textStart = end + 3;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (input.startsWith('<!--', lt)) {
|
|
47
|
+
const end = input.indexOf('-->', lt + 4);
|
|
48
|
+
if (end === -1)
|
|
49
|
+
throw new StorageParseError('unterminated comment', lt);
|
|
50
|
+
current.push({ kind: 'comment', text: input.slice(lt + 4, end) });
|
|
51
|
+
i = textStart = end + 3;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (input.startsWith('</', lt)) {
|
|
55
|
+
NAME_RE.lastIndex = lt + 2;
|
|
56
|
+
const m = NAME_RE.exec(input);
|
|
57
|
+
if (!m)
|
|
58
|
+
throw new StorageParseError('malformed closing tag', lt);
|
|
59
|
+
const gt = input.indexOf('>', lt);
|
|
60
|
+
if (gt === -1)
|
|
61
|
+
throw new StorageParseError('unterminated closing tag', lt);
|
|
62
|
+
const frame = stack.pop();
|
|
63
|
+
if (!frame || frame.el.name !== m[0]) {
|
|
64
|
+
throw new StorageParseError(`unexpected </${m[0]}>${frame ? `, open element is <${frame.el.name}>` : ''}`, lt);
|
|
65
|
+
}
|
|
66
|
+
current = stack.length > 0 ? stack[stack.length - 1].children : root;
|
|
67
|
+
i = textStart = gt + 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Открывающий тег
|
|
71
|
+
NAME_RE.lastIndex = lt + 1;
|
|
72
|
+
const nameMatch = NAME_RE.exec(input);
|
|
73
|
+
if (!nameMatch) {
|
|
74
|
+
// Не тег (например, одинокий '<' — в валидном storage не бывает).
|
|
75
|
+
throw new StorageParseError('malformed tag', lt);
|
|
76
|
+
}
|
|
77
|
+
const el = {
|
|
78
|
+
kind: 'el',
|
|
79
|
+
name: nameMatch[0],
|
|
80
|
+
attrs: [],
|
|
81
|
+
children: [],
|
|
82
|
+
selfClosing: false,
|
|
83
|
+
};
|
|
84
|
+
i = NAME_RE.lastIndex;
|
|
85
|
+
// Атрибуты
|
|
86
|
+
for (;;) {
|
|
87
|
+
while (/[\s]/.test(input[i] ?? ''))
|
|
88
|
+
i++;
|
|
89
|
+
const ch = input[i];
|
|
90
|
+
if (ch === '>') {
|
|
91
|
+
i++;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '/') {
|
|
95
|
+
if (input[i + 1] !== '>')
|
|
96
|
+
throw new StorageParseError('malformed self-closing tag', i);
|
|
97
|
+
el.selfClosing = true;
|
|
98
|
+
i += 2;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
NAME_RE.lastIndex = i;
|
|
102
|
+
const attrName = NAME_RE.exec(input);
|
|
103
|
+
if (!attrName)
|
|
104
|
+
throw new StorageParseError(`malformed attribute in <${el.name}>`, i);
|
|
105
|
+
i = NAME_RE.lastIndex;
|
|
106
|
+
while (/\s/.test(input[i] ?? ''))
|
|
107
|
+
i++;
|
|
108
|
+
if (input[i] !== '=') {
|
|
109
|
+
// Булев атрибут без значения — в storage не встречается, но переживём.
|
|
110
|
+
el.attrs.push([attrName[0], '']);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
i++;
|
|
114
|
+
while (/\s/.test(input[i] ?? ''))
|
|
115
|
+
i++;
|
|
116
|
+
const quote = input[i];
|
|
117
|
+
if (quote !== '"' && quote !== "'") {
|
|
118
|
+
throw new StorageParseError(`unquoted attribute value in <${el.name}>`, i);
|
|
119
|
+
}
|
|
120
|
+
const endQuote = input.indexOf(quote, i + 1);
|
|
121
|
+
if (endQuote === -1)
|
|
122
|
+
throw new StorageParseError('unterminated attribute value', i);
|
|
123
|
+
el.attrs.push([attrName[0], input.slice(i + 1, endQuote)]);
|
|
124
|
+
i = endQuote + 1;
|
|
125
|
+
}
|
|
126
|
+
current.push(el);
|
|
127
|
+
if (!el.selfClosing && !VOID_ELEMENTS.has(el.name.toLowerCase())) {
|
|
128
|
+
stack.push({ el, children: el.children });
|
|
129
|
+
current = el.children;
|
|
130
|
+
}
|
|
131
|
+
else if (!el.selfClosing) {
|
|
132
|
+
el.selfClosing = true; // нормализуем невалидный <br> к <br/>
|
|
133
|
+
}
|
|
134
|
+
textStart = i;
|
|
135
|
+
}
|
|
136
|
+
pushText(textStart, input.length);
|
|
137
|
+
if (stack.length > 0) {
|
|
138
|
+
throw new StorageParseError(`unclosed element <${stack[stack.length - 1].el.name}>`, input.length);
|
|
139
|
+
}
|
|
140
|
+
return root;
|
|
141
|
+
}
|
|
142
|
+
/** Сериализует узлы обратно в storage. Для нетронутого дерева — побайтово. */
|
|
143
|
+
export function serializeStorage(nodes) {
|
|
144
|
+
let out = '';
|
|
145
|
+
for (const n of nodes) {
|
|
146
|
+
switch (n.kind) {
|
|
147
|
+
case 'text':
|
|
148
|
+
out += n.raw;
|
|
149
|
+
break;
|
|
150
|
+
case 'cdata':
|
|
151
|
+
out += `<![CDATA[${n.text}]]>`;
|
|
152
|
+
break;
|
|
153
|
+
case 'comment':
|
|
154
|
+
out += `<!--${n.text}-->`;
|
|
155
|
+
break;
|
|
156
|
+
case 'el': {
|
|
157
|
+
const attrs = n.attrs.map(([k, v]) => ` ${k}="${v}"`).join('');
|
|
158
|
+
if (n.selfClosing) {
|
|
159
|
+
out += `<${n.name}${attrs} />`;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
out += `<${n.name}${attrs}>${serializeStorage(n.children)}</${n.name}>`;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
// Частые именованные сущности Confluence-страниц; остальное — числовые.
|
|
171
|
+
const NAMED_ENTITIES = {
|
|
172
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
|
|
173
|
+
nbsp: ' ', shy: '',
|
|
174
|
+
rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
|
|
175
|
+
ndash: '–', mdash: '—', hellip: '…',
|
|
176
|
+
laquo: '«', raquo: '»', times: '×', middot: '·',
|
|
177
|
+
deg: '°', plusmn: '±', copy: '©', reg: '®', trade: '™',
|
|
178
|
+
bull: '•', rarr: '→', larr: '←', darr: '↓', uarr: '↑',
|
|
179
|
+
sect: '§', para: '¶', euro: '€',
|
|
180
|
+
};
|
|
181
|
+
/** Декодирует HTML/XML-сущности (именованные из словаря + числовые). */
|
|
182
|
+
export function decodeEntities(raw) {
|
|
183
|
+
return raw.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (full, body) => {
|
|
184
|
+
if (body.startsWith('#x') || body.startsWith('#X')) {
|
|
185
|
+
return String.fromCodePoint(parseInt(body.slice(2), 16));
|
|
186
|
+
}
|
|
187
|
+
if (body.startsWith('#')) {
|
|
188
|
+
return String.fromCodePoint(parseInt(body.slice(1), 10));
|
|
189
|
+
}
|
|
190
|
+
return NAMED_ENTITIES[body] ?? full;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Значение атрибута элемента (декодированное) или undefined. */
|
|
194
|
+
export function getAttr(el, name) {
|
|
195
|
+
const found = el.attrs.find(([k]) => k === name);
|
|
196
|
+
return found === undefined ? undefined : decodeEntities(found[1]);
|
|
197
|
+
}
|
|
198
|
+
/** Текстовое содержимое поддерева (сущности декодированы, теги отброшены). */
|
|
199
|
+
export function textContent(nodes) {
|
|
200
|
+
let out = '';
|
|
201
|
+
for (const n of nodes) {
|
|
202
|
+
if (n.kind === 'text')
|
|
203
|
+
out += decodeEntities(n.raw);
|
|
204
|
+
else if (n.kind === 'cdata')
|
|
205
|
+
out += n.text;
|
|
206
|
+
else if (n.kind === 'el')
|
|
207
|
+
out += textContent(n.children);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
/** true, если в поддереве есть элементы с namespace-префиксом (ac:, ri:, …). */
|
|
212
|
+
export function hasNamespacedElements(nodes) {
|
|
213
|
+
for (const n of nodes) {
|
|
214
|
+
if (n.kind === 'el') {
|
|
215
|
+
if (n.name.includes(':'))
|
|
216
|
+
return true;
|
|
217
|
+
if (hasNamespacedElements(n.children))
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
/** Только элементы среди узлов. */
|
|
224
|
+
export function elements(nodes) {
|
|
225
|
+
return nodes.filter((n) => n.kind === 'el');
|
|
226
|
+
}
|
|
227
|
+
/** Экранирует текст для XML (используется при генерации новых узлов). */
|
|
228
|
+
export function escapeXmlText(s) {
|
|
229
|
+
return s.replace(/[<>&]/g, (c) => (c === '<' ? '<' : c === '>' ? '>' : '&'));
|
|
230
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, } from './markdown/render.js';
|
|
4
|
+
export { renderToStorage, extractPlaceholders, parsePlaceholder, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, type PlaceholderRef, type RenderStorageOptions, } from './markdown/render.js';
|
|
5
5
|
export { validateMarkdown, MarkdownValidationError, type ValidateOptions } from './markdown/validate.js';
|
|
6
6
|
export * from './macros/index.js';
|
|
7
7
|
export { convertBpmn, convertBpmnFolder, isBpmnFile, bpmnOutputName, BPMN_FILE_RE, type BpmnConversion, type BpmnImageFormat, type ConvertBpmnFolderOptions, } from './bpmn/convert.js';
|
|
@@ -10,5 +10,11 @@ export { Attachment, AttachmentService, toAttachmentVersion, SRC_SHA_SIDECAR_SUF
|
|
|
10
10
|
export { Page, Table } from './pages/page.js';
|
|
11
11
|
export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, type ColumnAlign, type TableColumn, } from './pages/tables.js';
|
|
12
12
|
export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, type PublishPageOptions, type PublishPageResult, type TableData, } from './publish/publish.js';
|
|
13
|
+
export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
|
|
13
14
|
export { runPublish, type Here, type Build, type PublishPlan, type RunPublishOptions, } from './publish/runner.js';
|
|
15
|
+
export { parseStorage, serializeStorage, decodeEntities, StorageParseError, type XNode, type XElement, } from './export/xhtml.js';
|
|
16
|
+
export { canonicalize, compareStorage, type CNode, type CompareResult, type StorageDiff, } from './export/canonical.js';
|
|
17
|
+
export { storageToMarkdown, type StorageToMarkdownOptions, type StorageToMarkdownResult, } from './export/to-markdown.js';
|
|
18
|
+
export { roundTripStorage, roundTripPage, renderExportedMarkdown, type RoundTripResult, } from './export/roundtrip.js';
|
|
19
|
+
export { exportPage, type ExportPageOptions, type ExportPageResult } from './export/export-page.js';
|
|
14
20
|
export { confluence, ConfluenceWrapper } from './wrapper.js';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ 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, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
|
|
6
|
+
export { renderToStorage, extractPlaceholders, parsePlaceholder, 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';
|
|
@@ -17,6 +17,13 @@ export { Page, Table } from './pages/page.js';
|
|
|
17
17
|
export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, } from './pages/tables.js';
|
|
18
18
|
// Publish
|
|
19
19
|
export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, } from './publish/publish.js';
|
|
20
|
+
export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
|
|
20
21
|
export { runPublish, } from './publish/runner.js';
|
|
22
|
+
// Export (storage → markdown) & round-trip
|
|
23
|
+
export { parseStorage, serializeStorage, decodeEntities, StorageParseError, } from './export/xhtml.js';
|
|
24
|
+
export { canonicalize, compareStorage, } from './export/canonical.js';
|
|
25
|
+
export { storageToMarkdown, } from './export/to-markdown.js';
|
|
26
|
+
export { roundTripStorage, roundTripPage, renderExportedMarkdown, } from './export/roundtrip.js';
|
|
27
|
+
export { exportPage } from './export/export-page.js';
|
|
21
28
|
// Facade
|
|
22
29
|
export { confluence, ConfluenceWrapper } from './wrapper.js';
|
package/dist/macros/builder.d.ts
CHANGED
|
@@ -26,5 +26,12 @@ export declare class MacroBuilder {
|
|
|
26
26
|
* macro('table-excerpt').param('name', 'data').body(md).toMarkdown()
|
|
27
27
|
*/
|
|
28
28
|
export declare function macro(name: string): MacroBuilder;
|
|
29
|
+
/**
|
|
30
|
+
* Экранирование значения параметра для однострочного маркера-комментария.
|
|
31
|
+
* Кодируются: `%` (первым — иначе двойное декодирование), `=`/`:`
|
|
32
|
+
* (разделители маркера), `<`/`>` (чтобы значение с `-->` не оборвало
|
|
33
|
+
* комментарий) и переводы строк (маркер обязан остаться одной строкой —
|
|
34
|
+
* нужно для многострочных параметров вроде SQL у table-joiner).
|
|
35
|
+
*/
|
|
29
36
|
export declare function escapeParamValue(s: string): string;
|
|
30
37
|
export declare function unescapeParamValue(s: string): string;
|
package/dist/macros/builder.js
CHANGED
|
@@ -50,9 +50,30 @@ export class MacroBuilder {
|
|
|
50
50
|
export function macro(name) {
|
|
51
51
|
return new MacroBuilder(name);
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Экранирование значения параметра для однострочного маркера-комментария.
|
|
55
|
+
* Кодируются: `%` (первым — иначе двойное декодирование), `=`/`:`
|
|
56
|
+
* (разделители маркера), `<`/`>` (чтобы значение с `-->` не оборвало
|
|
57
|
+
* комментарий) и переводы строк (маркер обязан остаться одной строкой —
|
|
58
|
+
* нужно для многострочных параметров вроде SQL у table-joiner).
|
|
59
|
+
*/
|
|
53
60
|
export function escapeParamValue(s) {
|
|
54
|
-
return s
|
|
61
|
+
return s
|
|
62
|
+
.replace(/%/g, '%25')
|
|
63
|
+
.replace(/=/g, '%3D')
|
|
64
|
+
.replace(/:/g, '%3A')
|
|
65
|
+
.replace(/</g, '%3C')
|
|
66
|
+
.replace(/>/g, '%3E')
|
|
67
|
+
.replace(/\r/g, '%0D')
|
|
68
|
+
.replace(/\n/g, '%0A');
|
|
55
69
|
}
|
|
56
70
|
export function unescapeParamValue(s) {
|
|
57
|
-
return s
|
|
71
|
+
return s
|
|
72
|
+
.replace(/%0A/g, '\n')
|
|
73
|
+
.replace(/%0D/g, '\r')
|
|
74
|
+
.replace(/%3E/g, '>')
|
|
75
|
+
.replace(/%3C/g, '<')
|
|
76
|
+
.replace(/%3A/g, ':')
|
|
77
|
+
.replace(/%3D/g, '=')
|
|
78
|
+
.replace(/%25/g, '%');
|
|
58
79
|
}
|
package/dist/macros/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { coreMacrosPlugin, extractPlainText } from './plugins/core.js';
|
|
|
6
6
|
export { tableFilterPlugin, TABLE_FILTER_DEFAULTS } from './plugins/table-filter.js';
|
|
7
7
|
import { MacroRegistry } from './registry.js';
|
|
8
8
|
import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePage, jiraIssue, panel, status, toc } from './plugins/core.js';
|
|
9
|
-
import { tableExcerpt, tableExcerptInclude, tableFilter } from './plugins/table-filter.js';
|
|
9
|
+
import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
|
|
10
10
|
/** Creates a registry pre-loaded with all built-in plugins. */
|
|
11
11
|
export declare function createDefaultRegistry(): MacroRegistry;
|
|
12
12
|
/**
|
|
@@ -41,4 +41,5 @@ export declare const macros: {
|
|
|
41
41
|
tableExcerpt: typeof tableExcerpt;
|
|
42
42
|
tableFilter: typeof tableFilter;
|
|
43
43
|
tableExcerptInclude: typeof tableExcerptInclude;
|
|
44
|
+
tableJoiner: typeof tableJoiner;
|
|
44
45
|
};
|
package/dist/macros/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { MacroRegistry } from './registry.js';
|
|
|
8
8
|
import { coreMacrosPlugin } from './plugins/core.js';
|
|
9
9
|
import { tableFilterPlugin } from './plugins/table-filter.js';
|
|
10
10
|
import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePage, info, jiraIssue, note, panel, status, tip, toc, warning, } from './plugins/core.js';
|
|
11
|
-
import { tableExcerpt, tableExcerptInclude, tableFilter } from './plugins/table-filter.js';
|
|
11
|
+
import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
|
|
12
12
|
/** Creates a registry pre-loaded with all built-in plugins. */
|
|
13
13
|
export function createDefaultRegistry() {
|
|
14
14
|
return new MacroRegistry().use(coreMacrosPlugin).use(tableFilterPlugin);
|
|
@@ -47,4 +47,5 @@ export const macros = {
|
|
|
47
47
|
tableExcerpt,
|
|
48
48
|
tableFilter,
|
|
49
49
|
tableExcerptInclude,
|
|
50
|
+
tableJoiner,
|
|
50
51
|
};
|
|
@@ -109,6 +109,8 @@ export const coreMacrosPlugin = {
|
|
|
109
109
|
richBodyMacro('tip', ['title', 'icon']),
|
|
110
110
|
richBodyMacro('panel'),
|
|
111
111
|
richBodyMacro('excerpt', ['hidden', 'atlassian-macro-output-type']),
|
|
112
|
+
// Page Properties (details) + Page Properties Report (detailssummary)
|
|
113
|
+
richBodyMacro('details'),
|
|
112
114
|
// Plain-text body
|
|
113
115
|
codeMacro,
|
|
114
116
|
// Bodyless
|
|
@@ -118,6 +120,7 @@ export const coreMacrosPlugin = {
|
|
|
118
120
|
bodylessMacro('children'),
|
|
119
121
|
bodylessMacro('pagetree'),
|
|
120
122
|
bodylessMacro('recently-updated'),
|
|
123
|
+
bodylessMacro('detailssummary'),
|
|
121
124
|
anchorMacro,
|
|
122
125
|
includeMacro,
|
|
123
126
|
excerptIncludeMacro,
|
|
@@ -28,6 +28,14 @@ export declare function tableExcerpt(body: Markdown | string, name?: string, hid
|
|
|
28
28
|
* tableFilter(tableExcerptMd, { totalrow: ',,,Sum,Sum,Sum' })
|
|
29
29
|
*/
|
|
30
30
|
export declare function tableFilter(body: Markdown | string, opts?: Record<string, string | undefined>): Markdown;
|
|
31
|
+
/**
|
|
32
|
+
* Обёртывает markdown (обычно table-excerpt-include'ы) в макрос
|
|
33
|
+
* "объединение таблиц" с SQL-запросом. Многострочный SQL допустим.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* tableJoiner(includesMd, "SELECT * FROM T1 LEFT JOIN T2 ON T1.'Код' = T2.'Код'")
|
|
37
|
+
*/
|
|
38
|
+
export declare function tableJoiner(body: Markdown | string, sql: string, opts?: Record<string, string | undefined>): Markdown;
|
|
31
39
|
/** Включение table-excerpt с другой страницы по имени выборки. */
|
|
32
40
|
export declare function tableExcerptInclude(name: string, page: string, opts?: {
|
|
33
41
|
space?: string;
|
|
@@ -89,15 +89,37 @@ export const tableFilterPlugin = {
|
|
|
89
89
|
}),
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
|
-
//
|
|
92
|
+
// Макрос "Объединение таблиц" (table-joiner): SQL-запрос по таблицам
|
|
93
|
+
// из тела макроса (обычно table-excerpt-include). Параметры — как есть;
|
|
94
|
+
// многострочный sql в маркере кодируется %0A (см. escapeParamValue).
|
|
95
|
+
name: 'table-joiner',
|
|
96
|
+
render: (ctx) => structuredMacro('table-joiner', ctx.macroId, {
|
|
97
|
+
params: ctx.params,
|
|
98
|
+
richBody: ctx.body,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
// Включение table-excerpt с другой страницы. page/space собираются в
|
|
103
|
+
// ac:link-параметр; остальные параметры (v, merge-tables, …)
|
|
104
|
+
// пробрасываются как есть.
|
|
93
105
|
name: 'table-excerpt-include',
|
|
94
106
|
render: (ctx) => {
|
|
95
107
|
const map = paramMap(ctx.params);
|
|
96
108
|
const params = [
|
|
97
109
|
{ name: 'name', value: map.name ?? '' },
|
|
98
|
-
{ name: 'page', value: pageLinkValue(map.page ?? '', map.space), raw: true },
|
|
99
|
-
{ name: 'type', value: map.type ?? 'page' },
|
|
100
110
|
];
|
|
111
|
+
// Без page макрос ссылается на текущую страницу — параметр не пишем.
|
|
112
|
+
if (map.page !== undefined) {
|
|
113
|
+
params.push({ name: 'page', value: pageLinkValue(map.page, map.space), raw: true });
|
|
114
|
+
params.push({ name: 'type', value: map.type ?? 'page' });
|
|
115
|
+
}
|
|
116
|
+
else if (map.type !== undefined) {
|
|
117
|
+
params.push({ name: 'type', value: map.type });
|
|
118
|
+
}
|
|
119
|
+
for (const p of ctx.params) {
|
|
120
|
+
if (!['name', 'page', 'space', 'type'].includes(p.name))
|
|
121
|
+
params.push(p);
|
|
122
|
+
}
|
|
101
123
|
return structuredMacro('table-excerpt-include', ctx.macroId, { params });
|
|
102
124
|
},
|
|
103
125
|
},
|
|
@@ -144,6 +166,16 @@ export function tableFilter(body, opts = {}) {
|
|
|
144
166
|
}
|
|
145
167
|
return builder.toMarkdown();
|
|
146
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Обёртывает markdown (обычно table-excerpt-include'ы) в макрос
|
|
171
|
+
* "объединение таблиц" с SQL-запросом. Многострочный SQL допустим.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* tableJoiner(includesMd, "SELECT * FROM T1 LEFT JOIN T2 ON T1.'Код' = T2.'Код'")
|
|
175
|
+
*/
|
|
176
|
+
export function tableJoiner(body, sql, opts = {}) {
|
|
177
|
+
return macro('table-joiner').param('sql', sql).withParams(opts).body(body).toMarkdown();
|
|
178
|
+
}
|
|
147
179
|
/** Включение table-excerpt с другой страницы по имени выборки. */
|
|
148
180
|
export function tableExcerptInclude(name, page, opts = {}) {
|
|
149
181
|
const builder = macro('table-excerpt-include').param('name', name).param('page', page);
|
|
@@ -13,12 +13,23 @@ import type { MacroPlugin, MacroRenderer } from './types.js';
|
|
|
13
13
|
export declare class MacroRegistry {
|
|
14
14
|
private renderers;
|
|
15
15
|
private pluginNames;
|
|
16
|
+
private passthroughUnknown;
|
|
16
17
|
/** Registers all macros of a plugin. Later registrations win on name clash. */
|
|
17
18
|
use(plugin: MacroPlugin): this;
|
|
18
19
|
register(name: string, renderer: MacroRenderer): this;
|
|
19
20
|
has(name: string): boolean;
|
|
20
21
|
getRenderer(name: string): MacroRenderer | undefined;
|
|
21
22
|
getAllRenderers(): Map<string, MacroRenderer>;
|
|
23
|
+
/**
|
|
24
|
+
* Управляет обработкой маркеров с именами, которых нет в реестре.
|
|
25
|
+
* По умолчанию ВКЛЮЧЕНО: такой маркер рендерится «как есть» —
|
|
26
|
+
* <ac:structured-macro> с параметрами из маркера и rich-text телом
|
|
27
|
+
* (если тело непустое). Это делает публикуемым любой макрос без явной
|
|
28
|
+
* регистрации (в т.ч. страницы, полученные из exportPage). Выключение
|
|
29
|
+
* возвращает старое поведение: незнакомый маркер остаётся комментарием.
|
|
30
|
+
*/
|
|
31
|
+
passthroughUnknownMacros(enable?: boolean): this;
|
|
32
|
+
get allowsUnknownMacros(): boolean;
|
|
22
33
|
/** Names of plugins registered via {@link use} (for diagnostics). */
|
|
23
34
|
get plugins(): string[];
|
|
24
35
|
}
|
package/dist/macros/registry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Markdown } from '../markdown/markdown.js';
|
|
2
2
|
import { unescapeParamValue } from './builder.js';
|
|
3
|
-
import { generateMacroId } from './xml.js';
|
|
3
|
+
import { generateMacroId, structuredMacro } from './xml.js';
|
|
4
4
|
/**
|
|
5
5
|
* Реестр макросов. Пустой при создании — наполняется плагинами через
|
|
6
6
|
* {@link use} или точечной регистрацией через {@link register}.
|
|
@@ -14,6 +14,7 @@ import { generateMacroId } from './xml.js';
|
|
|
14
14
|
export class MacroRegistry {
|
|
15
15
|
renderers = new Map();
|
|
16
16
|
pluginNames = [];
|
|
17
|
+
passthroughUnknown = true;
|
|
17
18
|
/** Registers all macros of a plugin. Later registrations win on name clash. */
|
|
18
19
|
use(plugin) {
|
|
19
20
|
for (const def of plugin.macros) {
|
|
@@ -35,11 +36,34 @@ export class MacroRegistry {
|
|
|
35
36
|
getAllRenderers() {
|
|
36
37
|
return this.renderers;
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Управляет обработкой маркеров с именами, которых нет в реестре.
|
|
41
|
+
* По умолчанию ВКЛЮЧЕНО: такой маркер рендерится «как есть» —
|
|
42
|
+
* <ac:structured-macro> с параметрами из маркера и rich-text телом
|
|
43
|
+
* (если тело непустое). Это делает публикуемым любой макрос без явной
|
|
44
|
+
* регистрации (в т.ч. страницы, полученные из exportPage). Выключение
|
|
45
|
+
* возвращает старое поведение: незнакомый маркер остаётся комментарием.
|
|
46
|
+
*/
|
|
47
|
+
passthroughUnknownMacros(enable = true) {
|
|
48
|
+
this.passthroughUnknown = enable;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
get allowsUnknownMacros() {
|
|
52
|
+
return this.passthroughUnknown;
|
|
53
|
+
}
|
|
38
54
|
/** Names of plugins registered via {@link use} (for diagnostics). */
|
|
39
55
|
get plugins() {
|
|
40
56
|
return [...this.pluginNames];
|
|
41
57
|
}
|
|
42
58
|
}
|
|
59
|
+
/** Renderer «как есть» для макросов, не зарегистрированных в реестре. */
|
|
60
|
+
function passthroughRenderer(name) {
|
|
61
|
+
return (ctx) => structuredMacro(name, ctx.macroId, {
|
|
62
|
+
params: ctx.params,
|
|
63
|
+
...(ctx.body.trim() !== '' ? { richBody: ctx.body } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const ANY_START_MARKER_RE = /<!-- MACRO:start:([A-Za-z0-9_-]+)/g;
|
|
43
67
|
/**
|
|
44
68
|
* Преобразует маркеры макросов в XHTML storage format.
|
|
45
69
|
* Обрабатывает вложенные макросы снизу вверх (от inner к outer).
|
|
@@ -61,9 +85,29 @@ export function processMacros(storage, registry) {
|
|
|
61
85
|
break;
|
|
62
86
|
}
|
|
63
87
|
}
|
|
88
|
+
// Незнакомые имена — passthrough-рендером (если разрешён). После
|
|
89
|
+
// зарегистрированных, чтобы явные рендереры всегда были в приоритете.
|
|
90
|
+
if (!modified && registry.allowsUnknownMacros) {
|
|
91
|
+
for (const name of findMarkerNames(content)) {
|
|
92
|
+
if (registry.has(name))
|
|
93
|
+
continue;
|
|
94
|
+
const result = processMacroType(content, name, passthroughRenderer(name));
|
|
95
|
+
if (result !== content) {
|
|
96
|
+
content = result;
|
|
97
|
+
modified = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
64
102
|
}
|
|
65
103
|
return new Markdown(content);
|
|
66
104
|
}
|
|
105
|
+
function findMarkerNames(storage) {
|
|
106
|
+
const names = new Set();
|
|
107
|
+
for (const m of storage.matchAll(ANY_START_MARKER_RE))
|
|
108
|
+
names.add(m[1]);
|
|
109
|
+
return names;
|
|
110
|
+
}
|
|
67
111
|
function processMacroType(storage, macroName, renderer) {
|
|
68
112
|
// `:[^\n]*?` — lazy до конца строки маркера. Раньше тут было `[^-]*`,
|
|
69
113
|
// что ломалось на дефис в имени параметра (например cell-width=) или
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Плейсхолдеры: `{{img:name}}`, `{{file:name}}`, `{{page:Title}}`.
|
|
3
|
+
* После имени допустимы `|key=value`-атрибуты:
|
|
4
|
+
* {{img:chart.png|thumbnail=true|height=250}}
|
|
5
|
+
* {{page:Другая страница|space=DOCS|text=якорный текст}}
|
|
6
|
+
*/
|
|
1
7
|
export declare const PLACEHOLDER_RE: RegExp;
|
|
8
|
+
export interface PlaceholderRef {
|
|
9
|
+
/** Имя файла (img/file) или title страницы (page). */
|
|
10
|
+
name: string;
|
|
11
|
+
/** `|key=value`-атрибуты в порядке записи. */
|
|
12
|
+
attrs: Array<[string, string]>;
|
|
13
|
+
}
|
|
14
|
+
/** Разбирает содержимое плейсхолдера: `name|k=v|k2=v2`. */
|
|
15
|
+
export declare function parsePlaceholder(body: string): PlaceholderRef;
|
|
2
16
|
export interface ExtractedPlaceholders {
|
|
3
17
|
images: string[];
|
|
4
18
|
files: string[];
|
|
19
|
+
pages: string[];
|
|
5
20
|
}
|
|
6
21
|
export declare function extractPlaceholders(markdown: string): ExtractedPlaceholders;
|
|
7
22
|
/**
|
|
@@ -15,6 +30,20 @@ export interface AttachmentUrls {
|
|
|
15
30
|
images: Map<string, string>;
|
|
16
31
|
files: Map<string, string>;
|
|
17
32
|
}
|
|
33
|
+
export interface RenderStorageOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Как рендерить `{{img:...}}` / `{{file:...}}`:
|
|
36
|
+
* - 'url' (default) — `<img src=…>` / `<a href=…>` с download-URL аттача;
|
|
37
|
+
* - 'attachment' — нативные `<ac:image><ri:attachment/>` /
|
|
38
|
+
* `<ac:link><ri:attachment/>` (ссылка по имени файла, URL не нужен).
|
|
39
|
+
* Используется exportPage/round-trip: форма совпадает с тем, что
|
|
40
|
+
* пишет сам Confluence.
|
|
41
|
+
*/
|
|
42
|
+
imageStyle?: 'url' | 'attachment';
|
|
43
|
+
fileStyle?: 'url' | 'attachment';
|
|
44
|
+
/** Автопревращение голых URL в ссылки (default: true). */
|
|
45
|
+
linkify?: boolean;
|
|
46
|
+
}
|
|
18
47
|
export declare class MissingAttachmentUrlError extends Error {
|
|
19
48
|
constructor(message: string);
|
|
20
49
|
}
|
|
@@ -38,4 +67,4 @@ export declare class MissingAttachmentUrlError extends Error {
|
|
|
38
67
|
* HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
|
|
39
68
|
* посимвольно.
|
|
40
69
|
*/
|
|
41
|
-
export declare function renderToStorage(markdown: string, urls: AttachmentUrls): string;
|
|
70
|
+
export declare function renderToStorage(markdown: string, urls: AttachmentUrls, opts?: RenderStorageOptions): string;
|