quikdown 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -356
- package/dist/quikdown.cjs +105 -41
- package/dist/quikdown.d.ts +14 -0
- package/dist/quikdown.dark.css +1 -1
- package/dist/quikdown.esm.js +105 -41
- package/dist/quikdown.esm.min.js +2 -2
- package/dist/quikdown.esm.min.js.map +1 -1
- package/dist/quikdown.light.css +1 -1
- package/dist/quikdown.umd.js +105 -41
- package/dist/quikdown.umd.min.js +2 -2
- package/dist/quikdown.umd.min.js.map +1 -1
- package/dist/quikdown_bd.cjs +981 -0
- package/dist/quikdown_bd.d.ts +112 -0
- package/dist/quikdown_bd.esm.js +979 -0
- package/dist/quikdown_bd.esm.min.js +8 -0
- package/dist/quikdown_bd.esm.min.js.map +1 -0
- package/dist/quikdown_bd.umd.js +987 -0
- package/dist/quikdown_bd.umd.min.js +8 -0
- package/dist/quikdown_bd.umd.min.js.map +1 -0
- package/dist/quikdown_edit.cjs +2319 -0
- package/dist/quikdown_edit.d.ts +195 -0
- package/dist/quikdown_edit.esm.js +2317 -0
- package/dist/quikdown_edit.esm.min.js +14 -0
- package/dist/quikdown_edit.esm.min.js.map +1 -0
- package/dist/quikdown_edit.umd.js +2325 -0
- package/dist/quikdown_edit.umd.min.js +14 -0
- package/dist/quikdown_edit.umd.min.js.map +1 -0
- package/package.json +98 -10
- package/dist/quikdown-lex.cjs +0 -810
- package/dist/quikdown-lex.esm.js +0 -808
- package/dist/quikdown-lex.esm.min.js +0 -8
- package/dist/quikdown-lex.esm.min.js.map +0 -1
- package/dist/quikdown-lex.umd.js +0 -816
- package/dist/quikdown-lex.umd.min.js +0 -8
- package/dist/quikdown-lex.umd.min.js.map +0 -1
package/dist/quikdown.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* quikdown - Lightweight Markdown Parser
|
|
3
|
-
* @version 1.0.
|
|
3
|
+
* @version 1.0.5
|
|
4
4
|
* @license BSD-2-Clause
|
|
5
5
|
* @copyright DeftIO 2025
|
|
6
6
|
*/
|
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
* @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
|
|
15
15
|
* (content, fence_string) => html string
|
|
16
16
|
* @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
|
|
17
|
+
* @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
|
|
18
|
+
* @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
|
|
17
19
|
* @returns {string} - The rendered HTML
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
// Version will be injected at build time
|
|
21
|
-
const quikdownVersion = '1.0.
|
|
23
|
+
const quikdownVersion = '1.0.5';
|
|
22
24
|
|
|
23
25
|
// Constants for reuse
|
|
24
26
|
const CLASS_PREFIX = 'quikdown-';
|
|
@@ -60,12 +62,25 @@ const QUIKDOWN_STYLES = {
|
|
|
60
62
|
function createGetAttr(inline_styles, styles) {
|
|
61
63
|
return function(tag, additionalStyle = '') {
|
|
62
64
|
if (inline_styles) {
|
|
63
|
-
|
|
65
|
+
let style = styles[tag];
|
|
64
66
|
if (!style && !additionalStyle) return '';
|
|
65
|
-
|
|
67
|
+
|
|
68
|
+
// Remove default text-align if we're adding a different alignment
|
|
69
|
+
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
70
|
+
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
71
|
+
if (style && !style.endsWith(';')) style += ';';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
|
|
75
|
+
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
|
|
66
76
|
return ` style="${fullStyle}"`;
|
|
67
77
|
} else {
|
|
68
|
-
|
|
78
|
+
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
|
|
79
|
+
// Apply inline styles for alignment even when using CSS classes
|
|
80
|
+
if (additionalStyle) {
|
|
81
|
+
return `${classAttr} style="${additionalStyle}"`;
|
|
82
|
+
}
|
|
83
|
+
return classAttr;
|
|
69
84
|
}
|
|
70
85
|
};
|
|
71
86
|
}
|
|
@@ -75,7 +90,7 @@ function quikdown(markdown, options = {}) {
|
|
|
75
90
|
return '';
|
|
76
91
|
}
|
|
77
92
|
|
|
78
|
-
const { fence_plugin, inline_styles = false } = options;
|
|
93
|
+
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
|
|
79
94
|
const styles = QUIKDOWN_STYLES; // Use module-level styles
|
|
80
95
|
const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
|
|
81
96
|
|
|
@@ -84,8 +99,12 @@ function quikdown(markdown, options = {}) {
|
|
|
84
99
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
// Helper to add data-qd attributes for bidirectional support
|
|
103
|
+
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
104
|
+
|
|
87
105
|
// Sanitize URLs to prevent XSS attacks
|
|
88
106
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
107
|
+
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
89
108
|
if (!url) return '';
|
|
90
109
|
|
|
91
110
|
// If unsafe URLs are explicitly allowed, return as-is
|
|
@@ -132,13 +151,15 @@ function quikdown(markdown, options = {}) {
|
|
|
132
151
|
codeBlocks.push({
|
|
133
152
|
lang: langTrimmed,
|
|
134
153
|
code: code.trimEnd(),
|
|
135
|
-
custom: true
|
|
154
|
+
custom: true,
|
|
155
|
+
fence: fence
|
|
136
156
|
});
|
|
137
157
|
} else {
|
|
138
158
|
codeBlocks.push({
|
|
139
159
|
lang: langTrimmed,
|
|
140
160
|
code: escapeHtml(code.trimEnd()),
|
|
141
|
-
custom: false
|
|
161
|
+
custom: false,
|
|
162
|
+
fence: fence
|
|
142
163
|
});
|
|
143
164
|
}
|
|
144
165
|
return placeholder;
|
|
@@ -162,7 +183,7 @@ function quikdown(markdown, options = {}) {
|
|
|
162
183
|
// Process headings (supports optional trailing #'s)
|
|
163
184
|
html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
|
|
164
185
|
const level = hashes.length;
|
|
165
|
-
return `<h${level}${getAttr('h' + level)}>${content}</h${level}>`;
|
|
186
|
+
return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
|
|
166
187
|
});
|
|
167
188
|
|
|
168
189
|
// Process blockquotes (must handle escaped > since we already escaped HTML)
|
|
@@ -174,14 +195,16 @@ function quikdown(markdown, options = {}) {
|
|
|
174
195
|
html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
|
|
175
196
|
|
|
176
197
|
// Process lists
|
|
177
|
-
html = processLists(html, getAttr, inline_styles);
|
|
198
|
+
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
178
199
|
|
|
179
200
|
// Phase 3: Process inline elements
|
|
180
201
|
|
|
181
202
|
// Images (must come before links, with URL sanitization)
|
|
182
203
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
183
204
|
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
184
|
-
|
|
205
|
+
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
206
|
+
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
207
|
+
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
185
208
|
});
|
|
186
209
|
|
|
187
210
|
// Links (with URL sanitization)
|
|
@@ -190,7 +213,8 @@ function quikdown(markdown, options = {}) {
|
|
|
190
213
|
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
191
214
|
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
192
215
|
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
193
|
-
|
|
216
|
+
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
217
|
+
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
194
218
|
});
|
|
195
219
|
|
|
196
220
|
// Autolinks - convert bare URLs to clickable links
|
|
@@ -201,23 +225,56 @@ function quikdown(markdown, options = {}) {
|
|
|
201
225
|
|
|
202
226
|
// Process inline formatting (bold, italic, strikethrough)
|
|
203
227
|
const inlinePatterns = [
|
|
204
|
-
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
205
|
-
[/__(.+?)__/g, 'strong'],
|
|
206
|
-
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
|
|
207
|
-
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
|
|
208
|
-
[/~~(.+?)~~/g, 'del']
|
|
228
|
+
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
229
|
+
[/__(.+?)__/g, 'strong', '__'],
|
|
230
|
+
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
|
|
231
|
+
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
232
|
+
[/~~(.+?)~~/g, 'del', '~~']
|
|
209
233
|
];
|
|
210
234
|
|
|
211
|
-
inlinePatterns.forEach(([pattern, tag]) => {
|
|
212
|
-
html = html.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
235
|
+
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
236
|
+
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
213
237
|
});
|
|
214
238
|
|
|
215
|
-
// Line breaks
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
239
|
+
// Line breaks
|
|
240
|
+
if (lazy_linefeeds) {
|
|
241
|
+
// Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
|
|
242
|
+
const blocks = [];
|
|
243
|
+
let bi = 0;
|
|
244
|
+
|
|
245
|
+
// Protect tables and lists
|
|
246
|
+
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
247
|
+
blocks[bi] = m;
|
|
248
|
+
return `§B${bi++}§`;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Handle paragraphs and block elements
|
|
252
|
+
html = html.replace(/\n\n+/g, '§P§')
|
|
253
|
+
// After block elements
|
|
254
|
+
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
255
|
+
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
256
|
+
// Before block elements
|
|
257
|
+
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
258
|
+
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
259
|
+
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
260
|
+
// Convert remaining newlines
|
|
261
|
+
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
262
|
+
// Restore
|
|
263
|
+
.replace(/§N§/g, '\n')
|
|
264
|
+
.replace(/§P§/g, '</p><p>');
|
|
265
|
+
|
|
266
|
+
// Restore protected blocks
|
|
267
|
+
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
268
|
+
|
|
269
|
+
html = '<p>' + html + '</p>';
|
|
270
|
+
} else {
|
|
271
|
+
// Standard: two spaces at end of line for line breaks
|
|
272
|
+
html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
|
|
273
|
+
|
|
274
|
+
// Paragraphs (double newlines)
|
|
275
|
+
html = html.replace(/\n\n+/g, '</p><p>');
|
|
276
|
+
html = '<p>' + html + '</p>';
|
|
277
|
+
}
|
|
221
278
|
|
|
222
279
|
// Clean up empty paragraphs and unwrap block elements
|
|
223
280
|
const cleanupPatterns = [
|
|
@@ -253,13 +310,17 @@ function quikdown(markdown, options = {}) {
|
|
|
253
310
|
if (replacement === undefined) {
|
|
254
311
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
255
312
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
256
|
-
|
|
313
|
+
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
314
|
+
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
315
|
+
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
257
316
|
}
|
|
258
317
|
} else {
|
|
259
318
|
// Default rendering
|
|
260
319
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
261
320
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
262
|
-
|
|
321
|
+
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
322
|
+
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
323
|
+
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
263
324
|
}
|
|
264
325
|
|
|
265
326
|
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
@@ -269,7 +330,7 @@ function quikdown(markdown, options = {}) {
|
|
|
269
330
|
// Restore inline code
|
|
270
331
|
inlineCodes.forEach((code, i) => {
|
|
271
332
|
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
272
|
-
html = html.replace(placeholder, `<code${getAttr('code')}>${code}</code>`);
|
|
333
|
+
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
273
334
|
});
|
|
274
335
|
|
|
275
336
|
return html.trim();
|
|
@@ -383,9 +444,9 @@ function buildTable(lines, getAttr) {
|
|
|
383
444
|
let html = `<table${getAttr('table')}>\n`;
|
|
384
445
|
|
|
385
446
|
// Build header
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
447
|
+
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
448
|
+
html += `<thead${getAttr('thead')}>\n`;
|
|
449
|
+
headerLines.forEach(line => {
|
|
389
450
|
html += `<tr${getAttr('tr')}>\n`;
|
|
390
451
|
// Handle pipes at start/end or not
|
|
391
452
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
@@ -395,9 +456,8 @@ function buildTable(lines, getAttr) {
|
|
|
395
456
|
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
396
457
|
});
|
|
397
458
|
html += '</tr>\n';
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
459
|
+
});
|
|
460
|
+
html += '</thead>\n';
|
|
401
461
|
|
|
402
462
|
// Build body
|
|
403
463
|
if (bodyLines.length > 0) {
|
|
@@ -423,12 +483,16 @@ function buildTable(lines, getAttr) {
|
|
|
423
483
|
/**
|
|
424
484
|
* Process markdown lists (ordered and unordered)
|
|
425
485
|
*/
|
|
426
|
-
function processLists(text, getAttr, inline_styles) {
|
|
486
|
+
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
427
487
|
|
|
428
488
|
const lines = text.split('\n');
|
|
429
489
|
const result = [];
|
|
430
490
|
let listStack = []; // Track nested lists
|
|
431
491
|
|
|
492
|
+
// Helper to escape HTML for data-qd attributes
|
|
493
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
494
|
+
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
495
|
+
|
|
432
496
|
for (let i = 0; i < lines.length; i++) {
|
|
433
497
|
const line = lines[i];
|
|
434
498
|
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
@@ -476,7 +540,7 @@ function processLists(text, getAttr, inline_styles) {
|
|
|
476
540
|
}
|
|
477
541
|
|
|
478
542
|
const liAttr = taskListClass || getAttr('li');
|
|
479
|
-
result.push(`<li${liAttr}>${listItemContent}</li>`);
|
|
543
|
+
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
480
544
|
} else {
|
|
481
545
|
// Not a list item, close all lists
|
|
482
546
|
while (listStack.length > 0) {
|
|
@@ -522,8 +586,7 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
|
522
586
|
|
|
523
587
|
let css = '';
|
|
524
588
|
for (const [tag, style] of Object.entries(styles)) {
|
|
525
|
-
|
|
526
|
-
let themedStyle = style;
|
|
589
|
+
let themedStyle = style;
|
|
527
590
|
|
|
528
591
|
// Apply theme overrides if dark theme
|
|
529
592
|
if (theme === 'dark' && themeOverrides.dark) {
|
|
@@ -546,9 +609,8 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
|
546
609
|
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
547
610
|
}
|
|
548
611
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
612
|
+
|
|
613
|
+
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
552
614
|
}
|
|
553
615
|
|
|
554
616
|
return css;
|
|
@@ -571,11 +633,13 @@ quikdown.configure = function(options) {
|
|
|
571
633
|
quikdown.version = quikdownVersion;
|
|
572
634
|
|
|
573
635
|
// Export for both CommonJS and ES6
|
|
636
|
+
/* istanbul ignore next */
|
|
574
637
|
if (typeof module !== 'undefined' && module.exports) {
|
|
575
638
|
module.exports = quikdown;
|
|
576
639
|
}
|
|
577
640
|
|
|
578
641
|
// For browser global
|
|
642
|
+
/* istanbul ignore next */
|
|
579
643
|
if (typeof window !== 'undefined') {
|
|
580
644
|
window.quikdown = quikdown;
|
|
581
645
|
}
|
package/dist/quikdown.d.ts
CHANGED
|
@@ -30,6 +30,20 @@ declare module 'quikdown' {
|
|
|
30
30
|
* @default false
|
|
31
31
|
*/
|
|
32
32
|
allow_unsafe_urls?: boolean;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* If true, adds data-qd attributes for bidirectional conversion.
|
|
36
|
+
* Enables HTML to Markdown conversion.
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
bidirectional?: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* If true, single newlines become <br> tags.
|
|
43
|
+
* Useful for chat/LLM applications where Enter should create a line break.
|
|
44
|
+
* @default false
|
|
45
|
+
*/
|
|
46
|
+
lazy_linefeeds?: boolean;
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
/**
|
package/dist/quikdown.dark.css
CHANGED
package/dist/quikdown.esm.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* quikdown - Lightweight Markdown Parser
|
|
3
|
-
* @version 1.0.
|
|
3
|
+
* @version 1.0.5
|
|
4
4
|
* @license BSD-2-Clause
|
|
5
5
|
* @copyright DeftIO 2025
|
|
6
6
|
*/
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
|
|
13
13
|
* (content, fence_string) => html string
|
|
14
14
|
* @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
|
|
15
|
+
* @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
|
|
16
|
+
* @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
|
|
15
17
|
* @returns {string} - The rendered HTML
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
// Version will be injected at build time
|
|
19
|
-
const quikdownVersion = '1.0.
|
|
21
|
+
const quikdownVersion = '1.0.5';
|
|
20
22
|
|
|
21
23
|
// Constants for reuse
|
|
22
24
|
const CLASS_PREFIX = 'quikdown-';
|
|
@@ -58,12 +60,25 @@ const QUIKDOWN_STYLES = {
|
|
|
58
60
|
function createGetAttr(inline_styles, styles) {
|
|
59
61
|
return function(tag, additionalStyle = '') {
|
|
60
62
|
if (inline_styles) {
|
|
61
|
-
|
|
63
|
+
let style = styles[tag];
|
|
62
64
|
if (!style && !additionalStyle) return '';
|
|
63
|
-
|
|
65
|
+
|
|
66
|
+
// Remove default text-align if we're adding a different alignment
|
|
67
|
+
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
68
|
+
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
69
|
+
if (style && !style.endsWith(';')) style += ';';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
|
|
73
|
+
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
|
|
64
74
|
return ` style="${fullStyle}"`;
|
|
65
75
|
} else {
|
|
66
|
-
|
|
76
|
+
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
|
|
77
|
+
// Apply inline styles for alignment even when using CSS classes
|
|
78
|
+
if (additionalStyle) {
|
|
79
|
+
return `${classAttr} style="${additionalStyle}"`;
|
|
80
|
+
}
|
|
81
|
+
return classAttr;
|
|
67
82
|
}
|
|
68
83
|
};
|
|
69
84
|
}
|
|
@@ -73,7 +88,7 @@ function quikdown(markdown, options = {}) {
|
|
|
73
88
|
return '';
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
const { fence_plugin, inline_styles = false } = options;
|
|
91
|
+
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
|
|
77
92
|
const styles = QUIKDOWN_STYLES; // Use module-level styles
|
|
78
93
|
const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
|
|
79
94
|
|
|
@@ -82,8 +97,12 @@ function quikdown(markdown, options = {}) {
|
|
|
82
97
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
83
98
|
}
|
|
84
99
|
|
|
100
|
+
// Helper to add data-qd attributes for bidirectional support
|
|
101
|
+
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
102
|
+
|
|
85
103
|
// Sanitize URLs to prevent XSS attacks
|
|
86
104
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
105
|
+
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
87
106
|
if (!url) return '';
|
|
88
107
|
|
|
89
108
|
// If unsafe URLs are explicitly allowed, return as-is
|
|
@@ -130,13 +149,15 @@ function quikdown(markdown, options = {}) {
|
|
|
130
149
|
codeBlocks.push({
|
|
131
150
|
lang: langTrimmed,
|
|
132
151
|
code: code.trimEnd(),
|
|
133
|
-
custom: true
|
|
152
|
+
custom: true,
|
|
153
|
+
fence: fence
|
|
134
154
|
});
|
|
135
155
|
} else {
|
|
136
156
|
codeBlocks.push({
|
|
137
157
|
lang: langTrimmed,
|
|
138
158
|
code: escapeHtml(code.trimEnd()),
|
|
139
|
-
custom: false
|
|
159
|
+
custom: false,
|
|
160
|
+
fence: fence
|
|
140
161
|
});
|
|
141
162
|
}
|
|
142
163
|
return placeholder;
|
|
@@ -160,7 +181,7 @@ function quikdown(markdown, options = {}) {
|
|
|
160
181
|
// Process headings (supports optional trailing #'s)
|
|
161
182
|
html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
|
|
162
183
|
const level = hashes.length;
|
|
163
|
-
return `<h${level}${getAttr('h' + level)}>${content}</h${level}>`;
|
|
184
|
+
return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
|
|
164
185
|
});
|
|
165
186
|
|
|
166
187
|
// Process blockquotes (must handle escaped > since we already escaped HTML)
|
|
@@ -172,14 +193,16 @@ function quikdown(markdown, options = {}) {
|
|
|
172
193
|
html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
|
|
173
194
|
|
|
174
195
|
// Process lists
|
|
175
|
-
html = processLists(html, getAttr, inline_styles);
|
|
196
|
+
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
176
197
|
|
|
177
198
|
// Phase 3: Process inline elements
|
|
178
199
|
|
|
179
200
|
// Images (must come before links, with URL sanitization)
|
|
180
201
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
181
202
|
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
182
|
-
|
|
203
|
+
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
204
|
+
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
205
|
+
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
183
206
|
});
|
|
184
207
|
|
|
185
208
|
// Links (with URL sanitization)
|
|
@@ -188,7 +211,8 @@ function quikdown(markdown, options = {}) {
|
|
|
188
211
|
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
189
212
|
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
190
213
|
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
191
|
-
|
|
214
|
+
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
215
|
+
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
192
216
|
});
|
|
193
217
|
|
|
194
218
|
// Autolinks - convert bare URLs to clickable links
|
|
@@ -199,23 +223,56 @@ function quikdown(markdown, options = {}) {
|
|
|
199
223
|
|
|
200
224
|
// Process inline formatting (bold, italic, strikethrough)
|
|
201
225
|
const inlinePatterns = [
|
|
202
|
-
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
203
|
-
[/__(.+?)__/g, 'strong'],
|
|
204
|
-
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
|
|
205
|
-
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
|
|
206
|
-
[/~~(.+?)~~/g, 'del']
|
|
226
|
+
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
227
|
+
[/__(.+?)__/g, 'strong', '__'],
|
|
228
|
+
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
|
|
229
|
+
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
230
|
+
[/~~(.+?)~~/g, 'del', '~~']
|
|
207
231
|
];
|
|
208
232
|
|
|
209
|
-
inlinePatterns.forEach(([pattern, tag]) => {
|
|
210
|
-
html = html.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
233
|
+
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
234
|
+
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
211
235
|
});
|
|
212
236
|
|
|
213
|
-
// Line breaks
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
237
|
+
// Line breaks
|
|
238
|
+
if (lazy_linefeeds) {
|
|
239
|
+
// Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
|
|
240
|
+
const blocks = [];
|
|
241
|
+
let bi = 0;
|
|
242
|
+
|
|
243
|
+
// Protect tables and lists
|
|
244
|
+
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
245
|
+
blocks[bi] = m;
|
|
246
|
+
return `§B${bi++}§`;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Handle paragraphs and block elements
|
|
250
|
+
html = html.replace(/\n\n+/g, '§P§')
|
|
251
|
+
// After block elements
|
|
252
|
+
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
253
|
+
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
254
|
+
// Before block elements
|
|
255
|
+
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
256
|
+
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
257
|
+
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
258
|
+
// Convert remaining newlines
|
|
259
|
+
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
260
|
+
// Restore
|
|
261
|
+
.replace(/§N§/g, '\n')
|
|
262
|
+
.replace(/§P§/g, '</p><p>');
|
|
263
|
+
|
|
264
|
+
// Restore protected blocks
|
|
265
|
+
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
266
|
+
|
|
267
|
+
html = '<p>' + html + '</p>';
|
|
268
|
+
} else {
|
|
269
|
+
// Standard: two spaces at end of line for line breaks
|
|
270
|
+
html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
|
|
271
|
+
|
|
272
|
+
// Paragraphs (double newlines)
|
|
273
|
+
html = html.replace(/\n\n+/g, '</p><p>');
|
|
274
|
+
html = '<p>' + html + '</p>';
|
|
275
|
+
}
|
|
219
276
|
|
|
220
277
|
// Clean up empty paragraphs and unwrap block elements
|
|
221
278
|
const cleanupPatterns = [
|
|
@@ -251,13 +308,17 @@ function quikdown(markdown, options = {}) {
|
|
|
251
308
|
if (replacement === undefined) {
|
|
252
309
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
253
310
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
254
|
-
|
|
311
|
+
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
312
|
+
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
313
|
+
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
255
314
|
}
|
|
256
315
|
} else {
|
|
257
316
|
// Default rendering
|
|
258
317
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
259
318
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
260
|
-
|
|
319
|
+
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
320
|
+
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
321
|
+
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
261
322
|
}
|
|
262
323
|
|
|
263
324
|
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
@@ -267,7 +328,7 @@ function quikdown(markdown, options = {}) {
|
|
|
267
328
|
// Restore inline code
|
|
268
329
|
inlineCodes.forEach((code, i) => {
|
|
269
330
|
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
270
|
-
html = html.replace(placeholder, `<code${getAttr('code')}>${code}</code>`);
|
|
331
|
+
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
271
332
|
});
|
|
272
333
|
|
|
273
334
|
return html.trim();
|
|
@@ -381,9 +442,9 @@ function buildTable(lines, getAttr) {
|
|
|
381
442
|
let html = `<table${getAttr('table')}>\n`;
|
|
382
443
|
|
|
383
444
|
// Build header
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
445
|
+
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
446
|
+
html += `<thead${getAttr('thead')}>\n`;
|
|
447
|
+
headerLines.forEach(line => {
|
|
387
448
|
html += `<tr${getAttr('tr')}>\n`;
|
|
388
449
|
// Handle pipes at start/end or not
|
|
389
450
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
@@ -393,9 +454,8 @@ function buildTable(lines, getAttr) {
|
|
|
393
454
|
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
394
455
|
});
|
|
395
456
|
html += '</tr>\n';
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
457
|
+
});
|
|
458
|
+
html += '</thead>\n';
|
|
399
459
|
|
|
400
460
|
// Build body
|
|
401
461
|
if (bodyLines.length > 0) {
|
|
@@ -421,12 +481,16 @@ function buildTable(lines, getAttr) {
|
|
|
421
481
|
/**
|
|
422
482
|
* Process markdown lists (ordered and unordered)
|
|
423
483
|
*/
|
|
424
|
-
function processLists(text, getAttr, inline_styles) {
|
|
484
|
+
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
425
485
|
|
|
426
486
|
const lines = text.split('\n');
|
|
427
487
|
const result = [];
|
|
428
488
|
let listStack = []; // Track nested lists
|
|
429
489
|
|
|
490
|
+
// Helper to escape HTML for data-qd attributes
|
|
491
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
492
|
+
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
493
|
+
|
|
430
494
|
for (let i = 0; i < lines.length; i++) {
|
|
431
495
|
const line = lines[i];
|
|
432
496
|
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
@@ -474,7 +538,7 @@ function processLists(text, getAttr, inline_styles) {
|
|
|
474
538
|
}
|
|
475
539
|
|
|
476
540
|
const liAttr = taskListClass || getAttr('li');
|
|
477
|
-
result.push(`<li${liAttr}>${listItemContent}</li>`);
|
|
541
|
+
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
478
542
|
} else {
|
|
479
543
|
// Not a list item, close all lists
|
|
480
544
|
while (listStack.length > 0) {
|
|
@@ -520,8 +584,7 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
|
520
584
|
|
|
521
585
|
let css = '';
|
|
522
586
|
for (const [tag, style] of Object.entries(styles)) {
|
|
523
|
-
|
|
524
|
-
let themedStyle = style;
|
|
587
|
+
let themedStyle = style;
|
|
525
588
|
|
|
526
589
|
// Apply theme overrides if dark theme
|
|
527
590
|
if (theme === 'dark' && themeOverrides.dark) {
|
|
@@ -544,9 +607,8 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
|
544
607
|
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
545
608
|
}
|
|
546
609
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
610
|
+
|
|
611
|
+
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
550
612
|
}
|
|
551
613
|
|
|
552
614
|
return css;
|
|
@@ -569,11 +631,13 @@ quikdown.configure = function(options) {
|
|
|
569
631
|
quikdown.version = quikdownVersion;
|
|
570
632
|
|
|
571
633
|
// Export for both CommonJS and ES6
|
|
634
|
+
/* istanbul ignore next */
|
|
572
635
|
if (typeof module !== 'undefined' && module.exports) {
|
|
573
636
|
module.exports = quikdown;
|
|
574
637
|
}
|
|
575
638
|
|
|
576
639
|
// For browser global
|
|
640
|
+
/* istanbul ignore next */
|
|
577
641
|
if (typeof window !== 'undefined') {
|
|
578
642
|
window.quikdown = quikdown;
|
|
579
643
|
}
|