formatferry 0.1.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/commands/auth.d.ts +17 -0
  2. package/dist/commands/auth.d.ts.map +1 -0
  3. package/dist/commands/auth.js +102 -0
  4. package/dist/commands/auth.js.map +1 -0
  5. package/dist/commands/batch.d.ts +10 -0
  6. package/dist/commands/batch.d.ts.map +1 -0
  7. package/dist/commands/batch.js +92 -0
  8. package/dist/commands/batch.js.map +1 -0
  9. package/dist/commands/convert.d.ts +26 -0
  10. package/dist/commands/convert.d.ts.map +1 -0
  11. package/dist/commands/convert.js +198 -0
  12. package/dist/commands/convert.js.map +1 -0
  13. package/dist/config-store.d.ts +27 -0
  14. package/dist/config-store.d.ts.map +1 -0
  15. package/dist/config-store.js +58 -0
  16. package/dist/config-store.js.map +1 -0
  17. package/dist/entitlement.d.ts +11 -0
  18. package/dist/entitlement.d.ts.map +1 -0
  19. package/dist/entitlement.js +91 -0
  20. package/dist/entitlement.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +10 -9
  24. package/dist/index.js.map +1 -0
  25. package/dist/packages/cli/src/commands/auth.d.ts +17 -0
  26. package/dist/packages/cli/src/commands/auth.d.ts.map +1 -0
  27. package/dist/packages/cli/src/commands/auth.js +102 -0
  28. package/dist/packages/cli/src/commands/auth.js.map +1 -0
  29. package/dist/packages/cli/src/commands/batch.d.ts +10 -0
  30. package/dist/packages/cli/src/commands/batch.d.ts.map +1 -0
  31. package/dist/packages/cli/src/commands/batch.js +92 -0
  32. package/dist/packages/cli/src/commands/batch.js.map +1 -0
  33. package/dist/packages/cli/src/commands/convert.d.ts +26 -0
  34. package/dist/packages/cli/src/commands/convert.d.ts.map +1 -0
  35. package/dist/packages/cli/src/commands/convert.js +198 -0
  36. package/dist/packages/cli/src/commands/convert.js.map +1 -0
  37. package/dist/packages/cli/src/config-store.d.ts +27 -0
  38. package/dist/packages/cli/src/config-store.d.ts.map +1 -0
  39. package/dist/packages/cli/src/config-store.js +58 -0
  40. package/dist/packages/cli/src/config-store.js.map +1 -0
  41. package/dist/packages/cli/src/entitlement.d.ts +11 -0
  42. package/dist/packages/cli/src/entitlement.d.ts.map +1 -0
  43. package/dist/packages/cli/src/entitlement.js +91 -0
  44. package/dist/packages/cli/src/entitlement.js.map +1 -0
  45. package/dist/packages/cli/src/index.d.ts +2 -0
  46. package/dist/packages/cli/src/index.d.ts.map +1 -0
  47. package/dist/packages/cli/src/index.js +28 -0
  48. package/dist/packages/cli/src/index.js.map +1 -0
  49. package/dist/packages/cli/src/shim.d.ts +2 -0
  50. package/dist/packages/cli/src/shim.d.ts.map +1 -0
  51. package/dist/packages/cli/src/shim.js +21 -0
  52. package/dist/packages/cli/src/shim.js.map +1 -0
  53. package/dist/shim.d.ts +2 -0
  54. package/dist/shim.d.ts.map +1 -0
  55. package/dist/shim.js +21 -0
  56. package/dist/shim.js.map +1 -0
  57. package/dist/src/shared/adapters/docx-adapter.d.ts +85 -0
  58. package/dist/src/shared/adapters/docx-adapter.d.ts.map +1 -0
  59. package/dist/src/shared/adapters/docx-adapter.js +164 -0
  60. package/dist/src/shared/adapters/docx-adapter.js.map +1 -0
  61. package/dist/src/shared/adapters/pdf-adapter.d.ts +94 -0
  62. package/dist/src/shared/adapters/pdf-adapter.d.ts.map +1 -0
  63. package/dist/src/shared/adapters/pdf-adapter.js +322 -0
  64. package/dist/src/shared/adapters/pdf-adapter.js.map +1 -0
  65. package/dist/src/shared/adapters/spreadsheet-adapter.d.ts +98 -0
  66. package/dist/src/shared/adapters/spreadsheet-adapter.d.ts.map +1 -0
  67. package/dist/src/shared/adapters/spreadsheet-adapter.js +424 -0
  68. package/dist/src/shared/adapters/spreadsheet-adapter.js.map +1 -0
  69. package/dist/src/shared/atlassian-detector.d.ts +6 -0
  70. package/dist/src/shared/atlassian-detector.d.ts.map +1 -0
  71. package/dist/src/shared/atlassian-detector.js +23 -0
  72. package/dist/src/shared/atlassian-detector.js.map +1 -0
  73. package/dist/src/shared/conversion-engine.d.ts +114 -0
  74. package/dist/src/shared/conversion-engine.d.ts.map +1 -0
  75. package/dist/src/shared/conversion-engine.js +790 -0
  76. package/dist/src/shared/conversion-engine.js.map +1 -0
  77. package/dist/src/shared/entitlement-service.d.ts +42 -0
  78. package/dist/src/shared/entitlement-service.d.ts.map +1 -0
  79. package/dist/src/shared/entitlement-service.js +38 -0
  80. package/dist/src/shared/entitlement-service.js.map +1 -0
  81. package/dist/src/shared/input-sanitizer.d.ts +93 -0
  82. package/dist/src/shared/input-sanitizer.d.ts.map +1 -0
  83. package/dist/src/shared/input-sanitizer.js +527 -0
  84. package/dist/src/shared/input-sanitizer.js.map +1 -0
  85. package/package.json +6 -2
