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