@aaronshaf/confluence-cli 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +73 -0
- package/src/cli/commands/attachments.ts +113 -0
- package/src/cli/commands/clone.ts +188 -0
- package/src/cli/commands/comments.ts +56 -0
- package/src/cli/commands/create.ts +58 -0
- package/src/cli/commands/delete.ts +46 -0
- package/src/cli/commands/doctor.ts +161 -0
- package/src/cli/commands/duplicate-check.ts +89 -0
- package/src/cli/commands/file-rename.ts +113 -0
- package/src/cli/commands/folder-hierarchy.ts +241 -0
- package/src/cli/commands/info.ts +56 -0
- package/src/cli/commands/labels.ts +53 -0
- package/src/cli/commands/move.ts +23 -0
- package/src/cli/commands/open.ts +145 -0
- package/src/cli/commands/pull.ts +241 -0
- package/src/cli/commands/push-errors.ts +40 -0
- package/src/cli/commands/push.ts +699 -0
- package/src/cli/commands/search.ts +62 -0
- package/src/cli/commands/setup.ts +124 -0
- package/src/cli/commands/spaces.ts +42 -0
- package/src/cli/commands/status.ts +88 -0
- package/src/cli/commands/tree.ts +190 -0
- package/src/cli/help.ts +425 -0
- package/src/cli/index.ts +413 -0
- package/src/cli/utils/browser.ts +34 -0
- package/src/cli/utils/progress-reporter.ts +49 -0
- package/src/cli.ts +6 -0
- package/src/lib/config.ts +156 -0
- package/src/lib/confluence-client/attachment-operations.ts +221 -0
- package/src/lib/confluence-client/client.ts +653 -0
- package/src/lib/confluence-client/comment-operations.ts +60 -0
- package/src/lib/confluence-client/folder-operations.ts +203 -0
- package/src/lib/confluence-client/index.ts +47 -0
- package/src/lib/confluence-client/label-operations.ts +102 -0
- package/src/lib/confluence-client/page-operations.ts +270 -0
- package/src/lib/confluence-client/search-operations.ts +60 -0
- package/src/lib/confluence-client/types.ts +329 -0
- package/src/lib/confluence-client/user-operations.ts +58 -0
- package/src/lib/dependency-sorter.ts +233 -0
- package/src/lib/errors.ts +237 -0
- package/src/lib/file-scanner.ts +195 -0
- package/src/lib/formatters.ts +314 -0
- package/src/lib/health-check.ts +204 -0
- package/src/lib/markdown/converter.ts +427 -0
- package/src/lib/markdown/frontmatter.ts +116 -0
- package/src/lib/markdown/html-converter.ts +398 -0
- package/src/lib/markdown/index.ts +21 -0
- package/src/lib/markdown/link-converter.ts +189 -0
- package/src/lib/markdown/reference-updater.ts +251 -0
- package/src/lib/markdown/slugify.ts +32 -0
- package/src/lib/page-state.ts +195 -0
- package/src/lib/resolve-page-target.ts +33 -0
- package/src/lib/space-config.ts +264 -0
- package/src/lib/sync/cleanup.ts +50 -0
- package/src/lib/sync/folder-path.ts +61 -0
- package/src/lib/sync/index.ts +2 -0
- package/src/lib/sync/link-resolution-pass.ts +139 -0
- package/src/lib/sync/sync-engine.ts +681 -0
- package/src/lib/sync/sync-specific.ts +221 -0
- package/src/lib/sync/types.ts +42 -0
- package/src/test/attachments.test.ts +68 -0
- package/src/test/clone.test.ts +373 -0
- package/src/test/comments.test.ts +53 -0
- package/src/test/config.test.ts +209 -0
- package/src/test/confluence-client.test.ts +535 -0
- package/src/test/delete.test.ts +39 -0
- package/src/test/dependency-sorter.test.ts +384 -0
- package/src/test/errors.test.ts +199 -0
- package/src/test/file-rename.test.ts +305 -0
- package/src/test/file-scanner.test.ts +331 -0
- package/src/test/folder-hierarchy.test.ts +337 -0
- package/src/test/formatters.test.ts +213 -0
- package/src/test/html-converter.test.ts +399 -0
- package/src/test/info.test.ts +56 -0
- package/src/test/labels.test.ts +70 -0
- package/src/test/link-conversion-integration.test.ts +189 -0
- package/src/test/link-converter.test.ts +413 -0
- package/src/test/link-resolution-pass.test.ts +368 -0
- package/src/test/markdown.test.ts +443 -0
- package/src/test/mocks/handlers.ts +228 -0
- package/src/test/move.test.ts +53 -0
- package/src/test/msw-schema-validation.ts +151 -0
- package/src/test/page-state.test.ts +542 -0
- package/src/test/push.test.ts +551 -0
- package/src/test/reference-updater.test.ts +293 -0
- package/src/test/resolve-page-target.test.ts +55 -0
- package/src/test/search.test.ts +64 -0
- package/src/test/setup-msw.ts +75 -0
- package/src/test/space-config.test.ts +516 -0
- package/src/test/spaces.test.ts +53 -0
- package/src/test/sync-engine.test.ts +486 -0
- package/src/types/turndown-plugin-gfm.d.ts +9 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { Marked, type Tokens, type Renderer } from 'marked';
|
|
2
|
+
import { relativePathToConfluenceLink, type PageLookupMap } from './link-converter.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HTML converter that transforms Markdown to Confluence Storage Format
|
|
6
|
+
* Inverse of MarkdownConverter - used for pushing local changes to Confluence
|
|
7
|
+
* Per ADR-0022: Converts relative markdown links to Confluence page links
|
|
8
|
+
*/
|
|
9
|
+
export class HtmlConverter {
|
|
10
|
+
private warnings: string[] = [];
|
|
11
|
+
private spaceRoot: string = '';
|
|
12
|
+
private currentPagePath: string = '';
|
|
13
|
+
private spaceKey: string = '';
|
|
14
|
+
private pageLookupMap: PageLookupMap | null = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Escape special XML characters for use in attributes
|
|
18
|
+
* Converts: & < > " ' to their XML entity equivalents
|
|
19
|
+
* @param text - Text to escape
|
|
20
|
+
* @returns XML-safe text
|
|
21
|
+
*/
|
|
22
|
+
private escapeXml(text: string): string {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"')
|
|
28
|
+
.replace(/'/g, ''');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Escape CDATA sections by replacing ]]> with ]]]]><![CDATA[>
|
|
33
|
+
* This allows code containing ]]> to be safely embedded in CDATA
|
|
34
|
+
* @param text - Text to escape
|
|
35
|
+
* @returns CDATA-safe text
|
|
36
|
+
*/
|
|
37
|
+
private escapeCdata(text: string): string {
|
|
38
|
+
return text.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate and sanitize language identifier for code blocks
|
|
43
|
+
* Only allows alphanumeric, dash, underscore, and plus
|
|
44
|
+
* @param lang - Language identifier from code fence (e.g., "javascript", "python")
|
|
45
|
+
* @returns Sanitized language string safe for XML attributes
|
|
46
|
+
*/
|
|
47
|
+
private sanitizeLanguage(lang: string | undefined): string {
|
|
48
|
+
if (!lang) return '';
|
|
49
|
+
// Allow only safe characters for language identifiers
|
|
50
|
+
return lang.replace(/[^a-zA-Z0-9\-_+]/g, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a configured Marked instance with custom renderer
|
|
55
|
+
* Uses marked v12+ token-based API
|
|
56
|
+
*/
|
|
57
|
+
private createMarkedInstance(): Marked {
|
|
58
|
+
const self = this;
|
|
59
|
+
|
|
60
|
+
const renderer: Partial<Renderer> = {
|
|
61
|
+
// Code blocks - use Confluence code macro for syntax highlighting
|
|
62
|
+
code(this: Renderer, token: Tokens.Code): string {
|
|
63
|
+
const language = self.sanitizeLanguage(token.lang);
|
|
64
|
+
const escapedCode = self.escapeCdata(token.text);
|
|
65
|
+
return `<ac:structured-macro ac:name="code" ac:schema-version="1">
|
|
66
|
+
<ac:parameter ac:name="language">${language}</ac:parameter>
|
|
67
|
+
<ac:plain-text-body><![CDATA[${escapedCode}]]></ac:plain-text-body>
|
|
68
|
+
</ac:structured-macro>\n`;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Blockquotes - convert to Confluence info panel for better visibility
|
|
72
|
+
blockquote(this: Renderer, token: Tokens.Blockquote): string {
|
|
73
|
+
// Parse inner tokens to get HTML content
|
|
74
|
+
const innerHtml = this.parser.parse(token.tokens);
|
|
75
|
+
|
|
76
|
+
// Check if this is a special panel (Info:, Note:, Warning:, Tip:)
|
|
77
|
+
const panelMatch = innerHtml.match(/^<p>\s*<strong>(Info|Note|Warning|Tip):<\/strong>\s*/i);
|
|
78
|
+
if (panelMatch) {
|
|
79
|
+
const panelType = panelMatch[1].toLowerCase();
|
|
80
|
+
const content = innerHtml.replace(panelMatch[0], '<p>');
|
|
81
|
+
return `<ac:structured-macro ac:name="${panelType}" ac:schema-version="1">
|
|
82
|
+
<ac:rich-text-body>${content}</ac:rich-text-body>
|
|
83
|
+
</ac:structured-macro>\n`;
|
|
84
|
+
}
|
|
85
|
+
// Regular blockquote - just use standard HTML
|
|
86
|
+
return `<blockquote>${innerHtml}</blockquote>\n`;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// Tables - standard XHTML tables work in Confluence
|
|
90
|
+
table(this: Renderer, token: Tokens.Table): string {
|
|
91
|
+
let header = '<tr>';
|
|
92
|
+
for (const cell of token.header) {
|
|
93
|
+
const align = cell.align ? ` style="text-align:${cell.align}"` : '';
|
|
94
|
+
const content = this.parser.parseInline(cell.tokens);
|
|
95
|
+
header += `<th${align}>${content}</th>`;
|
|
96
|
+
}
|
|
97
|
+
header += '</tr>\n';
|
|
98
|
+
|
|
99
|
+
let body = '';
|
|
100
|
+
for (const row of token.rows) {
|
|
101
|
+
body += '<tr>';
|
|
102
|
+
for (const cell of row) {
|
|
103
|
+
const align = cell.align ? ` style="text-align:${cell.align}"` : '';
|
|
104
|
+
const content = this.parser.parseInline(cell.tokens);
|
|
105
|
+
body += `<td${align}>${content}</td>`;
|
|
106
|
+
}
|
|
107
|
+
body += '</tr>\n';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return `<table>
|
|
111
|
+
<thead>${header}</thead>
|
|
112
|
+
<tbody>${body}</tbody>
|
|
113
|
+
</table>\n`;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// Links - convert relative .md links to Confluence page links (ADR-0022)
|
|
117
|
+
link(this: Renderer, token: Tokens.Link): string {
|
|
118
|
+
const text = this.parser.parseInline(token.tokens);
|
|
119
|
+
|
|
120
|
+
// Check if this is a relative .md link (local page reference)
|
|
121
|
+
if (token.href.endsWith('.md') && !token.href.startsWith('http://') && !token.href.startsWith('https://')) {
|
|
122
|
+
// Try to convert to Confluence page link - requires full context
|
|
123
|
+
if (self.pageLookupMap && self.currentPagePath && self.spaceRoot && self.spaceKey) {
|
|
124
|
+
const linkInfo = relativePathToConfluenceLink(
|
|
125
|
+
token.href,
|
|
126
|
+
self.currentPagePath,
|
|
127
|
+
self.spaceRoot,
|
|
128
|
+
self.pageLookupMap,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (linkInfo?.title) {
|
|
132
|
+
// Generate Confluence page link format
|
|
133
|
+
return `<ac:link>
|
|
134
|
+
<ri:page ri:content-title="${self.escapeXml(linkInfo.title)}" ri:space-key="${self.spaceKey}" />
|
|
135
|
+
<ac:plain-text-link-body><![CDATA[${text}]]></ac:plain-text-link-body>
|
|
136
|
+
</ac:link>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Target not found or missing title - warn and fall through to standard HTML link
|
|
140
|
+
if (linkInfo && !linkInfo.title) {
|
|
141
|
+
self.warnings.push(`Link to "${token.href}" has missing title in sync state - preserving as HTML link`);
|
|
142
|
+
} else {
|
|
143
|
+
self.warnings.push(`Link to "${token.href}" could not be resolved - target page not found in sync state`);
|
|
144
|
+
}
|
|
145
|
+
} else if (process.env.DEBUG) {
|
|
146
|
+
// DEBUG mode: warn about missing context for link conversion
|
|
147
|
+
const missingContext = [];
|
|
148
|
+
if (!self.pageLookupMap) missingContext.push('pageLookupMap');
|
|
149
|
+
if (!self.currentPagePath) missingContext.push('currentPagePath');
|
|
150
|
+
if (!self.spaceRoot) missingContext.push('spaceRoot');
|
|
151
|
+
if (!self.spaceKey) missingContext.push('spaceKey');
|
|
152
|
+
console.warn(`DEBUG: Link "${token.href}" not converted - missing context: ${missingContext.join(', ')}`);
|
|
153
|
+
}
|
|
154
|
+
// If missing context (pageLookupMap, etc.), silently fall through to standard HTML link
|
|
155
|
+
// This handles cases where conversion wasn't set up (e.g., standalone markdown processing)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// External link or fallback - use standard HTML
|
|
159
|
+
const titleAttr = token.title ? ` title="${self.escapeXml(token.title)}"` : '';
|
|
160
|
+
return `<a href="${self.escapeXml(token.href)}"${titleAttr}>${text}</a>`;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// Images - standard HTML, warn about local images
|
|
164
|
+
image(this: Renderer, token: Tokens.Image): string {
|
|
165
|
+
// Warn about non-URL images (local references)
|
|
166
|
+
if (!token.href.startsWith('http://') && !token.href.startsWith('https://')) {
|
|
167
|
+
self.warnings.push(`Local image "${token.href}" will not display in Confluence. Use absolute URLs.`);
|
|
168
|
+
}
|
|
169
|
+
const alt = token.text ? ` alt="${self.escapeXml(token.text)}"` : '';
|
|
170
|
+
const titleAttr = token.title ? ` title="${self.escapeXml(token.title)}"` : '';
|
|
171
|
+
return `<img src="${self.escapeXml(token.href)}"${alt}${titleAttr} />`;
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
// Paragraphs
|
|
175
|
+
paragraph(this: Renderer, token: Tokens.Paragraph): string {
|
|
176
|
+
const text = this.parser.parseInline(token.tokens);
|
|
177
|
+
return `<p>${text}</p>\n`;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
// Headings
|
|
181
|
+
heading(this: Renderer, token: Tokens.Heading): string {
|
|
182
|
+
const text = this.parser.parseInline(token.tokens);
|
|
183
|
+
return `<h${token.depth}>${text}</h${token.depth}>\n`;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
// Bold
|
|
187
|
+
strong(this: Renderer, token: Tokens.Strong): string {
|
|
188
|
+
const text = this.parser.parseInline(token.tokens);
|
|
189
|
+
return `<strong>${text}</strong>`;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// Italic
|
|
193
|
+
em(this: Renderer, token: Tokens.Em): string {
|
|
194
|
+
const text = this.parser.parseInline(token.tokens);
|
|
195
|
+
return `<em>${text}</em>`;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// Strikethrough
|
|
199
|
+
del(this: Renderer, token: Tokens.Del): string {
|
|
200
|
+
const text = this.parser.parseInline(token.tokens);
|
|
201
|
+
return `<del>${text}</del>`;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// Inline code
|
|
205
|
+
codespan(this: Renderer, token: Tokens.Codespan): string {
|
|
206
|
+
return `<code>${self.escapeXml(token.text)}</code>`;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
// Line breaks
|
|
210
|
+
br(this: Renderer): string {
|
|
211
|
+
return '<br />';
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
// Horizontal rule
|
|
215
|
+
hr(this: Renderer): string {
|
|
216
|
+
return '<hr />\n';
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// Lists
|
|
220
|
+
list(this: Renderer, token: Tokens.List): string {
|
|
221
|
+
const tag = token.ordered ? 'ol' : 'ul';
|
|
222
|
+
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : '';
|
|
223
|
+
let body = '';
|
|
224
|
+
for (const item of token.items) {
|
|
225
|
+
let itemContent = '';
|
|
226
|
+
if (item.tokens) {
|
|
227
|
+
itemContent = this.parser.parse(item.tokens);
|
|
228
|
+
}
|
|
229
|
+
// Remove wrapping <p> tags for simple list items
|
|
230
|
+
itemContent = itemContent.replace(/^<p>(.*)<\/p>\n?$/s, '$1');
|
|
231
|
+
body += `<li>${itemContent}</li>\n`;
|
|
232
|
+
}
|
|
233
|
+
return `<${tag}${startAttr}>\n${body}</${tag}>\n`;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
// HTML passthrough - sanitize dangerous elements
|
|
237
|
+
html(this: Renderer, token: Tokens.HTML): string {
|
|
238
|
+
let html = token.raw;
|
|
239
|
+
let sanitized = false;
|
|
240
|
+
|
|
241
|
+
// Remove script tags and their content
|
|
242
|
+
const withoutScripts = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
|
243
|
+
if (withoutScripts !== html) sanitized = true;
|
|
244
|
+
html = withoutScripts;
|
|
245
|
+
|
|
246
|
+
// Remove iframe tags (can load arbitrary content)
|
|
247
|
+
const withoutIframes = html.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '');
|
|
248
|
+
if (withoutIframes !== html) sanitized = true;
|
|
249
|
+
html = withoutIframes;
|
|
250
|
+
|
|
251
|
+
// Remove object and embed tags (can execute plugins/ActiveX)
|
|
252
|
+
const withoutObjects = html
|
|
253
|
+
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
|
|
254
|
+
.replace(/<embed\b[^>]*>/gi, '');
|
|
255
|
+
if (withoutObjects !== html) sanitized = true;
|
|
256
|
+
html = withoutObjects;
|
|
257
|
+
|
|
258
|
+
// Remove event handlers (onclick, onerror, etc.)
|
|
259
|
+
const withoutHandlers = html.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '');
|
|
260
|
+
if (withoutHandlers !== html) sanitized = true;
|
|
261
|
+
html = withoutHandlers;
|
|
262
|
+
|
|
263
|
+
// Remove javascript: protocol in hrefs and srcs
|
|
264
|
+
const withoutJsProtocol = html
|
|
265
|
+
.replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"')
|
|
266
|
+
.replace(/src\s*=\s*["']javascript:[^"']*["']/gi, 'src=""');
|
|
267
|
+
if (withoutJsProtocol !== html) sanitized = true;
|
|
268
|
+
html = withoutJsProtocol;
|
|
269
|
+
|
|
270
|
+
// Remove data: URLs in images (can contain embedded scripts)
|
|
271
|
+
const withoutDataUrls = html.replace(/src\s*=\s*["']data:[^"']*["']/gi, 'src=""');
|
|
272
|
+
if (withoutDataUrls !== html) sanitized = true;
|
|
273
|
+
html = withoutDataUrls;
|
|
274
|
+
|
|
275
|
+
// Warn if we sanitized anything
|
|
276
|
+
if (sanitized) {
|
|
277
|
+
self.warnings.push(
|
|
278
|
+
'Potentially unsafe HTML was sanitized (scripts, iframes, event handlers, or dangerous URLs removed).',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return html;
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
// Text
|
|
286
|
+
text(this: Renderer, token: Tokens.Text): string {
|
|
287
|
+
// If text has nested tokens (e.g., bold/italic/links inside list items),
|
|
288
|
+
// parse them instead of returning raw text
|
|
289
|
+
if ('tokens' in token && token.tokens && token.tokens.length > 0) {
|
|
290
|
+
return this.parser.parseInline(token.tokens);
|
|
291
|
+
}
|
|
292
|
+
return token.text;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return new Marked({
|
|
297
|
+
gfm: true,
|
|
298
|
+
breaks: false,
|
|
299
|
+
renderer,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Strip @ prefix from user mentions, converting @username to just username
|
|
305
|
+
* Confluence requires account IDs for proper mentions, so we render as plain text
|
|
306
|
+
* Avoids email addresses (user@example.com) by checking preceding character
|
|
307
|
+
*/
|
|
308
|
+
private stripMentionPrefix(markdown: string): string {
|
|
309
|
+
// Match @username at word boundaries, but not in email addresses
|
|
310
|
+
// Pattern: @ preceded by start-of-line or whitespace/punctuation (not alphanumeric or dot)
|
|
311
|
+
return markdown.replace(/(?<=^|[^a-zA-Z0-9.])@([a-zA-Z0-9_-]+)/gm, '$1');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Detect unsupported markdown features and add warnings
|
|
316
|
+
*/
|
|
317
|
+
private detectUnsupportedFeatures(markdown: string): void {
|
|
318
|
+
// Check for task lists with checkboxes
|
|
319
|
+
if (/^\s*-\s*\[[x ]\]/im.test(markdown)) {
|
|
320
|
+
this.warnings.push('Task list checkboxes (- [x]) will be converted to regular list items.');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Check for footnotes
|
|
324
|
+
if (/\[\^.+\]/.test(markdown)) {
|
|
325
|
+
this.warnings.push('Footnotes are not supported and will render as plain text.');
|
|
326
|
+
}
|
|
327
|
+
// Note: Local images are detected in the image renderer itself
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Ensure XHTML compliance (self-closing tags, etc.)
|
|
332
|
+
*/
|
|
333
|
+
private ensureXhtmlCompliance(html: string): string {
|
|
334
|
+
return (
|
|
335
|
+
html
|
|
336
|
+
// Ensure br tags are self-closing
|
|
337
|
+
.replace(/<br\s*>/gi, '<br />')
|
|
338
|
+
// Ensure hr tags are self-closing
|
|
339
|
+
.replace(/<hr\s*>/gi, '<hr />')
|
|
340
|
+
// Ensure img tags are self-closing (not already handled)
|
|
341
|
+
.replace(/<img([^>]+)(?<!\/)>/gi, '<img$1 />')
|
|
342
|
+
// Clean up extra whitespace
|
|
343
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
344
|
+
.trim()
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Convert Markdown to Confluence Storage Format HTML
|
|
350
|
+
* Per ADR-0022: Converts relative .md links to Confluence page links
|
|
351
|
+
*
|
|
352
|
+
* @param markdown - Markdown content to convert
|
|
353
|
+
* @param spaceRoot - Absolute path to space root directory (for link resolution)
|
|
354
|
+
* @param currentPagePath - Current page's path relative to space root (for link resolution)
|
|
355
|
+
* @param spaceKey - Confluence space key (for link generation)
|
|
356
|
+
* @param pageLookupMap - Page lookup map for finding target pages
|
|
357
|
+
*/
|
|
358
|
+
convert(
|
|
359
|
+
markdown: string,
|
|
360
|
+
spaceRoot?: string,
|
|
361
|
+
currentPagePath?: string,
|
|
362
|
+
spaceKey?: string,
|
|
363
|
+
pageLookupMap?: PageLookupMap,
|
|
364
|
+
): { html: string; warnings: string[] } {
|
|
365
|
+
this.warnings = [];
|
|
366
|
+
this.spaceRoot = spaceRoot || '';
|
|
367
|
+
this.currentPagePath = currentPagePath || '';
|
|
368
|
+
this.spaceKey = spaceKey || '';
|
|
369
|
+
this.pageLookupMap = pageLookupMap || null;
|
|
370
|
+
|
|
371
|
+
// Preprocess: strip @ from mentions (Confluence requires account IDs for real mentions)
|
|
372
|
+
const preprocessedMarkdown = this.stripMentionPrefix(markdown);
|
|
373
|
+
|
|
374
|
+
// Detect unsupported features before conversion
|
|
375
|
+
this.detectUnsupportedFeatures(preprocessedMarkdown);
|
|
376
|
+
|
|
377
|
+
// Create marked instance with custom renderer for this conversion
|
|
378
|
+
const markedInstance = this.createMarkedInstance();
|
|
379
|
+
|
|
380
|
+
// Convert markdown to HTML using custom renderer
|
|
381
|
+
const rawHtml = markedInstance.parse(preprocessedMarkdown);
|
|
382
|
+
|
|
383
|
+
// Ensure XHTML compliance
|
|
384
|
+
const html = this.ensureXhtmlCompliance(rawHtml as string);
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
html,
|
|
388
|
+
warnings: [...this.warnings],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get any warnings from the last conversion
|
|
394
|
+
*/
|
|
395
|
+
getWarnings(): string[] {
|
|
396
|
+
return [...this.warnings];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { MarkdownConverter } from './converter.js';
|
|
2
|
+
export { HtmlConverter } from './html-converter.js';
|
|
3
|
+
export {
|
|
4
|
+
createFrontmatter,
|
|
5
|
+
extractH1Title,
|
|
6
|
+
extractPageId,
|
|
7
|
+
parseMarkdown,
|
|
8
|
+
serializeMarkdown,
|
|
9
|
+
stripH1Title,
|
|
10
|
+
type PageFrontmatter,
|
|
11
|
+
} from './frontmatter.js';
|
|
12
|
+
export { generateUniqueFilename, slugify } from './slugify.js';
|
|
13
|
+
export {
|
|
14
|
+
buildPageLookupMapFromCache,
|
|
15
|
+
confluenceLinkToRelativePath,
|
|
16
|
+
extractPageTitleFromLink,
|
|
17
|
+
relativePathToConfluenceLink,
|
|
18
|
+
type PageLinkInfo,
|
|
19
|
+
type PageLookupMap,
|
|
20
|
+
} from './link-converter.js';
|
|
21
|
+
export { updateReferencesAfterRename, type ReferenceUpdateResult } from './reference-updater.js';
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
2
|
+
import type { FullPageInfo, PageStateCache } from '../page-state.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decode common HTML entities in a string
|
|
6
|
+
* Handles the most common entities: & < > " '
|
|
7
|
+
*/
|
|
8
|
+
function decodeHtmlEntities(text: string): string {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'/g, "'")
|
|
15
|
+
.replace(/'/g, "'");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Page info for link conversion (subset of FullPageInfo)
|
|
20
|
+
*/
|
|
21
|
+
export interface PageLinkInfo {
|
|
22
|
+
pageId: string;
|
|
23
|
+
localPath: string;
|
|
24
|
+
title: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Page lookup map for link conversion
|
|
29
|
+
* Per ADR-0024: Uses PageLinkInfo which can come from FullPageInfo or PageStateCache
|
|
30
|
+
*/
|
|
31
|
+
export interface PageLookupMap {
|
|
32
|
+
// Title -> PageLinkInfo mapping for quick lookup
|
|
33
|
+
titleToPage: Map<string, PageLinkInfo>;
|
|
34
|
+
// PageId -> PageLinkInfo mapping
|
|
35
|
+
idToPage: Map<string, PageLinkInfo>;
|
|
36
|
+
// LocalPath -> PageLinkInfo mapping for O(1) path lookups
|
|
37
|
+
pathToPage: Map<string, PageLinkInfo>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a lookup map from PageStateCache for efficient link conversion
|
|
42
|
+
* Per ADR-0024: This is the primary way to build lookup maps
|
|
43
|
+
*
|
|
44
|
+
* @param pageState - PageStateCache built from frontmatter
|
|
45
|
+
* @param warnDuplicates - If true, log warnings for duplicate titles
|
|
46
|
+
* @returns Page lookup map with three indices for efficient lookups
|
|
47
|
+
*/
|
|
48
|
+
export function buildPageLookupMapFromCache(pageState: PageStateCache, warnDuplicates = false): PageLookupMap {
|
|
49
|
+
const titleToPage = new Map<string, PageLinkInfo>();
|
|
50
|
+
const idToPage = new Map<string, PageLinkInfo>();
|
|
51
|
+
const pathToPage = new Map<string, PageLinkInfo>();
|
|
52
|
+
|
|
53
|
+
for (const [pageId, pageInfo] of pageState.pages) {
|
|
54
|
+
const linkInfo: PageLinkInfo = {
|
|
55
|
+
pageId: pageInfo.pageId,
|
|
56
|
+
localPath: pageInfo.localPath,
|
|
57
|
+
title: pageInfo.title,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
idToPage.set(pageId, linkInfo);
|
|
61
|
+
pathToPage.set(pageInfo.localPath, linkInfo);
|
|
62
|
+
|
|
63
|
+
const title = pageInfo.title || '';
|
|
64
|
+
|
|
65
|
+
// Skip entries without titles
|
|
66
|
+
if (!title) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check for duplicate titles
|
|
71
|
+
const existingPage = titleToPage.get(title);
|
|
72
|
+
if (existingPage) {
|
|
73
|
+
// Use deterministic ordering: prefer page with lexicographically smaller pageId
|
|
74
|
+
// This ensures consistent behavior across runs
|
|
75
|
+
const shouldReplace = pageId < existingPage.pageId;
|
|
76
|
+
|
|
77
|
+
if (warnDuplicates) {
|
|
78
|
+
console.warn(
|
|
79
|
+
`Warning: Duplicate page title "${title}" found:\n` +
|
|
80
|
+
` - ${existingPage.localPath} (page ID: ${existingPage.pageId})\n` +
|
|
81
|
+
` - ${linkInfo.localPath} (page ID: ${pageId})\n` +
|
|
82
|
+
` Links to this title will point to ${shouldReplace ? linkInfo.localPath : existingPage.localPath}\n` +
|
|
83
|
+
` Recommendation: Rename one of these pages in Confluence to make titles unique.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (shouldReplace) {
|
|
88
|
+
titleToPage.set(title, linkInfo);
|
|
89
|
+
}
|
|
90
|
+
// else: keep existing page
|
|
91
|
+
} else {
|
|
92
|
+
// First page with this title
|
|
93
|
+
titleToPage.set(title, linkInfo);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { titleToPage, idToPage, pathToPage };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convert Confluence page link to relative markdown path
|
|
102
|
+
*
|
|
103
|
+
* @param targetTitle - Title from ri:content-title attribute
|
|
104
|
+
* @param currentPagePath - Current page's local path (relative to space root)
|
|
105
|
+
* @param lookupMap - Page lookup map for finding target pages
|
|
106
|
+
* @returns Relative path to target page, or null if not found
|
|
107
|
+
*
|
|
108
|
+
* TODO: Add support for cross-space links by accepting optional targetSpaceKey parameter
|
|
109
|
+
* and maintaining separate lookup maps per space. Cross-space links could use a format
|
|
110
|
+
* like `../OTHER-SPACE/path/to/page.md` or a special prefix.
|
|
111
|
+
*/
|
|
112
|
+
export function confluenceLinkToRelativePath(
|
|
113
|
+
targetTitle: string,
|
|
114
|
+
currentPagePath: string,
|
|
115
|
+
lookupMap: PageLookupMap,
|
|
116
|
+
): string | null {
|
|
117
|
+
// Look up target page by title
|
|
118
|
+
const targetPage = lookupMap.titleToPage.get(targetTitle);
|
|
119
|
+
if (!targetPage) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Get directory of current page
|
|
124
|
+
const currentDir = dirname(currentPagePath);
|
|
125
|
+
|
|
126
|
+
// Calculate relative path from current page to target page
|
|
127
|
+
// Both paths are relative to space root, so we need to resolve them
|
|
128
|
+
const relativePath = relative(currentDir, targetPage.localPath);
|
|
129
|
+
|
|
130
|
+
// Ensure path starts with ./ for relative paths in same directory
|
|
131
|
+
// or ../ for parent directories
|
|
132
|
+
if (!relativePath.startsWith('.')) {
|
|
133
|
+
return `./${relativePath}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return relativePath;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extract title from Confluence page link element
|
|
141
|
+
* Handles both <ac:link><ri:page> and direct <ri:page> elements
|
|
142
|
+
* Decodes HTML entities in the title
|
|
143
|
+
*
|
|
144
|
+
* @param html - HTML containing Confluence link
|
|
145
|
+
* @returns Page title from ri:content-title attribute (decoded), or null
|
|
146
|
+
*/
|
|
147
|
+
export function extractPageTitleFromLink(html: string): string | null {
|
|
148
|
+
// Match ri:content-title attribute in ri:page elements
|
|
149
|
+
const match = html.match(/ri:content-title=["']([^"']+)["']/);
|
|
150
|
+
return match ? decodeHtmlEntities(match[1]) : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Convert relative markdown path to Confluence page link components
|
|
155
|
+
*
|
|
156
|
+
* @param relativePath - Relative path from markdown link (e.g., "./path/to/page.md")
|
|
157
|
+
* @param currentPagePath - Current page's local path (relative to space root)
|
|
158
|
+
* @param spaceRoot - Absolute path to space root directory
|
|
159
|
+
* @param lookupMap - Page lookup map for finding target pages
|
|
160
|
+
* @returns Object with title and pageId, or null if not found
|
|
161
|
+
*
|
|
162
|
+
* TODO: Add support for cross-space links by detecting paths like `../OTHER-SPACE/path.md`
|
|
163
|
+
* and looking up pages in different space configs.
|
|
164
|
+
*/
|
|
165
|
+
export function relativePathToConfluenceLink(
|
|
166
|
+
relativePath: string,
|
|
167
|
+
currentPagePath: string,
|
|
168
|
+
spaceRoot: string,
|
|
169
|
+
lookupMap: PageLookupMap,
|
|
170
|
+
): { title: string; pageId: string } | null {
|
|
171
|
+
// Resolve relative path to absolute path
|
|
172
|
+
const currentDir = resolve(spaceRoot, dirname(currentPagePath));
|
|
173
|
+
const targetAbsolutePath = resolve(currentDir, relativePath);
|
|
174
|
+
|
|
175
|
+
// Convert back to path relative to space root
|
|
176
|
+
const targetRelativePath = relative(spaceRoot, targetAbsolutePath);
|
|
177
|
+
|
|
178
|
+
// O(1) lookup by path
|
|
179
|
+
const pageInfo = lookupMap.pathToPage.get(targetRelativePath);
|
|
180
|
+
if (pageInfo?.title) {
|
|
181
|
+
return {
|
|
182
|
+
title: pageInfo.title,
|
|
183
|
+
pageId: pageInfo.pageId,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Page found but missing title, or page not found
|
|
188
|
+
return null;
|
|
189
|
+
}
|