@@ -0,0 +1,790 @@
1
+ import TurndownService from 'turndown';
2
+ import { sanitizeHTML } from '../shared/input-sanitizer';
3
+ export class ConversionEngine {
4
+ githubService;
5
+ commonmarkService;
6
+ customService;
7
+ discordService;
8
+ redditService;
9
+ rmarkdownService;
10
+ constructor() {
11
+ this.initializeServices();
12
+ }
13
+ initializeServices() {
14
+ // GitHub Flavored Markdown configuration
15
+ this.githubService = new TurndownService({
16
+ headingStyle: 'atx',
17
+ hr: '---',
18
+ bulletListMarker: '-',
19
+ codeBlockStyle: 'fenced',
20
+ emDelimiter: '_',
21
+ });
22
+ // CommonMark configuration (more strict)
23
+ this.commonmarkService = new TurndownService({
24
+ headingStyle: 'atx',
25
+ hr: '***',
26
+ bulletListMarker: '*',
27
+ codeBlockStyle: 'indented',
28
+ emDelimiter: '*',
29
+ });
30
+ // Custom service (starts as GitHub, can be modified)
31
+ this.customService = new TurndownService({
32
+ headingStyle: 'atx',
33
+ hr: '---',
34
+ bulletListMarker: '-',
35
+ codeBlockStyle: 'fenced',
36
+ emDelimiter: '_',
37
+ });
38
+ // Add enhanced Google Docs formatting detection to all services
39
+ this.addGoogleDocsRules(this.githubService);
40
+ this.addGoogleDocsRules(this.commonmarkService);
41
+ this.addGoogleDocsRules(this.customService);
42
+ // Add table preservation rules
43
+ this.addTableRules(this.githubService);
44
+ this.addTableRules(this.commonmarkService);
45
+ this.addTableRules(this.customService);
46
+ // Add enhanced list rules
47
+ this.addListRules(this.githubService);
48
+ this.addListRules(this.commonmarkService);
49
+ this.addListRules(this.customService);
50
+ // Add PDF page marker rules (Req 3.5)
51
+ this.addPdfPageMarkerRules(this.githubService);
52
+ this.addPdfPageMarkerRules(this.commonmarkService);
53
+ this.addPdfPageMarkerRules(this.customService);
54
+ // Initialize platform-specific services
55
+ this.discordService = this.createDiscordService();
56
+ this.redditService = this.createRedditService();
57
+ this.rmarkdownService = this.createRMarkdownService();
58
+ }
59
+ /**
60
+ * Create Discord-optimized Turndown service
61
+ * - Underline support (__text__)
62
+ * - Heading limit (H1-H3 only)
63
+ * - Tables converted to code blocks
64
+ * - Images converted to links
65
+ */
66
+ createDiscordService() {
67
+ const service = new TurndownService({
68
+ headingStyle: 'atx',
69
+ hr: '---',
70
+ bulletListMarker: '-',
71
+ codeBlockStyle: 'fenced',
72
+ emDelimiter: '*',
73
+ });
74
+ // Apply shared rules
75
+ this.addGoogleDocsRules(service);
76
+ this.addListRules(service);
77
+ this.addPdfPageMarkerRules(service);
78
+ // Discord-specific: Underline support
79
+ service.addRule('discordUnderline', {
80
+ filter: ['u', 'ins'],
81
+ replacement: (content) => content.trim() ? `__${content}__` : content
82
+ });
83
+ // Discord-specific: Limit headings to H3
84
+ service.addRule('discordHeadingLimit', {
85
+ filter: ['h4', 'h5', 'h6'],
86
+ replacement: (content) => `### ${content.trim()}\n\n`
87
+ });
88
+ // Discord-specific: Tables to code blocks
89
+ service.addRule('discordTable', {
90
+ filter: 'table',
91
+ replacement: (_content, node) => {
92
+ const plainText = this.tableToPlainText(node);
93
+ return `\`\`\`\n${plainText}\n\`\`\`\n\n`;
94
+ }
95
+ });
96
+ // Discord-specific: Images to links
97
+ service.addRule('discordImage', {
98
+ filter: 'img',
99
+ replacement: (_content, node) => {
100
+ const element = node;
101
+ const src = element.getAttribute('src') || '';
102
+ const alt = element.getAttribute('alt') || 'image';
103
+ return `[${alt}](${src})`;
104
+ }
105
+ });
106
+ // Discord-specific: Task list checkboxes to emoji (HTML input)
107
+ service.addRule('discordTaskList', {
108
+ filter: (node) => {
109
+ return node.nodeName === 'INPUT' &&
110
+ node.type === 'checkbox';
111
+ },
112
+ replacement: (_content, node) => {
113
+ const checked = node.checked;
114
+ return checked ? '☑ ' : '☐ ';
115
+ }
116
+ });
117
+ return service;
118
+ }
119
+ /**
120
+ * Apply format-specific post-processing for Unicode characters
121
+ * Handles checkboxes, superscript, and subscript Unicode characters
122
+ */
123
+ applyFormatPostProcessing(markdown, format) {
124
+ switch (format) {
125
+ case 'github':
126
+ return this.normalizeGitHubCheckboxes(markdown);
127
+ case 'discord':
128
+ return this.normalizeDiscordCheckboxes(markdown);
129
+ case 'reddit':
130
+ return this.normalizeRedditUnicode(markdown);
131
+ case 'rmarkdown':
132
+ return this.normalizeRMarkdownUnicode(markdown);
133
+ default:
134
+ return markdown;
135
+ }
136
+ }
137
+ /**
138
+ * Normalize checkbox patterns for GitHub task list syntax
139
+ * Converts Unicode checkboxes to [x] and [ ]
140
+ */
141
+ normalizeGitHubCheckboxes(markdown) {
142
+ let result = markdown;
143
+ // Convert checked Unicode checkboxes to [x]
144
+ result = result.replace(/^([-*]\s+)[☑✓✔■⬛]/gm, '$1[x]');
145
+ // Convert unchecked Unicode checkboxes to [ ]
146
+ result = result.replace(/^([-*]\s+)[☐✗✘□⬜]/gm, '$1[ ]');
147
+ return result;
148
+ }
149
+ /**
150
+ * Normalize checkbox patterns in markdown output for Discord
151
+ * Converts [x], [ ], and other checkbox patterns to emoji
152
+ */
153
+ normalizeDiscordCheckboxes(markdown) {
154
+ // Normalize checked checkbox patterns to ☑
155
+ let result = markdown.replace(/^([-*]\s+)\[x\]/gmi, '$1☑');
156
+ result = result.replace(/^([-*]\s+)[✓✔■⬛]/gm, '$1☑');
157
+ // Normalize unchecked checkbox patterns to ☐
158
+ result = result.replace(/^([-*]\s+)\[\s?\]/gm, '$1☐');
159
+ result = result.replace(/^([-*]\s+)[✗✘□⬜]/gm, '$1☐');
160
+ return result;
161
+ }
162
+ /**
163
+ * Normalize Unicode for Reddit format
164
+ * Converts Unicode superscript to ^text syntax
165
+ */
166
+ normalizeRedditUnicode(markdown) {
167
+ let result = markdown;
168
+ // Convert Unicode superscript digits to Reddit syntax
169
+ const superscriptMap = {
170
+ '⁰': '0', '¹': '1', '²': '2', '³': '3', '⁴': '4',
171
+ '⁵': '5', '⁶': '6', '⁷': '7', '⁸': '8', '⁹': '9',
172
+ 'ⁿ': 'n', 'ⁱ': 'i'
173
+ };
174
+ // Replace Unicode superscripts with ^n format
175
+ for (const [unicode, digit] of Object.entries(superscriptMap)) {
176
+ result = result.replace(new RegExp(unicode, 'g'), `^${digit}`);
177
+ }
178
+ // Convert Unicode subscript digits to [n] format
179
+ const subscriptMap = {
180
+ '₀': '0', '₁': '1', '₂': '2', '₃': '3', '₄': '4',
181
+ '₅': '5', '₆': '6', '₇': '7', '₈': '8', '₉': '9'
182
+ };
183
+ for (const [unicode, digit] of Object.entries(subscriptMap)) {
184
+ result = result.replace(new RegExp(unicode, 'g'), `[${digit}]`);
185
+ }
186
+ return result;
187
+ }
188
+ /**
189
+ * Normalize Unicode for R Markdown format
190
+ * Converts Unicode superscript/subscript to Pandoc syntax
191
+ */
192
+ normalizeRMarkdownUnicode(markdown) {
193
+ let result = markdown;
194
+ // Convert Unicode superscript digits to Pandoc ^n^ format
195
+ const superscriptMap = {
196
+ '⁰': '0', '¹': '1', '²': '2', '³': '3', '⁴': '4',
197
+ '⁵': '5', '⁶': '6', '⁷': '7', '⁸': '8', '⁹': '9',
198
+ 'ⁿ': 'n', 'ⁱ': 'i'
199
+ };
200
+ for (const [unicode, digit] of Object.entries(superscriptMap)) {
201
+ result = result.replace(new RegExp(unicode, 'g'), `^${digit}^`);
202
+ }
203
+ // Convert Unicode subscript digits to Pandoc ~n~ format
204
+ const subscriptMap = {
205
+ '₀': '0', '₁': '1', '₂': '2', '₃': '3', '₄': '4',
206
+ '₅': '5', '₆': '6', '₇': '7', '₈': '8', '₉': '9'
207
+ };
208
+ for (const [unicode, digit] of Object.entries(subscriptMap)) {
209
+ result = result.replace(new RegExp(unicode, 'g'), `~${digit}~`);
210
+ }
211
+ return result;
212
+ }
213
+ /**
214
+ * Create Reddit-optimized Turndown service
215
+ * - Superscript support (^text or ^(text with spaces))
216
+ * - Subscript to bracketed text
217
+ * - Fenced code blocks (new Reddit)
218
+ */
219
+ createRedditService() {
220
+ const service = new TurndownService({
221
+ headingStyle: 'atx',
222
+ hr: '---',
223
+ bulletListMarker: '-',
224
+ codeBlockStyle: 'fenced',
225
+ emDelimiter: '*',
226
+ });
227
+ // Apply all shared rules
228
+ this.addGoogleDocsRules(service);
229
+ this.addTableRules(service);
230
+ this.addListRules(service);
231
+ this.addPdfPageMarkerRules(service);
232
+ // Reddit-specific: Superscript (semantic tag)
233
+ service.addRule('redditSuperscript', {
234
+ filter: (node) => node.nodeName === 'SUP',
235
+ replacement: (content) => {
236
+ const trimmed = content.trim();
237
+ if (!trimmed)
238
+ return '';
239
+ return trimmed.includes(' ') ? `^(${trimmed})` : `^${trimmed}`;
240
+ }
241
+ });
242
+ // Reddit-specific: Superscript (CSS vertical-align: super)
243
+ service.addRule('redditSuperscriptStyle', {
244
+ filter: (node) => {
245
+ if (node.nodeName !== 'SPAN')
246
+ return false;
247
+ const style = node.getAttribute('style') || '';
248
+ return style.includes('vertical-align: super') ||
249
+ style.includes('vertical-align:super');
250
+ },
251
+ replacement: (content) => {
252
+ const trimmed = content.trim();
253
+ if (!trimmed)
254
+ return '';
255
+ return trimmed.includes(' ') ? `^(${trimmed})` : `^${trimmed}`;
256
+ }
257
+ });
258
+ // Reddit-specific: Subscript to brackets (semantic tag)
259
+ service.addRule('redditSubscript', {
260
+ filter: (node) => node.nodeName === 'SUB',
261
+ replacement: (content) => {
262
+ const trimmed = content.trim();
263
+ return trimmed ? `[${trimmed}]` : '';
264
+ }
265
+ });
266
+ // Reddit-specific: Subscript to brackets (CSS vertical-align: sub)
267
+ service.addRule('redditSubscriptStyle', {
268
+ filter: (node) => {
269
+ if (node.nodeName !== 'SPAN')
270
+ return false;
271
+ const style = node.getAttribute('style') || '';
272
+ return style.includes('vertical-align: sub') ||
273
+ style.includes('vertical-align:sub');
274
+ },
275
+ replacement: (content) => {
276
+ const trimmed = content.trim();
277
+ return trimmed ? `[${trimmed}]` : '';
278
+ }
279
+ });
280
+ return service;
281
+ }
282
+ /**
283
+ * Create R Markdown-optimized Turndown service
284
+ * - Pandoc superscript (^text^)
285
+ * - Pandoc subscript (~text~)
286
+ * - Math expression preservation
287
+ */
288
+ createRMarkdownService() {
289
+ const service = new TurndownService({
290
+ headingStyle: 'atx',
291
+ hr: '---',
292
+ bulletListMarker: '-',
293
+ codeBlockStyle: 'fenced',
294
+ emDelimiter: '*',
295
+ });
296
+ // Apply all shared rules
297
+ this.addGoogleDocsRules(service);
298
+ this.addTableRules(service);
299
+ this.addListRules(service);
300
+ this.addPdfPageMarkerRules(service);
301
+ // R Markdown-specific: Pandoc superscript (semantic tag)
302
+ service.addRule('rmdSuperscript', {
303
+ filter: (node) => node.nodeName === 'SUP',
304
+ replacement: (content) => {
305
+ const trimmed = content.trim();
306
+ return trimmed ? `^${trimmed}^` : '';
307
+ }
308
+ });
309
+ // R Markdown-specific: Pandoc superscript (CSS vertical-align: super)
310
+ service.addRule('rmdSuperscriptStyle', {
311
+ filter: (node) => {
312
+ if (node.nodeName !== 'SPAN')
313
+ return false;
314
+ const style = node.getAttribute('style') || '';
315
+ return style.includes('vertical-align: super') ||
316
+ style.includes('vertical-align:super');
317
+ },
318
+ replacement: (content) => {
319
+ const trimmed = content.trim();
320
+ return trimmed ? `^${trimmed}^` : '';
321
+ }
322
+ });
323
+ // R Markdown-specific: Pandoc subscript (semantic tag)
324
+ service.addRule('rmdSubscript', {
325
+ filter: (node) => node.nodeName === 'SUB',
326
+ replacement: (content) => {
327
+ const trimmed = content.trim();
328
+ return trimmed ? `~${trimmed}~` : '';
329
+ }
330
+ });
331
+ // R Markdown-specific: Pandoc subscript (CSS vertical-align: sub)
332
+ service.addRule('rmdSubscriptStyle', {
333
+ filter: (node) => {
334
+ if (node.nodeName !== 'SPAN')
335
+ return false;
336
+ const style = node.getAttribute('style') || '';
337
+ return style.includes('vertical-align: sub') ||
338
+ style.includes('vertical-align:sub');
339
+ },
340
+ replacement: (content) => {
341
+ const trimmed = content.trim();
342
+ return trimmed ? `~${trimmed}~` : '';
343
+ }
344
+ });
345
+ // R Markdown-specific: Preserve math expressions
346
+ service.addRule('rmdMath', {
347
+ filter: (node) => {
348
+ if (node.nodeName !== 'SPAN')
349
+ return false;
350
+ return node.classList.contains('math') ||
351
+ node.classList.contains('MathJax') ||
352
+ node.hasAttribute('data-math');
353
+ },
354
+ replacement: (content, node) => {
355
+ const element = node;
356
+ const isDisplay = element.classList.contains('display');
357
+ const math = element.getAttribute('data-math') || content;
358
+ return isDisplay ? `$$${math}$$\n\n` : `$${math}$`;
359
+ }
360
+ });
361
+ return service;
362
+ }
363
+ /**
364
+ * Convert HTML table to plain text for Discord code blocks
365
+ */
366
+ tableToPlainText(table) {
367
+ const rows = Array.from(table.querySelectorAll('tr'));
368
+ if (rows.length === 0)
369
+ return '';
370
+ // Calculate column widths
371
+ const colWidths = [];
372
+ rows.forEach(row => {
373
+ const cells = Array.from(row.querySelectorAll('th, td'));
374
+ cells.forEach((cell, i) => {
375
+ const text = (cell.textContent || '').trim();
376
+ colWidths[i] = Math.max(colWidths[i] || 0, text.length);
377
+ });
378
+ });
379
+ // Build plain text table
380
+ const lines = [];
381
+ rows.forEach(row => {
382
+ const cells = Array.from(row.querySelectorAll('th, td'));
383
+ const cellTexts = cells.map((cell, i) => {
384
+ const text = (cell.textContent || '').trim();
385
+ return text.padEnd(colWidths[i] || 0);
386
+ });
387
+ lines.push(cellTexts.join(' '));
388
+ });
389
+ return lines.join('\n');
390
+ }
391
+ addGoogleDocsRules(service) {
392
+ // Enhanced Google Docs bold text detection
393
+ service.addRule('googleDocsBold', {
394
+ filter: (node) => {
395
+ if (node.nodeName !== 'SPAN')
396
+ return false;
397
+ const style = node.getAttribute('style') || '';
398
+ // Check for font-weight: bold in the style
399
+ if (style.includes('font-weight: bold') || style.includes('font-weight:bold')) {
400
+ return true;
401
+ }
402
+ // Check for bold font family names (Arial-BoldMT, etc)
403
+ const fontFamilyMatch = style.match(/font-family:\s*['"]*([^'"]+)['"]*[;]?/);
404
+ if (fontFamilyMatch && fontFamilyMatch[1].toLowerCase().includes('bold')) {
405
+ return true;
406
+ }
407
+ // Check for font-weight numeric values >= 600
408
+ const fontWeightMatch = style.match(/font-weight:\s*(\d+)/);
409
+ if (fontWeightMatch && parseInt(fontWeightMatch[1]) >= 600) {
410
+ return true;
411
+ }
412
+ return false;
413
+ },
414
+ replacement: (content) => {
415
+ return content.trim() ? `**${content}**` : content;
416
+ }
417
+ });
418
+ // Enhanced Google Docs italic text detection
419
+ service.addRule('googleDocsItalic', {
420
+ filter: (node) => {
421
+ if (node.nodeName !== 'SPAN')
422
+ return false;
423
+ const style = node.getAttribute('style') || '';
424
+ // Check for font-style: italic
425
+ if (style.includes('font-style: italic') || style.includes('font-style:italic')) {
426
+ return true;
427
+ }
428
+ // Check for italic font family names
429
+ const fontFamilyMatch = style.match(/font-family:\s*['"]*([^'"]+)['"]*[;]?/);
430
+ if (fontFamilyMatch && fontFamilyMatch[1].toLowerCase().includes('italic')) {
431
+ return true;
432
+ }
433
+ return false;
434
+ },
435
+ replacement: (content) => {
436
+ return content.trim() ? `*${content}*` : content;
437
+ }
438
+ });
439
+ // Enhanced Google Docs headings detection
440
+ service.addRule('googleDocsHeadings', {
441
+ filter: (node) => {
442
+ if (node.nodeName === 'P' || node.nodeName === 'DIV') {
443
+ const style = node.getAttribute('style') || '';
444
+ // Check for large font sizes
445
+ const fontSizeMatch = style.match(/font-size:\s*(\d+(?:\.\d+)?)px/);
446
+ if (fontSizeMatch) {
447
+ const fontSize = parseFloat(fontSizeMatch[1]);
448
+ return fontSize > 16;
449
+ }
450
+ // Check for font-weight bold combined with larger text
451
+ if (style.includes('font-weight: bold') || style.includes('font-weight:bold')) {
452
+ const textContent = node.textContent || '';
453
+ // If it's short and bold, likely a heading
454
+ return textContent.length < 100 && textContent.trim().length > 0;
455
+ }
456
+ }
457
+ return false;
458
+ },
459
+ replacement: (content, node) => {
460
+ const style = node?.getAttribute('style') || '';
461
+ const fontSizeMatch = style.match(/font-size:\s*(\d+(?:\.\d+)?)px/);
462
+ const fontSize = fontSizeMatch ? parseFloat(fontSizeMatch[1]) : 0;
463
+ // Determine heading level based on font size
464
+ let level = 3; // default to h3
465
+ if (fontSize >= 28)
466
+ level = 1;
467
+ else if (fontSize >= 24)
468
+ level = 2;
469
+ else if (fontSize >= 20)
470
+ level = 3;
471
+ else if (fontSize >= 18)
472
+ level = 4;
473
+ else
474
+ level = 5;
475
+ const trimmedContent = content.trim();
476
+ return trimmedContent ? '\n' + '#'.repeat(level) + ' ' + trimmedContent + '\n\n' : content;
477
+ }
478
+ });
479
+ // Google Docs underline detection
480
+ service.addRule('googleDocsUnderline', {
481
+ filter: (node) => {
482
+ if (node.nodeName === 'SPAN') {
483
+ const style = node.getAttribute('style') || '';
484
+ return style.includes('text-decoration: underline') || style.includes('text-decoration:underline');
485
+ }
486
+ return false;
487
+ },
488
+ replacement: (content) => {
489
+ return content.trim() ? `<u>${content}</u>` : content;
490
+ }
491
+ });
492
+ // Strikethrough detection - semantic tags
493
+ service.addRule('strikethroughTags', {
494
+ filter: ['del', 's'],
495
+ replacement: (content) => {
496
+ return content.trim() ? `~~${content}~~` : content;
497
+ }
498
+ });
499
+ // Strikethrough detection - deprecated <strike> tag (filter as node check for TS compatibility)
500
+ service.addRule('strikethroughStrikeTag', {
501
+ filter: (node) => node.nodeName === 'STRIKE',
502
+ replacement: (content) => {
503
+ return content.trim() ? `~~${content}~~` : content;
504
+ }
505
+ });
506
+ // Strikethrough detection - CSS text-decoration: line-through
507
+ service.addRule('strikethroughStyle', {
508
+ filter: (node) => {
509
+ if (node.nodeName !== 'SPAN')
510
+ return false;
511
+ const style = node.getAttribute('style') || '';
512
+ return style.includes('text-decoration: line-through') ||
513
+ style.includes('text-decoration:line-through') ||
514
+ style.includes('text-decoration-line: line-through') ||
515
+ style.includes('text-decoration-line:line-through');
516
+ },
517
+ replacement: (content) => {
518
+ return content.trim() ? `~~${content}~~` : content;
519
+ }
520
+ });
521
+ // Inline code detection - CSS monospace font-family
522
+ service.addRule('monospaceCode', {
523
+ filter: (node) => {
524
+ if (node.nodeName !== 'SPAN')
525
+ return false;
526
+ const style = node.getAttribute('style') || '';
527
+ const fontFamily = style.toLowerCase();
528
+ // Check for common monospace font indicators
529
+ return fontFamily.includes('monospace') ||
530
+ fontFamily.includes('courier') ||
531
+ fontFamily.includes('consolas') ||
532
+ fontFamily.includes('monaco') ||
533
+ fontFamily.includes('menlo') ||
534
+ fontFamily.includes('source code') ||
535
+ fontFamily.includes('fira code') ||
536
+ fontFamily.includes('roboto mono') ||
537
+ fontFamily.includes('ubuntu mono');
538
+ },
539
+ replacement: (content) => {
540
+ const trimmed = content.trim();
541
+ if (!trimmed)
542
+ return content;
543
+ // Use backticks for inline code
544
+ // If content contains backticks, use double backticks
545
+ if (trimmed.includes('`')) {
546
+ return `\`\` ${trimmed} \`\``;
547
+ }
548
+ return `\`${trimmed}\``;
549
+ }
550
+ });
551
+ }
552
+ addTableRules(service) {
553
+ // Enhanced table handling
554
+ service.addRule('enhancedTable', {
555
+ filter: 'table',
556
+ replacement: (content, node) => {
557
+ const table = node;
558
+ const rows = Array.from(table.querySelectorAll('tr'));
559
+ if (rows.length === 0)
560
+ return content;
561
+ let markdown = '\n';
562
+ let headerProcessed = false;
563
+ rows.forEach((row, index) => {
564
+ const cells = Array.from(row.querySelectorAll('th, td'));
565
+ const cellContents = cells.map(cell => {
566
+ const cellContent = cell.textContent || '';
567
+ return cellContent.trim().replace(/\|/g, '\\|'); // Escape pipes
568
+ });
569
+ if (cellContents.length > 0) {
570
+ markdown += '| ' + cellContents.join(' | ') + ' |\n';
571
+ // Add header separator after first row if it contains th elements
572
+ if (!headerProcessed && (row.querySelector('th') || index === 0)) {
573
+ const separator = cellContents.map(() => '---').join(' | ');
574
+ markdown += '| ' + separator + ' |\n';
575
+ headerProcessed = true;
576
+ }
577
+ }
578
+ });
579
+ return markdown + '\n';
580
+ }
581
+ });
582
+ }
583
+ addListRules(service) {
584
+ // Enhanced nested list handling
585
+ service.addRule('enhancedList', {
586
+ filter: ['ul', 'ol'],
587
+ replacement: (content, node) => {
588
+ const listType = node?.nodeName.toLowerCase();
589
+ const items = Array.from(node.children).filter(child => child.nodeName.toLowerCase() === 'li');
590
+ if (items.length === 0)
591
+ return content;
592
+ let markdown = '\n';
593
+ const isOrdered = listType === 'ol';
594
+ const marker = isOrdered ? '1.' : (service.options.bulletListMarker || '-');
595
+ items.forEach((item, index) => {
596
+ const itemContent = this.processListItem(item, service);
597
+ const actualMarker = isOrdered ? `${index + 1}.` : marker;
598
+ markdown += `${actualMarker} ${itemContent}\n`;
599
+ });
600
+ return markdown + '\n';
601
+ }
602
+ });
603
+ }
604
+ processListItem(item, service, depth = 0) {
605
+ let content = '';
606
+ // Use numeric constants instead of Node.TEXT_NODE / Node.ELEMENT_NODE
607
+ // to avoid direct browser-global reference (Req 14.3)
608
+ const TEXT_NODE = 3;
609
+ const ELEMENT_NODE = 1;
610
+ Array.from(item.childNodes).forEach(child => {
611
+ if (child.nodeType === TEXT_NODE) {
612
+ content += child.textContent || '';
613
+ }
614
+ else if (child.nodeType === ELEMENT_NODE) {
615
+ const element = child;
616
+ if (element.nodeName.toLowerCase() === 'ul' || element.nodeName.toLowerCase() === 'ol') {
617
+ // Handle nested lists, incrementing depth so indentation grows correctly
618
+ const nestedContent = this.processNestedList(element, service, depth + 1);
619
+ content += '\n' + nestedContent;
620
+ }
621
+ else {
622
+ content += service.turndown(element.outerHTML);
623
+ }
624
+ }
625
+ });
626
+ return content.trim();
627
+ }
628
+ processNestedList(list, service, depth) {
629
+ const items = Array.from(list.children).filter(child => child.nodeName.toLowerCase() === 'li');
630
+ if (items.length === 0)
631
+ return '';
632
+ let markdown = '';
633
+ const isOrdered = list.nodeName.toLowerCase() === 'ol';
634
+ const indent = ' '.repeat(depth);
635
+ const marker = isOrdered ? '1.' : (service.options.bulletListMarker || '-');
636
+ items.forEach((item, index) => {
637
+ const itemContent = this.processListItem(item, service, depth);
638
+ const actualMarker = isOrdered ? `${index + 1}.` : marker;
639
+ markdown += `${indent}${actualMarker} ${itemContent}\n`;
640
+ });
641
+ return markdown;
642
+ }
643
+ /**
644
+ * Add PDF page marker rules to convert data-page-marker divs to Markdown comments
645
+ * This preserves page boundaries from PDF documents (Req 3.5)
646
+ */
647
+ addPdfPageMarkerRules(service) {
648
+ service.addRule('pdfPageMarker', {
649
+ filter: (node) => {
650
+ if (node.nodeName !== 'DIV')
651
+ return false;
652
+ return node.hasAttribute('data-page-marker');
653
+ },
654
+ replacement: (_content, node) => {
655
+ const pageNumber = node.getAttribute('data-page-marker');
656
+ // Output as a Markdown comment-style marker that's visible in the output
657
+ return `\n\n---\n\n**[Page ${pageNumber}]**\n\n`;
658
+ }
659
+ });
660
+ }
661
+ async convert(html, options = {
662
+ format: 'github',
663
+ preserveTables: true,
664
+ preserveLists: true,
665
+ customRules: []
666
+ }) {
667
+ const startTime = performance.now();
668
+ const warnings = [];
669
+ const rulesApplied = [];
670
+ // Sanitize input
671
+ const sanitizedHtml = this.sanitize(html);
672
+ if (sanitizedHtml !== html) {
673
+ warnings.push('HTML content was sanitized for security');
674
+ }
675
+ // Validate input
676
+ const validation = this.validateInput(sanitizedHtml);
677
+ if (!validation.isValid) {
678
+ throw new Error(`Invalid input: ${validation.errors.join(', ')}`);
679
+ }
680
+ warnings.push(...validation.warnings);
681
+ // Select appropriate service
682
+ let service;
683
+ switch (options.format) {
684
+ case 'commonmark':
685
+ service = this.commonmarkService;
686
+ break;
687
+ case 'custom':
688
+ service = this.customService;
689
+ // Apply custom rules
690
+ options.customRules.forEach(rule => {
691
+ service.addRule(rule.name, {
692
+ filter: rule.filter,
693
+ replacement: rule.replacement
694
+ });
695
+ rulesApplied.push(rule.name);
696
+ });
697
+ break;
698
+ case 'discord':
699
+ service = this.discordService;
700
+ break;
701
+ case 'reddit':
702
+ service = this.redditService;
703
+ break;
704
+ case 'rmarkdown':
705
+ service = this.rmarkdownService;
706
+ break;
707
+ case 'github':
708
+ default:
709
+ service = this.githubService;
710
+ break;
711
+ }
712
+ // Perform conversion
713
+ let markdown = service.turndown(sanitizedHtml);
714
+ // Apply format-specific post-processing for Unicode characters
715
+ markdown = this.applyFormatPostProcessing(markdown, options.format);
716
+ const endTime = performance.now();
717
+ const metadata = {
718
+ originalLength: html.length,
719
+ convertedLength: markdown.length,
720
+ processingTime: endTime - startTime,
721
+ formatUsed: options.format,
722
+ rulesApplied
723
+ };
724
+ return {
725
+ markdown,
726
+ warnings,
727
+ metadata
728
+ };
729
+ }
730
+ sanitize(html) {
731
+ // Use the centralized sanitization from input-sanitizer
732
+ return sanitizeHTML(html, {
733
+ level: 'moderate' // Use moderate level for conversion
734
+ });
735
+ }
736
+ validateInput(input) {
737
+ const errors = [];
738
+ const warnings = [];
739
+ if (!input || input.trim().length === 0) {
740
+ errors.push('Input is empty');
741
+ }
742
+ if (input.length > 1024 * 1024) { // 1MB limit
743
+ errors.push('Input exceeds maximum size limit');
744
+ }
745
+ if (input.length > 100 * 1024) { // 100KB warning
746
+ warnings.push('Large input detected, processing may take longer');
747
+ }
748
+ // Check for potentially problematic content
749
+ if (input.includes('<script')) {
750
+ warnings.push('Script tags detected and will be removed');
751
+ }
752
+ if (input.includes('javascript:')) {
753
+ warnings.push('JavaScript URLs detected and will be sanitized');
754
+ }
755
+ return {
756
+ isValid: errors.length === 0,
757
+ errors,
758
+ warnings
759
+ };
760
+ }
761
+ // Method to get available format options
762
+ getAvailableFormats() {
763
+ return [
764
+ {
765
+ value: 'github',
766
+ label: 'GitHub Flavored Markdown',
767
+ description: 'Optimized for GitHub with fenced code blocks and dashes for lists'
768
+ },
769
+ {
770
+ value: 'commonmark',
771
+ label: 'CommonMark',
772
+ description: 'Strict CommonMark specification with indented code blocks'
773
+ },
774
+ {
775
+ value: 'custom',
776
+ label: 'Custom Rules',
777
+ description: 'GitHub base with additional custom conversion rules'
778
+ }
779
+ ];
780
+ }
781
+ // Method to create custom rules easily
782
+ createCustomRule(name, selector, replacement) {
783
+ return {
784
+ name,
785
+ filter: selector,
786
+ replacement
787
+ };
788
+ }
789
+ }
790
+ //# sourceMappingURL=conversion-engine.js.map