quikdown 1.0.4 → 1.1.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.
- package/README.md +124 -499
- package/dist/quikdown.cjs +130 -48
- package/dist/quikdown.d.ts +40 -5
- package/dist/quikdown.dark.css +1 -1
- package/dist/quikdown.esm.js +130 -48
- 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 +130 -48
- package/dist/quikdown.umd.min.js +2 -2
- package/dist/quikdown.umd.min.js.map +1 -1
- package/dist/quikdown_bd.cjs +652 -260
- package/dist/quikdown_bd.d.ts +53 -19
- package/dist/quikdown_bd.esm.js +652 -260
- package/dist/quikdown_bd.esm.min.js +2 -2
- package/dist/quikdown_bd.esm.min.js.map +1 -1
- package/dist/quikdown_bd.umd.js +652 -260
- package/dist/quikdown_bd.umd.min.js +2 -2
- package/dist/quikdown_bd.umd.min.js.map +1 -1
- package/dist/quikdown_edit.cjs +2474 -0
- package/dist/quikdown_edit.d.ts +195 -0
- package/dist/quikdown_edit.esm.js +2472 -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 +2480 -0
- package/dist/quikdown_edit.umd.min.js +14 -0
- package/dist/quikdown_edit.umd.min.js.map +1 -0
- package/package.json +71 -29
- 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_bd.cjs
CHANGED
|
@@ -1,277 +1,385 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* quikdown_bd - Bidirectional Markdown Parser
|
|
3
|
-
* @version 1.0
|
|
3
|
+
* @version 1.1.0
|
|
4
4
|
* @license BSD-2-Clause
|
|
5
5
|
* @copyright DeftIO 2025
|
|
6
6
|
*/
|
|
7
7
|
'use strict';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* quikdown - A minimal markdown parser optimized for chat/LLM output
|
|
11
|
+
* Supports tables, code blocks, lists, and common formatting
|
|
12
|
+
* @param {string} markdown - The markdown source text
|
|
13
|
+
* @param {Object} options - Optional configuration object
|
|
14
|
+
* @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
|
|
15
|
+
* (content, fence_string) => html string
|
|
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
|
|
19
|
+
* @returns {string} - The rendered HTML
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
|
-
// Version
|
|
18
|
-
const
|
|
22
|
+
// Version will be injected at build time
|
|
23
|
+
const quikdownVersion = '1.1.0';
|
|
19
24
|
|
|
20
|
-
//
|
|
25
|
+
// Constants for reuse
|
|
26
|
+
const CLASS_PREFIX = 'quikdown-';
|
|
27
|
+
const PLACEHOLDER_CB = '§CB';
|
|
28
|
+
const PLACEHOLDER_IC = '§IC';
|
|
29
|
+
|
|
30
|
+
// Escape map at module level
|
|
21
31
|
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
|
22
|
-
function escapeHtml(text) {
|
|
23
|
-
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
24
|
-
}
|
|
25
32
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
// Single source of truth for all style definitions - optimized
|
|
34
|
+
const QUIKDOWN_STYLES = {
|
|
35
|
+
h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
|
|
36
|
+
h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
|
|
37
|
+
h3: 'font-size:1.25em;font-weight:600;margin:1em 0',
|
|
38
|
+
h4: 'font-size:1em;font-weight:600;margin:1.33em 0',
|
|
39
|
+
h5: 'font-size:.875em;font-weight:600;margin:1.67em 0',
|
|
40
|
+
h6: 'font-size:.85em;font-weight:600;margin:2em 0',
|
|
41
|
+
pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
|
|
42
|
+
code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
|
|
43
|
+
blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
|
|
44
|
+
table: 'border-collapse:collapse;width:100%;margin:1em 0',
|
|
45
|
+
th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
|
|
46
|
+
td: 'border:1px solid #ddd;padding:8px;text-align:left',
|
|
47
|
+
hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
|
|
48
|
+
img: 'max-width:100%;height:auto',
|
|
49
|
+
a: 'color:#06c;text-decoration:underline',
|
|
50
|
+
strong: 'font-weight:bold',
|
|
51
|
+
em: 'font-style:italic',
|
|
52
|
+
del: 'text-decoration:line-through',
|
|
53
|
+
ul: 'margin:.5em 0;padding-left:2em',
|
|
54
|
+
ol: 'margin:.5em 0;padding-left:2em',
|
|
55
|
+
li: 'margin:.25em 0',
|
|
56
|
+
// Task list specific styles
|
|
57
|
+
'task-item': 'list-style:none',
|
|
58
|
+
'task-checkbox': 'margin-right:.5em'
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Factory function to create getAttr for a given context
|
|
62
|
+
function createGetAttr(inline_styles, styles) {
|
|
63
|
+
return function(tag, additionalStyle = '') {
|
|
37
64
|
if (inline_styles) {
|
|
38
|
-
|
|
39
|
-
if (style
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
let style = styles[tag];
|
|
66
|
+
if (!style && !additionalStyle) return '';
|
|
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 += ';';
|
|
42
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;
|
|
76
|
+
return ` style="${fullStyle}"`;
|
|
43
77
|
} else {
|
|
44
|
-
|
|
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;
|
|
45
84
|
}
|
|
46
|
-
|
|
47
|
-
return attrs;
|
|
48
85
|
};
|
|
49
86
|
}
|
|
50
87
|
|
|
51
|
-
|
|
52
|
-
* Enhanced markdown parser with bidirectional support
|
|
53
|
-
* Wraps the core parser and adds data-qd attributes
|
|
54
|
-
*/
|
|
55
|
-
function quikdown_bd(markdown, options = {}) {
|
|
88
|
+
function quikdown(markdown, options = {}) {
|
|
56
89
|
if (!markdown || typeof markdown !== 'string') {
|
|
57
90
|
return '';
|
|
58
91
|
}
|
|
59
92
|
|
|
60
|
-
const { fence_plugin, inline_styles = false, bidirectional =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
|
|
94
|
+
const styles = QUIKDOWN_STYLES; // Use module-level styles
|
|
95
|
+
const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
|
|
96
|
+
|
|
97
|
+
// Escape HTML entities to prevent XSS
|
|
98
|
+
function escapeHtml(text) {
|
|
99
|
+
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
66
100
|
}
|
|
67
101
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const QUIKDOWN_STYLES = {
|
|
72
|
-
h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
|
|
73
|
-
h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
|
|
74
|
-
h3: 'font-size:1.25em;font-weight:600;margin:1em 0',
|
|
75
|
-
h4: 'font-size:1em;font-weight:600;margin:1.33em 0',
|
|
76
|
-
h5: 'font-size:.875em;font-weight:600;margin:1.67em 0',
|
|
77
|
-
h6: 'font-size:.85em;font-weight:600;margin:2em 0',
|
|
78
|
-
pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
|
|
79
|
-
code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
|
|
80
|
-
blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
|
|
81
|
-
table: 'border-collapse:collapse;width:100%;margin:1em 0',
|
|
82
|
-
th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
|
|
83
|
-
td: 'border:1px solid #ddd;padding:8px;text-align:left',
|
|
84
|
-
hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
|
|
85
|
-
img: 'max-width:100%;height:auto',
|
|
86
|
-
a: 'color:#06c;text-decoration:underline',
|
|
87
|
-
strong: 'font-weight:bold',
|
|
88
|
-
em: 'font-style:italic',
|
|
89
|
-
del: 'text-decoration:line-through',
|
|
90
|
-
ul: 'margin:.5em 0;padding-left:2em',
|
|
91
|
-
ol: 'margin:.5em 0;padding-left:2em',
|
|
92
|
-
li: 'margin:.25em 0',
|
|
93
|
-
'task-item': 'list-style:none',
|
|
94
|
-
'task-checkbox': 'margin-right:.5em'
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const getAttr = createGetAttrBD(inline_styles, QUIKDOWN_STYLES);
|
|
102
|
+
// Helper to add data-qd attributes for bidirectional support
|
|
103
|
+
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
98
104
|
|
|
99
|
-
//
|
|
105
|
+
// Sanitize URLs to prevent XSS attacks
|
|
106
|
+
function sanitizeUrl(url, allowUnsafe = false) {
|
|
107
|
+
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
108
|
+
if (!url) return '';
|
|
109
|
+
|
|
110
|
+
// If unsafe URLs are explicitly allowed, return as-is
|
|
111
|
+
if (allowUnsafe) return url;
|
|
112
|
+
|
|
113
|
+
const trimmedUrl = url.trim();
|
|
114
|
+
const lowerUrl = trimmedUrl.toLowerCase();
|
|
115
|
+
|
|
116
|
+
// Block dangerous protocols
|
|
117
|
+
const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
|
|
118
|
+
|
|
119
|
+
for (const protocol of dangerousProtocols) {
|
|
120
|
+
if (lowerUrl.startsWith(protocol)) {
|
|
121
|
+
// Exception: Allow data:image/* for images
|
|
122
|
+
if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
|
|
123
|
+
return trimmedUrl;
|
|
124
|
+
}
|
|
125
|
+
// Return safe empty link for dangerous protocols
|
|
126
|
+
return '#';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return trimmedUrl;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Process the markdown in phases
|
|
100
134
|
let html = markdown;
|
|
101
135
|
|
|
102
|
-
// Phase 1: Extract and protect code blocks
|
|
136
|
+
// Phase 1: Extract and protect code blocks and inline code
|
|
103
137
|
const codeBlocks = [];
|
|
104
138
|
const inlineCodes = [];
|
|
105
139
|
|
|
106
|
-
// Extract fenced code blocks
|
|
140
|
+
// Extract fenced code blocks first (supports both ``` and ~~~)
|
|
141
|
+
// Match paired fences - ``` with ``` and ~~~ with ~~~
|
|
142
|
+
// Fence must be at start of line
|
|
107
143
|
html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
|
|
108
|
-
const placeholder =
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
|
|
145
|
+
|
|
146
|
+
// Trim the language specification
|
|
147
|
+
const langTrimmed = lang ? lang.trim() : '';
|
|
148
|
+
|
|
149
|
+
// If custom fence plugin is provided, use it (v1.1.0: object format required)
|
|
150
|
+
if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
|
|
151
|
+
codeBlocks.push({
|
|
152
|
+
lang: langTrimmed,
|
|
153
|
+
code: code.trimEnd(),
|
|
154
|
+
custom: true,
|
|
155
|
+
fence: fence,
|
|
156
|
+
hasReverse: !!fence_plugin.reverse
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
codeBlocks.push({
|
|
160
|
+
lang: langTrimmed,
|
|
161
|
+
code: escapeHtml(code.trimEnd()),
|
|
162
|
+
custom: false,
|
|
163
|
+
fence: fence
|
|
164
|
+
});
|
|
165
|
+
}
|
|
115
166
|
return placeholder;
|
|
116
167
|
});
|
|
117
168
|
|
|
118
169
|
// Extract inline code
|
|
119
170
|
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
|
120
|
-
const placeholder =
|
|
121
|
-
inlineCodes.push(
|
|
122
|
-
code: escapeHtml(code),
|
|
123
|
-
original: match
|
|
124
|
-
});
|
|
171
|
+
const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
|
|
172
|
+
inlineCodes.push(escapeHtml(code));
|
|
125
173
|
return placeholder;
|
|
126
174
|
});
|
|
127
175
|
|
|
128
|
-
//
|
|
176
|
+
// Now escape HTML in the rest of the content
|
|
129
177
|
html = escapeHtml(html);
|
|
130
178
|
|
|
131
|
-
//
|
|
179
|
+
// Phase 2: Process block elements
|
|
180
|
+
|
|
181
|
+
// Process tables
|
|
182
|
+
html = processTable(html, getAttr);
|
|
183
|
+
|
|
184
|
+
// Process headings (supports optional trailing #'s)
|
|
132
185
|
html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
|
|
133
186
|
const level = hashes.length;
|
|
134
|
-
|
|
135
|
-
return `<h${level}${getAttr('h' + level, '', sourceMarker)}>${content}</h${level}>`;
|
|
187
|
+
return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
|
|
136
188
|
});
|
|
137
189
|
|
|
138
|
-
// Process
|
|
139
|
-
html = html.replace(
|
|
140
|
-
|
|
141
|
-
html = html.replace(
|
|
142
|
-
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, `<em${getAttr('em', '', '_')}>$1</em>`);
|
|
143
|
-
html = html.replace(/~~(.+?)~~/g, `<del${getAttr('del', '', '~~')}>$1</del>`);
|
|
190
|
+
// Process blockquotes (must handle escaped > since we already escaped HTML)
|
|
191
|
+
html = html.replace(/^>\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
|
|
192
|
+
// Merge consecutive blockquotes
|
|
193
|
+
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
144
194
|
|
|
145
|
-
// Process
|
|
146
|
-
html = html.replace(
|
|
147
|
-
html = html.replace(/<\/blockquote>\n<blockquote[^>]*>/g, '\n');
|
|
195
|
+
// Process horizontal rules (allow trailing spaces)
|
|
196
|
+
html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
|
|
148
197
|
|
|
149
|
-
// Process
|
|
150
|
-
html = html
|
|
198
|
+
// Process lists
|
|
199
|
+
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
151
200
|
|
|
152
|
-
//
|
|
153
|
-
html = processListsBD(html, getAttr);
|
|
201
|
+
// Phase 3: Process inline elements
|
|
154
202
|
|
|
155
|
-
//
|
|
203
|
+
// Images (must come before links, with URL sanitization)
|
|
156
204
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
157
|
-
|
|
205
|
+
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
206
|
+
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
207
|
+
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
208
|
+
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
158
209
|
});
|
|
159
210
|
|
|
211
|
+
// Links (with URL sanitization)
|
|
160
212
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
|
|
161
|
-
|
|
213
|
+
// Sanitize URL to prevent XSS
|
|
214
|
+
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
215
|
+
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
216
|
+
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
217
|
+
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
218
|
+
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
162
219
|
});
|
|
163
220
|
|
|
164
|
-
//
|
|
165
|
-
html =
|
|
221
|
+
// Autolinks - convert bare URLs to clickable links
|
|
222
|
+
html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
|
|
223
|
+
const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
|
|
224
|
+
return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
|
|
225
|
+
});
|
|
166
226
|
|
|
167
|
-
//
|
|
168
|
-
|
|
227
|
+
// Process inline formatting (bold, italic, strikethrough)
|
|
228
|
+
const inlinePatterns = [
|
|
229
|
+
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
230
|
+
[/__(.+?)__/g, 'strong', '__'],
|
|
231
|
+
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
|
|
232
|
+
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
233
|
+
[/~~(.+?)~~/g, 'del', '~~']
|
|
234
|
+
];
|
|
169
235
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
236
|
+
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
237
|
+
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Line breaks
|
|
241
|
+
if (lazy_linefeeds) {
|
|
242
|
+
// Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
|
|
243
|
+
const blocks = [];
|
|
244
|
+
let bi = 0;
|
|
245
|
+
|
|
246
|
+
// Protect tables and lists
|
|
247
|
+
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
248
|
+
blocks[bi] = m;
|
|
249
|
+
return `§B${bi++}§`;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Handle paragraphs and block elements
|
|
253
|
+
html = html.replace(/\n\n+/g, '§P§')
|
|
254
|
+
// After block elements
|
|
255
|
+
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
256
|
+
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
257
|
+
// Before block elements
|
|
258
|
+
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
259
|
+
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
260
|
+
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
261
|
+
// Convert remaining newlines
|
|
262
|
+
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
263
|
+
// Restore
|
|
264
|
+
.replace(/§N§/g, '\n')
|
|
265
|
+
.replace(/§P§/g, '</p><p>');
|
|
266
|
+
|
|
267
|
+
// Restore protected blocks
|
|
268
|
+
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
269
|
+
|
|
270
|
+
html = '<p>' + html + '</p>';
|
|
271
|
+
} else {
|
|
272
|
+
// Standard: two spaces at end of line for line breaks
|
|
273
|
+
html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
|
|
274
|
+
|
|
275
|
+
// Paragraphs (double newlines)
|
|
276
|
+
// Don't add </p> after block elements (they're not in paragraphs)
|
|
277
|
+
html = html.replace(/\n\n+/g, (match, offset) => {
|
|
278
|
+
// Check if we're after a block element closing tag
|
|
279
|
+
const before = html.substring(0, offset);
|
|
280
|
+
if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
|
|
281
|
+
return '<p>'; // Just open a new paragraph
|
|
282
|
+
}
|
|
283
|
+
return '</p><p>'; // Normal paragraph break
|
|
284
|
+
});
|
|
285
|
+
html = '<p>' + html + '</p>';
|
|
286
|
+
}
|
|
173
287
|
|
|
174
288
|
// Clean up empty paragraphs and unwrap block elements
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
289
|
+
const cleanupPatterns = [
|
|
290
|
+
[/<p><\/p>/g, ''],
|
|
291
|
+
[/<p>(<h[1-6][^>]*>)/g, '$1'],
|
|
292
|
+
[/(<\/h[1-6]>)<\/p>/g, '$1'],
|
|
293
|
+
[/<p>(<blockquote[^>]*>)/g, '$1'],
|
|
294
|
+
[/(<\/blockquote>)<\/p>/g, '$1'],
|
|
295
|
+
[/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1'],
|
|
296
|
+
[/(<\/ul>|<\/ol>)<\/p>/g, '$1'],
|
|
297
|
+
[/<p>(<hr[^>]*>)<\/p>/g, '$1'],
|
|
298
|
+
[/<p>(<table[^>]*>)/g, '$1'],
|
|
299
|
+
[/(<\/table>)<\/p>/g, '$1'],
|
|
300
|
+
[/<p>(<pre[^>]*>)/g, '$1'],
|
|
301
|
+
[/(<\/pre>)<\/p>/g, '$1'],
|
|
302
|
+
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
306
|
+
html = html.replace(pattern, replacement);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Fix orphaned closing </p> tags after block elements
|
|
310
|
+
// When a paragraph follows a block element, ensure it has opening <p>
|
|
311
|
+
html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
|
|
312
|
+
|
|
313
|
+
// Phase 4: Restore code blocks and inline code
|
|
185
314
|
|
|
186
315
|
// Restore code blocks
|
|
187
316
|
codeBlocks.forEach((block, i) => {
|
|
188
|
-
const placeholder = `§CB${i}§`;
|
|
189
317
|
let replacement;
|
|
190
318
|
|
|
191
|
-
if (
|
|
192
|
-
|
|
319
|
+
if (block.custom && fence_plugin && fence_plugin.render) {
|
|
320
|
+
// Use custom fence plugin (v1.1.0: object format with render function)
|
|
321
|
+
replacement = fence_plugin.render(block.code, block.lang);
|
|
322
|
+
|
|
323
|
+
// If plugin returns undefined, fall back to default rendering
|
|
193
324
|
if (replacement === undefined) {
|
|
194
|
-
|
|
325
|
+
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
326
|
+
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
327
|
+
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
328
|
+
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
329
|
+
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
330
|
+
} else if (bidirectional) {
|
|
331
|
+
// If bidirectional and plugin provided HTML, add data attributes for roundtrip
|
|
332
|
+
replacement = replacement.replace(/^<(\w+)/,
|
|
333
|
+
`<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
|
|
195
334
|
}
|
|
196
335
|
} else {
|
|
197
|
-
|
|
336
|
+
// Default rendering
|
|
337
|
+
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
338
|
+
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
339
|
+
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
340
|
+
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
341
|
+
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
198
342
|
}
|
|
199
343
|
|
|
344
|
+
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
200
345
|
html = html.replace(placeholder, replacement);
|
|
201
346
|
});
|
|
202
347
|
|
|
203
|
-
// Restore inline
|
|
204
|
-
inlineCodes.forEach((
|
|
205
|
-
const placeholder =
|
|
206
|
-
html = html.replace(placeholder, `<code${getAttr('code'
|
|
348
|
+
// Restore inline code
|
|
349
|
+
inlineCodes.forEach((code, i) => {
|
|
350
|
+
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
351
|
+
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
207
352
|
});
|
|
208
353
|
|
|
209
354
|
return html.trim();
|
|
210
355
|
}
|
|
211
356
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
let listStack = [];
|
|
357
|
+
/**
|
|
358
|
+
* Process inline markdown formatting
|
|
359
|
+
*/
|
|
360
|
+
function processInlineMarkdown(text, getAttr) {
|
|
217
361
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const sourceMarker = isOrdered ? '1.' : marker;
|
|
228
|
-
|
|
229
|
-
// Handle task lists
|
|
230
|
-
let listItemContent = content;
|
|
231
|
-
let taskAttrs = '';
|
|
232
|
-
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
|
|
233
|
-
if (taskMatch && !isOrdered) {
|
|
234
|
-
const [, checked, taskContent] = taskMatch;
|
|
235
|
-
const isChecked = checked.toLowerCase() === 'x';
|
|
236
|
-
listItemContent = `<input type="checkbox"${getAttr('task-checkbox', '', '[')}${isChecked ? ' checked' : ''}> ${taskContent}`;
|
|
237
|
-
taskAttrs = getAttr('task-item', '', '- [ ]');
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Close deeper levels
|
|
241
|
-
while (listStack.length > level + 1) {
|
|
242
|
-
const list = listStack.pop();
|
|
243
|
-
result.push(`</${list.type}>`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Open new level if needed
|
|
247
|
-
if (listStack.length === level) {
|
|
248
|
-
listStack.push({ type: listType, level, marker: sourceMarker });
|
|
249
|
-
result.push(`<${listType}${getAttr(listType, '', sourceMarker)}>`);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const liAttr = taskAttrs || getAttr('li', '', sourceMarker);
|
|
253
|
-
result.push(`<li${liAttr}>${listItemContent}</li>`);
|
|
254
|
-
} else {
|
|
255
|
-
// Close all lists
|
|
256
|
-
while (listStack.length > 0) {
|
|
257
|
-
const list = listStack.pop();
|
|
258
|
-
result.push(`</${list.type}>`);
|
|
259
|
-
}
|
|
260
|
-
result.push(line);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
362
|
+
// Process inline formatting patterns
|
|
363
|
+
const patterns = [
|
|
364
|
+
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
365
|
+
[/__(.+?)__/g, 'strong'],
|
|
366
|
+
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
|
|
367
|
+
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
|
|
368
|
+
[/~~(.+?)~~/g, 'del'],
|
|
369
|
+
[/`([^`]+)`/g, 'code']
|
|
370
|
+
];
|
|
263
371
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
result.push(`</${list.type}>`);
|
|
268
|
-
}
|
|
372
|
+
patterns.forEach(([pattern, tag]) => {
|
|
373
|
+
text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
374
|
+
});
|
|
269
375
|
|
|
270
|
-
return
|
|
376
|
+
return text;
|
|
271
377
|
}
|
|
272
378
|
|
|
273
|
-
|
|
274
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Process markdown tables
|
|
381
|
+
*/
|
|
382
|
+
function processTable(text, getAttr) {
|
|
275
383
|
const lines = text.split('\n');
|
|
276
384
|
const result = [];
|
|
277
385
|
let inTable = false;
|
|
@@ -280,18 +388,22 @@ function processTablesBD(text, getAttr) {
|
|
|
280
388
|
for (let i = 0; i < lines.length; i++) {
|
|
281
389
|
const line = lines[i].trim();
|
|
282
390
|
|
|
283
|
-
if
|
|
391
|
+
// Check if this line looks like a table row (with or without trailing |)
|
|
392
|
+
if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
|
|
284
393
|
if (!inTable) {
|
|
285
394
|
inTable = true;
|
|
286
395
|
tableLines = [];
|
|
287
396
|
}
|
|
288
397
|
tableLines.push(line);
|
|
289
398
|
} else {
|
|
399
|
+
// Not a table line
|
|
290
400
|
if (inTable) {
|
|
291
|
-
|
|
401
|
+
// Process the accumulated table
|
|
402
|
+
const tableHtml = buildTable(tableLines, getAttr);
|
|
292
403
|
if (tableHtml) {
|
|
293
404
|
result.push(tableHtml);
|
|
294
405
|
} else {
|
|
406
|
+
// Not a valid table, restore original lines
|
|
295
407
|
result.push(...tableLines);
|
|
296
408
|
}
|
|
297
409
|
inTable = false;
|
|
@@ -301,8 +413,9 @@ function processTablesBD(text, getAttr) {
|
|
|
301
413
|
}
|
|
302
414
|
}
|
|
303
415
|
|
|
416
|
+
// Handle table at end of text
|
|
304
417
|
if (inTable && tableLines.length > 0) {
|
|
305
|
-
const tableHtml =
|
|
418
|
+
const tableHtml = buildTable(tableLines, getAttr);
|
|
306
419
|
if (tableHtml) {
|
|
307
420
|
result.push(tableHtml);
|
|
308
421
|
} else {
|
|
@@ -313,53 +426,68 @@ function processTablesBD(text, getAttr) {
|
|
|
313
426
|
return result.join('\n');
|
|
314
427
|
}
|
|
315
428
|
|
|
316
|
-
|
|
317
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Build an HTML table from markdown table lines
|
|
431
|
+
*/
|
|
432
|
+
function buildTable(lines, getAttr) {
|
|
433
|
+
|
|
318
434
|
if (lines.length < 2) return null;
|
|
319
435
|
|
|
320
|
-
//
|
|
436
|
+
// Check for separator line (second line should be the separator)
|
|
321
437
|
let separatorIndex = -1;
|
|
322
|
-
let alignments = [];
|
|
323
|
-
|
|
324
438
|
for (let i = 1; i < lines.length; i++) {
|
|
439
|
+
// Support separator with or without leading/trailing pipes
|
|
325
440
|
if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
|
|
326
441
|
separatorIndex = i;
|
|
327
|
-
const cells = lines[i].replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
328
|
-
alignments = cells.map(cell => {
|
|
329
|
-
const trimmed = cell.trim();
|
|
330
|
-
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
|
|
331
|
-
if (trimmed.endsWith(':')) return 'right';
|
|
332
|
-
return 'left';
|
|
333
|
-
});
|
|
334
442
|
break;
|
|
335
443
|
}
|
|
336
444
|
}
|
|
337
445
|
|
|
338
446
|
if (separatorIndex === -1) return null;
|
|
339
447
|
|
|
340
|
-
|
|
448
|
+
const headerLines = lines.slice(0, separatorIndex);
|
|
449
|
+
const bodyLines = lines.slice(separatorIndex + 1);
|
|
341
450
|
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
451
|
+
// Parse alignment from separator
|
|
452
|
+
const separator = lines[separatorIndex];
|
|
453
|
+
// Handle pipes at start/end or not
|
|
454
|
+
const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
455
|
+
const alignments = separatorCells.map(cell => {
|
|
456
|
+
const trimmed = cell.trim();
|
|
457
|
+
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
|
|
458
|
+
if (trimmed.endsWith(':')) return 'right';
|
|
459
|
+
return 'left';
|
|
460
|
+
});
|
|
352
461
|
|
|
353
|
-
|
|
354
|
-
|
|
462
|
+
let html = `<table${getAttr('table')}>\n`;
|
|
463
|
+
|
|
464
|
+
// Build header
|
|
465
|
+
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
466
|
+
html += `<thead${getAttr('thead')}>\n`;
|
|
467
|
+
headerLines.forEach(line => {
|
|
468
|
+
html += `<tr${getAttr('tr')}>\n`;
|
|
469
|
+
// Handle pipes at start/end or not
|
|
470
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
471
|
+
cells.forEach((cell, i) => {
|
|
472
|
+
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
473
|
+
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
|
|
474
|
+
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
475
|
+
});
|
|
476
|
+
html += '</tr>\n';
|
|
477
|
+
});
|
|
478
|
+
html += '</thead>\n';
|
|
479
|
+
|
|
480
|
+
// Build body
|
|
355
481
|
if (bodyLines.length > 0) {
|
|
356
|
-
html += `<tbody${getAttr('tbody'
|
|
482
|
+
html += `<tbody${getAttr('tbody')}>\n`;
|
|
357
483
|
bodyLines.forEach(line => {
|
|
358
|
-
html += `<tr${getAttr('tr'
|
|
359
|
-
|
|
484
|
+
html += `<tr${getAttr('tr')}>\n`;
|
|
485
|
+
// Handle pipes at start/end or not
|
|
486
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
360
487
|
cells.forEach((cell, i) => {
|
|
361
|
-
const
|
|
362
|
-
|
|
488
|
+
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
489
|
+
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
|
|
490
|
+
html += `<td${getAttr('td', alignStyle)}>${processedCell}</td>\n`;
|
|
363
491
|
});
|
|
364
492
|
html += '</tr>\n';
|
|
365
493
|
});
|
|
@@ -371,17 +499,201 @@ function buildTableBD(lines, getAttr) {
|
|
|
371
499
|
}
|
|
372
500
|
|
|
373
501
|
/**
|
|
374
|
-
*
|
|
375
|
-
|
|
376
|
-
|
|
502
|
+
* Process markdown lists (ordered and unordered)
|
|
503
|
+
*/
|
|
504
|
+
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
505
|
+
|
|
506
|
+
const lines = text.split('\n');
|
|
507
|
+
const result = [];
|
|
508
|
+
let listStack = []; // Track nested lists
|
|
509
|
+
|
|
510
|
+
// Helper to escape HTML for data-qd attributes
|
|
511
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
512
|
+
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
513
|
+
|
|
514
|
+
for (let i = 0; i < lines.length; i++) {
|
|
515
|
+
const line = lines[i];
|
|
516
|
+
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
517
|
+
|
|
518
|
+
if (match) {
|
|
519
|
+
const [, indent, marker, content] = match;
|
|
520
|
+
const level = Math.floor(indent.length / 2);
|
|
521
|
+
const isOrdered = /^\d+\./.test(marker);
|
|
522
|
+
const listType = isOrdered ? 'ol' : 'ul';
|
|
523
|
+
|
|
524
|
+
// Check for task list items
|
|
525
|
+
let listItemContent = content;
|
|
526
|
+
let taskListClass = '';
|
|
527
|
+
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
|
|
528
|
+
if (taskMatch && !isOrdered) {
|
|
529
|
+
const [, checked, taskContent] = taskMatch;
|
|
530
|
+
const isChecked = checked.toLowerCase() === 'x';
|
|
531
|
+
const checkboxAttr = inline_styles
|
|
532
|
+
? ' style="margin-right:.5em"'
|
|
533
|
+
: ` class="${CLASS_PREFIX}task-checkbox"`;
|
|
534
|
+
listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
|
|
535
|
+
taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Close deeper levels
|
|
539
|
+
while (listStack.length > level + 1) {
|
|
540
|
+
const list = listStack.pop();
|
|
541
|
+
result.push(`</${list.type}>`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Open new level if needed
|
|
545
|
+
if (listStack.length === level) {
|
|
546
|
+
// Need to open a new list
|
|
547
|
+
listStack.push({ type: listType, level });
|
|
548
|
+
result.push(`<${listType}${getAttr(listType)}>`);
|
|
549
|
+
} else if (listStack.length === level + 1) {
|
|
550
|
+
// Check if we need to switch list type
|
|
551
|
+
const currentList = listStack[listStack.length - 1];
|
|
552
|
+
if (currentList.type !== listType) {
|
|
553
|
+
result.push(`</${currentList.type}>`);
|
|
554
|
+
listStack.pop();
|
|
555
|
+
listStack.push({ type: listType, level });
|
|
556
|
+
result.push(`<${listType}${getAttr(listType)}>`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const liAttr = taskListClass || getAttr('li');
|
|
561
|
+
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
562
|
+
} else {
|
|
563
|
+
// Not a list item, close all lists
|
|
564
|
+
while (listStack.length > 0) {
|
|
565
|
+
const list = listStack.pop();
|
|
566
|
+
result.push(`</${list.type}>`);
|
|
567
|
+
}
|
|
568
|
+
result.push(line);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Close any remaining lists
|
|
573
|
+
while (listStack.length > 0) {
|
|
574
|
+
const list = listStack.pop();
|
|
575
|
+
result.push(`</${list.type}>`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return result.join('\n');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Emit CSS styles for quikdown elements
|
|
583
|
+
* @param {string} prefix - Optional class prefix (default: 'quikdown-')
|
|
584
|
+
* @param {string} theme - Optional theme: 'light' (default) or 'dark'
|
|
585
|
+
* @returns {string} CSS string with quikdown styles
|
|
377
586
|
*/
|
|
378
|
-
|
|
587
|
+
quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
588
|
+
const styles = QUIKDOWN_STYLES;
|
|
589
|
+
|
|
590
|
+
// Define theme color overrides
|
|
591
|
+
const themeOverrides = {
|
|
592
|
+
dark: {
|
|
593
|
+
'#f4f4f4': '#2a2a2a', // pre background
|
|
594
|
+
'#f0f0f0': '#2a2a2a', // code background
|
|
595
|
+
'#f2f2f2': '#2a2a2a', // th background
|
|
596
|
+
'#ddd': '#3a3a3a', // borders
|
|
597
|
+
'#06c': '#6db3f2', // links
|
|
598
|
+
_textColor: '#e0e0e0'
|
|
599
|
+
},
|
|
600
|
+
light: {
|
|
601
|
+
_textColor: '#333' // Explicit text color for light theme
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
let css = '';
|
|
606
|
+
for (const [tag, style] of Object.entries(styles)) {
|
|
607
|
+
let themedStyle = style;
|
|
608
|
+
|
|
609
|
+
// Apply theme overrides if dark theme
|
|
610
|
+
if (theme === 'dark' && themeOverrides.dark) {
|
|
611
|
+
// Replace colors
|
|
612
|
+
for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
|
|
613
|
+
if (!oldColor.startsWith('_')) {
|
|
614
|
+
themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Add text color for certain elements in dark theme
|
|
619
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
620
|
+
if (needsTextColor.includes(tag)) {
|
|
621
|
+
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
622
|
+
}
|
|
623
|
+
} else if (theme === 'light' && themeOverrides.light) {
|
|
624
|
+
// Add explicit text color for light theme elements too
|
|
625
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
626
|
+
if (needsTextColor.includes(tag)) {
|
|
627
|
+
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return css;
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Configure quikdown with options and return a function
|
|
639
|
+
* @param {Object} options - Configuration options
|
|
640
|
+
* @returns {Function} Configured quikdown function
|
|
641
|
+
*/
|
|
642
|
+
quikdown.configure = function(options) {
|
|
643
|
+
return function(markdown) {
|
|
644
|
+
return quikdown(markdown, options);
|
|
645
|
+
};
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Version information
|
|
650
|
+
*/
|
|
651
|
+
quikdown.version = quikdownVersion;
|
|
652
|
+
|
|
653
|
+
// Export for both CommonJS and ES6
|
|
654
|
+
/* istanbul ignore next */
|
|
655
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
656
|
+
module.exports = quikdown;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// For browser global
|
|
660
|
+
/* istanbul ignore next */
|
|
661
|
+
if (typeof window !== 'undefined') {
|
|
662
|
+
window.quikdown = quikdown;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* quikdown_bd - Bidirectional markdown/HTML converter
|
|
667
|
+
* Extends core quikdown with HTML→Markdown conversion
|
|
668
|
+
*
|
|
669
|
+
* Uses data-qd attributes to preserve original markdown syntax
|
|
670
|
+
* Enables HTML→Markdown conversion for quikdown-generated HTML
|
|
671
|
+
*/
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Create bidirectional version by extending quikdown
|
|
676
|
+
* This wraps quikdown and adds the toMarkdown method
|
|
677
|
+
*/
|
|
678
|
+
function quikdown_bd(markdown, options = {}) {
|
|
679
|
+
// Use core quikdown with bidirectional flag to add data-qd attributes
|
|
680
|
+
return quikdown(markdown, { ...options, bidirectional: true });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Copy all properties and methods from quikdown (including version)
|
|
684
|
+
Object.keys(quikdown).forEach(key => {
|
|
685
|
+
quikdown_bd[key] = quikdown[key];
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Add the toMarkdown method for HTML→Markdown conversion
|
|
689
|
+
quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
379
690
|
// Accept either HTML string or DOM element
|
|
380
691
|
let container;
|
|
381
692
|
if (typeof htmlOrElement === 'string') {
|
|
382
693
|
container = document.createElement('div');
|
|
383
694
|
container.innerHTML = htmlOrElement;
|
|
384
695
|
} else if (htmlOrElement instanceof Element) {
|
|
696
|
+
/* istanbul ignore next - browser-only code path, not testable in jsdom */
|
|
385
697
|
container = htmlOrElement;
|
|
386
698
|
} else {
|
|
387
699
|
return '';
|
|
@@ -400,7 +712,6 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
|
|
|
400
712
|
|
|
401
713
|
const tag = node.tagName.toLowerCase();
|
|
402
714
|
const dataQd = node.getAttribute('data-qd');
|
|
403
|
-
const styles = window.getComputedStyle ? window.getComputedStyle(node) : {};
|
|
404
715
|
|
|
405
716
|
// Process children with context
|
|
406
717
|
let childContent = '';
|
|
@@ -422,40 +733,55 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
|
|
|
422
733
|
|
|
423
734
|
case 'strong':
|
|
424
735
|
case 'b':
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
return `${boldMarker}${childContent}${boldMarker}`;
|
|
429
|
-
}
|
|
430
|
-
return childContent;
|
|
736
|
+
if (!childContent) return ''; // Don't add markers for empty content
|
|
737
|
+
const boldMarker = dataQd || '**';
|
|
738
|
+
return `${boldMarker}${childContent}${boldMarker}`;
|
|
431
739
|
|
|
432
740
|
case 'em':
|
|
433
741
|
case 'i':
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
return `${emMarker}${childContent}${emMarker}`;
|
|
438
|
-
}
|
|
439
|
-
return childContent;
|
|
742
|
+
if (!childContent) return ''; // Don't add markers for empty content
|
|
743
|
+
const emMarker = dataQd || '*';
|
|
744
|
+
return `${emMarker}${childContent}${emMarker}`;
|
|
440
745
|
|
|
441
746
|
case 'del':
|
|
442
747
|
case 's':
|
|
443
748
|
case 'strike':
|
|
749
|
+
if (!childContent) return ''; // Don't add markers for empty content
|
|
444
750
|
const delMarker = dataQd || '~~';
|
|
445
751
|
return `${delMarker}${childContent}${delMarker}`;
|
|
446
752
|
|
|
447
753
|
case 'code':
|
|
448
|
-
//
|
|
449
|
-
if (
|
|
450
|
-
return childContent;
|
|
451
|
-
}
|
|
754
|
+
// Note: code inside pre is handled directly by the pre case using querySelector
|
|
755
|
+
if (!childContent) return ''; // Don't add markers for empty content
|
|
452
756
|
const codeMarker = dataQd || '`';
|
|
453
757
|
return `${codeMarker}${childContent}${codeMarker}`;
|
|
454
758
|
|
|
455
759
|
case 'pre':
|
|
456
760
|
const fence = node.getAttribute('data-qd-fence') || dataQd || '```';
|
|
457
761
|
const lang = node.getAttribute('data-qd-lang') || '';
|
|
458
|
-
|
|
762
|
+
|
|
763
|
+
// Check if this was created by a fence plugin with reverse handler
|
|
764
|
+
if (options.fence_plugin && options.fence_plugin.reverse && lang) {
|
|
765
|
+
try {
|
|
766
|
+
const result = options.fence_plugin.reverse(node);
|
|
767
|
+
if (result && result.content) {
|
|
768
|
+
const fenceMarker = result.fence || fence;
|
|
769
|
+
const langStr = result.lang || lang;
|
|
770
|
+
return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
|
|
771
|
+
}
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.warn('Fence reverse handler error:', err);
|
|
774
|
+
// Fall through to default handling
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Fallback: use data-qd-source if available
|
|
779
|
+
const source = node.getAttribute('data-qd-source');
|
|
780
|
+
if (source) {
|
|
781
|
+
return `${fence}${lang}\n${source}\n${fence}\n\n`;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Final fallback: extract text content
|
|
459
785
|
const codeEl = node.querySelector('code');
|
|
460
786
|
const codeContent = codeEl ? codeEl.textContent : childContent;
|
|
461
787
|
return `${fence}${lang}\n${codeContent.trimEnd()}\n${fence}\n\n`;
|
|
@@ -502,16 +828,87 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
|
|
|
502
828
|
case 'p':
|
|
503
829
|
// Check if it's actually a paragraph or just a wrapper
|
|
504
830
|
if (childContent.trim()) {
|
|
505
|
-
|
|
831
|
+
// Check if paragraph ends with a line that's just whitespace
|
|
832
|
+
// This indicates an intentional blank line before the next element
|
|
833
|
+
const lines = childContent.split('\n');
|
|
834
|
+
let content = childContent.trim();
|
|
835
|
+
|
|
836
|
+
// If the last line(s) are just whitespace, preserve one blank line
|
|
837
|
+
if (lines.length > 1) {
|
|
838
|
+
let trailingBlankLines = 0;
|
|
839
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
840
|
+
if (lines[i].trim() === '') {
|
|
841
|
+
trailingBlankLines++;
|
|
842
|
+
} else {
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (trailingBlankLines > 0) {
|
|
847
|
+
// Add a line with just a space, followed by single newline
|
|
848
|
+
// The \n\n will be added below for paragraph separation
|
|
849
|
+
content = content + '\n ';
|
|
850
|
+
// Only add one newline since we're preserving the space line
|
|
851
|
+
return content + '\n';
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return content + '\n\n';
|
|
506
856
|
}
|
|
507
857
|
return '';
|
|
508
858
|
|
|
509
859
|
case 'div':
|
|
860
|
+
// Check if this was created by a fence plugin with reverse handler
|
|
861
|
+
const divLang = node.getAttribute('data-qd-lang');
|
|
862
|
+
const divFence = node.getAttribute('data-qd-fence');
|
|
863
|
+
|
|
864
|
+
if (divLang && options.fence_plugin && options.fence_plugin.reverse) {
|
|
865
|
+
try {
|
|
866
|
+
const result = options.fence_plugin.reverse(node);
|
|
867
|
+
if (result && result.content) {
|
|
868
|
+
const fenceMarker = result.fence || divFence || '```';
|
|
869
|
+
const langStr = result.lang || divLang;
|
|
870
|
+
return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
|
|
871
|
+
}
|
|
872
|
+
} catch (err) {
|
|
873
|
+
console.warn('Fence reverse handler error:', err);
|
|
874
|
+
// Fall through to default handling
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Fallback: use data-qd-source if available
|
|
879
|
+
const divSource = node.getAttribute('data-qd-source');
|
|
880
|
+
if (divSource && divFence) {
|
|
881
|
+
return `${divFence}${divLang || ''}\n${divSource}\n${divFence}\n\n`;
|
|
882
|
+
}
|
|
883
|
+
|
|
510
884
|
// Check if it's a mermaid container
|
|
511
885
|
if (node.classList && node.classList.contains('mermaid-container')) {
|
|
512
886
|
const fence = node.getAttribute('data-qd-fence') || '```';
|
|
513
887
|
const lang = node.getAttribute('data-qd-lang') || 'mermaid';
|
|
514
|
-
|
|
888
|
+
|
|
889
|
+
// First check for data-qd-source attribute on the container
|
|
890
|
+
const source = node.getAttribute('data-qd-source');
|
|
891
|
+
if (source) {
|
|
892
|
+
// Decode HTML entities from the attribute (mainly ")
|
|
893
|
+
const temp = document.createElement('textarea');
|
|
894
|
+
temp.innerHTML = source;
|
|
895
|
+
const code = temp.value;
|
|
896
|
+
return `${fence}${lang}\n${code}\n${fence}\n\n`;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Check for source on the pre.mermaid element
|
|
900
|
+
const mermaidPre = node.querySelector('pre.mermaid');
|
|
901
|
+
if (mermaidPre) {
|
|
902
|
+
const preSource = mermaidPre.getAttribute('data-qd-source');
|
|
903
|
+
if (preSource) {
|
|
904
|
+
const temp = document.createElement('textarea');
|
|
905
|
+
temp.innerHTML = preSource;
|
|
906
|
+
const code = temp.value;
|
|
907
|
+
return `${fence}${lang}\n${code}\n${fence}\n\n`;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Fallback: Look for the legacy .mermaid-source element
|
|
515
912
|
const sourceElement = node.querySelector('.mermaid-source');
|
|
516
913
|
if (sourceElement) {
|
|
517
914
|
// Decode HTML entities
|
|
@@ -520,7 +917,8 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
|
|
|
520
917
|
const code = temp.textContent;
|
|
521
918
|
return `${fence}${lang}\n${code}\n${fence}\n\n`;
|
|
522
919
|
}
|
|
523
|
-
|
|
920
|
+
|
|
921
|
+
// Final fallback: try to extract from the mermaid element (unreliable after rendering)
|
|
524
922
|
const mermaidElement = node.querySelector('.mermaid');
|
|
525
923
|
if (mermaidElement && mermaidElement.textContent.includes('graph')) {
|
|
526
924
|
return `${fence}${lang}\n${mermaidElement.textContent.trim()}\n${fence}\n\n`;
|
|
@@ -611,7 +1009,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
|
|
|
611
1009
|
|
|
612
1010
|
// Add separator with alignment
|
|
613
1011
|
const separators = headers.map((_, i) => {
|
|
614
|
-
const align = alignments[i] ||
|
|
1012
|
+
const align = alignments[i] || 'left';
|
|
615
1013
|
if (align === 'center') return ':---:';
|
|
616
1014
|
if (align === 'right') return '---:';
|
|
617
1015
|
return '---';
|
|
@@ -647,29 +1045,23 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
|
|
|
647
1045
|
return markdown;
|
|
648
1046
|
};
|
|
649
1047
|
|
|
650
|
-
//
|
|
651
|
-
quikdown_bd.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
652
|
-
// This would generate CSS based on the styles
|
|
653
|
-
// For now, returning empty string as placeholder
|
|
654
|
-
// In production, this would generate the full CSS
|
|
655
|
-
return '';
|
|
656
|
-
};
|
|
657
|
-
|
|
658
|
-
// Configure method
|
|
1048
|
+
// Override the configure method to return a bidirectional version
|
|
659
1049
|
quikdown_bd.configure = function(options) {
|
|
660
1050
|
return function(markdown) {
|
|
661
1051
|
return quikdown_bd(markdown, options);
|
|
662
1052
|
};
|
|
663
1053
|
};
|
|
664
1054
|
|
|
665
|
-
//
|
|
666
|
-
|
|
1055
|
+
// Set version
|
|
1056
|
+
// Version is already copied from quikdown via Object.keys loop
|
|
667
1057
|
|
|
668
1058
|
// Export for both module and browser
|
|
1059
|
+
/* istanbul ignore next */
|
|
669
1060
|
if (typeof module !== 'undefined' && module.exports) {
|
|
670
1061
|
module.exports = quikdown_bd;
|
|
671
1062
|
}
|
|
672
1063
|
|
|
1064
|
+
/* istanbul ignore next */
|
|
673
1065
|
if (typeof window !== 'undefined') {
|
|
674
1066
|
window.quikdown_bd = quikdown_bd;
|
|
675
1067
|
}
|