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