@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.
Files changed (92) hide show
  1. package/README.md +12 -2
  2. package/bin/confluence +9 -0
  3. package/bin/confluence-fetch +9 -0
  4. package/bin/jira +9 -0
  5. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
  6. package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
  7. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
  8. package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
  9. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
  10. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
  11. package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
  12. package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
  13. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
  14. package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
  15. package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
  17. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
  18. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
  19. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
  20. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
  22. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
  25. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
  29. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
  30. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
  31. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
  32. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
  33. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
  34. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
  35. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
  36. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
  37. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
  38. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
  39. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
  40. package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
  41. package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
  42. package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
  45. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
  46. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
  47. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
  48. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
  49. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
  50. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
  51. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
  52. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
  53. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
  54. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
  55. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
  56. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
  57. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
  58. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
  59. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
  60. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
  61. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
  62. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
  63. package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
  64. package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
  65. package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
  66. package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
  67. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
  68. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
  69. package/dist/engineering-pa-scaffold/README.md +32 -1
  70. package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
  71. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
  72. package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
  73. package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
  74. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
  75. package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
  76. package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
  77. package/dist/engineering-pa-types-engineering/index.mjs +14 -6
  78. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
  79. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
  80. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
  81. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
  82. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
  83. package/dist/engineering-pa-types-engineering/package.json +2 -2
  84. package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
  85. package/dist/engineering-pa-workspace/app/home.html +7 -7
  86. package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
  87. package/dist/engineering-pa-workspace/src/api.mjs +1 -1
  88. package/dist/engineering-pa-workspace/src/server.mjs +1 -1
  89. package/docs/installing.md +78 -0
  90. package/docs/onboarding-solo.md +162 -0
  91. package/docs/what-it-can-do.md +85 -0
  92. 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: ![alt](url) → <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 `![${alt}](${src})`;
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 `![](${url.attrs['ri:value'] || ''})`;
725
+ const att = findChild(node, 'ri:attachment');
726
+ if (att) return `![](${att.attrs['ri:filename'] || ''})`;
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, '&amp;')
877
+ .replace(/</g, '&lt;')
878
+ .replace(/>/g, '&gt;')
879
+ .replace(/"/g, '&quot;');
880
+ }