quikdown 1.2.7 → 1.2.9
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 +9 -4
- package/dist/quikdown.cjs +496 -243
- package/dist/quikdown.dark.css +1 -1
- package/dist/quikdown.esm.js +496 -243
- package/dist/quikdown.esm.min.js +2 -2
- package/dist/quikdown.esm.min.js.gz +0 -0
- package/dist/quikdown.esm.min.js.map +1 -1
- package/dist/quikdown.light.css +1 -1
- package/dist/quikdown.umd.js +496 -243
- package/dist/quikdown.umd.min.js +2 -2
- package/dist/quikdown.umd.min.js.gz +0 -0
- package/dist/quikdown.umd.min.js.map +1 -1
- package/dist/quikdown_ast.cjs +2 -2
- package/dist/quikdown_ast.esm.js +2 -2
- package/dist/quikdown_ast.esm.min.js +2 -2
- package/dist/quikdown_ast.esm.min.js.gz +0 -0
- package/dist/quikdown_ast.umd.js +2 -2
- package/dist/quikdown_ast.umd.min.js +2 -2
- package/dist/quikdown_ast.umd.min.js.gz +0 -0
- package/dist/quikdown_ast_html.cjs +3 -3
- package/dist/quikdown_ast_html.esm.js +3 -3
- package/dist/quikdown_ast_html.esm.min.js +2 -2
- package/dist/quikdown_ast_html.esm.min.js.gz +0 -0
- package/dist/quikdown_ast_html.umd.js +3 -3
- package/dist/quikdown_ast_html.umd.min.js +2 -2
- package/dist/quikdown_ast_html.umd.min.js.gz +0 -0
- package/dist/quikdown_bd.cjs +496 -243
- package/dist/quikdown_bd.esm.js +496 -243
- package/dist/quikdown_bd.esm.min.js +2 -2
- package/dist/quikdown_bd.esm.min.js.gz +0 -0
- package/dist/quikdown_bd.esm.min.js.map +1 -1
- package/dist/quikdown_bd.umd.js +496 -243
- package/dist/quikdown_bd.umd.min.js +2 -2
- package/dist/quikdown_bd.umd.min.js.gz +0 -0
- package/dist/quikdown_bd.umd.min.js.map +1 -1
- package/dist/quikdown_edit.cjs +760 -327
- package/dist/quikdown_edit.esm.js +760 -327
- package/dist/quikdown_edit.esm.min.js +3 -3
- package/dist/quikdown_edit.esm.min.js.gz +0 -0
- package/dist/quikdown_edit.esm.min.js.map +1 -1
- package/dist/quikdown_edit.umd.js +760 -327
- package/dist/quikdown_edit.umd.min.js +3 -3
- package/dist/quikdown_edit.umd.min.js.gz +0 -0
- package/dist/quikdown_edit.umd.min.js.map +1 -1
- package/dist/quikdown_edit_standalone.esm.min.js.gz +0 -0
- package/dist/quikdown_edit_standalone.umd.min.js.gz +0 -0
- package/dist/quikdown_json.cjs +3 -3
- package/dist/quikdown_json.esm.js +3 -3
- package/dist/quikdown_json.esm.min.js +2 -2
- package/dist/quikdown_json.esm.min.js.gz +0 -0
- package/dist/quikdown_json.umd.js +3 -3
- package/dist/quikdown_json.umd.min.js +2 -2
- package/dist/quikdown_json.umd.min.js.gz +0 -0
- package/dist/quikdown_yaml.cjs +3 -3
- package/dist/quikdown_yaml.esm.js +3 -3
- package/dist/quikdown_yaml.esm.min.js +2 -2
- package/dist/quikdown_yaml.esm.min.js.gz +0 -0
- package/dist/quikdown_yaml.umd.js +3 -3
- package/dist/quikdown_yaml.umd.min.js +2 -2
- package/dist/quikdown_yaml.umd.min.js.gz +0 -0
- package/package.json +18 -13
package/dist/quikdown_edit.cjs
CHANGED
|
@@ -1,36 +1,251 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Quikdown Editor - Drop-in Markdown Parser
|
|
3
|
-
* @version 1.2.
|
|
3
|
+
* @version 1.2.9
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
10
|
+
* quikdown_classify — Shared line-classification utilities
|
|
11
|
+
* ═════════════════════════════════════════════════════════
|
|
12
|
+
*
|
|
13
|
+
* Pure functions for classifying markdown lines. Used by both the main
|
|
14
|
+
* parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
|
|
15
|
+
* lives in one place.
|
|
16
|
+
*
|
|
17
|
+
* All functions operate on a **trimmed** line (caller must trim).
|
|
18
|
+
* None use regexes with nested quantifiers — every check is either a
|
|
19
|
+
* simple regex or a linear scan, so there is zero ReDoS risk.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Full CommonMark HR check: three or more identical characters from
|
|
24
|
+
* {-, *, _} with optional interspersed whitespace.
|
|
25
|
+
*
|
|
26
|
+
* Examples that return true: ---, ***, ___, ----, - - -, * * *, _ _ _
|
|
27
|
+
* Examples that return false: --, - text, ---text, mixed -_*, empty
|
|
28
|
+
*
|
|
29
|
+
* Algorithm (O(n), single pass, no backtracking):
|
|
30
|
+
* 1. Strip all whitespace
|
|
31
|
+
* 2. Verify length >= 3
|
|
32
|
+
* 3. First char must be -, *, or _
|
|
33
|
+
* 4. Every remaining char must equal the first
|
|
34
|
+
*
|
|
35
|
+
* @param {string} trimmed The line, already trimmed
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function isHRLine(trimmed) {
|
|
39
|
+
if (trimmed.length < 3) return false;
|
|
40
|
+
|
|
41
|
+
// Strip whitespace via linear scan
|
|
42
|
+
let stripped = '';
|
|
43
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
44
|
+
const ch = trimmed[i];
|
|
45
|
+
if (ch !== ' ' && ch !== '\t') stripped += ch;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (stripped.length < 3) return false;
|
|
49
|
+
|
|
50
|
+
const ch = stripped[0];
|
|
51
|
+
if (ch !== '-' && ch !== '*' && ch !== '_') return false;
|
|
24
52
|
|
|
25
|
-
|
|
53
|
+
for (let i = 1; i < stripped.length; i++) {
|
|
54
|
+
if (stripped[i] !== ch) return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Dash-only HR check — exact parity with the main parser's original
|
|
61
|
+
* regex `/^---+\s*$/`. Only matches lines of three or more dashes
|
|
62
|
+
* with optional trailing whitespace (no interspersed spaces).
|
|
63
|
+
*
|
|
64
|
+
* @param {string} trimmed The line, already trimmed
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
function isDashHRLine(trimmed) {
|
|
68
|
+
if (trimmed.length < 3) return false;
|
|
69
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
70
|
+
const ch = trimmed[i];
|
|
71
|
+
if (ch === '-') continue;
|
|
72
|
+
// Allow trailing whitespace only
|
|
73
|
+
if (ch === ' ' || ch === '\t') {
|
|
74
|
+
for (let j = i + 1; j < trimmed.length; j++) {
|
|
75
|
+
if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
|
|
76
|
+
}
|
|
77
|
+
return i >= 3; // at least 3 dashes before whitespace
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true; // all dashes
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a trimmed line opens a code fence.
|
|
86
|
+
* Returns { char, len, lang } if it does, or null otherwise.
|
|
87
|
+
*
|
|
88
|
+
* A fence opener is 3+ identical backticks or tildes at the start of a line,
|
|
89
|
+
* optionally followed by a language tag.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} trimmed The line, already trimmed
|
|
92
|
+
* @returns {{ char: string, len: number, lang: string } | null}
|
|
93
|
+
*/
|
|
94
|
+
function fenceOpen(trimmed) {
|
|
95
|
+
if (trimmed.length < 3) return null;
|
|
96
|
+
const ch = trimmed[0];
|
|
97
|
+
if (ch !== '`' && ch !== '~') return null;
|
|
98
|
+
|
|
99
|
+
let len = 1;
|
|
100
|
+
while (len < trimmed.length && trimmed[len] === ch) len++;
|
|
101
|
+
if (len < 3) return null;
|
|
102
|
+
|
|
103
|
+
const lang = trimmed.slice(len).trim();
|
|
104
|
+
return { char: ch, len, lang };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if a trimmed line closes an open fence.
|
|
109
|
+
* The closing fence must use the same character, be at least as long,
|
|
110
|
+
* and have no content after (optional trailing whitespace only).
|
|
111
|
+
*
|
|
112
|
+
* @param {string} trimmed The line, already trimmed
|
|
113
|
+
* @param {string} openChar The fence character ('`' or '~')
|
|
114
|
+
* @param {number} openLen Length of the opening fence marker
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function isFenceClose(trimmed, openChar, openLen) {
|
|
118
|
+
if (trimmed.length < openLen) return false;
|
|
119
|
+
|
|
120
|
+
let len = 0;
|
|
121
|
+
while (len < trimmed.length && trimmed[len] === openChar) len++;
|
|
122
|
+
if (len < openLen) return false;
|
|
123
|
+
|
|
124
|
+
// Rest must be whitespace only
|
|
125
|
+
for (let i = len; i < trimmed.length; i++) {
|
|
126
|
+
if (trimmed[i] !== ' ' && trimmed[i] !== '\t') return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Classify a content line into a category string.
|
|
133
|
+
* Order matters: HR before list-ul (since `- - -` looks like a list start).
|
|
134
|
+
*
|
|
135
|
+
* @param {string} trimmed The line, already trimmed
|
|
136
|
+
* @returns {string} One of: 'heading', 'hr', 'list-ol', 'list-ul',
|
|
137
|
+
* 'blockquote', 'table', 'paragraph'
|
|
138
|
+
*/
|
|
139
|
+
function classifyLine(trimmed) {
|
|
140
|
+
if (/^#{1,6}\s/.test(trimmed)) return 'heading';
|
|
141
|
+
if (isHRLine(trimmed)) return 'hr';
|
|
142
|
+
if (/^\d+\.\s/.test(trimmed)) return 'list-ol';
|
|
143
|
+
if (/^[-*+]\s/.test(trimmed)) return 'list-ul';
|
|
144
|
+
if (/^>/.test(trimmed)) return 'blockquote';
|
|
145
|
+
if (/^\|/.test(trimmed)) return 'table';
|
|
146
|
+
return 'paragraph';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Heuristic: does a line look like a markdown table row?
|
|
151
|
+
* @param {string} line The line (trimmed or untrimmed)
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
function looksLikeTableRow(line) {
|
|
155
|
+
return line.includes('|');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* quikdown — A compact, scanner-based markdown parser
|
|
160
|
+
* ════════════════════════════════════════════════════
|
|
161
|
+
*
|
|
162
|
+
* Architecture overview (v1.2.8 — lexer rewrite)
|
|
163
|
+
* ───────────────────────────────────────────────
|
|
164
|
+
* Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
|
|
165
|
+
* type (headings, blockquotes, HR, lists, tables) and each inline format
|
|
166
|
+
* (bold, italic, links, …) was handled by its own global regex applied
|
|
167
|
+
* sequentially to the full document string. That worked but made the code
|
|
168
|
+
* hard to extend and debug — a new construct meant adding another regex
|
|
169
|
+
* pass, and ordering bugs between passes were subtle.
|
|
170
|
+
*
|
|
171
|
+
* Starting in v1.2.8 the parser uses a **line-scanning** approach for
|
|
172
|
+
* block detection and a **per-block inline pass** for formatting:
|
|
173
|
+
*
|
|
174
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
175
|
+
* │ Phase 1 — Code Extraction │
|
|
176
|
+
* │ Scan for fenced code blocks (``` / ~~~) and inline │
|
|
177
|
+
* │ code spans (`…`). Replace with §CB§ / §IC§ place- │
|
|
178
|
+
* │ holders so code content is never touched by later │
|
|
179
|
+
* │ phases. │
|
|
180
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
181
|
+
* │ Phase 2 — HTML Escaping │
|
|
182
|
+
* │ Escape &, <, >, ", ' in the remaining text to prevent │
|
|
183
|
+
* │ XSS. (Skipped when allow_unsafe_html is true.) │
|
|
184
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
185
|
+
* │ Phase 3 — Block Scanning │
|
|
186
|
+
* │ Walk the text **line by line**. At each line, the │
|
|
187
|
+
* │ scanner checks (in order): │
|
|
188
|
+
* │ • table rows (|) │
|
|
189
|
+
* │ • headings (#) │
|
|
190
|
+
* │ • HR (---) │
|
|
191
|
+
* │ • blockquotes (>) │
|
|
192
|
+
* │ • list items (-, *, +, 1.) │
|
|
193
|
+
* │ • code-block placeholder (§CB…§) │
|
|
194
|
+
* │ • paragraph text (everything else) │
|
|
195
|
+
* │ │
|
|
196
|
+
* │ Block text is run through the **inline formatter** │
|
|
197
|
+
* │ which handles bold, italic, strikethrough, links, │
|
|
198
|
+
* │ images, and autolinks. │
|
|
199
|
+
* │ │
|
|
200
|
+
* │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
|
|
201
|
+
* │ (single \n → <br>) are handled here too. │
|
|
202
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
203
|
+
* │ Phase 4 — Code Restoration │
|
|
204
|
+
* │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
|
|
205
|
+
* │ / <code> HTML, applying the fence_plugin if present. │
|
|
206
|
+
* └─────────────────────────────────────────────────────────┘
|
|
207
|
+
*
|
|
208
|
+
* Why this design?
|
|
209
|
+
* • Single pass over lines for block identification — no re-scanning.
|
|
210
|
+
* • Each block type is a clearly separated branch, easy to add new ones.
|
|
211
|
+
* • Inline formatting is confined to block text — can't accidentally
|
|
212
|
+
* match across block boundaries or inside HTML tags.
|
|
213
|
+
* • Code extraction still uses a simple regex (it's one pattern, not a
|
|
214
|
+
* chain) because the §-placeholder approach is proven and simple.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} markdown The markdown source text
|
|
217
|
+
* @param {Object} options Configuration (see below)
|
|
218
|
+
* @returns {string} Rendered HTML
|
|
219
|
+
*/
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
// ────────────────────────────────────────────────────────────────────
|
|
223
|
+
// Constants
|
|
224
|
+
// ────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/** Build-time version stamp (injected by tools/updateVersion) */
|
|
227
|
+
const quikdownVersion = '1.2.9';
|
|
228
|
+
|
|
229
|
+
/** CSS class prefix used for all generated elements */
|
|
26
230
|
const CLASS_PREFIX = 'quikdown-';
|
|
27
|
-
const PLACEHOLDER_CB = '§CB';
|
|
28
|
-
const PLACEHOLDER_IC = '§IC';
|
|
29
231
|
|
|
30
|
-
|
|
232
|
+
/** Placeholder sigils — chosen to be extremely unlikely in real text */
|
|
233
|
+
const PLACEHOLDER_CB = '§CB'; // fenced code blocks
|
|
234
|
+
const PLACEHOLDER_IC = '§IC'; // inline code spans
|
|
235
|
+
|
|
236
|
+
/** HTML entity escape map */
|
|
31
237
|
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
|
32
238
|
|
|
33
|
-
//
|
|
239
|
+
// ────────────────────────────────────────────────────────────────────
|
|
240
|
+
// Style definitions
|
|
241
|
+
// ────────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Inline styles for every element quikdown can emit.
|
|
245
|
+
* When `inline_styles: true` these are injected as style="…" attributes.
|
|
246
|
+
* When `inline_styles: false` (default) we use class="quikdown-<tag>"
|
|
247
|
+
* and these same values are emitted by `quikdown.emitStyles()`.
|
|
248
|
+
*/
|
|
34
249
|
const QUIKDOWN_STYLES = {
|
|
35
250
|
h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
|
|
36
251
|
h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
|
|
@@ -53,35 +268,41 @@ const QUIKDOWN_STYLES = {
|
|
|
53
268
|
ul: 'margin:.5em 0;padding-left:2em',
|
|
54
269
|
ol: 'margin:.5em 0;padding-left:2em',
|
|
55
270
|
li: 'margin:.25em 0',
|
|
56
|
-
// Task list specific styles
|
|
57
271
|
'task-item': 'list-style:none',
|
|
58
272
|
'task-checkbox': 'margin-right:.5em'
|
|
59
273
|
};
|
|
60
274
|
|
|
61
|
-
//
|
|
275
|
+
// ────────────────────────────────────────────────────────────────────
|
|
276
|
+
// Attribute factory
|
|
277
|
+
// ────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Creates a `getAttr(tag, additionalStyle?)` helper that returns
|
|
281
|
+
* either a class="…" or style="…" attribute string depending on mode.
|
|
282
|
+
*
|
|
283
|
+
* @param {boolean} inline_styles True → emit style="…"; false → class="…"
|
|
284
|
+
* @param {Object} styles The QUIKDOWN_STYLES map
|
|
285
|
+
* @returns {Function}
|
|
286
|
+
*/
|
|
62
287
|
function createGetAttr(inline_styles, styles) {
|
|
63
288
|
return function(tag, additionalStyle = '') {
|
|
64
289
|
if (inline_styles) {
|
|
65
290
|
let style = styles[tag];
|
|
66
291
|
if (!style && !additionalStyle) return '';
|
|
67
|
-
|
|
68
|
-
//
|
|
292
|
+
|
|
293
|
+
// When adding alignment that conflicts with the tag's default,
|
|
294
|
+
// strip the default text-align first.
|
|
69
295
|
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
70
296
|
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
71
|
-
// Ensure trailing semicolon before concatenating additionalStyle.
|
|
72
|
-
// Both short-circuit paths of this guard (empty `style` or
|
|
73
|
-
// already-has-`;`) are defensive and unreachable with the
|
|
74
|
-
// current QUIKDOWN_STYLES values — istanbul ignore next.
|
|
75
297
|
/* istanbul ignore next */
|
|
76
298
|
if (style && !style.endsWith(';')) style += ';';
|
|
77
299
|
}
|
|
78
|
-
|
|
300
|
+
|
|
79
301
|
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
|
|
80
302
|
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
|
|
81
303
|
return ` style="${fullStyle}"`;
|
|
82
304
|
} else {
|
|
83
305
|
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
|
|
84
|
-
// Apply inline styles for alignment even when using CSS classes
|
|
85
306
|
if (additionalStyle) {
|
|
86
307
|
return `${classAttr} style="${additionalStyle}"`;
|
|
87
308
|
}
|
|
@@ -90,72 +311,84 @@ function createGetAttr(inline_styles, styles) {
|
|
|
90
311
|
};
|
|
91
312
|
}
|
|
92
313
|
|
|
314
|
+
// ════════════════════════════════════════════════════════════════════
|
|
315
|
+
// Main parser function
|
|
316
|
+
// ════════════════════════════════════════════════════════════════════
|
|
317
|
+
|
|
93
318
|
function quikdown(markdown, options = {}) {
|
|
319
|
+
// ── Guard: only process non-empty strings ──
|
|
94
320
|
if (!markdown || typeof markdown !== 'string') {
|
|
95
321
|
return '';
|
|
96
322
|
}
|
|
97
|
-
|
|
323
|
+
|
|
324
|
+
// ── Unpack options ──
|
|
98
325
|
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
|
|
99
|
-
const styles = QUIKDOWN_STYLES;
|
|
100
|
-
const getAttr = createGetAttr(inline_styles, styles);
|
|
326
|
+
const styles = QUIKDOWN_STYLES;
|
|
327
|
+
const getAttr = createGetAttr(inline_styles, styles);
|
|
328
|
+
|
|
329
|
+
// ── Helpers (closed over options) ──
|
|
101
330
|
|
|
102
|
-
|
|
331
|
+
/** Escape the five HTML-special characters. */
|
|
103
332
|
function escapeHtml(text) {
|
|
104
333
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
105
334
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Bidirectional marker helper.
|
|
338
|
+
* When bidirectional mode is on, returns ` data-qd="…"`.
|
|
339
|
+
* The non-bidirectional branch is a trivial no-op arrow; it is
|
|
340
|
+
* exercised in the core bundle but never in quikdown_bd.
|
|
341
|
+
*/
|
|
110
342
|
/* istanbul ignore next - trivial no-op fallback */
|
|
111
343
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
112
344
|
|
|
113
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
|
|
347
|
+
* Returns '#' for blocked URLs.
|
|
348
|
+
*/
|
|
114
349
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
115
350
|
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
116
351
|
if (!url) return '';
|
|
117
|
-
|
|
118
|
-
// If unsafe URLs are explicitly allowed, return as-is
|
|
119
352
|
if (allowUnsafe) return url;
|
|
120
|
-
|
|
353
|
+
|
|
121
354
|
const trimmedUrl = url.trim();
|
|
122
355
|
const lowerUrl = trimmedUrl.toLowerCase();
|
|
123
|
-
|
|
124
|
-
// Block dangerous protocols
|
|
125
356
|
const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
|
|
126
|
-
|
|
357
|
+
|
|
127
358
|
for (const protocol of dangerousProtocols) {
|
|
128
359
|
if (lowerUrl.startsWith(protocol)) {
|
|
129
|
-
// Exception: Allow data:image/* for images
|
|
130
360
|
if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
|
|
131
361
|
return trimmedUrl;
|
|
132
362
|
}
|
|
133
|
-
// Return safe empty link for dangerous protocols
|
|
134
363
|
return '#';
|
|
135
364
|
}
|
|
136
365
|
}
|
|
137
|
-
|
|
138
366
|
return trimmedUrl;
|
|
139
367
|
}
|
|
140
368
|
|
|
141
|
-
//
|
|
369
|
+
// ────────────────────────────────────────────────────────────────
|
|
370
|
+
// Phase 1 — Code Extraction
|
|
371
|
+
// ────────────────────────────────────────────────────────────────
|
|
372
|
+
// Why extract code first? Fenced blocks and inline code spans can
|
|
373
|
+
// contain markdown-like characters (*, _, #, |, etc.) that must NOT
|
|
374
|
+
// be interpreted as formatting. By pulling them out and replacing
|
|
375
|
+
// with unique placeholders, the rest of the pipeline never sees them.
|
|
376
|
+
|
|
142
377
|
let html = markdown;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// Fence must be at start of line
|
|
378
|
+
const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
|
|
379
|
+
const inlineCodes = []; // Array of escaped-HTML strings
|
|
380
|
+
|
|
381
|
+
// ── Fenced code blocks ──
|
|
382
|
+
// Matches paired fences: ``` with ``` and ~~~ with ~~~.
|
|
383
|
+
// The fence must start at column 0 of a line (^ with /m flag).
|
|
384
|
+
// Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
|
|
151
385
|
html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
|
|
152
386
|
const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
|
|
153
|
-
|
|
154
|
-
// Trim the language specification
|
|
155
387
|
const langTrimmed = lang ? lang.trim() : '';
|
|
156
|
-
|
|
157
|
-
// If custom fence plugin is provided, use it (v1.1.0: object format required)
|
|
388
|
+
|
|
158
389
|
if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
|
|
390
|
+
// Custom plugin — store raw code (un-escaped) so the plugin
|
|
391
|
+
// receives the original source.
|
|
159
392
|
codeBlocks.push({
|
|
160
393
|
lang: langTrimmed,
|
|
161
394
|
code: code.trimEnd(),
|
|
@@ -164,6 +397,7 @@ function quikdown(markdown, options = {}) {
|
|
|
164
397
|
hasReverse: !!fence_plugin.reverse
|
|
165
398
|
});
|
|
166
399
|
} else {
|
|
400
|
+
// Default — pre-escape the code for safe HTML output.
|
|
167
401
|
codeBlocks.push({
|
|
168
402
|
lang: langTrimmed,
|
|
169
403
|
code: escapeHtml(code.trimEnd()),
|
|
@@ -173,69 +407,94 @@ function quikdown(markdown, options = {}) {
|
|
|
173
407
|
}
|
|
174
408
|
return placeholder;
|
|
175
409
|
});
|
|
176
|
-
|
|
177
|
-
//
|
|
410
|
+
|
|
411
|
+
// ── Inline code spans ──
|
|
412
|
+
// Matches a single backtick pair: `content`.
|
|
413
|
+
// Content is captured and HTML-escaped immediately.
|
|
178
414
|
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
|
179
415
|
const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
|
|
180
416
|
inlineCodes.push(escapeHtml(code));
|
|
181
417
|
return placeholder;
|
|
182
418
|
});
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
419
|
+
|
|
420
|
+
// ────────────────────────────────────────────────────────────────
|
|
421
|
+
// Phase 2 — HTML Escaping
|
|
422
|
+
// ────────────────────────────────────────────────────────────────
|
|
423
|
+
// All remaining text (everything except code placeholders) is escaped
|
|
424
|
+
// to prevent XSS. The `allow_unsafe_html` option skips this for
|
|
425
|
+
// trusted pipelines that intentionally embed raw HTML.
|
|
426
|
+
|
|
186
427
|
if (!allow_unsafe_html) {
|
|
187
428
|
html = escapeHtml(html);
|
|
188
429
|
}
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
//
|
|
430
|
+
|
|
431
|
+
// ────────────────────────────────────────────────────────────────
|
|
432
|
+
// Phase 3 — Block Scanning + Inline Formatting + Paragraphs
|
|
433
|
+
// ────────────────────────────────────────────────────────────────
|
|
434
|
+
// This is the heart of the lexer rewrite. Instead of applying
|
|
435
|
+
// 10+ global regex passes, we:
|
|
436
|
+
// 1. Process tables (line walker — tables need multi-line lookahead)
|
|
437
|
+
// 2. Scan remaining lines for headings, HR, blockquotes
|
|
438
|
+
// 3. Process lists (line walker — lists need indent tracking)
|
|
439
|
+
// 4. Apply inline formatting to all text content
|
|
440
|
+
// 5. Wrap remaining text in <p> tags
|
|
441
|
+
//
|
|
442
|
+
// Steps 1 and 3 are line-walkers that process the full text in a
|
|
443
|
+
// single pass each. Step 2 replaces global regex with a per-line
|
|
444
|
+
// scanner. Steps 4-5 are applied to the result.
|
|
445
|
+
//
|
|
446
|
+
// Total: 3 structured passes instead of 10+ regex passes.
|
|
447
|
+
|
|
448
|
+
// ── Step 1: Tables ──
|
|
449
|
+
// Tables need multi-line lookahead (header → separator → body rows)
|
|
450
|
+
// so they're handled by a dedicated line-walker first.
|
|
193
451
|
html = processTable(html, getAttr);
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
// Merge consecutive blockquotes
|
|
204
|
-
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
205
|
-
|
|
206
|
-
// Process horizontal rules (allow trailing spaces)
|
|
207
|
-
html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
|
|
208
|
-
|
|
209
|
-
// Process lists
|
|
452
|
+
|
|
453
|
+
// ── Step 2: Headings, HR, Blockquotes ──
|
|
454
|
+
// These are simple line-level constructs. We scan each line once
|
|
455
|
+
// and replace matching lines with their HTML representation.
|
|
456
|
+
html = scanLineBlocks(html, getAttr, dataQd);
|
|
457
|
+
|
|
458
|
+
// ── Step 3: Lists ──
|
|
459
|
+
// Lists need indent-level tracking across lines, so they get their
|
|
460
|
+
// own line-walker.
|
|
210
461
|
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
//
|
|
462
|
+
|
|
463
|
+
// ── Step 4: Inline formatting ──
|
|
464
|
+
// Apply bold, italic, strikethrough, images, links, and autolinks
|
|
465
|
+
// to all text content. This runs on the output of steps 1-3, so
|
|
466
|
+
// it sees text inside headings, blockquotes, table cells, list
|
|
467
|
+
// items, and paragraph text.
|
|
468
|
+
|
|
469
|
+
// Images (must come before links —  vs [text](url))
|
|
215
470
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
216
471
|
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
472
|
+
// Bidirectional attributes are only exercised via quikdown_bd bundle.
|
|
473
|
+
/* istanbul ignore next - bd-only branch */
|
|
217
474
|
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
475
|
+
/* istanbul ignore next - bd-only branch */
|
|
218
476
|
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
219
477
|
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
220
478
|
});
|
|
221
|
-
|
|
222
|
-
// Links
|
|
479
|
+
|
|
480
|
+
// Links
|
|
223
481
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
|
|
224
|
-
// Sanitize URL to prevent XSS
|
|
225
482
|
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
226
483
|
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
227
484
|
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
485
|
+
/* istanbul ignore next - bd-only branch */
|
|
228
486
|
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
229
487
|
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
230
488
|
});
|
|
231
|
-
|
|
232
|
-
// Autolinks
|
|
489
|
+
|
|
490
|
+
// Autolinks — bare https?:// URLs become clickable <a> tags
|
|
233
491
|
html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
|
|
234
492
|
const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
|
|
235
493
|
return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
|
|
236
494
|
});
|
|
237
|
-
|
|
238
|
-
//
|
|
495
|
+
|
|
496
|
+
// Bold, italic, strikethrough
|
|
497
|
+
// Order matters: ** before * (so ** isn't consumed as two *s)
|
|
239
498
|
const inlinePatterns = [
|
|
240
499
|
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
241
500
|
[/__(.+?)__/g, 'strong', '__'],
|
|
@@ -243,60 +502,63 @@ function quikdown(markdown, options = {}) {
|
|
|
243
502
|
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
244
503
|
[/~~(.+?)~~/g, 'del', '~~']
|
|
245
504
|
];
|
|
246
|
-
|
|
247
505
|
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
248
506
|
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
249
507
|
});
|
|
250
|
-
|
|
251
|
-
// Line breaks
|
|
508
|
+
|
|
509
|
+
// ── Step 5: Line breaks + paragraph wrapping ──
|
|
252
510
|
if (lazy_linefeeds) {
|
|
253
|
-
// Lazy linefeeds: single
|
|
511
|
+
// Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
|
|
512
|
+
// • Double newlines → paragraph break
|
|
513
|
+
// • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
|
|
514
|
+
//
|
|
515
|
+
// Strategy: protect block-adjacent newlines with §N§, convert
|
|
516
|
+
// the rest, then restore.
|
|
517
|
+
|
|
254
518
|
const blocks = [];
|
|
255
519
|
let bi = 0;
|
|
256
|
-
|
|
257
|
-
// Protect tables and lists
|
|
520
|
+
|
|
521
|
+
// Protect tables and lists from <br> injection
|
|
258
522
|
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
259
523
|
blocks[bi] = m;
|
|
260
524
|
return `§B${bi++}§`;
|
|
261
525
|
});
|
|
262
|
-
|
|
263
|
-
// Handle paragraphs and block elements
|
|
526
|
+
|
|
264
527
|
html = html.replace(/\n\n+/g, '§P§')
|
|
265
|
-
// After block
|
|
528
|
+
// After block-level closing tags
|
|
266
529
|
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
267
530
|
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
268
|
-
// Before block
|
|
531
|
+
// Before block-level opening tags
|
|
269
532
|
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
270
533
|
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
271
534
|
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
272
|
-
// Convert
|
|
535
|
+
// Convert surviving newlines to <br>
|
|
273
536
|
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
274
537
|
// Restore
|
|
275
538
|
.replace(/§N§/g, '\n')
|
|
276
539
|
.replace(/§P§/g, '</p><p>');
|
|
277
|
-
|
|
540
|
+
|
|
278
541
|
// Restore protected blocks
|
|
279
542
|
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
280
|
-
|
|
543
|
+
|
|
281
544
|
html = '<p>' + html + '</p>';
|
|
282
545
|
} else {
|
|
283
|
-
// Standard: two spaces
|
|
546
|
+
// Standard mode: two trailing spaces → <br>, double newline → new paragraph
|
|
284
547
|
html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
|
|
285
|
-
|
|
286
|
-
// Paragraphs (double newlines)
|
|
287
|
-
// Don't add </p> after block elements (they're not in paragraphs)
|
|
548
|
+
|
|
288
549
|
html = html.replace(/\n\n+/g, (match, offset) => {
|
|
289
|
-
// Check if we're after a block element closing tag
|
|
290
550
|
const before = html.substring(0, offset);
|
|
291
551
|
if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
|
|
292
|
-
return '<p>';
|
|
552
|
+
return '<p>';
|
|
293
553
|
}
|
|
294
|
-
return '</p><p>';
|
|
554
|
+
return '</p><p>';
|
|
295
555
|
});
|
|
296
556
|
html = '<p>' + html + '</p>';
|
|
297
557
|
}
|
|
298
|
-
|
|
299
|
-
//
|
|
558
|
+
|
|
559
|
+
// ── Step 6: Cleanup ──
|
|
560
|
+
// Remove <p> wrappers that accidentally enclose block elements.
|
|
561
|
+
// This is simpler than trying to prevent them during wrapping.
|
|
300
562
|
const cleanupPatterns = [
|
|
301
563
|
[/<p><\/p>/g, ''],
|
|
302
564
|
[/<p>(<h[1-6][^>]*>)/g, '$1'],
|
|
@@ -312,65 +574,152 @@ function quikdown(markdown, options = {}) {
|
|
|
312
574
|
[/(<\/pre>)<\/p>/g, '$1'],
|
|
313
575
|
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
|
|
314
576
|
];
|
|
315
|
-
|
|
316
577
|
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
317
578
|
html = html.replace(pattern, replacement);
|
|
318
579
|
});
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
// When a paragraph follows a block element, ensure it has opening <p>
|
|
580
|
+
|
|
581
|
+
// When a block element is followed by a newline and then text, open a <p>.
|
|
322
582
|
html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
//
|
|
583
|
+
|
|
584
|
+
// ────────────────────────────────────────────────────────────────
|
|
585
|
+
// Phase 4 — Code Restoration
|
|
586
|
+
// ────────────────────────────────────────────────────────────────
|
|
587
|
+
// Replace placeholders with rendered HTML. For fenced blocks this
|
|
588
|
+
// means wrapping in <pre><code>…</code></pre> (or calling the
|
|
589
|
+
// fence_plugin). For inline code it means <code>…</code>.
|
|
590
|
+
|
|
327
591
|
codeBlocks.forEach((block, i) => {
|
|
328
592
|
let replacement;
|
|
329
|
-
|
|
593
|
+
|
|
330
594
|
if (block.custom && fence_plugin && fence_plugin.render) {
|
|
331
|
-
//
|
|
595
|
+
// Delegate to the user-provided fence plugin.
|
|
332
596
|
replacement = fence_plugin.render(block.code, block.lang);
|
|
333
|
-
|
|
334
|
-
// If plugin returns undefined, fall back to default rendering
|
|
597
|
+
|
|
335
598
|
if (replacement === undefined) {
|
|
599
|
+
// Plugin declined — fall back to default rendering.
|
|
336
600
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
337
601
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
602
|
+
/* istanbul ignore next - bd-only branch */
|
|
338
603
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
604
|
+
/* istanbul ignore next - bd-only branch */
|
|
339
605
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
340
606
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
341
|
-
} else if (bidirectional) {
|
|
342
|
-
//
|
|
343
|
-
replacement = replacement.replace(/^<(\w+)/,
|
|
607
|
+
} else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
|
|
608
|
+
// Plugin returned HTML — inject data attributes for roundtrip.
|
|
609
|
+
replacement = replacement.replace(/^<(\w+)/,
|
|
344
610
|
`<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
|
|
345
611
|
}
|
|
346
612
|
} else {
|
|
347
|
-
// Default rendering
|
|
613
|
+
// Default rendering — wrap in <pre><code>.
|
|
348
614
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
349
615
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
616
|
+
/* istanbul ignore next - bd-only branch */
|
|
350
617
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
618
|
+
/* istanbul ignore next - bd-only branch */
|
|
351
619
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
352
620
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
353
621
|
}
|
|
354
|
-
|
|
622
|
+
|
|
355
623
|
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
356
624
|
html = html.replace(placeholder, replacement);
|
|
357
625
|
});
|
|
358
|
-
|
|
359
|
-
// Restore inline code
|
|
626
|
+
|
|
627
|
+
// Restore inline code spans
|
|
360
628
|
inlineCodes.forEach((code, i) => {
|
|
361
629
|
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
362
630
|
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
363
631
|
});
|
|
364
|
-
|
|
632
|
+
|
|
365
633
|
return html.trim();
|
|
366
634
|
}
|
|
367
635
|
|
|
636
|
+
// ════════════════════════════════════════════════════════════════════
|
|
637
|
+
// Block-level line scanner
|
|
638
|
+
// ════════════════════════════════════════════════════════════════════
|
|
639
|
+
|
|
368
640
|
/**
|
|
369
|
-
*
|
|
641
|
+
* scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
|
|
642
|
+
*
|
|
643
|
+
* Walks the text line by line. For each line it checks (in order):
|
|
644
|
+
* 1. Heading — starts with 1-6 '#' followed by a space
|
|
645
|
+
* 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
|
|
646
|
+
* 3. Blockquote — starts with '> ' (the > was already HTML-escaped)
|
|
647
|
+
*
|
|
648
|
+
* Lines that don't match any block pattern are passed through unchanged.
|
|
649
|
+
*
|
|
650
|
+
* This replaces three separate global regex passes from the pre-1.2.8
|
|
651
|
+
* architecture with one structured scan.
|
|
652
|
+
*
|
|
653
|
+
* @param {string} text The document text (HTML-escaped, code extracted)
|
|
654
|
+
* @param {Function} getAttr Attribute factory (class or style)
|
|
655
|
+
* @param {Function} dataQd Bidirectional marker factory
|
|
656
|
+
* @returns {string} Text with block-level elements rendered
|
|
657
|
+
*/
|
|
658
|
+
function scanLineBlocks(text, getAttr, dataQd) {
|
|
659
|
+
const lines = text.split('\n');
|
|
660
|
+
const result = [];
|
|
661
|
+
let i = 0;
|
|
662
|
+
|
|
663
|
+
while (i < lines.length) {
|
|
664
|
+
const line = lines[i];
|
|
665
|
+
|
|
666
|
+
// ── Heading ──
|
|
667
|
+
// Count leading '#' characters. Valid heading: 1-6 hashes then a space.
|
|
668
|
+
// Example: "## Hello World ##" → <h2>Hello World</h2>
|
|
669
|
+
let hashCount = 0;
|
|
670
|
+
while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
|
|
671
|
+
hashCount++;
|
|
672
|
+
}
|
|
673
|
+
if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
|
|
674
|
+
// Extract content after "# " and strip trailing hashes
|
|
675
|
+
const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
|
|
676
|
+
const tag = 'h' + hashCount;
|
|
677
|
+
result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
|
|
678
|
+
i++;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Horizontal Rule ──
|
|
683
|
+
// Three or more dashes, optional trailing whitespace, nothing else.
|
|
684
|
+
if (isDashHRLine(line)) {
|
|
685
|
+
result.push(`<hr${getAttr('hr')}>`);
|
|
686
|
+
i++;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── Blockquote ──
|
|
691
|
+
// After Phase 2, the '>' character has been escaped to '>'.
|
|
692
|
+
// Pattern: "> content" or merged consecutive blockquotes.
|
|
693
|
+
if (/^>\s+/.test(line)) {
|
|
694
|
+
result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^>\s+/, '')}</blockquote>`);
|
|
695
|
+
i++;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ── Pass-through ──
|
|
700
|
+
result.push(line);
|
|
701
|
+
i++;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Merge consecutive blockquotes into a single element.
|
|
705
|
+
// <blockquote>A</blockquote>\n<blockquote>B</blockquote>
|
|
706
|
+
// → <blockquote>A\nB</blockquote>
|
|
707
|
+
let joined = result.join('\n');
|
|
708
|
+
joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
709
|
+
return joined;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ════════════════════════════════════════════════════════════════════
|
|
713
|
+
// Table processing (line walker)
|
|
714
|
+
// ════════════════════════════════════════════════════════════════════
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Inline markdown formatter for table cells.
|
|
718
|
+
* Handles bold, italic, strikethrough, and code within cell text.
|
|
719
|
+
* Links / images / autolinks are handled by the global inline pass
|
|
720
|
+
* (Phase 3 Step 4) which runs after table processing.
|
|
370
721
|
*/
|
|
371
722
|
function processInlineMarkdown(text, getAttr) {
|
|
372
|
-
|
|
373
|
-
// Process inline formatting patterns
|
|
374
723
|
const patterns = [
|
|
375
724
|
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
376
725
|
[/__(.+?)__/g, 'strong'],
|
|
@@ -379,27 +728,32 @@ function processInlineMarkdown(text, getAttr) {
|
|
|
379
728
|
[/~~(.+?)~~/g, 'del'],
|
|
380
729
|
[/`([^`]+)`/g, 'code']
|
|
381
730
|
];
|
|
382
|
-
|
|
383
731
|
patterns.forEach(([pattern, tag]) => {
|
|
384
732
|
text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
385
733
|
});
|
|
386
|
-
|
|
387
734
|
return text;
|
|
388
735
|
}
|
|
389
736
|
|
|
390
737
|
/**
|
|
391
|
-
*
|
|
738
|
+
* processTable — line walker for markdown tables
|
|
739
|
+
*
|
|
740
|
+
* Walks through lines looking for runs of pipe-containing lines.
|
|
741
|
+
* Each run is validated (must contain a separator row: |---|---|)
|
|
742
|
+
* and rendered as an HTML <table>. Invalid runs are restored as-is.
|
|
743
|
+
*
|
|
744
|
+
* @param {string} text Full document text
|
|
745
|
+
* @param {Function} getAttr Attribute factory
|
|
746
|
+
* @returns {string} Text with tables rendered
|
|
392
747
|
*/
|
|
393
748
|
function processTable(text, getAttr) {
|
|
394
749
|
const lines = text.split('\n');
|
|
395
750
|
const result = [];
|
|
396
751
|
let inTable = false;
|
|
397
752
|
let tableLines = [];
|
|
398
|
-
|
|
753
|
+
|
|
399
754
|
for (let i = 0; i < lines.length; i++) {
|
|
400
755
|
const line = lines[i].trim();
|
|
401
|
-
|
|
402
|
-
// Check if this line looks like a table row (with or without trailing |)
|
|
756
|
+
|
|
403
757
|
if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
|
|
404
758
|
if (!inTable) {
|
|
405
759
|
inTable = true;
|
|
@@ -407,14 +761,11 @@ function processTable(text, getAttr) {
|
|
|
407
761
|
}
|
|
408
762
|
tableLines.push(line);
|
|
409
763
|
} else {
|
|
410
|
-
// Not a table line
|
|
411
764
|
if (inTable) {
|
|
412
|
-
// Process the accumulated table
|
|
413
765
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
414
766
|
if (tableHtml) {
|
|
415
767
|
result.push(tableHtml);
|
|
416
768
|
} else {
|
|
417
|
-
// Not a valid table, restore original lines
|
|
418
769
|
result.push(...tableLines);
|
|
419
770
|
}
|
|
420
771
|
inTable = false;
|
|
@@ -423,8 +774,8 @@ function processTable(text, getAttr) {
|
|
|
423
774
|
result.push(lines[i]);
|
|
424
775
|
}
|
|
425
776
|
}
|
|
426
|
-
|
|
427
|
-
// Handle table at end of
|
|
777
|
+
|
|
778
|
+
// Handle table at end of document
|
|
428
779
|
if (inTable && tableLines.length > 0) {
|
|
429
780
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
430
781
|
if (tableHtml) {
|
|
@@ -433,35 +784,35 @@ function processTable(text, getAttr) {
|
|
|
433
784
|
result.push(...tableLines);
|
|
434
785
|
}
|
|
435
786
|
}
|
|
436
|
-
|
|
787
|
+
|
|
437
788
|
return result.join('\n');
|
|
438
789
|
}
|
|
439
790
|
|
|
440
791
|
/**
|
|
441
|
-
*
|
|
792
|
+
* buildTable — validate and render a table from accumulated lines
|
|
793
|
+
*
|
|
794
|
+
* @param {string[]} lines Array of pipe-containing lines
|
|
795
|
+
* @param {Function} getAttr Attribute factory
|
|
796
|
+
* @returns {string|null} HTML table string, or null if invalid
|
|
442
797
|
*/
|
|
443
798
|
function buildTable(lines, getAttr) {
|
|
444
|
-
|
|
445
799
|
if (lines.length < 2) return null;
|
|
446
|
-
|
|
447
|
-
//
|
|
800
|
+
|
|
801
|
+
// Find the separator row (---|---|)
|
|
448
802
|
let separatorIndex = -1;
|
|
449
803
|
for (let i = 1; i < lines.length; i++) {
|
|
450
|
-
// Support separator with or without leading/trailing pipes
|
|
451
804
|
if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
|
|
452
805
|
separatorIndex = i;
|
|
453
806
|
break;
|
|
454
807
|
}
|
|
455
808
|
}
|
|
456
|
-
|
|
457
809
|
if (separatorIndex === -1) return null;
|
|
458
|
-
|
|
810
|
+
|
|
459
811
|
const headerLines = lines.slice(0, separatorIndex);
|
|
460
812
|
const bodyLines = lines.slice(separatorIndex + 1);
|
|
461
|
-
|
|
462
|
-
// Parse alignment from separator
|
|
813
|
+
|
|
814
|
+
// Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
|
|
463
815
|
const separator = lines[separatorIndex];
|
|
464
|
-
// Handle pipes at start/end or not
|
|
465
816
|
const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
466
817
|
const alignments = separatorCells.map(cell => {
|
|
467
818
|
const trimmed = cell.trim();
|
|
@@ -469,31 +820,28 @@ function buildTable(lines, getAttr) {
|
|
|
469
820
|
if (trimmed.endsWith(':')) return 'right';
|
|
470
821
|
return 'left';
|
|
471
822
|
});
|
|
472
|
-
|
|
823
|
+
|
|
473
824
|
let html = `<table${getAttr('table')}>\n`;
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
825
|
+
|
|
826
|
+
// Header
|
|
477
827
|
html += `<thead${getAttr('thead')}>\n`;
|
|
478
828
|
headerLines.forEach(line => {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
html += '</tr>\n';
|
|
829
|
+
html += `<tr${getAttr('tr')}>\n`;
|
|
830
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
831
|
+
cells.forEach((cell, i) => {
|
|
832
|
+
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
833
|
+
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
|
|
834
|
+
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
835
|
+
});
|
|
836
|
+
html += '</tr>\n';
|
|
488
837
|
});
|
|
489
838
|
html += '</thead>\n';
|
|
490
|
-
|
|
491
|
-
//
|
|
839
|
+
|
|
840
|
+
// Body
|
|
492
841
|
if (bodyLines.length > 0) {
|
|
493
842
|
html += `<tbody${getAttr('tbody')}>\n`;
|
|
494
843
|
bodyLines.forEach(line => {
|
|
495
844
|
html += `<tr${getAttr('tr')}>\n`;
|
|
496
|
-
// Handle pipes at start/end or not
|
|
497
845
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
498
846
|
cells.forEach((cell, i) => {
|
|
499
847
|
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
@@ -504,66 +852,81 @@ function buildTable(lines, getAttr) {
|
|
|
504
852
|
});
|
|
505
853
|
html += '</tbody>\n';
|
|
506
854
|
}
|
|
507
|
-
|
|
855
|
+
|
|
508
856
|
html += '</table>';
|
|
509
857
|
return html;
|
|
510
858
|
}
|
|
511
859
|
|
|
860
|
+
// ════════════════════════════════════════════════════════════════════
|
|
861
|
+
// List processing (line walker)
|
|
862
|
+
// ════════════════════════════════════════════════════════════════════
|
|
863
|
+
|
|
512
864
|
/**
|
|
513
|
-
*
|
|
865
|
+
* processLists — line walker for ordered, unordered, and task lists
|
|
866
|
+
*
|
|
867
|
+
* Scans each line for list markers (-, *, +, 1., 2., etc.) with
|
|
868
|
+
* optional leading indentation for nesting. Non-list lines close
|
|
869
|
+
* any open lists and pass through unchanged.
|
|
870
|
+
*
|
|
871
|
+
* Task lists (- [ ] / - [x]) are detected and rendered with
|
|
872
|
+
* checkbox inputs.
|
|
873
|
+
*
|
|
874
|
+
* @param {string} text Full document text
|
|
875
|
+
* @param {Function} getAttr Attribute factory
|
|
876
|
+
* @param {boolean} inline_styles Whether to use inline styles
|
|
877
|
+
* @param {boolean} bidirectional Whether to add data-qd markers
|
|
878
|
+
* @returns {string} Text with lists rendered
|
|
514
879
|
*/
|
|
515
880
|
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
516
|
-
|
|
517
881
|
const lines = text.split('\n');
|
|
518
882
|
const result = [];
|
|
519
|
-
const listStack = [];
|
|
520
|
-
|
|
883
|
+
const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
|
|
884
|
+
|
|
521
885
|
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
|
|
522
886
|
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
|
|
523
887
|
// callback is defensive-only and never actually fires in practice.
|
|
888
|
+
/* istanbul ignore next - defensive: list markers never trigger escaping */
|
|
524
889
|
const escapeHtml = (text) => text.replace(/[&<>"']/g,
|
|
525
890
|
/* istanbul ignore next - defensive: list markers never contain HTML specials */
|
|
526
891
|
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
527
892
|
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
|
|
528
893
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
529
|
-
|
|
894
|
+
|
|
530
895
|
for (let i = 0; i < lines.length; i++) {
|
|
531
896
|
const line = lines[i];
|
|
532
897
|
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
533
|
-
|
|
898
|
+
|
|
534
899
|
if (match) {
|
|
535
900
|
const [, indent, marker, content] = match;
|
|
536
901
|
const level = Math.floor(indent.length / 2);
|
|
537
902
|
const isOrdered = /^\d+\./.test(marker);
|
|
538
903
|
const listType = isOrdered ? 'ol' : 'ul';
|
|
539
|
-
|
|
540
|
-
//
|
|
904
|
+
|
|
905
|
+
// Task list detection (only in unordered lists)
|
|
541
906
|
let listItemContent = content;
|
|
542
907
|
let taskListClass = '';
|
|
543
908
|
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
|
|
544
909
|
if (taskMatch && !isOrdered) {
|
|
545
910
|
const [, checked, taskContent] = taskMatch;
|
|
546
911
|
const isChecked = checked.toLowerCase() === 'x';
|
|
547
|
-
const checkboxAttr = inline_styles
|
|
548
|
-
? ' style="margin-right:.5em"'
|
|
912
|
+
const checkboxAttr = inline_styles
|
|
913
|
+
? ' style="margin-right:.5em"'
|
|
549
914
|
: ` class="${CLASS_PREFIX}task-checkbox"`;
|
|
550
915
|
listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
|
|
551
916
|
taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
|
|
552
917
|
}
|
|
553
|
-
|
|
554
|
-
// Close deeper levels
|
|
918
|
+
|
|
919
|
+
// Close deeper nesting levels
|
|
555
920
|
while (listStack.length > level + 1) {
|
|
556
921
|
const list = listStack.pop();
|
|
557
922
|
result.push(`</${list.type}>`);
|
|
558
923
|
}
|
|
559
|
-
|
|
560
|
-
// Open new
|
|
924
|
+
|
|
925
|
+
// Open new list or switch type at current level
|
|
561
926
|
if (listStack.length === level) {
|
|
562
|
-
// Need to open a new list
|
|
563
927
|
listStack.push({ type: listType, level });
|
|
564
928
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
565
929
|
} else if (listStack.length === level + 1) {
|
|
566
|
-
// Check if we need to switch list type
|
|
567
930
|
const currentList = listStack[listStack.length - 1];
|
|
568
931
|
if (currentList.type !== listType) {
|
|
569
932
|
result.push(`</${currentList.type}>`);
|
|
@@ -572,11 +935,11 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
|
572
935
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
573
936
|
}
|
|
574
937
|
}
|
|
575
|
-
|
|
938
|
+
|
|
576
939
|
const liAttr = taskListClass || getAttr('li');
|
|
577
940
|
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
578
941
|
} else {
|
|
579
|
-
// Not a list item
|
|
942
|
+
// Not a list item — close all open lists
|
|
580
943
|
while (listStack.length > 0) {
|
|
581
944
|
const list = listStack.pop();
|
|
582
945
|
result.push(`</${list.type}>`);
|
|
@@ -584,76 +947,76 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
|
584
947
|
result.push(line);
|
|
585
948
|
}
|
|
586
949
|
}
|
|
587
|
-
|
|
588
|
-
// Close any remaining lists
|
|
950
|
+
|
|
951
|
+
// Close any remaining open lists
|
|
589
952
|
while (listStack.length > 0) {
|
|
590
953
|
const list = listStack.pop();
|
|
591
954
|
result.push(`</${list.type}>`);
|
|
592
955
|
}
|
|
593
|
-
|
|
956
|
+
|
|
594
957
|
return result.join('\n');
|
|
595
958
|
}
|
|
596
959
|
|
|
960
|
+
// ════════════════════════════════════════════════════════════════════
|
|
961
|
+
// Static API
|
|
962
|
+
// ════════════════════════════════════════════════════════════════════
|
|
963
|
+
|
|
597
964
|
/**
|
|
598
|
-
* Emit CSS
|
|
599
|
-
*
|
|
600
|
-
* @param {string}
|
|
601
|
-
* @
|
|
965
|
+
* Emit CSS rules for all quikdown elements.
|
|
966
|
+
*
|
|
967
|
+
* @param {string} prefix Class prefix (default: 'quikdown-')
|
|
968
|
+
* @param {string} theme 'light' (default) or 'dark'
|
|
969
|
+
* @returns {string} CSS text
|
|
602
970
|
*/
|
|
603
971
|
quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
604
972
|
const styles = QUIKDOWN_STYLES;
|
|
605
|
-
|
|
606
|
-
// Define theme color overrides
|
|
973
|
+
|
|
607
974
|
const themeOverrides = {
|
|
608
975
|
dark: {
|
|
609
|
-
'#f4f4f4': '#2a2a2a',
|
|
610
|
-
'#f0f0f0': '#2a2a2a',
|
|
611
|
-
'#f2f2f2': '#2a2a2a',
|
|
612
|
-
'#ddd': '#3a3a3a',
|
|
613
|
-
'#06c': '#6db3f2',
|
|
976
|
+
'#f4f4f4': '#2a2a2a', // pre background
|
|
977
|
+
'#f0f0f0': '#2a2a2a', // code background
|
|
978
|
+
'#f2f2f2': '#2a2a2a', // th background
|
|
979
|
+
'#ddd': '#3a3a3a', // borders
|
|
980
|
+
'#06c': '#6db3f2', // links
|
|
614
981
|
_textColor: '#e0e0e0'
|
|
615
982
|
},
|
|
616
983
|
light: {
|
|
617
|
-
_textColor: '#333'
|
|
984
|
+
_textColor: '#333'
|
|
618
985
|
}
|
|
619
986
|
};
|
|
620
|
-
|
|
987
|
+
|
|
621
988
|
let css = '';
|
|
622
989
|
for (const [tag, style] of Object.entries(styles)) {
|
|
623
990
|
let themedStyle = style;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (!oldColor.startsWith('_')) {
|
|
630
|
-
themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Add text color for certain elements in dark theme
|
|
635
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
636
|
-
if (needsTextColor.includes(tag)) {
|
|
637
|
-
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
638
|
-
}
|
|
639
|
-
} else if (theme === 'light' && themeOverrides.light) {
|
|
640
|
-
// Add explicit text color for light theme elements too
|
|
641
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
642
|
-
if (needsTextColor.includes(tag)) {
|
|
643
|
-
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
991
|
+
|
|
992
|
+
if (theme === 'dark' && themeOverrides.dark) {
|
|
993
|
+
for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
|
|
994
|
+
if (!oldColor.startsWith('_')) {
|
|
995
|
+
themedStyle = themedStyle.replaceAll(oldColor, newColor);
|
|
644
996
|
}
|
|
645
997
|
}
|
|
646
|
-
|
|
998
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
999
|
+
if (needsTextColor.includes(tag)) {
|
|
1000
|
+
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
1001
|
+
}
|
|
1002
|
+
} else if (theme === 'light' && themeOverrides.light) {
|
|
1003
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1004
|
+
if (needsTextColor.includes(tag)) {
|
|
1005
|
+
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
647
1009
|
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
648
1010
|
}
|
|
649
|
-
|
|
1011
|
+
|
|
650
1012
|
return css;
|
|
651
1013
|
};
|
|
652
1014
|
|
|
653
1015
|
/**
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
* @
|
|
1016
|
+
* Create a pre-configured parser with baked-in options.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {Object} options Options to bake in
|
|
1019
|
+
* @returns {Function} Configured quikdown(markdown) function
|
|
657
1020
|
*/
|
|
658
1021
|
quikdown.configure = function(options) {
|
|
659
1022
|
return function(markdown) {
|
|
@@ -661,18 +1024,18 @@ quikdown.configure = function(options) {
|
|
|
661
1024
|
};
|
|
662
1025
|
};
|
|
663
1026
|
|
|
664
|
-
/**
|
|
665
|
-
* Version information
|
|
666
|
-
*/
|
|
1027
|
+
/** Semantic version (injected at build time) */
|
|
667
1028
|
quikdown.version = quikdownVersion;
|
|
668
1029
|
|
|
669
|
-
//
|
|
1030
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1031
|
+
// Exports
|
|
1032
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1033
|
+
|
|
670
1034
|
/* istanbul ignore next */
|
|
671
1035
|
if (typeof module !== 'undefined' && module.exports) {
|
|
672
1036
|
module.exports = quikdown;
|
|
673
1037
|
}
|
|
674
1038
|
|
|
675
|
-
// For browser global
|
|
676
1039
|
/* istanbul ignore next */
|
|
677
1040
|
if (typeof window !== 'undefined') {
|
|
678
1041
|
window.quikdown = quikdown;
|
|
@@ -2154,8 +2517,8 @@ async function getRenderedContent(previewPanel) {
|
|
|
2154
2517
|
if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
|
|
2155
2518
|
ctx.drawImage(tile, x, y, w + 1, h + 1);
|
|
2156
2519
|
}
|
|
2157
|
-
} catch (
|
|
2158
|
-
console.warn('Failed to draw tile:',
|
|
2520
|
+
} catch (_e) {
|
|
2521
|
+
console.warn('Failed to draw tile:', _e);
|
|
2159
2522
|
}
|
|
2160
2523
|
}
|
|
2161
2524
|
|
|
@@ -2174,8 +2537,8 @@ async function getRenderedContent(previewPanel) {
|
|
|
2174
2537
|
const h = Math.round(r.height);
|
|
2175
2538
|
const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
|
|
2176
2539
|
if (w > 0 && h > 0 && overlaps) ctx.drawImage(img, x, y, w, h);
|
|
2177
|
-
} catch (
|
|
2178
|
-
console.warn('Failed to draw overlay SVG:',
|
|
2540
|
+
} catch (_e) {
|
|
2541
|
+
console.warn('Failed to draw overlay SVG:', _e);
|
|
2179
2542
|
}
|
|
2180
2543
|
}
|
|
2181
2544
|
|
|
@@ -2193,8 +2556,8 @@ async function getRenderedContent(previewPanel) {
|
|
|
2193
2556
|
if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
|
|
2194
2557
|
ctx.drawImage(icon, x, y, w, h);
|
|
2195
2558
|
}
|
|
2196
|
-
} catch (
|
|
2197
|
-
console.warn('Failed to draw marker icon:',
|
|
2559
|
+
} catch (_e) {
|
|
2560
|
+
console.warn('Failed to draw marker icon:', _e);
|
|
2198
2561
|
}
|
|
2199
2562
|
}
|
|
2200
2563
|
|
|
@@ -2622,13 +2985,28 @@ const FENCE_LIBRARIES = {
|
|
|
2622
2985
|
},
|
|
2623
2986
|
math: {
|
|
2624
2987
|
check: () => typeof window.MathJax !== 'undefined',
|
|
2625
|
-
script: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
|
|
2988
|
+
script: 'https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js',
|
|
2626
2989
|
beforeLoad: () => {
|
|
2627
2990
|
// Configure MathJax before loading (must be set on window before script runs)
|
|
2991
|
+
// Must match the config in ensureMathJaxLoaded() for consistent behavior
|
|
2628
2992
|
if (!window.MathJax) {
|
|
2629
2993
|
window.MathJax = {
|
|
2630
|
-
|
|
2631
|
-
|
|
2994
|
+
loader: { load: ['input/tex', 'output/svg'] },
|
|
2995
|
+
tex: {
|
|
2996
|
+
packages: { '[+]': ['ams'] },
|
|
2997
|
+
inlineMath: [['$', '$'], ['\\(', '\\)']],
|
|
2998
|
+
displayMath: [['$$', '$$'], ['\\[', '\\]']],
|
|
2999
|
+
processEscapes: true,
|
|
3000
|
+
processEnvironments: true
|
|
3001
|
+
},
|
|
3002
|
+
options: {
|
|
3003
|
+
renderActions: { addMenu: [] },
|
|
3004
|
+
ignoreHtmlClass: 'tex2jax_ignore',
|
|
3005
|
+
processHtmlClass: 'tex2jax_process'
|
|
3006
|
+
},
|
|
3007
|
+
svg: {
|
|
3008
|
+
fontCache: 'none' // self-contained SVGs (required for copy-rendered)
|
|
3009
|
+
},
|
|
2632
3010
|
startup: { typeset: false }
|
|
2633
3011
|
};
|
|
2634
3012
|
}
|
|
@@ -2765,7 +3143,14 @@ class QuikdownEditor {
|
|
|
2765
3143
|
btn.title = `Switch to ${modeLabels[mode]} view`;
|
|
2766
3144
|
toolbar.appendChild(btn);
|
|
2767
3145
|
});
|
|
2768
|
-
|
|
3146
|
+
|
|
3147
|
+
// Mobile split toggle (hidden by default, shown via CSS on narrow viewports)
|
|
3148
|
+
const splitToggle = document.createElement('button');
|
|
3149
|
+
splitToggle.className = 'qde-btn qde-split-toggle';
|
|
3150
|
+
splitToggle.textContent = 'Preview';
|
|
3151
|
+
splitToggle.title = 'Toggle between source and preview in split mode';
|
|
3152
|
+
toolbar.appendChild(splitToggle);
|
|
3153
|
+
|
|
2769
3154
|
// Undo/Redo buttons (if enabled)
|
|
2770
3155
|
if (this.options.showUndoRedo) {
|
|
2771
3156
|
const undoBtn = document.createElement('button');
|
|
@@ -2850,6 +3235,7 @@ class QuikdownEditor {
|
|
|
2850
3235
|
.qde-toolbar {
|
|
2851
3236
|
display: flex;
|
|
2852
3237
|
align-items: center;
|
|
3238
|
+
flex-wrap: wrap;
|
|
2853
3239
|
padding: 8px;
|
|
2854
3240
|
background: #f5f5f5;
|
|
2855
3241
|
border-bottom: 1px solid #ddd;
|
|
@@ -3235,19 +3621,45 @@ class QuikdownEditor {
|
|
|
3235
3621
|
background: #252525;
|
|
3236
3622
|
}
|
|
3237
3623
|
|
|
3238
|
-
/* Mobile
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3624
|
+
/* Mobile split toggle — hidden by default */
|
|
3625
|
+
.qde-split-toggle { display: none; }
|
|
3626
|
+
|
|
3627
|
+
/* Mobile responsive — compact toolbar for all small screens */
|
|
3628
|
+
@media (max-width: 720px) {
|
|
3629
|
+
.qde-toolbar {
|
|
3630
|
+
padding: 6px;
|
|
3631
|
+
gap: 3px;
|
|
3242
3632
|
}
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
border-bottom: 1px solid #ddd;
|
|
3633
|
+
.qde-btn {
|
|
3634
|
+
padding: 5px 8px;
|
|
3635
|
+
font-size: 12px;
|
|
3247
3636
|
}
|
|
3248
|
-
.qde-
|
|
3249
|
-
|
|
3637
|
+
.qde-source, .qde-preview {
|
|
3638
|
+
padding: 10px;
|
|
3250
3639
|
}
|
|
3640
|
+
.qde-textarea {
|
|
3641
|
+
padding: 10px;
|
|
3642
|
+
}
|
|
3643
|
+
/* Undo/Redo: show circular arrows instead of text */
|
|
3644
|
+
.qde-btn[data-action="undo"] { font-size: 0; }
|
|
3645
|
+
.qde-btn[data-action="undo"]::after { content: "\\21B6"; font-size: 14px; }
|
|
3646
|
+
.qde-btn[data-action="redo"] { font-size: 0; }
|
|
3647
|
+
.qde-btn[data-action="redo"]::after { content: "\\21B7"; font-size: 14px; }
|
|
3648
|
+
/* Hide secondary utility buttons to reduce clutter */
|
|
3649
|
+
.qde-btn[data-action="remove-hr"],
|
|
3650
|
+
.qde-btn[data-action="lazy-linefeeds"],
|
|
3651
|
+
.qde-btn[data-action="copy-rendered"] { display: none; }
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
/* Portrait mobile: drop split mode entirely */
|
|
3655
|
+
@media (max-width: 720px) and (orientation: portrait) {
|
|
3656
|
+
.qde-btn[data-mode="split"] { display: none; }
|
|
3657
|
+
.qde-split-toggle { display: none !important; }
|
|
3658
|
+
/* Fallback: if still in split mode, show source only */
|
|
3659
|
+
.qde-mode-split .qde-source { border-right: none; }
|
|
3660
|
+
.qde-mode-split .qde-preview { display: none; }
|
|
3661
|
+
.qde-mode-split.qde-split-preview .qde-source { display: none; }
|
|
3662
|
+
.qde-mode-split.qde-split-preview .qde-preview { display: block; }
|
|
3251
3663
|
}
|
|
3252
3664
|
`;
|
|
3253
3665
|
|
|
@@ -3273,7 +3685,15 @@ class QuikdownEditor {
|
|
|
3273
3685
|
this.toolbar.addEventListener('click', (e) => {
|
|
3274
3686
|
const btn = e.target.closest('.qde-btn');
|
|
3275
3687
|
if (!btn) return;
|
|
3276
|
-
|
|
3688
|
+
|
|
3689
|
+
// Mobile split-toggle button
|
|
3690
|
+
if (btn.classList.contains('qde-split-toggle')) {
|
|
3691
|
+
this.container.classList.toggle('qde-split-preview');
|
|
3692
|
+
const showingPreview = this.container.classList.contains('qde-split-preview');
|
|
3693
|
+
btn.textContent = showingPreview ? 'Source' : 'Preview';
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3277
3697
|
if (btn.dataset.mode) {
|
|
3278
3698
|
this.setMode(btn.dataset.mode);
|
|
3279
3699
|
} else if (btn.dataset.action) {
|
|
@@ -3316,8 +3736,22 @@ class QuikdownEditor {
|
|
|
3316
3736
|
}
|
|
3317
3737
|
}
|
|
3318
3738
|
});
|
|
3739
|
+
|
|
3740
|
+
// On narrow portrait viewports, auto-switch out of split mode to source.
|
|
3741
|
+
// Split is kept available on landscape where there is enough width.
|
|
3742
|
+
if (typeof window.matchMedia === 'function') {
|
|
3743
|
+
const portraitQuery = window.matchMedia('(max-width: 720px) and (orientation: portrait)');
|
|
3744
|
+
const switchIfPortrait = () => {
|
|
3745
|
+
if (portraitQuery.matches && this.currentMode === 'split') {
|
|
3746
|
+
this.setMode('source');
|
|
3747
|
+
}
|
|
3748
|
+
};
|
|
3749
|
+
// Check after init's setMode() has run (microtask fires after sync code).
|
|
3750
|
+
Promise.resolve().then(switchIfPortrait);
|
|
3751
|
+
portraitQuery.addEventListener('change', switchIfPortrait);
|
|
3752
|
+
}
|
|
3319
3753
|
}
|
|
3320
|
-
|
|
3754
|
+
|
|
3321
3755
|
/**
|
|
3322
3756
|
* Handle source textarea input
|
|
3323
3757
|
*/
|
|
@@ -4424,6 +4858,7 @@ class QuikdownEditor {
|
|
|
4424
4858
|
// below would otherwise wipe it out — this used to be a no-op bug
|
|
4425
4859
|
// where dark mode was lost on every setMode call).
|
|
4426
4860
|
const wasDark = this.container.classList.contains('qde-dark');
|
|
4861
|
+
const previousMode = this.currentMode;
|
|
4427
4862
|
|
|
4428
4863
|
this.currentMode = mode;
|
|
4429
4864
|
this.container.className = `qde-container qde-mode-${mode}`;
|
|
@@ -4431,18 +4866,37 @@ class QuikdownEditor {
|
|
|
4431
4866
|
this.container.classList.add('qde-dark');
|
|
4432
4867
|
}
|
|
4433
4868
|
|
|
4869
|
+
// Reset mobile split-toggle button text
|
|
4870
|
+
if (this.toolbar) {
|
|
4871
|
+
const splitToggle = this.toolbar.querySelector('.qde-split-toggle');
|
|
4872
|
+
if (splitToggle) {
|
|
4873
|
+
splitToggle.textContent = 'Preview';
|
|
4874
|
+
}
|
|
4875
|
+
}
|
|
4876
|
+
|
|
4434
4877
|
// Update toolbar buttons
|
|
4435
4878
|
if (this.toolbar) {
|
|
4436
4879
|
this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
|
|
4437
4880
|
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
4438
4881
|
});
|
|
4439
4882
|
}
|
|
4440
|
-
|
|
4441
|
-
//
|
|
4442
|
-
|
|
4883
|
+
|
|
4884
|
+
// If the preview was hidden (source-only) it may have missed content
|
|
4885
|
+
// updates. Re-render it now, including MathJax typesetting.
|
|
4886
|
+
// Do NOT re-render if the preview was already visible — that would
|
|
4887
|
+
// destroy MathJax-typeset SVG output with raw pre-typeset HTML.
|
|
4888
|
+
if (mode !== 'source' && previousMode === 'source' && this._html) {
|
|
4889
|
+
this.previewPanel.innerHTML = this._html;
|
|
4443
4890
|
setTimeout(() => this.makeFencesNonEditable(), 0);
|
|
4891
|
+
if (typeof window !== 'undefined' && window.MathJax && window.MathJax.typesetPromise) {
|
|
4892
|
+
const mathElements = this.previewPanel.querySelectorAll('.math-display');
|
|
4893
|
+
if (mathElements.length > 0) {
|
|
4894
|
+
window.MathJax.typesetPromise(Array.from(mathElements))
|
|
4895
|
+
.catch(() => {});
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4444
4898
|
}
|
|
4445
|
-
|
|
4899
|
+
|
|
4446
4900
|
// Trigger mode change event
|
|
4447
4901
|
if (this.options.onModeChange) {
|
|
4448
4902
|
this.options.onModeChange(mode);
|
|
@@ -4690,36 +5144,29 @@ class QuikdownEditor {
|
|
|
4690
5144
|
const lines = (markdown || '').split('\n');
|
|
4691
5145
|
const result = [];
|
|
4692
5146
|
let inFence = false;
|
|
4693
|
-
let
|
|
4694
|
-
let
|
|
5147
|
+
let openChar = null;
|
|
5148
|
+
let openLen = 0;
|
|
4695
5149
|
|
|
4696
5150
|
for (let i = 0; i < lines.length; i++) {
|
|
4697
5151
|
const line = lines[i];
|
|
4698
5152
|
const trimmed = line.trim();
|
|
4699
5153
|
|
|
4700
|
-
// Track fence open/close
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
const matchLen = fenceMatch[1].length;
|
|
4705
|
-
if (!inFence) {
|
|
5154
|
+
// Track fence open/close
|
|
5155
|
+
if (!inFence) {
|
|
5156
|
+
const fo = fenceOpen(trimmed);
|
|
5157
|
+
if (fo) {
|
|
4706
5158
|
inFence = true;
|
|
4707
|
-
|
|
4708
|
-
|
|
5159
|
+
openChar = fo.char;
|
|
5160
|
+
openLen = fo.len;
|
|
4709
5161
|
result.push(line);
|
|
4710
5162
|
continue;
|
|
4711
|
-
}
|
|
4712
|
-
|
|
5163
|
+
}
|
|
5164
|
+
} else {
|
|
5165
|
+
if (isFenceClose(trimmed, openChar, openLen)) {
|
|
4713
5166
|
inFence = false;
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
result.push(line);
|
|
4717
|
-
continue;
|
|
5167
|
+
openChar = null;
|
|
5168
|
+
openLen = 0;
|
|
4718
5169
|
}
|
|
4719
|
-
}
|
|
4720
|
-
|
|
4721
|
-
// Inside a fence — keep everything
|
|
4722
|
-
if (inFence) {
|
|
4723
5170
|
result.push(line);
|
|
4724
5171
|
continue;
|
|
4725
5172
|
}
|
|
@@ -4730,14 +5177,13 @@ class QuikdownEditor {
|
|
|
4730
5177
|
continue;
|
|
4731
5178
|
}
|
|
4732
5179
|
|
|
4733
|
-
// Check if this line is a standalone HR
|
|
4734
|
-
|
|
4735
|
-
if (isHR) {
|
|
5180
|
+
// Check if this line is a standalone HR (no ReDoS — linear scan)
|
|
5181
|
+
if (isHRLine(trimmed)) {
|
|
4736
5182
|
// Table separator heuristic: immediately adjacent lines (no blank
|
|
4737
5183
|
// lines between) that look like table rows protect this HR-like line
|
|
4738
5184
|
const prevLine = i > 0 ? lines[i - 1].trim() : '';
|
|
4739
5185
|
const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
|
|
4740
|
-
if (
|
|
5186
|
+
if (looksLikeTableRow(prevLine) || looksLikeTableRow(nextLine)) {
|
|
4741
5187
|
result.push(line);
|
|
4742
5188
|
continue;
|
|
4743
5189
|
}
|
|
@@ -4816,30 +5262,28 @@ class QuikdownEditor {
|
|
|
4816
5262
|
// 'blockquote', 'table', 'heading', 'hr', 'paragraph'
|
|
4817
5263
|
const items = [];
|
|
4818
5264
|
let inFence = false;
|
|
4819
|
-
let
|
|
4820
|
-
let
|
|
5265
|
+
let openChar = null;
|
|
5266
|
+
let openLen = 0;
|
|
4821
5267
|
|
|
4822
5268
|
for (const rawLine of inputLines) {
|
|
4823
5269
|
const line = rawLine;
|
|
4824
5270
|
const trimmed = line.trim();
|
|
4825
5271
|
|
|
4826
|
-
// Fence tracking
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
fenceMatch[1].length >= fenceLen &&
|
|
4839
|
-
/^(`{3,}|~{3,})\s*$/.test(trimmed)) {
|
|
5272
|
+
// Fence tracking via shared utilities
|
|
5273
|
+
if (!inFence) {
|
|
5274
|
+
const fo = fenceOpen(trimmed);
|
|
5275
|
+
if (fo) {
|
|
5276
|
+
inFence = true;
|
|
5277
|
+
openChar = fo.char;
|
|
5278
|
+
openLen = fo.len;
|
|
5279
|
+
items.push({ line, kind: 'fence-open' });
|
|
5280
|
+
continue;
|
|
5281
|
+
}
|
|
5282
|
+
} else {
|
|
5283
|
+
if (isFenceClose(trimmed, openChar, openLen)) {
|
|
4840
5284
|
inFence = false;
|
|
4841
|
-
|
|
4842
|
-
|
|
5285
|
+
openChar = null;
|
|
5286
|
+
openLen = 0;
|
|
4843
5287
|
items.push({ line, kind: 'fence-close' });
|
|
4844
5288
|
} else {
|
|
4845
5289
|
items.push({ line, kind: 'fence-body' });
|
|
@@ -4853,16 +5297,12 @@ class QuikdownEditor {
|
|
|
4853
5297
|
continue;
|
|
4854
5298
|
}
|
|
4855
5299
|
|
|
4856
|
-
// Categorize content lines
|
|
4857
|
-
let category =
|
|
4858
|
-
if (/^#{1,6}\s/.test(trimmed)) category = 'heading';
|
|
4859
|
-
else if (/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed)) category = 'hr';
|
|
4860
|
-
else if (/^(\d+\.)\s/.test(trimmed)) category = 'list-ol';
|
|
4861
|
-
else if (/^[-*+]\s/.test(trimmed)) category = 'list-ul';
|
|
4862
|
-
else if (/^>/.test(trimmed)) category = 'blockquote';
|
|
4863
|
-
else if (/^\|/.test(trimmed)) category = 'table';
|
|
5300
|
+
// Categorize content lines (no ReDoS — classifyLine uses linear scan for HR)
|
|
5301
|
+
let category = classifyLine(trimmed);
|
|
4864
5302
|
// Indented continuation of a list (2+ leading spaces or tab)
|
|
4865
|
-
|
|
5303
|
+
if (category === 'paragraph' && /^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) {
|
|
5304
|
+
category = 'list-cont';
|
|
5305
|
+
}
|
|
4866
5306
|
|
|
4867
5307
|
items.push({ line, kind: 'content', category });
|
|
4868
5308
|
}
|
|
@@ -4962,13 +5402,6 @@ class QuikdownEditor {
|
|
|
4962
5402
|
}
|
|
4963
5403
|
}
|
|
4964
5404
|
|
|
4965
|
-
// --- Internal helpers for removeHR fence/table awareness ---
|
|
4966
|
-
|
|
4967
|
-
/** Heuristic: does this line look like a markdown table row? */
|
|
4968
|
-
function _looksLikeTableRow(line) {
|
|
4969
|
-
return line.includes('|');
|
|
4970
|
-
}
|
|
4971
|
-
|
|
4972
5405
|
// Export for CommonJS (needed for bundled ESM to work with Jest)
|
|
4973
5406
|
if (typeof module !== 'undefined' && module.exports) {
|
|
4974
5407
|
module.exports = QuikdownEditor;
|