@stravica-ai/digital-operator-pa 0.1.39 → 0.1.41
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 +12 -2
- package/bin/confluence +9 -0
- package/bin/confluence-fetch +9 -0
- package/bin/jira +9 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
- package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
- package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
- package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
- package/dist/engineering-pa-scaffold/README.md +32 -1
- package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
- package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
- package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
- package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
- package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
- package/dist/engineering-pa-types-engineering/index.mjs +14 -6
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
- package/dist/engineering-pa-types-engineering/package.json +2 -2
- package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
- package/dist/engineering-pa-workspace/app/home.html +7 -7
- package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
- package/dist/engineering-pa-workspace/src/api.mjs +1 -1
- package/dist/engineering-pa-workspace/src/server.mjs +1 -1
- package/docs/installing.md +78 -0
- package/docs/onboarding-solo.md +162 -0
- package/docs/what-it-can-do.md +85 -0
- package/package.json +6 -2
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
// storage.mjs - Confluence XHTML storage format ↔ markdown conversion.
|
|
2
|
+
//
|
|
3
|
+
// Confluence stores page content in XHTML "storage format" with proprietary
|
|
4
|
+
// macros using ac: and ri: namespaces. This module converts between that
|
|
5
|
+
// format and markdown, enabling markdown-first workflows.
|
|
6
|
+
//
|
|
7
|
+
// Zero external dependencies - internals use a hand-rolled recursive-descent
|
|
8
|
+
// XHTML parser that produces a small DOM-like tree, then walks the tree to
|
|
9
|
+
// render. This replaces an older regex-based approach that misfired on
|
|
10
|
+
// nested macros (e.g. an info macro containing a code block: the outer
|
|
11
|
+
// `</ac:structured-macro>` close would be greedy-matched against the inner
|
|
12
|
+
// macro's close, dropping content).
|
|
13
|
+
//
|
|
14
|
+
// Public API (unchanged):
|
|
15
|
+
// markdownToStorage(md) - markdown → Confluence storage XHTML
|
|
16
|
+
// storageToMarkdown(xhtml) - storage XHTML → markdown
|
|
17
|
+
// storageToText(xhtml) - storage XHTML → plain text
|
|
18
|
+
// textToStorage(text) - plain text → storage XHTML
|
|
19
|
+
|
|
20
|
+
// ── Markdown → Storage Format ────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert markdown to Confluence XHTML storage format.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} md Markdown input
|
|
26
|
+
* @returns {string} XHTML storage format
|
|
27
|
+
*/
|
|
28
|
+
export function markdownToStorage(md) {
|
|
29
|
+
if (!md || typeof md !== 'string') return '';
|
|
30
|
+
|
|
31
|
+
const lines = md.split('\n');
|
|
32
|
+
const output = [];
|
|
33
|
+
let i = 0;
|
|
34
|
+
|
|
35
|
+
while (i < lines.length) {
|
|
36
|
+
const line = lines[i];
|
|
37
|
+
|
|
38
|
+
// Fenced code block
|
|
39
|
+
const codeMatch = line.match(/^```(\w*)$/);
|
|
40
|
+
if (codeMatch) {
|
|
41
|
+
const lang = codeMatch[1] || '';
|
|
42
|
+
const codeLines = [];
|
|
43
|
+
i++;
|
|
44
|
+
while (i < lines.length && !lines[i].match(/^```$/)) {
|
|
45
|
+
codeLines.push(lines[i]);
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
i++; // skip closing ```
|
|
49
|
+
const codeContent = codeLines.join('\n');
|
|
50
|
+
if (lang) {
|
|
51
|
+
output.push(
|
|
52
|
+
`<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${escapeXml(lang)}</ac:parameter><ac:plain-text-body><![CDATA[${codeContent}]]></ac:plain-text-body></ac:structured-macro>`
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
output.push(
|
|
56
|
+
`<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[${codeContent}]]></ac:plain-text-body></ac:structured-macro>`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Horizontal rule
|
|
63
|
+
if (line.match(/^---+$/)) {
|
|
64
|
+
output.push('<hr />');
|
|
65
|
+
i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Headings
|
|
70
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
71
|
+
if (headingMatch) {
|
|
72
|
+
const level = headingMatch[1].length;
|
|
73
|
+
const text = convertInlineMarkdown(headingMatch[2]);
|
|
74
|
+
output.push(`<h${level}>${text}</h${level}>`);
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Table - detect pipe-delimited rows
|
|
80
|
+
if (line.match(/^\|.+\|$/)) {
|
|
81
|
+
const tableLines = [];
|
|
82
|
+
while (i < lines.length && lines[i].match(/^\|.+\|$/)) {
|
|
83
|
+
tableLines.push(lines[i]);
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
output.push(convertTable(tableLines));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Blockquote
|
|
91
|
+
if (line.match(/^>\s/)) {
|
|
92
|
+
const quoteLines = [];
|
|
93
|
+
while (i < lines.length && lines[i].match(/^>\s?/)) {
|
|
94
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ''));
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
const quoteContent = quoteLines.map((l) => `<p>${convertInlineMarkdown(l)}</p>`).join('');
|
|
98
|
+
output.push(`<blockquote>${quoteContent}</blockquote>`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Unordered list
|
|
103
|
+
if (line.match(/^[-*]\s/)) {
|
|
104
|
+
const listItems = [];
|
|
105
|
+
while (i < lines.length && lines[i].match(/^[-*]\s/)) {
|
|
106
|
+
listItems.push(lines[i].replace(/^[-*]\s/, ''));
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
const items = listItems.map((item) => `<li>${convertInlineMarkdown(item)}</li>`).join('');
|
|
110
|
+
output.push(`<ul>${items}</ul>`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Ordered list
|
|
115
|
+
if (line.match(/^\d+\.\s/)) {
|
|
116
|
+
const listItems = [];
|
|
117
|
+
while (i < lines.length && lines[i].match(/^\d+\.\s/)) {
|
|
118
|
+
listItems.push(lines[i].replace(/^\d+\.\s/, ''));
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
const items = listItems.map((item) => `<li>${convertInlineMarkdown(item)}</li>`).join('');
|
|
122
|
+
output.push(`<ol>${items}</ol>`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Empty line - skip (paragraph separation)
|
|
127
|
+
if (line.trim() === '') {
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Regular paragraph
|
|
133
|
+
output.push(`<p>${convertInlineMarkdown(line)}</p>`);
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return output.join('');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Convert inline markdown elements to XHTML.
|
|
142
|
+
* Handles: bold, italic, code, links, images.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} text Inline markdown text
|
|
145
|
+
* @returns {string} XHTML text
|
|
146
|
+
*/
|
|
147
|
+
function convertInlineMarkdown(text) {
|
|
148
|
+
if (!text) return '';
|
|
149
|
+
|
|
150
|
+
let result = escapeXml(text);
|
|
151
|
+
|
|
152
|
+
// Images:  → <ac:image><ri:url ri:value="url" /></ac:image>
|
|
153
|
+
// Process before links to avoid conflict
|
|
154
|
+
result = result.replace(
|
|
155
|
+
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
|
156
|
+
'<ac:image><ri:url ri:value="$2" /></ac:image>'
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Links: [text](url) → <a href="url">text</a>
|
|
160
|
+
result = result.replace(
|
|
161
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
162
|
+
'<a href="$2">$1</a>'
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Bold: **text** → <strong>text</strong>
|
|
166
|
+
result = result.replace(
|
|
167
|
+
/\*\*([^*]+)\*\*/g,
|
|
168
|
+
'<strong>$1</strong>'
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Italic: *text* → <em>text</em>
|
|
172
|
+
result = result.replace(
|
|
173
|
+
/\*([^*]+)\*/g,
|
|
174
|
+
'<em>$1</em>'
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Inline code: `text` → <code>text</code>
|
|
178
|
+
result = result.replace(
|
|
179
|
+
/`([^`]+)`/g,
|
|
180
|
+
'<code>$1</code>'
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Convert markdown pipe table to XHTML table.
|
|
188
|
+
*
|
|
189
|
+
* @param {string[]} lines Table lines including header, separator, and body rows
|
|
190
|
+
* @returns {string} XHTML table
|
|
191
|
+
*/
|
|
192
|
+
function convertTable(lines) {
|
|
193
|
+
if (lines.length < 2) return '';
|
|
194
|
+
|
|
195
|
+
const parseRow = (line) =>
|
|
196
|
+
line.split('|').slice(1, -1).map((cell) => cell.trim());
|
|
197
|
+
|
|
198
|
+
const headerCells = parseRow(lines[0]);
|
|
199
|
+
|
|
200
|
+
// Skip separator row (line[1] with dashes)
|
|
201
|
+
const bodyStart = lines[1].match(/^[\s|:-]+$/) ? 2 : 1;
|
|
202
|
+
const bodyRows = lines.slice(bodyStart).map(parseRow);
|
|
203
|
+
|
|
204
|
+
const thead = `<thead><tr>${headerCells.map((c) => `<th>${convertInlineMarkdown(c)}</th>`).join('')}</tr></thead>`;
|
|
205
|
+
|
|
206
|
+
let tbody = '';
|
|
207
|
+
if (bodyRows.length > 0) {
|
|
208
|
+
const rows = bodyRows.map((cells) =>
|
|
209
|
+
`<tr>${cells.map((c) => `<td>${convertInlineMarkdown(c)}</td>`).join('')}</tr>`
|
|
210
|
+
).join('');
|
|
211
|
+
tbody = `<tbody>${rows}</tbody>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return `<table>${thead}${tbody}</table>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── XHTML Parser (DOM-style tree) ────────────────────────────────────
|
|
218
|
+
//
|
|
219
|
+
// Produces a small tree of nodes:
|
|
220
|
+
// { type: 'element', name, attrs: { k: v }, children: [...] }
|
|
221
|
+
// { type: 'text', value }
|
|
222
|
+
// { type: 'cdata', value } - preserves CDATA body verbatim
|
|
223
|
+
// { type: 'comment', value } - discarded by renderers
|
|
224
|
+
//
|
|
225
|
+
// HTML void elements close themselves (<br>, <hr>, <img>, <meta>, ...).
|
|
226
|
+
// Self-closing syntax (`<x />`) is honoured for any tag.
|
|
227
|
+
// Tag names are case-preserved as written (Confluence uses lower-case + ac:/ri:).
|
|
228
|
+
// Unclosed tags auto-close at end of input - this is a forgiving parser,
|
|
229
|
+
// not a validating one.
|
|
230
|
+
|
|
231
|
+
const VOID_ELEMENTS = new Set([
|
|
232
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
233
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse Confluence XHTML / HTML-ish input into a tree of nodes.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} input
|
|
240
|
+
* @returns {Array} children of an implicit root
|
|
241
|
+
*/
|
|
242
|
+
function parseXhtml(input) {
|
|
243
|
+
const root = { type: 'element', name: '#root', attrs: {}, children: [] };
|
|
244
|
+
const stack = [root];
|
|
245
|
+
let i = 0;
|
|
246
|
+
const n = input.length;
|
|
247
|
+
|
|
248
|
+
const top = () => stack[stack.length - 1];
|
|
249
|
+
const appendText = (s) => {
|
|
250
|
+
if (!s) return;
|
|
251
|
+
const parent = top();
|
|
252
|
+
const last = parent.children[parent.children.length - 1];
|
|
253
|
+
if (last && last.type === 'text') {
|
|
254
|
+
last.value += s;
|
|
255
|
+
} else {
|
|
256
|
+
parent.children.push({ type: 'text', value: s });
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
while (i < n) {
|
|
261
|
+
const ch = input[i];
|
|
262
|
+
|
|
263
|
+
if (ch !== '<') {
|
|
264
|
+
// Text run - accumulate until next '<'
|
|
265
|
+
const end = input.indexOf('<', i);
|
|
266
|
+
const chunk = end === -1 ? input.slice(i) : input.slice(i, end);
|
|
267
|
+
appendText(decodeEntities(chunk));
|
|
268
|
+
if (end === -1) break;
|
|
269
|
+
i = end;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// CDATA: <![CDATA[ ... ]]>
|
|
274
|
+
if (input.startsWith('<![CDATA[', i)) {
|
|
275
|
+
const end = input.indexOf(']]>', i + 9);
|
|
276
|
+
if (end === -1) {
|
|
277
|
+
// Unterminated - treat rest as CDATA verbatim
|
|
278
|
+
top().children.push({ type: 'cdata', value: input.slice(i + 9) });
|
|
279
|
+
i = n;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
top().children.push({ type: 'cdata', value: input.slice(i + 9, end) });
|
|
283
|
+
i = end + 3;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Comment: <!-- ... -->
|
|
288
|
+
if (input.startsWith('<!--', i)) {
|
|
289
|
+
const end = input.indexOf('-->', i + 4);
|
|
290
|
+
if (end === -1) { i = n; break; }
|
|
291
|
+
top().children.push({ type: 'comment', value: input.slice(i + 4, end) });
|
|
292
|
+
i = end + 3;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Doctype or other <! declaration - skip to next '>'
|
|
297
|
+
if (input[i + 1] === '!') {
|
|
298
|
+
const end = input.indexOf('>', i);
|
|
299
|
+
if (end === -1) { i = n; break; }
|
|
300
|
+
i = end + 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Processing instruction: <? ... ?>
|
|
305
|
+
if (input[i + 1] === '?') {
|
|
306
|
+
const end = input.indexOf('?>', i);
|
|
307
|
+
if (end === -1) { i = n; break; }
|
|
308
|
+
i = end + 2;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Closing tag: </name>
|
|
313
|
+
if (input[i + 1] === '/') {
|
|
314
|
+
const end = input.indexOf('>', i);
|
|
315
|
+
if (end === -1) {
|
|
316
|
+
// Unterminated - treat as text
|
|
317
|
+
appendText(input.slice(i));
|
|
318
|
+
i = n;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
const closeName = input.slice(i + 2, end).trim();
|
|
322
|
+
// Pop until we close a matching open tag; if no match, drop silently
|
|
323
|
+
let popIdx = -1;
|
|
324
|
+
for (let j = stack.length - 1; j >= 1; j--) {
|
|
325
|
+
if (stack[j].name === closeName) { popIdx = j; break; }
|
|
326
|
+
}
|
|
327
|
+
if (popIdx >= 1) {
|
|
328
|
+
stack.length = popIdx;
|
|
329
|
+
}
|
|
330
|
+
i = end + 1;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Open or self-closing tag
|
|
335
|
+
const tagEnd = findTagEnd(input, i);
|
|
336
|
+
if (tagEnd === -1) {
|
|
337
|
+
// Unterminated - treat as text
|
|
338
|
+
appendText(input.slice(i));
|
|
339
|
+
i = n;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
const raw = input.slice(i + 1, tagEnd);
|
|
343
|
+
i = tagEnd + 1;
|
|
344
|
+
|
|
345
|
+
const selfClosing = raw.endsWith('/');
|
|
346
|
+
const body = selfClosing ? raw.slice(0, -1).trim() : raw.trim();
|
|
347
|
+
if (!body) continue; // <> or </> garbage - skip
|
|
348
|
+
|
|
349
|
+
const { name, attrs } = parseTag(body);
|
|
350
|
+
if (!name) continue;
|
|
351
|
+
|
|
352
|
+
const node = { type: 'element', name, attrs, children: [] };
|
|
353
|
+
top().children.push(node);
|
|
354
|
+
|
|
355
|
+
const isVoid = VOID_ELEMENTS.has(name.toLowerCase());
|
|
356
|
+
if (!selfClosing && !isVoid) {
|
|
357
|
+
stack.push(node);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return root.children;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Find the position of the '>' that closes a tag started at `start`.
|
|
366
|
+
* Handles attribute values containing '>' inside quotes.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} input
|
|
369
|
+
* @param {number} start Index of the '<'
|
|
370
|
+
* @returns {number} Index of the matching '>', or -1
|
|
371
|
+
*/
|
|
372
|
+
function findTagEnd(input, start) {
|
|
373
|
+
const n = input.length;
|
|
374
|
+
let i = start + 1;
|
|
375
|
+
let quote = null;
|
|
376
|
+
while (i < n) {
|
|
377
|
+
const ch = input[i];
|
|
378
|
+
if (quote) {
|
|
379
|
+
if (ch === quote) quote = null;
|
|
380
|
+
} else {
|
|
381
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
382
|
+
else if (ch === '>') return i;
|
|
383
|
+
}
|
|
384
|
+
i++;
|
|
385
|
+
}
|
|
386
|
+
return -1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Parse "name attr1="v" attr2='v' attr3=v attr4" into { name, attrs }.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} body Tag body without `<`, `>`, or trailing `/`
|
|
393
|
+
* @returns {{ name: string, attrs: Record<string, string> }}
|
|
394
|
+
*/
|
|
395
|
+
function parseTag(body) {
|
|
396
|
+
// First token is the tag name
|
|
397
|
+
let i = 0;
|
|
398
|
+
const n = body.length;
|
|
399
|
+
while (i < n && /\s/.test(body[i])) i++;
|
|
400
|
+
const nameStart = i;
|
|
401
|
+
while (i < n && !/[\s/>]/.test(body[i])) i++;
|
|
402
|
+
const name = body.slice(nameStart, i);
|
|
403
|
+
const attrs = {};
|
|
404
|
+
if (!name) return { name: '', attrs };
|
|
405
|
+
|
|
406
|
+
while (i < n) {
|
|
407
|
+
while (i < n && /\s/.test(body[i])) i++;
|
|
408
|
+
if (i >= n) break;
|
|
409
|
+
|
|
410
|
+
// Attribute name
|
|
411
|
+
const aStart = i;
|
|
412
|
+
while (i < n && !/[\s=/>]/.test(body[i])) i++;
|
|
413
|
+
const aName = body.slice(aStart, i);
|
|
414
|
+
if (!aName) { i++; continue; }
|
|
415
|
+
|
|
416
|
+
while (i < n && /\s/.test(body[i])) i++;
|
|
417
|
+
|
|
418
|
+
if (body[i] === '=') {
|
|
419
|
+
i++;
|
|
420
|
+
while (i < n && /\s/.test(body[i])) i++;
|
|
421
|
+
const q = body[i];
|
|
422
|
+
let value = '';
|
|
423
|
+
if (q === '"' || q === "'") {
|
|
424
|
+
i++;
|
|
425
|
+
const vStart = i;
|
|
426
|
+
while (i < n && body[i] !== q) i++;
|
|
427
|
+
value = body.slice(vStart, i);
|
|
428
|
+
if (i < n) i++; // closing quote
|
|
429
|
+
} else {
|
|
430
|
+
// Unquoted value: read until whitespace
|
|
431
|
+
const vStart = i;
|
|
432
|
+
while (i < n && !/\s/.test(body[i])) i++;
|
|
433
|
+
value = body.slice(vStart, i);
|
|
434
|
+
}
|
|
435
|
+
attrs[aName] = decodeEntities(value);
|
|
436
|
+
} else {
|
|
437
|
+
// Boolean attribute
|
|
438
|
+
attrs[aName] = '';
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { name, attrs };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Decode the common XHTML named entities and numeric references.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} s
|
|
449
|
+
* @returns {string}
|
|
450
|
+
*/
|
|
451
|
+
function decodeEntities(s) {
|
|
452
|
+
if (!s || s.indexOf('&') === -1) return s;
|
|
453
|
+
return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (m, ent) => {
|
|
454
|
+
if (ent[0] === '#') {
|
|
455
|
+
const cp = ent[1] === 'x' || ent[1] === 'X'
|
|
456
|
+
? parseInt(ent.slice(2), 16)
|
|
457
|
+
: parseInt(ent.slice(1), 10);
|
|
458
|
+
if (Number.isFinite(cp) && cp >= 0 && cp <= 0x10FFFF) {
|
|
459
|
+
try { return String.fromCodePoint(cp); } catch { return m; }
|
|
460
|
+
}
|
|
461
|
+
return m;
|
|
462
|
+
}
|
|
463
|
+
switch (ent) {
|
|
464
|
+
case 'amp': return '&';
|
|
465
|
+
case 'lt': return '<';
|
|
466
|
+
case 'gt': return '>';
|
|
467
|
+
case 'quot': return '"';
|
|
468
|
+
case 'apos': return "'";
|
|
469
|
+
case 'nbsp': return ' ';
|
|
470
|
+
default: return m;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── Tree helpers ─────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
/** @returns {boolean} */
|
|
478
|
+
function isElement(node, name) {
|
|
479
|
+
return node && node.type === 'element' && (!name || node.name === name);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Find first child element matching `name`. */
|
|
483
|
+
function findChild(node, name) {
|
|
484
|
+
if (!node || !node.children) return null;
|
|
485
|
+
for (const c of node.children) {
|
|
486
|
+
if (isElement(c, name)) return c;
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Find all descendant elements matching `name` (depth-first). */
|
|
492
|
+
function findAll(node, name, out = []) {
|
|
493
|
+
if (!node || !node.children) return out;
|
|
494
|
+
for (const c of node.children) {
|
|
495
|
+
if (isElement(c, name)) out.push(c);
|
|
496
|
+
if (c.type === 'element') findAll(c, name, out);
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Find a macro parameter by name. Confluence storage:
|
|
503
|
+
* <ac:parameter ac:name="title">...</ac:parameter>
|
|
504
|
+
*
|
|
505
|
+
* @returns {string|null} parameter text content, or null
|
|
506
|
+
*/
|
|
507
|
+
function findMacroParam(macroNode, paramName) {
|
|
508
|
+
if (!macroNode || !macroNode.children) return null;
|
|
509
|
+
for (const c of macroNode.children) {
|
|
510
|
+
if (isElement(c, 'ac:parameter') && c.attrs['ac:name'] === paramName) {
|
|
511
|
+
return innerText(c);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Get the inner text of a node - concatenate all descendant text + CDATA,
|
|
519
|
+
* decode entities, ignore elements/comments.
|
|
520
|
+
*/
|
|
521
|
+
function innerText(node) {
|
|
522
|
+
if (!node) return '';
|
|
523
|
+
if (node.type === 'text') return node.value;
|
|
524
|
+
if (node.type === 'cdata') return node.value;
|
|
525
|
+
if (node.type === 'comment') return '';
|
|
526
|
+
let out = '';
|
|
527
|
+
for (const c of node.children || []) out += innerText(c);
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Storage Format → Markdown ────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Convert Confluence XHTML storage format to markdown.
|
|
535
|
+
*
|
|
536
|
+
* @param {string} xhtml XHTML storage format input
|
|
537
|
+
* @returns {string} Markdown output
|
|
538
|
+
*/
|
|
539
|
+
export function storageToMarkdown(xhtml) {
|
|
540
|
+
if (!xhtml || typeof xhtml !== 'string') return '';
|
|
541
|
+
const tree = parseXhtml(xhtml);
|
|
542
|
+
let out = renderNodesToMarkdown(tree);
|
|
543
|
+
out = out.replace(/\n{3,}/g, '\n\n');
|
|
544
|
+
return out.trim();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function renderNodesToMarkdown(nodes) {
|
|
548
|
+
let out = '';
|
|
549
|
+
for (const node of nodes) out += renderNodeToMarkdown(node);
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function renderNodeToMarkdown(node) {
|
|
554
|
+
if (!node) return '';
|
|
555
|
+
if (node.type === 'text') return node.value; // already decoded by the parser
|
|
556
|
+
if (node.type === 'cdata') return node.value;
|
|
557
|
+
if (node.type === 'comment') return '';
|
|
558
|
+
if (node.type !== 'element') return '';
|
|
559
|
+
|
|
560
|
+
const name = node.name;
|
|
561
|
+
|
|
562
|
+
// Macros
|
|
563
|
+
if (name === 'ac:structured-macro') return renderMacroToMarkdown(node);
|
|
564
|
+
if (name === 'ac:link') return renderAcLinkToMarkdown(node);
|
|
565
|
+
if (name === 'ac:image') return renderAcImageToMarkdown(node);
|
|
566
|
+
if (name === 'ac:emoticon') {
|
|
567
|
+
// Best-effort: emit the emoji shortname if present
|
|
568
|
+
const shortname = node.attrs['ac:emoji-shortname'] || node.attrs['ac:name'] || '';
|
|
569
|
+
return shortname ? `:${shortname.replace(/^:|:$/g, '')}:` : '';
|
|
570
|
+
}
|
|
571
|
+
// Ignore ri:* directly - they are consumed by their parents (link/image)
|
|
572
|
+
if (name.startsWith('ri:')) return '';
|
|
573
|
+
// Parameters inside macros - consumed by the macro renderer
|
|
574
|
+
if (name === 'ac:parameter' || name === 'ac:rich-text-body' || name === 'ac:plain-text-body' || name === 'ac:plain-text-link-body') {
|
|
575
|
+
return renderNodesToMarkdown(node.children);
|
|
576
|
+
}
|
|
577
|
+
// Drop any other unknown ac:* wrapper but keep contents
|
|
578
|
+
if (name.startsWith('ac:')) return renderNodesToMarkdown(node.children);
|
|
579
|
+
|
|
580
|
+
// HTML elements
|
|
581
|
+
switch (name.toLowerCase()) {
|
|
582
|
+
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': {
|
|
583
|
+
const level = parseInt(name.slice(1), 10);
|
|
584
|
+
return `\n${'#'.repeat(level)} ${plainInline(node).trim()}\n`;
|
|
585
|
+
}
|
|
586
|
+
case 'strong': case 'b':
|
|
587
|
+
return `**${renderNodesToMarkdown(node.children)}**`;
|
|
588
|
+
case 'em': case 'i':
|
|
589
|
+
return `*${renderNodesToMarkdown(node.children)}*`;
|
|
590
|
+
case 'code':
|
|
591
|
+
return `\`${renderNodesToMarkdown(node.children)}\``;
|
|
592
|
+
case 'a': {
|
|
593
|
+
const href = node.attrs.href || '';
|
|
594
|
+
return `[${renderNodesToMarkdown(node.children)}](${href})`;
|
|
595
|
+
}
|
|
596
|
+
case 'p':
|
|
597
|
+
return `${renderNodesToMarkdown(node.children)}\n\n`;
|
|
598
|
+
case 'br':
|
|
599
|
+
return '\n';
|
|
600
|
+
case 'hr':
|
|
601
|
+
return '\n---\n';
|
|
602
|
+
case 'blockquote': {
|
|
603
|
+
const inner = plainInline(node).trim();
|
|
604
|
+
if (!inner) return '';
|
|
605
|
+
return inner.split('\n').map((l) => `> ${l}`).join('\n') + '\n';
|
|
606
|
+
}
|
|
607
|
+
case 'ul': {
|
|
608
|
+
const items = [];
|
|
609
|
+
for (const c of node.children) {
|
|
610
|
+
if (isElement(c, 'li')) items.push(`- ${plainInline(c).trim()}`);
|
|
611
|
+
}
|
|
612
|
+
return '\n' + items.join('\n') + '\n';
|
|
613
|
+
}
|
|
614
|
+
case 'ol': {
|
|
615
|
+
const items = [];
|
|
616
|
+
let idx = 1;
|
|
617
|
+
for (const c of node.children) {
|
|
618
|
+
if (isElement(c, 'li')) items.push(`${idx++}. ${plainInline(c).trim()}`);
|
|
619
|
+
}
|
|
620
|
+
return '\n' + items.join('\n') + '\n';
|
|
621
|
+
}
|
|
622
|
+
case 'table':
|
|
623
|
+
return renderTableToMarkdown(node);
|
|
624
|
+
case 'img': {
|
|
625
|
+
const src = node.attrs.src || '';
|
|
626
|
+
const alt = node.attrs.alt || '';
|
|
627
|
+
return ``;
|
|
628
|
+
}
|
|
629
|
+
case 'span': case 'div': case 'section': case 'article': case 'header': case 'footer': case 'main': case 'nav':
|
|
630
|
+
return renderNodesToMarkdown(node.children);
|
|
631
|
+
default:
|
|
632
|
+
// Unknown tag - drop, keep contents
|
|
633
|
+
return renderNodesToMarkdown(node.children);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Plain inline rendering - collapses block-ish output (for use in headings,
|
|
639
|
+
* list items, blockquotes where block separation isn't wanted).
|
|
640
|
+
*/
|
|
641
|
+
function plainInline(node) {
|
|
642
|
+
return renderNodesToMarkdown(node.children)
|
|
643
|
+
.replace(/\n+/g, ' ')
|
|
644
|
+
.replace(/\s{2,}/g, ' ');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function renderMacroToMarkdown(node) {
|
|
648
|
+
const macroName = node.attrs['ac:name'];
|
|
649
|
+
|
|
650
|
+
switch (macroName) {
|
|
651
|
+
case 'code': {
|
|
652
|
+
const lang = findMacroParam(node, 'language') || '';
|
|
653
|
+
const bodyNode = findChild(node, 'ac:plain-text-body');
|
|
654
|
+
const code = bodyNode ? innerText(bodyNode) : '';
|
|
655
|
+
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
|
|
656
|
+
}
|
|
657
|
+
case 'info':
|
|
658
|
+
case 'warning':
|
|
659
|
+
case 'note':
|
|
660
|
+
case 'tip': {
|
|
661
|
+
const label = macroName.charAt(0).toUpperCase() + macroName.slice(1);
|
|
662
|
+
const body = findChild(node, 'ac:rich-text-body');
|
|
663
|
+
const content = body ? renderNodesToMarkdown(body.children).trim() : '';
|
|
664
|
+
return `\n> **${label}:** ${content}\n`;
|
|
665
|
+
}
|
|
666
|
+
case 'panel': {
|
|
667
|
+
const title = findMacroParam(node, 'title') || 'Panel';
|
|
668
|
+
const body = findChild(node, 'ac:rich-text-body');
|
|
669
|
+
const content = body ? renderNodesToMarkdown(body.children).trim() : '';
|
|
670
|
+
return `\n> **${title}:** ${content}\n`;
|
|
671
|
+
}
|
|
672
|
+
case 'status': {
|
|
673
|
+
const title = findMacroParam(node, 'title') || '';
|
|
674
|
+
return `[${title}]`;
|
|
675
|
+
}
|
|
676
|
+
case 'expand': {
|
|
677
|
+
const body = findChild(node, 'ac:rich-text-body');
|
|
678
|
+
return body ? renderNodesToMarkdown(body.children) : '';
|
|
679
|
+
}
|
|
680
|
+
default: {
|
|
681
|
+
// Unknown macro - surface rich-text body or plain-text body if present
|
|
682
|
+
const rich = findChild(node, 'ac:rich-text-body');
|
|
683
|
+
if (rich) return renderNodesToMarkdown(rich.children);
|
|
684
|
+
const plain = findChild(node, 'ac:plain-text-body');
|
|
685
|
+
if (plain) return innerText(plain);
|
|
686
|
+
return '';
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function renderAcLinkToMarkdown(node) {
|
|
692
|
+
// Confluence page link
|
|
693
|
+
const page = findChild(node, 'ri:page');
|
|
694
|
+
if (page) {
|
|
695
|
+
const title = page.attrs['ri:content-title'] || '';
|
|
696
|
+
return `[${title}]`;
|
|
697
|
+
}
|
|
698
|
+
// User mention
|
|
699
|
+
const user = findChild(node, 'ri:user');
|
|
700
|
+
if (user) {
|
|
701
|
+
const account = user.attrs['ri:account-id'] || user.attrs['ri:userkey'] || 'user';
|
|
702
|
+
return `[@${account}]`;
|
|
703
|
+
}
|
|
704
|
+
// Attachment
|
|
705
|
+
const att = findChild(node, 'ri:attachment');
|
|
706
|
+
if (att) {
|
|
707
|
+
const filename = att.attrs['ri:filename'] || 'attachment';
|
|
708
|
+
return `[${filename}]`;
|
|
709
|
+
}
|
|
710
|
+
// External URL link with plain-text body
|
|
711
|
+
const url = findChild(node, 'ri:url');
|
|
712
|
+
if (url) {
|
|
713
|
+
const value = url.attrs['ri:value'] || '';
|
|
714
|
+
const linkBody = findChild(node, 'ac:plain-text-link-body') || findChild(node, 'ac:link-body');
|
|
715
|
+
const text = linkBody ? innerText(linkBody).trim() : value;
|
|
716
|
+
return `[${text}](${value})`;
|
|
717
|
+
}
|
|
718
|
+
// Fallback - emit inner text
|
|
719
|
+
return innerText(node);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function renderAcImageToMarkdown(node) {
|
|
723
|
+
const url = findChild(node, 'ri:url');
|
|
724
|
+
if (url) return ``;
|
|
725
|
+
const att = findChild(node, 'ri:attachment');
|
|
726
|
+
if (att) return ``;
|
|
727
|
+
return '';
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function renderTableToMarkdown(node) {
|
|
731
|
+
const rows = [];
|
|
732
|
+
let hasHeader = false;
|
|
733
|
+
|
|
734
|
+
// Collect header rows from <thead>; if missing, first <tr> becomes header
|
|
735
|
+
const thead = findChild(node, 'thead');
|
|
736
|
+
const tbody = findChild(node, 'tbody');
|
|
737
|
+
|
|
738
|
+
const renderCell = (cell) => plainInline(cell).trim();
|
|
739
|
+
|
|
740
|
+
const collectRows = (container) => {
|
|
741
|
+
if (!container) return [];
|
|
742
|
+
const trs = [];
|
|
743
|
+
for (const c of container.children) {
|
|
744
|
+
if (isElement(c, 'tr')) trs.push(c);
|
|
745
|
+
}
|
|
746
|
+
return trs;
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
if (thead) {
|
|
750
|
+
const trs = collectRows(thead);
|
|
751
|
+
if (trs.length > 0) {
|
|
752
|
+
const cells = [];
|
|
753
|
+
for (const c of trs[0].children) {
|
|
754
|
+
if (isElement(c, 'th') || isElement(c, 'td')) cells.push(renderCell(c));
|
|
755
|
+
}
|
|
756
|
+
rows.push(`| ${cells.join(' | ')} |`);
|
|
757
|
+
rows.push(`| ${cells.map(() => '---').join(' | ')} |`);
|
|
758
|
+
hasHeader = true;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const bodyTrs = tbody
|
|
763
|
+
? collectRows(tbody)
|
|
764
|
+
: collectRows(node).filter((tr) => !thead || !thead.children.includes(tr));
|
|
765
|
+
|
|
766
|
+
for (let r = 0; r < bodyTrs.length; r++) {
|
|
767
|
+
const tr = bodyTrs[r];
|
|
768
|
+
const cells = [];
|
|
769
|
+
for (const c of tr.children) {
|
|
770
|
+
if (isElement(c, 'th') || isElement(c, 'td')) cells.push(renderCell(c));
|
|
771
|
+
}
|
|
772
|
+
if (cells.length === 0) continue;
|
|
773
|
+
if (!hasHeader && r === 0) {
|
|
774
|
+
rows.push(`| ${cells.join(' | ')} |`);
|
|
775
|
+
rows.push(`| ${cells.map(() => '---').join(' | ')} |`);
|
|
776
|
+
hasHeader = true;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
rows.push(`| ${cells.join(' | ')} |`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '';
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── Storage Format → Plain Text ──────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Convert Confluence XHTML storage format to plain text.
|
|
789
|
+
* Strips all tags and macro structures, but preserves CDATA bodies
|
|
790
|
+
* (code-macro contents) and inserts newlines for block elements.
|
|
791
|
+
*
|
|
792
|
+
* @param {string} xhtml XHTML storage format
|
|
793
|
+
* @returns {string} Plain text
|
|
794
|
+
*/
|
|
795
|
+
export function storageToText(xhtml) {
|
|
796
|
+
if (!xhtml || typeof xhtml !== 'string') return '';
|
|
797
|
+
const tree = parseXhtml(xhtml);
|
|
798
|
+
let out = renderNodesToText(tree);
|
|
799
|
+
out = out.replace(/\n{3,}/g, '\n\n');
|
|
800
|
+
return out.trim();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const TEXT_BLOCK_ELEMENTS = new Set([
|
|
804
|
+
'p', 'div', 'blockquote', 'li', 'tr',
|
|
805
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
806
|
+
]);
|
|
807
|
+
|
|
808
|
+
function renderNodesToText(nodes) {
|
|
809
|
+
let out = '';
|
|
810
|
+
for (const node of nodes) out += renderNodeToText(node);
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function renderNodeToText(node) {
|
|
815
|
+
if (!node) return '';
|
|
816
|
+
if (node.type === 'text') return node.value;
|
|
817
|
+
if (node.type === 'cdata') return node.value;
|
|
818
|
+
if (node.type === 'comment') return '';
|
|
819
|
+
if (node.type !== 'element') return '';
|
|
820
|
+
|
|
821
|
+
const name = node.name;
|
|
822
|
+
const lowerName = name.toLowerCase();
|
|
823
|
+
|
|
824
|
+
// Macros - recurse into their bodies (rich-text or plain-text); skip parameters.
|
|
825
|
+
if (name === 'ac:structured-macro') {
|
|
826
|
+
const rich = findChild(node, 'ac:rich-text-body');
|
|
827
|
+
if (rich) return renderNodesToText(rich.children) + '\n';
|
|
828
|
+
const plain = findChild(node, 'ac:plain-text-body');
|
|
829
|
+
if (plain) return innerText(plain);
|
|
830
|
+
return '';
|
|
831
|
+
}
|
|
832
|
+
if (name === 'ac:parameter') return ''; // metadata - drop
|
|
833
|
+
if (name.startsWith('ri:')) return '';
|
|
834
|
+
if (name === 'ac:image') return '';
|
|
835
|
+
if (name === 'ac:emoticon') return '';
|
|
836
|
+
if (name.startsWith('ac:')) return renderNodesToText(node.children);
|
|
837
|
+
|
|
838
|
+
if (lowerName === 'br') return '\n';
|
|
839
|
+
if (lowerName === 'hr') return '\n---\n';
|
|
840
|
+
|
|
841
|
+
const inner = renderNodesToText(node.children);
|
|
842
|
+
if (TEXT_BLOCK_ELEMENTS.has(lowerName)) return inner + '\n';
|
|
843
|
+
return inner;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── Plain Text → Storage Format ──────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Convert plain text to basic XHTML storage format.
|
|
850
|
+
* Wraps text in paragraphs, splitting on double newlines.
|
|
851
|
+
*
|
|
852
|
+
* @param {string} text Plain text input
|
|
853
|
+
* @returns {string} XHTML storage format
|
|
854
|
+
*/
|
|
855
|
+
export function textToStorage(text) {
|
|
856
|
+
if (!text || typeof text !== 'string') return '';
|
|
857
|
+
|
|
858
|
+
const paragraphs = text.split(/\n{2,}/);
|
|
859
|
+
return paragraphs
|
|
860
|
+
.map((p) => p.trim())
|
|
861
|
+
.filter((p) => p.length > 0)
|
|
862
|
+
.map((p) => `<p>${escapeXml(p).replace(/\n/g, '<br />')}</p>`)
|
|
863
|
+
.join('');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ── Utilities ────────────────────────────────────────────────────────
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Escape special XML characters for emission.
|
|
870
|
+
* @param {string} text
|
|
871
|
+
* @returns {string}
|
|
872
|
+
*/
|
|
873
|
+
function escapeXml(text) {
|
|
874
|
+
if (!text) return '';
|
|
875
|
+
return text
|
|
876
|
+
.replace(/&/g, '&')
|
|
877
|
+
.replace(/</g, '<')
|
|
878
|
+
.replace(/>/g, '>')
|
|
879
|
+
.replace(/"/g, '"');
|
|
880
|
+
}
|