@hanhnd/agent-kit 1.0.42 → 1.0.43
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/dist/mcp/confluence.test.d.ts +1 -0
- package/dist/mcp/confluence.test.js +258 -0
- package/dist/mcp/integration.d.ts +44 -2
- package/dist/mcp/integration.js +166 -9
- package/dist/mcp/jira.test.d.ts +1 -0
- package/dist/mcp/jira.test.js +37 -0
- package/dist/mcp/registration.test.js +12 -1
- package/dist/server.js +614 -17
- package/dist/services/integration/credentials.d.ts +1 -1
- package/dist/services/integration/credentials.js +1 -1
- package/dist/services/integration/parser/storage.d.ts +18 -0
- package/dist/services/integration/parser/storage.js +508 -0
- package/dist/services/integration/parser/storage.test.d.ts +1 -0
- package/dist/services/integration/parser/storage.test.js +211 -0
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confluence storage-format (XHTML) → markdown converter.
|
|
3
|
+
*
|
|
4
|
+
* Pure module: no I/O, no network, no MCP coupling. Three stages — a single forward
|
|
5
|
+
* tokenizer pass, a tolerant tree builder, then a recursive renderer.
|
|
6
|
+
*/
|
|
7
|
+
/** Max tree depth before rendering emits a truncation marker. */
|
|
8
|
+
export declare const MAX_STORAGE_DEPTH = 100;
|
|
9
|
+
/** Visible truncation marker emitted at MAX_STORAGE_DEPTH. */
|
|
10
|
+
export declare const DEPTH_TRUNCATION_MARKER = "[content nested too deeply - truncated]";
|
|
11
|
+
/**
|
|
12
|
+
* Convert Confluence storage-format XHTML into markdown.
|
|
13
|
+
*
|
|
14
|
+
* Never throws for any string input, including malformed XHTML, unbalanced tags, stray
|
|
15
|
+
* CDATA, and the empty string. Returns '' for input with no content — the caller decides
|
|
16
|
+
* how to present emptiness.
|
|
17
|
+
*/
|
|
18
|
+
export declare function storageToMarkdown(xhtml: string | null | undefined): string;
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confluence storage-format (XHTML) → markdown converter.
|
|
3
|
+
*
|
|
4
|
+
* Pure module: no I/O, no network, no MCP coupling. Three stages — a single forward
|
|
5
|
+
* tokenizer pass, a tolerant tree builder, then a recursive renderer.
|
|
6
|
+
*/
|
|
7
|
+
/** Max tree depth before rendering emits a truncation marker. */
|
|
8
|
+
export const MAX_STORAGE_DEPTH = 100;
|
|
9
|
+
/** Visible truncation marker emitted at MAX_STORAGE_DEPTH. */
|
|
10
|
+
export const DEPTH_TRUNCATION_MARKER = '[content nested too deeply - truncated]';
|
|
11
|
+
const VOID_TAGS = new Set([
|
|
12
|
+
'br',
|
|
13
|
+
'hr',
|
|
14
|
+
'img',
|
|
15
|
+
'col',
|
|
16
|
+
'input',
|
|
17
|
+
'meta',
|
|
18
|
+
'link',
|
|
19
|
+
'ri:page',
|
|
20
|
+
'ri:attachment',
|
|
21
|
+
'ri:url',
|
|
22
|
+
'ri:user',
|
|
23
|
+
'ri:space',
|
|
24
|
+
'ri:blog-post',
|
|
25
|
+
'ac:emoticon',
|
|
26
|
+
]);
|
|
27
|
+
// XML predefined entities plus the common HTML named entities Confluence emits in real
|
|
28
|
+
// pages (arrows, dashes, smart quotes, symbols). Case-sensitive \u2014 HTML entity names are.
|
|
29
|
+
const NAMED_ENTITIES = {
|
|
30
|
+
// XML predefined
|
|
31
|
+
nbsp: '\u00A0',
|
|
32
|
+
amp: '&',
|
|
33
|
+
lt: '<',
|
|
34
|
+
gt: '>',
|
|
35
|
+
quot: '"',
|
|
36
|
+
apos: "'",
|
|
37
|
+
// dashes & punctuation
|
|
38
|
+
ndash: '\u2013',
|
|
39
|
+
mdash: '\u2014',
|
|
40
|
+
hellip: '\u2026',
|
|
41
|
+
middot: '\u00B7',
|
|
42
|
+
bull: '\u2022',
|
|
43
|
+
dagger: '\u2020',
|
|
44
|
+
Dagger: '\u2021',
|
|
45
|
+
sect: '\u00A7',
|
|
46
|
+
para: '\u00B6',
|
|
47
|
+
// smart quotes
|
|
48
|
+
lsquo: '\u2018',
|
|
49
|
+
rsquo: '\u2019',
|
|
50
|
+
sbquo: '\u201A',
|
|
51
|
+
ldquo: '\u201C',
|
|
52
|
+
rdquo: '\u201D',
|
|
53
|
+
bdquo: '\u201E',
|
|
54
|
+
laquo: '\u00AB',
|
|
55
|
+
raquo: '\u00BB',
|
|
56
|
+
prime: '\u2032',
|
|
57
|
+
Prime: '\u2033',
|
|
58
|
+
// arrows
|
|
59
|
+
larr: '\u2190',
|
|
60
|
+
rarr: '\u2192',
|
|
61
|
+
uarr: '\u2191',
|
|
62
|
+
darr: '\u2193',
|
|
63
|
+
harr: '\u2194',
|
|
64
|
+
// symbols & math
|
|
65
|
+
copy: '\u00A9',
|
|
66
|
+
reg: '\u00AE',
|
|
67
|
+
trade: '\u2122',
|
|
68
|
+
deg: '\u00B0',
|
|
69
|
+
plusmn: '\u00B1',
|
|
70
|
+
times: '\u00D7',
|
|
71
|
+
divide: '\u00F7',
|
|
72
|
+
micro: '\u00B5',
|
|
73
|
+
frac12: '\u00BD',
|
|
74
|
+
frac14: '\u00BC',
|
|
75
|
+
frac34: '\u00BE',
|
|
76
|
+
ne: '\u2260',
|
|
77
|
+
le: '\u2264',
|
|
78
|
+
ge: '\u2265',
|
|
79
|
+
infin: '\u221E',
|
|
80
|
+
// currency
|
|
81
|
+
euro: '\u20AC',
|
|
82
|
+
pound: '\u00A3',
|
|
83
|
+
yen: '\u00A5',
|
|
84
|
+
cent: '\u00A2',
|
|
85
|
+
};
|
|
86
|
+
const PANEL_MACROS = new Set(['info', 'note', 'warning', 'tip', 'panel']);
|
|
87
|
+
const PLACEHOLDER_MACROS = new Set(['toc', 'children', 'excerpt-include']);
|
|
88
|
+
const ATTRIBUTE_PATTERN = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
89
|
+
function codePointOrLiteral(code, literal) {
|
|
90
|
+
if (!Number.isInteger(code) || code < 1 || code > 0x10ffff)
|
|
91
|
+
return literal;
|
|
92
|
+
try {
|
|
93
|
+
return String.fromCodePoint(code);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return literal;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function decodeEntities(text) {
|
|
100
|
+
if (!text.includes('&'))
|
|
101
|
+
return text;
|
|
102
|
+
return text.replace(/&(#[Xx][0-9A-Fa-f]+|#\d+|[A-Za-z][A-Za-z0-9]*);/g, (literal, body) => {
|
|
103
|
+
if (body[0] === '#') {
|
|
104
|
+
const isHex = body[1] === 'x' || body[1] === 'X';
|
|
105
|
+
const code = Number.parseInt(isHex ? body.slice(2) : body.slice(1), isHex ? 16 : 10);
|
|
106
|
+
return codePointOrLiteral(code, literal);
|
|
107
|
+
}
|
|
108
|
+
return NAMED_ENTITIES[body] ?? literal;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function parseAttributes(source) {
|
|
112
|
+
const attrs = {};
|
|
113
|
+
ATTRIBUTE_PATTERN.lastIndex = 0;
|
|
114
|
+
let match;
|
|
115
|
+
while ((match = ATTRIBUTE_PATTERN.exec(source)) !== null) {
|
|
116
|
+
const raw = match[2] ?? match[3] ?? match[4] ?? '';
|
|
117
|
+
attrs[match[1].toLowerCase()] = decodeEntities(raw);
|
|
118
|
+
}
|
|
119
|
+
return attrs;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Single forward pass with a monotonically advancing cursor. CDATA is recognised
|
|
123
|
+
* before any tag scanning so `<` and `>` inside a CDATA payload are never markup.
|
|
124
|
+
*/
|
|
125
|
+
function tokenize(input) {
|
|
126
|
+
const tokens = [];
|
|
127
|
+
let cursor = 0;
|
|
128
|
+
let textStart = 0;
|
|
129
|
+
const flushText = (end) => {
|
|
130
|
+
if (end > textStart)
|
|
131
|
+
tokens.push({ kind: 'text', value: input.slice(textStart, end) });
|
|
132
|
+
};
|
|
133
|
+
while (cursor < input.length) {
|
|
134
|
+
const lt = input.indexOf('<', cursor);
|
|
135
|
+
if (lt === -1)
|
|
136
|
+
break;
|
|
137
|
+
if (input.startsWith('<![CDATA[', lt)) {
|
|
138
|
+
const end = input.indexOf(']]>', lt + 9);
|
|
139
|
+
if (end === -1)
|
|
140
|
+
break;
|
|
141
|
+
flushText(lt);
|
|
142
|
+
tokens.push({ kind: 'cdata', value: input.slice(lt + 9, end) });
|
|
143
|
+
cursor = end + 3;
|
|
144
|
+
textStart = cursor;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (input.startsWith('<!--', lt)) {
|
|
148
|
+
const end = input.indexOf('-->', lt + 4);
|
|
149
|
+
if (end === -1)
|
|
150
|
+
break;
|
|
151
|
+
flushText(lt);
|
|
152
|
+
tokens.push({ kind: 'comment' });
|
|
153
|
+
cursor = end + 3;
|
|
154
|
+
textStart = cursor;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const gt = input.indexOf('>', lt + 1);
|
|
158
|
+
if (gt === -1)
|
|
159
|
+
break;
|
|
160
|
+
const raw = input.slice(lt + 1, gt);
|
|
161
|
+
if (!/^\/?[A-Za-z]/.test(raw)) {
|
|
162
|
+
// A stray `<` — leave it in the text run and keep scanning past it.
|
|
163
|
+
cursor = lt + 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
flushText(lt);
|
|
167
|
+
if (raw[0] === '/') {
|
|
168
|
+
tokens.push({ kind: 'close', name: raw.slice(1).trim().toLowerCase() });
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const selfClosing = raw.endsWith('/');
|
|
172
|
+
const inner = selfClosing ? raw.slice(0, -1) : raw;
|
|
173
|
+
const nameEnd = inner.search(/[\s/]/);
|
|
174
|
+
const name = (nameEnd === -1 ? inner : inner.slice(0, nameEnd)).toLowerCase();
|
|
175
|
+
const attrs = parseAttributes(nameEnd === -1 ? '' : inner.slice(nameEnd));
|
|
176
|
+
tokens.push({ kind: selfClosing ? 'selfclose' : 'open', name, attrs });
|
|
177
|
+
}
|
|
178
|
+
cursor = gt + 1;
|
|
179
|
+
textStart = cursor;
|
|
180
|
+
}
|
|
181
|
+
flushText(input.length);
|
|
182
|
+
return tokens;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Tolerant tree builder. A non-matching close auto-closes intermediate elements up to
|
|
186
|
+
* the nearest matching ancestor, or is discarded when no ancestor matches. Content
|
|
187
|
+
* nested past MAX_STORAGE_DEPTH is replaced by a single visible truncation marker so
|
|
188
|
+
* the renderer's recursion stays bounded.
|
|
189
|
+
*/
|
|
190
|
+
function buildTree(tokens) {
|
|
191
|
+
const root = [];
|
|
192
|
+
const stack = [];
|
|
193
|
+
let suppressDepth = 0;
|
|
194
|
+
const sink = () => (stack.length > 0 ? stack[stack.length - 1].children : root);
|
|
195
|
+
for (const token of tokens) {
|
|
196
|
+
switch (token.kind) {
|
|
197
|
+
case 'comment':
|
|
198
|
+
break;
|
|
199
|
+
case 'text':
|
|
200
|
+
case 'cdata':
|
|
201
|
+
if (suppressDepth === 0)
|
|
202
|
+
sink().push(token);
|
|
203
|
+
break;
|
|
204
|
+
case 'open':
|
|
205
|
+
case 'selfclose': {
|
|
206
|
+
const overDepth = stack.length >= MAX_STORAGE_DEPTH;
|
|
207
|
+
if (suppressDepth === 0 && overDepth) {
|
|
208
|
+
sink().push({ kind: 'text', value: DEPTH_TRUNCATION_MARKER });
|
|
209
|
+
}
|
|
210
|
+
const suppressing = suppressDepth > 0 || overDepth;
|
|
211
|
+
const isLeaf = token.kind === 'selfclose' || VOID_TAGS.has(token.name);
|
|
212
|
+
if (suppressing) {
|
|
213
|
+
if (!isLeaf) {
|
|
214
|
+
stack.push({ name: token.name, children: [], suppressing: true });
|
|
215
|
+
suppressDepth++;
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const element = { kind: 'element', name: token.name, attrs: token.attrs, children: [] };
|
|
220
|
+
sink().push(element);
|
|
221
|
+
if (!isLeaf)
|
|
222
|
+
stack.push({ name: token.name, children: element.children, suppressing: false });
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 'close': {
|
|
226
|
+
let target = -1;
|
|
227
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
228
|
+
if (stack[i].name === token.name) {
|
|
229
|
+
target = i;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (target === -1)
|
|
234
|
+
break;
|
|
235
|
+
while (stack.length > target) {
|
|
236
|
+
const frame = stack.pop();
|
|
237
|
+
if (frame?.suppressing)
|
|
238
|
+
suppressDepth--;
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return root;
|
|
245
|
+
}
|
|
246
|
+
function isElement(node, name) {
|
|
247
|
+
return node.kind === 'element' && (name === undefined || node.name === name);
|
|
248
|
+
}
|
|
249
|
+
function findChild(element, name) {
|
|
250
|
+
return element.children.find((child) => isElement(child, name));
|
|
251
|
+
}
|
|
252
|
+
/** Verbatim text of a subtree — no entity decoding, used for code and macro parameters. */
|
|
253
|
+
function rawText(nodes) {
|
|
254
|
+
let result = '';
|
|
255
|
+
for (const node of nodes) {
|
|
256
|
+
if (node.kind === 'element')
|
|
257
|
+
result += rawText(node.children);
|
|
258
|
+
else
|
|
259
|
+
result += node.value;
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
function macroParameters(element) {
|
|
264
|
+
const params = {};
|
|
265
|
+
for (const child of element.children) {
|
|
266
|
+
if (!isElement(child, 'ac:parameter'))
|
|
267
|
+
continue;
|
|
268
|
+
const key = child.attrs['ac:name'];
|
|
269
|
+
if (key)
|
|
270
|
+
params[key] = rawText(child.children).trim();
|
|
271
|
+
}
|
|
272
|
+
return params;
|
|
273
|
+
}
|
|
274
|
+
function collectRows(nodes) {
|
|
275
|
+
const rows = [];
|
|
276
|
+
for (const node of nodes) {
|
|
277
|
+
if (!isElement(node))
|
|
278
|
+
continue;
|
|
279
|
+
if (node.name === 'tr')
|
|
280
|
+
rows.push(node);
|
|
281
|
+
else if (node.name === 'thead' || node.name === 'tbody' || node.name === 'tfoot') {
|
|
282
|
+
rows.push(...collectRows(node.children));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
function renderTable(nodes) {
|
|
288
|
+
const rows = collectRows(nodes);
|
|
289
|
+
if (rows.length === 0)
|
|
290
|
+
return '';
|
|
291
|
+
let result = '\n';
|
|
292
|
+
let isFirstRow = true;
|
|
293
|
+
for (const row of rows) {
|
|
294
|
+
const cells = row.children.filter((child) => isElement(child, 'th') || isElement(child, 'td'));
|
|
295
|
+
const rendered = cells.map((cell) => renderNodes(cell.children).trim().replace(/\n+/g, ' ').replace(/\|/g, '\\|'));
|
|
296
|
+
result += '| ' + rendered.join(' | ') + ' |\n';
|
|
297
|
+
if (isFirstRow) {
|
|
298
|
+
result += '| ' + cells.map(() => '---').join(' | ') + ' |\n';
|
|
299
|
+
isFirstRow = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return result + '\n';
|
|
303
|
+
}
|
|
304
|
+
function renderMacro(element, listDepth) {
|
|
305
|
+
const name = element.attrs['ac:name'] || '';
|
|
306
|
+
const params = macroParameters(element);
|
|
307
|
+
const richBody = findChild(element, 'ac:rich-text-body');
|
|
308
|
+
if (name === 'code') {
|
|
309
|
+
const plainBody = findChild(element, 'ac:plain-text-body');
|
|
310
|
+
const code = plainBody ? rawText(plainBody.children) : rawText(element.children);
|
|
311
|
+
return `\`\`\`${params.language || ''}\n${code.replace(/^\n+|\n+$/g, '')}\n\`\`\`\n\n`;
|
|
312
|
+
}
|
|
313
|
+
if (PANEL_MACROS.has(name)) {
|
|
314
|
+
const body = richBody ? renderNodes(richBody.children, listDepth, name).trim() : '';
|
|
315
|
+
const block = `**${name.toUpperCase()}**${body ? `\n${body}` : ''}`;
|
|
316
|
+
return '> ' + block.replace(/\n/g, '\n> ') + '\n\n';
|
|
317
|
+
}
|
|
318
|
+
if (name === 'expand') {
|
|
319
|
+
const body = richBody ? renderNodes(richBody.children, listDepth, name).trim() : '';
|
|
320
|
+
return `**${params.title || 'Details'}**\n\n${body ? `${body}\n\n` : ''}`;
|
|
321
|
+
}
|
|
322
|
+
if (name === 'jira') {
|
|
323
|
+
return params.key ? `\`${params.key}\`` : '[macro: jira]';
|
|
324
|
+
}
|
|
325
|
+
if (PLACEHOLDER_MACROS.has(name))
|
|
326
|
+
return `[macro: ${name}]`;
|
|
327
|
+
return `[macro: ${name || 'unknown'}]`;
|
|
328
|
+
}
|
|
329
|
+
function renderLink(element, listDepth) {
|
|
330
|
+
const bodyElement = findChild(element, 'ac:plain-text-link-body') ?? findChild(element, 'ac:link-body');
|
|
331
|
+
const bodyText = bodyElement ? renderNodes(bodyElement.children, listDepth, element.name).trim() : '';
|
|
332
|
+
const page = findChild(element, 'ri:page');
|
|
333
|
+
if (page)
|
|
334
|
+
return `[[${bodyText || page.attrs['ri:content-title'] || 'page'}]]`;
|
|
335
|
+
const attachment = findChild(element, 'ri:attachment');
|
|
336
|
+
if (attachment)
|
|
337
|
+
return `[attachment: ${attachment.attrs['ri:filename'] || 'unknown'}]`;
|
|
338
|
+
const externalUrl = findChild(element, 'ri:url');
|
|
339
|
+
if (externalUrl) {
|
|
340
|
+
const href = externalUrl.attrs['ri:value'] || '';
|
|
341
|
+
return `[${bodyText || href}](${href})`;
|
|
342
|
+
}
|
|
343
|
+
if (findChild(element, 'ri:user'))
|
|
344
|
+
return '@user';
|
|
345
|
+
return bodyText;
|
|
346
|
+
}
|
|
347
|
+
function renderTaskList(element, listDepth) {
|
|
348
|
+
let result = '';
|
|
349
|
+
for (const task of element.children) {
|
|
350
|
+
if (!isElement(task, 'ac:task'))
|
|
351
|
+
continue;
|
|
352
|
+
const status = findChild(task, 'ac:task-status');
|
|
353
|
+
const body = findChild(task, 'ac:task-body');
|
|
354
|
+
const marker = rawText(status?.children ?? []).trim() === 'complete' ? '[x]' : '[ ]';
|
|
355
|
+
const text = body ? renderNodes(body.children, listDepth, 'ac:task').trim() : '';
|
|
356
|
+
result += `- ${marker} ${text}\n`;
|
|
357
|
+
}
|
|
358
|
+
return result ? result + '\n' : '';
|
|
359
|
+
}
|
|
360
|
+
function renderNodes(nodes, listDepth = 0, parentType) {
|
|
361
|
+
let result = '';
|
|
362
|
+
for (const node of nodes) {
|
|
363
|
+
if (node.kind === 'cdata') {
|
|
364
|
+
result += node.value;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (node.kind === 'text') {
|
|
368
|
+
result += decodeEntities(node.value);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const children = node.children;
|
|
372
|
+
switch (node.name) {
|
|
373
|
+
case 'h1':
|
|
374
|
+
case 'h2':
|
|
375
|
+
case 'h3':
|
|
376
|
+
case 'h4':
|
|
377
|
+
case 'h5':
|
|
378
|
+
case 'h6':
|
|
379
|
+
result += '#'.repeat(Number(node.name[1])) + ' ' + renderNodes(children, listDepth, node.name).trim() + '\n\n';
|
|
380
|
+
break;
|
|
381
|
+
case 'p': {
|
|
382
|
+
const text = renderNodes(children, listDepth, node.name);
|
|
383
|
+
result += parentType === 'li' ? text + '\n' : text + '\n\n';
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case 'strong':
|
|
387
|
+
case 'b':
|
|
388
|
+
result += `**${renderNodes(children, listDepth, node.name)}**`;
|
|
389
|
+
break;
|
|
390
|
+
case 'em':
|
|
391
|
+
case 'i':
|
|
392
|
+
result += `*${renderNodes(children, listDepth, node.name)}*`;
|
|
393
|
+
break;
|
|
394
|
+
case 'code':
|
|
395
|
+
result += `\`${renderNodes(children, listDepth, node.name)}\``;
|
|
396
|
+
break;
|
|
397
|
+
case 's':
|
|
398
|
+
case 'strike':
|
|
399
|
+
case 'del':
|
|
400
|
+
result += `~~${renderNodes(children, listDepth, node.name)}~~`;
|
|
401
|
+
break;
|
|
402
|
+
case 'sup':
|
|
403
|
+
result += `^${renderNodes(children, listDepth, node.name)}^`;
|
|
404
|
+
break;
|
|
405
|
+
case 'sub':
|
|
406
|
+
result += `~${renderNodes(children, listDepth, node.name)}~`;
|
|
407
|
+
break;
|
|
408
|
+
case 'pre':
|
|
409
|
+
result += `\`\`\`\n${renderNodes(children, listDepth, node.name).trim()}\n\`\`\`\n\n`;
|
|
410
|
+
break;
|
|
411
|
+
case 'blockquote':
|
|
412
|
+
result += '> ' + renderNodes(children, listDepth, node.name).trim().replace(/\n/g, '\n> ') + '\n\n';
|
|
413
|
+
break;
|
|
414
|
+
case 'hr':
|
|
415
|
+
result += '---\n\n';
|
|
416
|
+
break;
|
|
417
|
+
case 'br':
|
|
418
|
+
result += '\n';
|
|
419
|
+
break;
|
|
420
|
+
case 'a': {
|
|
421
|
+
const text = renderNodes(children, listDepth, node.name);
|
|
422
|
+
const href = node.attrs.href ?? '';
|
|
423
|
+
result += href ? `[${text}](${href})` : text;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case 'ul':
|
|
427
|
+
case 'ol': {
|
|
428
|
+
const inner = renderNodes(children, listDepth + 1, node.name);
|
|
429
|
+
if (parentType === 'li') {
|
|
430
|
+
if (result.length > 0 && !result.endsWith('\n'))
|
|
431
|
+
result += '\n';
|
|
432
|
+
result += inner;
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
result += inner;
|
|
436
|
+
if (listDepth === 0)
|
|
437
|
+
result += '\n';
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case 'li': {
|
|
442
|
+
const indent = ' '.repeat(Math.max(0, listDepth - 1));
|
|
443
|
+
const bullet = parentType === 'ol' ? '1.' : '-';
|
|
444
|
+
result += `${indent}${bullet} ${renderNodes(children, listDepth, 'li').trim()}\n`;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case 'table':
|
|
448
|
+
result += renderTable(children);
|
|
449
|
+
break;
|
|
450
|
+
case 'ac:structured-macro':
|
|
451
|
+
result += renderMacro(node, listDepth);
|
|
452
|
+
break;
|
|
453
|
+
case 'ac:link':
|
|
454
|
+
result += renderLink(node, listDepth);
|
|
455
|
+
break;
|
|
456
|
+
case 'ac:image': {
|
|
457
|
+
const attachment = findChild(node, 'ri:attachment');
|
|
458
|
+
const externalUrl = findChild(node, 'ri:url');
|
|
459
|
+
const label = attachment?.attrs['ri:filename'] ?? externalUrl?.attrs['ri:value'] ?? 'unknown';
|
|
460
|
+
const marker = `[image: ${label}]`;
|
|
461
|
+
if (parentType === 'p') {
|
|
462
|
+
// Inline within a paragraph — the paragraph provides its own block separation.
|
|
463
|
+
result += marker;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
// Block-level image (a direct child of a block container): give it its own line.
|
|
467
|
+
if (result.length > 0 && !result.endsWith('\n'))
|
|
468
|
+
result += '\n';
|
|
469
|
+
result += `${marker}\n\n`;
|
|
470
|
+
}
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
case 'ac:task-list':
|
|
474
|
+
result += renderTaskList(node, listDepth);
|
|
475
|
+
break;
|
|
476
|
+
case 'ac:emoticon':
|
|
477
|
+
result += `:${node.attrs['ac:name'] || 'emoticon'}:`;
|
|
478
|
+
break;
|
|
479
|
+
default:
|
|
480
|
+
result += renderNodes(children, listDepth, node.name);
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
/** Tag-stripping fallback used only if the pipeline itself faults. */
|
|
487
|
+
function extractPlainText(xhtml) {
|
|
488
|
+
return decodeEntities(xhtml.replace(/<[^>]*>/g, ' '))
|
|
489
|
+
.replace(/\s+/g, ' ')
|
|
490
|
+
.trim();
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Convert Confluence storage-format XHTML into markdown.
|
|
494
|
+
*
|
|
495
|
+
* Never throws for any string input, including malformed XHTML, unbalanced tags, stray
|
|
496
|
+
* CDATA, and the empty string. Returns '' for input with no content — the caller decides
|
|
497
|
+
* how to present emptiness.
|
|
498
|
+
*/
|
|
499
|
+
export function storageToMarkdown(xhtml) {
|
|
500
|
+
if (typeof xhtml !== 'string' || xhtml.length === 0)
|
|
501
|
+
return '';
|
|
502
|
+
try {
|
|
503
|
+
return renderNodes(buildTree(tokenize(xhtml))).trim();
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return extractPlainText(xhtml);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|