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