quikdown 1.2.7 → 1.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/quikdown.cjs +496 -243
- package/dist/quikdown.dark.css +1 -1
- package/dist/quikdown.esm.js +496 -243
- package/dist/quikdown.esm.min.js +2 -2
- package/dist/quikdown.esm.min.js.gz +0 -0
- package/dist/quikdown.esm.min.js.map +1 -1
- package/dist/quikdown.light.css +1 -1
- package/dist/quikdown.umd.js +496 -243
- package/dist/quikdown.umd.min.js +2 -2
- package/dist/quikdown.umd.min.js.gz +0 -0
- package/dist/quikdown.umd.min.js.map +1 -1
- package/dist/quikdown_ast.cjs +2 -2
- package/dist/quikdown_ast.esm.js +2 -2
- package/dist/quikdown_ast.esm.min.js +2 -2
- package/dist/quikdown_ast.esm.min.js.gz +0 -0
- package/dist/quikdown_ast.umd.js +2 -2
- package/dist/quikdown_ast.umd.min.js +2 -2
- package/dist/quikdown_ast.umd.min.js.gz +0 -0
- package/dist/quikdown_ast_html.cjs +3 -3
- package/dist/quikdown_ast_html.esm.js +3 -3
- package/dist/quikdown_ast_html.esm.min.js +2 -2
- package/dist/quikdown_ast_html.esm.min.js.gz +0 -0
- package/dist/quikdown_ast_html.umd.js +3 -3
- package/dist/quikdown_ast_html.umd.min.js +2 -2
- package/dist/quikdown_ast_html.umd.min.js.gz +0 -0
- package/dist/quikdown_bd.cjs +496 -243
- package/dist/quikdown_bd.esm.js +496 -243
- package/dist/quikdown_bd.esm.min.js +2 -2
- package/dist/quikdown_bd.esm.min.js.gz +0 -0
- package/dist/quikdown_bd.esm.min.js.map +1 -1
- package/dist/quikdown_bd.umd.js +496 -243
- package/dist/quikdown_bd.umd.min.js +2 -2
- package/dist/quikdown_bd.umd.min.js.gz +0 -0
- package/dist/quikdown_bd.umd.min.js.map +1 -1
- package/dist/quikdown_edit.cjs +760 -327
- package/dist/quikdown_edit.esm.js +760 -327
- package/dist/quikdown_edit.esm.min.js +3 -3
- package/dist/quikdown_edit.esm.min.js.gz +0 -0
- package/dist/quikdown_edit.esm.min.js.map +1 -1
- package/dist/quikdown_edit.umd.js +760 -327
- package/dist/quikdown_edit.umd.min.js +3 -3
- package/dist/quikdown_edit.umd.min.js.gz +0 -0
- package/dist/quikdown_edit.umd.min.js.map +1 -1
- package/dist/quikdown_edit_standalone.esm.min.js.gz +0 -0
- package/dist/quikdown_edit_standalone.umd.min.js.gz +0 -0
- package/dist/quikdown_json.cjs +3 -3
- package/dist/quikdown_json.esm.js +3 -3
- package/dist/quikdown_json.esm.min.js +2 -2
- package/dist/quikdown_json.esm.min.js.gz +0 -0
- package/dist/quikdown_json.umd.js +3 -3
- package/dist/quikdown_json.umd.min.js +2 -2
- package/dist/quikdown_json.umd.min.js.gz +0 -0
- package/dist/quikdown_yaml.cjs +3 -3
- package/dist/quikdown_yaml.esm.js +3 -3
- package/dist/quikdown_yaml.esm.min.js +2 -2
- package/dist/quikdown_yaml.esm.min.js.gz +0 -0
- package/dist/quikdown_yaml.umd.js +3 -3
- package/dist/quikdown_yaml.umd.min.js +2 -2
- package/dist/quikdown_yaml.umd.min.js.gz +0 -0
- package/package.json +18 -13
package/dist/quikdown.umd.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* quikdown - Lightweight 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,135 @@
|
|
|
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
|
-
// Version will be injected at build time
|
|
27
|
-
const quikdownVersion = '1.2.7';
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Dash-only HR check — exact parity with the main parser's original
|
|
29
|
+
* regex `/^---+\s*$/`. Only matches lines of three or more dashes
|
|
30
|
+
* with optional trailing whitespace (no interspersed spaces).
|
|
31
|
+
*
|
|
32
|
+
* @param {string} trimmed The line, already trimmed
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isDashHRLine(trimmed) {
|
|
36
|
+
if (trimmed.length < 3) return false;
|
|
37
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
38
|
+
const ch = trimmed[i];
|
|
39
|
+
if (ch === '-') continue;
|
|
40
|
+
// Allow trailing whitespace only
|
|
41
|
+
if (ch === ' ' || ch === '\t') {
|
|
42
|
+
for (let j = i + 1; j < trimmed.length; j++) {
|
|
43
|
+
if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
|
|
44
|
+
}
|
|
45
|
+
return i >= 3; // at least 3 dashes before whitespace
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true; // all dashes
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* quikdown — A compact, scanner-based markdown parser
|
|
54
|
+
* ════════════════════════════════════════════════════
|
|
55
|
+
*
|
|
56
|
+
* Architecture overview (v1.2.8 — lexer rewrite)
|
|
57
|
+
* ───────────────────────────────────────────────
|
|
58
|
+
* Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
|
|
59
|
+
* type (headings, blockquotes, HR, lists, tables) and each inline format
|
|
60
|
+
* (bold, italic, links, …) was handled by its own global regex applied
|
|
61
|
+
* sequentially to the full document string. That worked but made the code
|
|
62
|
+
* hard to extend and debug — a new construct meant adding another regex
|
|
63
|
+
* pass, and ordering bugs between passes were subtle.
|
|
64
|
+
*
|
|
65
|
+
* Starting in v1.2.8 the parser uses a **line-scanning** approach for
|
|
66
|
+
* block detection and a **per-block inline pass** for formatting:
|
|
67
|
+
*
|
|
68
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
69
|
+
* │ Phase 1 — Code Extraction │
|
|
70
|
+
* │ Scan for fenced code blocks (``` / ~~~) and inline │
|
|
71
|
+
* │ code spans (`…`). Replace with §CB§ / §IC§ place- │
|
|
72
|
+
* │ holders so code content is never touched by later │
|
|
73
|
+
* │ phases. │
|
|
74
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
75
|
+
* │ Phase 2 — HTML Escaping │
|
|
76
|
+
* │ Escape &, <, >, ", ' in the remaining text to prevent │
|
|
77
|
+
* │ XSS. (Skipped when allow_unsafe_html is true.) │
|
|
78
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
79
|
+
* │ Phase 3 — Block Scanning │
|
|
80
|
+
* │ Walk the text **line by line**. At each line, the │
|
|
81
|
+
* │ scanner checks (in order): │
|
|
82
|
+
* │ • table rows (|) │
|
|
83
|
+
* │ • headings (#) │
|
|
84
|
+
* │ • HR (---) │
|
|
85
|
+
* │ • blockquotes (>) │
|
|
86
|
+
* │ • list items (-, *, +, 1.) │
|
|
87
|
+
* │ • code-block placeholder (§CB…§) │
|
|
88
|
+
* │ • paragraph text (everything else) │
|
|
89
|
+
* │ │
|
|
90
|
+
* │ Block text is run through the **inline formatter** │
|
|
91
|
+
* │ which handles bold, italic, strikethrough, links, │
|
|
92
|
+
* │ images, and autolinks. │
|
|
93
|
+
* │ │
|
|
94
|
+
* │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
|
|
95
|
+
* │ (single \n → <br>) are handled here too. │
|
|
96
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
97
|
+
* │ Phase 4 — Code Restoration │
|
|
98
|
+
* │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
|
|
99
|
+
* │ / <code> HTML, applying the fence_plugin if present. │
|
|
100
|
+
* └─────────────────────────────────────────────────────────┘
|
|
101
|
+
*
|
|
102
|
+
* Why this design?
|
|
103
|
+
* • Single pass over lines for block identification — no re-scanning.
|
|
104
|
+
* • Each block type is a clearly separated branch, easy to add new ones.
|
|
105
|
+
* • Inline formatting is confined to block text — can't accidentally
|
|
106
|
+
* match across block boundaries or inside HTML tags.
|
|
107
|
+
* • Code extraction still uses a simple regex (it's one pattern, not a
|
|
108
|
+
* chain) because the §-placeholder approach is proven and simple.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} markdown The markdown source text
|
|
111
|
+
* @param {Object} options Configuration (see below)
|
|
112
|
+
* @returns {string} Rendered HTML
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
// ────────────────────────────────────────────────────────────────────
|
|
117
|
+
// Constants
|
|
118
|
+
// ────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/** Build-time version stamp (injected by tools/updateVersion) */
|
|
121
|
+
const quikdownVersion = '1.2.9';
|
|
122
|
+
|
|
123
|
+
/** CSS class prefix used for all generated elements */
|
|
30
124
|
const CLASS_PREFIX = 'quikdown-';
|
|
31
|
-
const PLACEHOLDER_CB = '§CB';
|
|
32
|
-
const PLACEHOLDER_IC = '§IC';
|
|
33
125
|
|
|
34
|
-
|
|
126
|
+
/** Placeholder sigils — chosen to be extremely unlikely in real text */
|
|
127
|
+
const PLACEHOLDER_CB = '§CB'; // fenced code blocks
|
|
128
|
+
const PLACEHOLDER_IC = '§IC'; // inline code spans
|
|
129
|
+
|
|
130
|
+
/** HTML entity escape map */
|
|
35
131
|
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
|
36
132
|
|
|
37
|
-
//
|
|
133
|
+
// ────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Style definitions
|
|
135
|
+
// ────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Inline styles for every element quikdown can emit.
|
|
139
|
+
* When `inline_styles: true` these are injected as style="…" attributes.
|
|
140
|
+
* When `inline_styles: false` (default) we use class="quikdown-<tag>"
|
|
141
|
+
* and these same values are emitted by `quikdown.emitStyles()`.
|
|
142
|
+
*/
|
|
38
143
|
const QUIKDOWN_STYLES = {
|
|
39
144
|
h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
|
|
40
145
|
h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
|
|
@@ -57,35 +162,41 @@
|
|
|
57
162
|
ul: 'margin:.5em 0;padding-left:2em',
|
|
58
163
|
ol: 'margin:.5em 0;padding-left:2em',
|
|
59
164
|
li: 'margin:.25em 0',
|
|
60
|
-
// Task list specific styles
|
|
61
165
|
'task-item': 'list-style:none',
|
|
62
166
|
'task-checkbox': 'margin-right:.5em'
|
|
63
167
|
};
|
|
64
168
|
|
|
65
|
-
//
|
|
169
|
+
// ────────────────────────────────────────────────────────────────────
|
|
170
|
+
// Attribute factory
|
|
171
|
+
// ────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Creates a `getAttr(tag, additionalStyle?)` helper that returns
|
|
175
|
+
* either a class="…" or style="…" attribute string depending on mode.
|
|
176
|
+
*
|
|
177
|
+
* @param {boolean} inline_styles True → emit style="…"; false → class="…"
|
|
178
|
+
* @param {Object} styles The QUIKDOWN_STYLES map
|
|
179
|
+
* @returns {Function}
|
|
180
|
+
*/
|
|
66
181
|
function createGetAttr(inline_styles, styles) {
|
|
67
182
|
return function(tag, additionalStyle = '') {
|
|
68
183
|
if (inline_styles) {
|
|
69
184
|
let style = styles[tag];
|
|
70
185
|
if (!style && !additionalStyle) return '';
|
|
71
|
-
|
|
72
|
-
//
|
|
186
|
+
|
|
187
|
+
// When adding alignment that conflicts with the tag's default,
|
|
188
|
+
// strip the default text-align first.
|
|
73
189
|
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
74
190
|
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
191
|
/* istanbul ignore next */
|
|
80
192
|
if (style && !style.endsWith(';')) style += ';';
|
|
81
193
|
}
|
|
82
|
-
|
|
194
|
+
|
|
83
195
|
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
|
|
84
196
|
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
|
|
85
197
|
return ` style="${fullStyle}"`;
|
|
86
198
|
} else {
|
|
87
199
|
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
|
|
88
|
-
// Apply inline styles for alignment even when using CSS classes
|
|
89
200
|
if (additionalStyle) {
|
|
90
201
|
return `${classAttr} style="${additionalStyle}"`;
|
|
91
202
|
}
|
|
@@ -94,72 +205,84 @@
|
|
|
94
205
|
};
|
|
95
206
|
}
|
|
96
207
|
|
|
208
|
+
// ════════════════════════════════════════════════════════════════════
|
|
209
|
+
// Main parser function
|
|
210
|
+
// ════════════════════════════════════════════════════════════════════
|
|
211
|
+
|
|
97
212
|
function quikdown(markdown, options = {}) {
|
|
213
|
+
// ── Guard: only process non-empty strings ──
|
|
98
214
|
if (!markdown || typeof markdown !== 'string') {
|
|
99
215
|
return '';
|
|
100
216
|
}
|
|
101
|
-
|
|
217
|
+
|
|
218
|
+
// ── Unpack options ──
|
|
102
219
|
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);
|
|
220
|
+
const styles = QUIKDOWN_STYLES;
|
|
221
|
+
const getAttr = createGetAttr(inline_styles, styles);
|
|
222
|
+
|
|
223
|
+
// ── Helpers (closed over options) ──
|
|
105
224
|
|
|
106
|
-
|
|
225
|
+
/** Escape the five HTML-special characters. */
|
|
107
226
|
function escapeHtml(text) {
|
|
108
227
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
109
228
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Bidirectional marker helper.
|
|
232
|
+
* When bidirectional mode is on, returns ` data-qd="…"`.
|
|
233
|
+
* The non-bidirectional branch is a trivial no-op arrow; it is
|
|
234
|
+
* exercised in the core bundle but never in quikdown_bd.
|
|
235
|
+
*/
|
|
114
236
|
/* istanbul ignore next - trivial no-op fallback */
|
|
115
237
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
116
238
|
|
|
117
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
|
|
241
|
+
* Returns '#' for blocked URLs.
|
|
242
|
+
*/
|
|
118
243
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
119
244
|
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
120
245
|
if (!url) return '';
|
|
121
|
-
|
|
122
|
-
// If unsafe URLs are explicitly allowed, return as-is
|
|
123
246
|
if (allowUnsafe) return url;
|
|
124
|
-
|
|
247
|
+
|
|
125
248
|
const trimmedUrl = url.trim();
|
|
126
249
|
const lowerUrl = trimmedUrl.toLowerCase();
|
|
127
|
-
|
|
128
|
-
// Block dangerous protocols
|
|
129
250
|
const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
|
|
130
|
-
|
|
251
|
+
|
|
131
252
|
for (const protocol of dangerousProtocols) {
|
|
132
253
|
if (lowerUrl.startsWith(protocol)) {
|
|
133
|
-
// Exception: Allow data:image/* for images
|
|
134
254
|
if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
|
|
135
255
|
return trimmedUrl;
|
|
136
256
|
}
|
|
137
|
-
// Return safe empty link for dangerous protocols
|
|
138
257
|
return '#';
|
|
139
258
|
}
|
|
140
259
|
}
|
|
141
|
-
|
|
142
260
|
return trimmedUrl;
|
|
143
261
|
}
|
|
144
262
|
|
|
145
|
-
//
|
|
263
|
+
// ────────────────────────────────────────────────────────────────
|
|
264
|
+
// Phase 1 — Code Extraction
|
|
265
|
+
// ────────────────────────────────────────────────────────────────
|
|
266
|
+
// Why extract code first? Fenced blocks and inline code spans can
|
|
267
|
+
// contain markdown-like characters (*, _, #, |, etc.) that must NOT
|
|
268
|
+
// be interpreted as formatting. By pulling them out and replacing
|
|
269
|
+
// with unique placeholders, the rest of the pipeline never sees them.
|
|
270
|
+
|
|
146
271
|
let html = markdown;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// Fence must be at start of line
|
|
272
|
+
const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
|
|
273
|
+
const inlineCodes = []; // Array of escaped-HTML strings
|
|
274
|
+
|
|
275
|
+
// ── Fenced code blocks ──
|
|
276
|
+
// Matches paired fences: ``` with ``` and ~~~ with ~~~.
|
|
277
|
+
// The fence must start at column 0 of a line (^ with /m flag).
|
|
278
|
+
// Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
|
|
155
279
|
html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
|
|
156
280
|
const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
|
|
157
|
-
|
|
158
|
-
// Trim the language specification
|
|
159
281
|
const langTrimmed = lang ? lang.trim() : '';
|
|
160
|
-
|
|
161
|
-
// If custom fence plugin is provided, use it (v1.1.0: object format required)
|
|
282
|
+
|
|
162
283
|
if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
|
|
284
|
+
// Custom plugin — store raw code (un-escaped) so the plugin
|
|
285
|
+
// receives the original source.
|
|
163
286
|
codeBlocks.push({
|
|
164
287
|
lang: langTrimmed,
|
|
165
288
|
code: code.trimEnd(),
|
|
@@ -168,6 +291,7 @@
|
|
|
168
291
|
hasReverse: !!fence_plugin.reverse
|
|
169
292
|
});
|
|
170
293
|
} else {
|
|
294
|
+
// Default — pre-escape the code for safe HTML output.
|
|
171
295
|
codeBlocks.push({
|
|
172
296
|
lang: langTrimmed,
|
|
173
297
|
code: escapeHtml(code.trimEnd()),
|
|
@@ -177,69 +301,94 @@
|
|
|
177
301
|
}
|
|
178
302
|
return placeholder;
|
|
179
303
|
});
|
|
180
|
-
|
|
181
|
-
//
|
|
304
|
+
|
|
305
|
+
// ── Inline code spans ──
|
|
306
|
+
// Matches a single backtick pair: `content`.
|
|
307
|
+
// Content is captured and HTML-escaped immediately.
|
|
182
308
|
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
|
183
309
|
const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
|
|
184
310
|
inlineCodes.push(escapeHtml(code));
|
|
185
311
|
return placeholder;
|
|
186
312
|
});
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
//
|
|
313
|
+
|
|
314
|
+
// ────────────────────────────────────────────────────────────────
|
|
315
|
+
// Phase 2 — HTML Escaping
|
|
316
|
+
// ────────────────────────────────────────────────────────────────
|
|
317
|
+
// All remaining text (everything except code placeholders) is escaped
|
|
318
|
+
// to prevent XSS. The `allow_unsafe_html` option skips this for
|
|
319
|
+
// trusted pipelines that intentionally embed raw HTML.
|
|
320
|
+
|
|
190
321
|
if (!allow_unsafe_html) {
|
|
191
322
|
html = escapeHtml(html);
|
|
192
323
|
}
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
//
|
|
324
|
+
|
|
325
|
+
// ────────────────────────────────────────────────────────────────
|
|
326
|
+
// Phase 3 — Block Scanning + Inline Formatting + Paragraphs
|
|
327
|
+
// ────────────────────────────────────────────────────────────────
|
|
328
|
+
// This is the heart of the lexer rewrite. Instead of applying
|
|
329
|
+
// 10+ global regex passes, we:
|
|
330
|
+
// 1. Process tables (line walker — tables need multi-line lookahead)
|
|
331
|
+
// 2. Scan remaining lines for headings, HR, blockquotes
|
|
332
|
+
// 3. Process lists (line walker — lists need indent tracking)
|
|
333
|
+
// 4. Apply inline formatting to all text content
|
|
334
|
+
// 5. Wrap remaining text in <p> tags
|
|
335
|
+
//
|
|
336
|
+
// Steps 1 and 3 are line-walkers that process the full text in a
|
|
337
|
+
// single pass each. Step 2 replaces global regex with a per-line
|
|
338
|
+
// scanner. Steps 4-5 are applied to the result.
|
|
339
|
+
//
|
|
340
|
+
// Total: 3 structured passes instead of 10+ regex passes.
|
|
341
|
+
|
|
342
|
+
// ── Step 1: Tables ──
|
|
343
|
+
// Tables need multi-line lookahead (header → separator → body rows)
|
|
344
|
+
// so they're handled by a dedicated line-walker first.
|
|
197
345
|
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
|
|
346
|
+
|
|
347
|
+
// ── Step 2: Headings, HR, Blockquotes ──
|
|
348
|
+
// These are simple line-level constructs. We scan each line once
|
|
349
|
+
// and replace matching lines with their HTML representation.
|
|
350
|
+
html = scanLineBlocks(html, getAttr, dataQd);
|
|
351
|
+
|
|
352
|
+
// ── Step 3: Lists ──
|
|
353
|
+
// Lists need indent-level tracking across lines, so they get their
|
|
354
|
+
// own line-walker.
|
|
214
355
|
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
//
|
|
356
|
+
|
|
357
|
+
// ── Step 4: Inline formatting ──
|
|
358
|
+
// Apply bold, italic, strikethrough, images, links, and autolinks
|
|
359
|
+
// to all text content. This runs on the output of steps 1-3, so
|
|
360
|
+
// it sees text inside headings, blockquotes, table cells, list
|
|
361
|
+
// items, and paragraph text.
|
|
362
|
+
|
|
363
|
+
// Images (must come before links —  vs [text](url))
|
|
219
364
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
220
365
|
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
366
|
+
// Bidirectional attributes are only exercised via quikdown_bd bundle.
|
|
367
|
+
/* istanbul ignore next - bd-only branch */
|
|
221
368
|
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
369
|
+
/* istanbul ignore next - bd-only branch */
|
|
222
370
|
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
223
371
|
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
224
372
|
});
|
|
225
|
-
|
|
226
|
-
// Links
|
|
373
|
+
|
|
374
|
+
// Links
|
|
227
375
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
|
|
228
|
-
// Sanitize URL to prevent XSS
|
|
229
376
|
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
230
377
|
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
231
378
|
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
379
|
+
/* istanbul ignore next - bd-only branch */
|
|
232
380
|
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
233
381
|
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
234
382
|
});
|
|
235
|
-
|
|
236
|
-
// Autolinks
|
|
383
|
+
|
|
384
|
+
// Autolinks — bare https?:// URLs become clickable <a> tags
|
|
237
385
|
html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
|
|
238
386
|
const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
|
|
239
387
|
return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
|
|
240
388
|
});
|
|
241
|
-
|
|
242
|
-
//
|
|
389
|
+
|
|
390
|
+
// Bold, italic, strikethrough
|
|
391
|
+
// Order matters: ** before * (so ** isn't consumed as two *s)
|
|
243
392
|
const inlinePatterns = [
|
|
244
393
|
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
245
394
|
[/__(.+?)__/g, 'strong', '__'],
|
|
@@ -247,60 +396,63 @@
|
|
|
247
396
|
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
248
397
|
[/~~(.+?)~~/g, 'del', '~~']
|
|
249
398
|
];
|
|
250
|
-
|
|
251
399
|
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
252
400
|
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
253
401
|
});
|
|
254
|
-
|
|
255
|
-
// Line breaks
|
|
402
|
+
|
|
403
|
+
// ── Step 5: Line breaks + paragraph wrapping ──
|
|
256
404
|
if (lazy_linefeeds) {
|
|
257
|
-
// Lazy linefeeds: single
|
|
405
|
+
// Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
|
|
406
|
+
// • Double newlines → paragraph break
|
|
407
|
+
// • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
|
|
408
|
+
//
|
|
409
|
+
// Strategy: protect block-adjacent newlines with §N§, convert
|
|
410
|
+
// the rest, then restore.
|
|
411
|
+
|
|
258
412
|
const blocks = [];
|
|
259
413
|
let bi = 0;
|
|
260
|
-
|
|
261
|
-
// Protect tables and lists
|
|
414
|
+
|
|
415
|
+
// Protect tables and lists from <br> injection
|
|
262
416
|
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
263
417
|
blocks[bi] = m;
|
|
264
418
|
return `§B${bi++}§`;
|
|
265
419
|
});
|
|
266
|
-
|
|
267
|
-
// Handle paragraphs and block elements
|
|
420
|
+
|
|
268
421
|
html = html.replace(/\n\n+/g, '§P§')
|
|
269
|
-
// After block
|
|
422
|
+
// After block-level closing tags
|
|
270
423
|
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
271
424
|
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
272
|
-
// Before block
|
|
425
|
+
// Before block-level opening tags
|
|
273
426
|
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
274
427
|
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
275
428
|
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
276
|
-
// Convert
|
|
429
|
+
// Convert surviving newlines to <br>
|
|
277
430
|
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
278
431
|
// Restore
|
|
279
432
|
.replace(/§N§/g, '\n')
|
|
280
433
|
.replace(/§P§/g, '</p><p>');
|
|
281
|
-
|
|
434
|
+
|
|
282
435
|
// Restore protected blocks
|
|
283
436
|
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
284
|
-
|
|
437
|
+
|
|
285
438
|
html = '<p>' + html + '</p>';
|
|
286
439
|
} else {
|
|
287
|
-
// Standard: two spaces
|
|
440
|
+
// Standard mode: two trailing spaces → <br>, double newline → new paragraph
|
|
288
441
|
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)
|
|
442
|
+
|
|
292
443
|
html = html.replace(/\n\n+/g, (match, offset) => {
|
|
293
|
-
// Check if we're after a block element closing tag
|
|
294
444
|
const before = html.substring(0, offset);
|
|
295
445
|
if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
|
|
296
|
-
return '<p>';
|
|
446
|
+
return '<p>';
|
|
297
447
|
}
|
|
298
|
-
return '</p><p>';
|
|
448
|
+
return '</p><p>';
|
|
299
449
|
});
|
|
300
450
|
html = '<p>' + html + '</p>';
|
|
301
451
|
}
|
|
302
|
-
|
|
303
|
-
//
|
|
452
|
+
|
|
453
|
+
// ── Step 6: Cleanup ──
|
|
454
|
+
// Remove <p> wrappers that accidentally enclose block elements.
|
|
455
|
+
// This is simpler than trying to prevent them during wrapping.
|
|
304
456
|
const cleanupPatterns = [
|
|
305
457
|
[/<p><\/p>/g, ''],
|
|
306
458
|
[/<p>(<h[1-6][^>]*>)/g, '$1'],
|
|
@@ -316,65 +468,152 @@
|
|
|
316
468
|
[/(<\/pre>)<\/p>/g, '$1'],
|
|
317
469
|
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
|
|
318
470
|
];
|
|
319
|
-
|
|
320
471
|
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
321
472
|
html = html.replace(pattern, replacement);
|
|
322
473
|
});
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
// When a paragraph follows a block element, ensure it has opening <p>
|
|
474
|
+
|
|
475
|
+
// When a block element is followed by a newline and then text, open a <p>.
|
|
326
476
|
html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
//
|
|
477
|
+
|
|
478
|
+
// ────────────────────────────────────────────────────────────────
|
|
479
|
+
// Phase 4 — Code Restoration
|
|
480
|
+
// ────────────────────────────────────────────────────────────────
|
|
481
|
+
// Replace placeholders with rendered HTML. For fenced blocks this
|
|
482
|
+
// means wrapping in <pre><code>…</code></pre> (or calling the
|
|
483
|
+
// fence_plugin). For inline code it means <code>…</code>.
|
|
484
|
+
|
|
331
485
|
codeBlocks.forEach((block, i) => {
|
|
332
486
|
let replacement;
|
|
333
|
-
|
|
487
|
+
|
|
334
488
|
if (block.custom && fence_plugin && fence_plugin.render) {
|
|
335
|
-
//
|
|
489
|
+
// Delegate to the user-provided fence plugin.
|
|
336
490
|
replacement = fence_plugin.render(block.code, block.lang);
|
|
337
|
-
|
|
338
|
-
// If plugin returns undefined, fall back to default rendering
|
|
491
|
+
|
|
339
492
|
if (replacement === undefined) {
|
|
493
|
+
// Plugin declined — fall back to default rendering.
|
|
340
494
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
341
495
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
496
|
+
/* istanbul ignore next - bd-only branch */
|
|
342
497
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
498
|
+
/* istanbul ignore next - bd-only branch */
|
|
343
499
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
344
500
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
345
|
-
} else if (bidirectional) {
|
|
346
|
-
//
|
|
347
|
-
replacement = replacement.replace(/^<(\w+)/,
|
|
501
|
+
} else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
|
|
502
|
+
// Plugin returned HTML — inject data attributes for roundtrip.
|
|
503
|
+
replacement = replacement.replace(/^<(\w+)/,
|
|
348
504
|
`<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
|
|
349
505
|
}
|
|
350
506
|
} else {
|
|
351
|
-
// Default rendering
|
|
507
|
+
// Default rendering — wrap in <pre><code>.
|
|
352
508
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
353
509
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
510
|
+
/* istanbul ignore next - bd-only branch */
|
|
354
511
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
512
|
+
/* istanbul ignore next - bd-only branch */
|
|
355
513
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
356
514
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
357
515
|
}
|
|
358
|
-
|
|
516
|
+
|
|
359
517
|
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
360
518
|
html = html.replace(placeholder, replacement);
|
|
361
519
|
});
|
|
362
|
-
|
|
363
|
-
// Restore inline code
|
|
520
|
+
|
|
521
|
+
// Restore inline code spans
|
|
364
522
|
inlineCodes.forEach((code, i) => {
|
|
365
523
|
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
366
524
|
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
367
525
|
});
|
|
368
|
-
|
|
526
|
+
|
|
369
527
|
return html.trim();
|
|
370
528
|
}
|
|
371
529
|
|
|
530
|
+
// ════════════════════════════════════════════════════════════════════
|
|
531
|
+
// Block-level line scanner
|
|
532
|
+
// ════════════════════════════════════════════════════════════════════
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
|
|
536
|
+
*
|
|
537
|
+
* Walks the text line by line. For each line it checks (in order):
|
|
538
|
+
* 1. Heading — starts with 1-6 '#' followed by a space
|
|
539
|
+
* 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
|
|
540
|
+
* 3. Blockquote — starts with '> ' (the > was already HTML-escaped)
|
|
541
|
+
*
|
|
542
|
+
* Lines that don't match any block pattern are passed through unchanged.
|
|
543
|
+
*
|
|
544
|
+
* This replaces three separate global regex passes from the pre-1.2.8
|
|
545
|
+
* architecture with one structured scan.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} text The document text (HTML-escaped, code extracted)
|
|
548
|
+
* @param {Function} getAttr Attribute factory (class or style)
|
|
549
|
+
* @param {Function} dataQd Bidirectional marker factory
|
|
550
|
+
* @returns {string} Text with block-level elements rendered
|
|
551
|
+
*/
|
|
552
|
+
function scanLineBlocks(text, getAttr, dataQd) {
|
|
553
|
+
const lines = text.split('\n');
|
|
554
|
+
const result = [];
|
|
555
|
+
let i = 0;
|
|
556
|
+
|
|
557
|
+
while (i < lines.length) {
|
|
558
|
+
const line = lines[i];
|
|
559
|
+
|
|
560
|
+
// ── Heading ──
|
|
561
|
+
// Count leading '#' characters. Valid heading: 1-6 hashes then a space.
|
|
562
|
+
// Example: "## Hello World ##" → <h2>Hello World</h2>
|
|
563
|
+
let hashCount = 0;
|
|
564
|
+
while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
|
|
565
|
+
hashCount++;
|
|
566
|
+
}
|
|
567
|
+
if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
|
|
568
|
+
// Extract content after "# " and strip trailing hashes
|
|
569
|
+
const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
|
|
570
|
+
const tag = 'h' + hashCount;
|
|
571
|
+
result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
|
|
572
|
+
i++;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── Horizontal Rule ──
|
|
577
|
+
// Three or more dashes, optional trailing whitespace, nothing else.
|
|
578
|
+
if (isDashHRLine(line)) {
|
|
579
|
+
result.push(`<hr${getAttr('hr')}>`);
|
|
580
|
+
i++;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── Blockquote ──
|
|
585
|
+
// After Phase 2, the '>' character has been escaped to '>'.
|
|
586
|
+
// Pattern: "> content" or merged consecutive blockquotes.
|
|
587
|
+
if (/^>\s+/.test(line)) {
|
|
588
|
+
result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^>\s+/, '')}</blockquote>`);
|
|
589
|
+
i++;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── Pass-through ──
|
|
594
|
+
result.push(line);
|
|
595
|
+
i++;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Merge consecutive blockquotes into a single element.
|
|
599
|
+
// <blockquote>A</blockquote>\n<blockquote>B</blockquote>
|
|
600
|
+
// → <blockquote>A\nB</blockquote>
|
|
601
|
+
let joined = result.join('\n');
|
|
602
|
+
joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
603
|
+
return joined;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ════════════════════════════════════════════════════════════════════
|
|
607
|
+
// Table processing (line walker)
|
|
608
|
+
// ════════════════════════════════════════════════════════════════════
|
|
609
|
+
|
|
372
610
|
/**
|
|
373
|
-
*
|
|
611
|
+
* Inline markdown formatter for table cells.
|
|
612
|
+
* Handles bold, italic, strikethrough, and code within cell text.
|
|
613
|
+
* Links / images / autolinks are handled by the global inline pass
|
|
614
|
+
* (Phase 3 Step 4) which runs after table processing.
|
|
374
615
|
*/
|
|
375
616
|
function processInlineMarkdown(text, getAttr) {
|
|
376
|
-
|
|
377
|
-
// Process inline formatting patterns
|
|
378
617
|
const patterns = [
|
|
379
618
|
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
380
619
|
[/__(.+?)__/g, 'strong'],
|
|
@@ -383,27 +622,32 @@
|
|
|
383
622
|
[/~~(.+?)~~/g, 'del'],
|
|
384
623
|
[/`([^`]+)`/g, 'code']
|
|
385
624
|
];
|
|
386
|
-
|
|
387
625
|
patterns.forEach(([pattern, tag]) => {
|
|
388
626
|
text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
389
627
|
});
|
|
390
|
-
|
|
391
628
|
return text;
|
|
392
629
|
}
|
|
393
630
|
|
|
394
631
|
/**
|
|
395
|
-
*
|
|
632
|
+
* processTable — line walker for markdown tables
|
|
633
|
+
*
|
|
634
|
+
* Walks through lines looking for runs of pipe-containing lines.
|
|
635
|
+
* Each run is validated (must contain a separator row: |---|---|)
|
|
636
|
+
* and rendered as an HTML <table>. Invalid runs are restored as-is.
|
|
637
|
+
*
|
|
638
|
+
* @param {string} text Full document text
|
|
639
|
+
* @param {Function} getAttr Attribute factory
|
|
640
|
+
* @returns {string} Text with tables rendered
|
|
396
641
|
*/
|
|
397
642
|
function processTable(text, getAttr) {
|
|
398
643
|
const lines = text.split('\n');
|
|
399
644
|
const result = [];
|
|
400
645
|
let inTable = false;
|
|
401
646
|
let tableLines = [];
|
|
402
|
-
|
|
647
|
+
|
|
403
648
|
for (let i = 0; i < lines.length; i++) {
|
|
404
649
|
const line = lines[i].trim();
|
|
405
|
-
|
|
406
|
-
// Check if this line looks like a table row (with or without trailing |)
|
|
650
|
+
|
|
407
651
|
if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
|
|
408
652
|
if (!inTable) {
|
|
409
653
|
inTable = true;
|
|
@@ -411,14 +655,11 @@
|
|
|
411
655
|
}
|
|
412
656
|
tableLines.push(line);
|
|
413
657
|
} else {
|
|
414
|
-
// Not a table line
|
|
415
658
|
if (inTable) {
|
|
416
|
-
// Process the accumulated table
|
|
417
659
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
418
660
|
if (tableHtml) {
|
|
419
661
|
result.push(tableHtml);
|
|
420
662
|
} else {
|
|
421
|
-
// Not a valid table, restore original lines
|
|
422
663
|
result.push(...tableLines);
|
|
423
664
|
}
|
|
424
665
|
inTable = false;
|
|
@@ -427,8 +668,8 @@
|
|
|
427
668
|
result.push(lines[i]);
|
|
428
669
|
}
|
|
429
670
|
}
|
|
430
|
-
|
|
431
|
-
// Handle table at end of
|
|
671
|
+
|
|
672
|
+
// Handle table at end of document
|
|
432
673
|
if (inTable && tableLines.length > 0) {
|
|
433
674
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
434
675
|
if (tableHtml) {
|
|
@@ -437,35 +678,35 @@
|
|
|
437
678
|
result.push(...tableLines);
|
|
438
679
|
}
|
|
439
680
|
}
|
|
440
|
-
|
|
681
|
+
|
|
441
682
|
return result.join('\n');
|
|
442
683
|
}
|
|
443
684
|
|
|
444
685
|
/**
|
|
445
|
-
*
|
|
686
|
+
* buildTable — validate and render a table from accumulated lines
|
|
687
|
+
*
|
|
688
|
+
* @param {string[]} lines Array of pipe-containing lines
|
|
689
|
+
* @param {Function} getAttr Attribute factory
|
|
690
|
+
* @returns {string|null} HTML table string, or null if invalid
|
|
446
691
|
*/
|
|
447
692
|
function buildTable(lines, getAttr) {
|
|
448
|
-
|
|
449
693
|
if (lines.length < 2) return null;
|
|
450
|
-
|
|
451
|
-
//
|
|
694
|
+
|
|
695
|
+
// Find the separator row (---|---|)
|
|
452
696
|
let separatorIndex = -1;
|
|
453
697
|
for (let i = 1; i < lines.length; i++) {
|
|
454
|
-
// Support separator with or without leading/trailing pipes
|
|
455
698
|
if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
|
|
456
699
|
separatorIndex = i;
|
|
457
700
|
break;
|
|
458
701
|
}
|
|
459
702
|
}
|
|
460
|
-
|
|
461
703
|
if (separatorIndex === -1) return null;
|
|
462
|
-
|
|
704
|
+
|
|
463
705
|
const headerLines = lines.slice(0, separatorIndex);
|
|
464
706
|
const bodyLines = lines.slice(separatorIndex + 1);
|
|
465
|
-
|
|
466
|
-
// Parse alignment from separator
|
|
707
|
+
|
|
708
|
+
// Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
|
|
467
709
|
const separator = lines[separatorIndex];
|
|
468
|
-
// Handle pipes at start/end or not
|
|
469
710
|
const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
470
711
|
const alignments = separatorCells.map(cell => {
|
|
471
712
|
const trimmed = cell.trim();
|
|
@@ -473,31 +714,28 @@
|
|
|
473
714
|
if (trimmed.endsWith(':')) return 'right';
|
|
474
715
|
return 'left';
|
|
475
716
|
});
|
|
476
|
-
|
|
717
|
+
|
|
477
718
|
let html = `<table${getAttr('table')}>\n`;
|
|
478
|
-
|
|
479
|
-
//
|
|
480
|
-
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
719
|
+
|
|
720
|
+
// Header
|
|
481
721
|
html += `<thead${getAttr('thead')}>\n`;
|
|
482
722
|
headerLines.forEach(line => {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
html += '</tr>\n';
|
|
723
|
+
html += `<tr${getAttr('tr')}>\n`;
|
|
724
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
725
|
+
cells.forEach((cell, i) => {
|
|
726
|
+
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
727
|
+
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
|
|
728
|
+
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
729
|
+
});
|
|
730
|
+
html += '</tr>\n';
|
|
492
731
|
});
|
|
493
732
|
html += '</thead>\n';
|
|
494
|
-
|
|
495
|
-
//
|
|
733
|
+
|
|
734
|
+
// Body
|
|
496
735
|
if (bodyLines.length > 0) {
|
|
497
736
|
html += `<tbody${getAttr('tbody')}>\n`;
|
|
498
737
|
bodyLines.forEach(line => {
|
|
499
738
|
html += `<tr${getAttr('tr')}>\n`;
|
|
500
|
-
// Handle pipes at start/end or not
|
|
501
739
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
502
740
|
cells.forEach((cell, i) => {
|
|
503
741
|
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
@@ -508,66 +746,81 @@
|
|
|
508
746
|
});
|
|
509
747
|
html += '</tbody>\n';
|
|
510
748
|
}
|
|
511
|
-
|
|
749
|
+
|
|
512
750
|
html += '</table>';
|
|
513
751
|
return html;
|
|
514
752
|
}
|
|
515
753
|
|
|
754
|
+
// ════════════════════════════════════════════════════════════════════
|
|
755
|
+
// List processing (line walker)
|
|
756
|
+
// ════════════════════════════════════════════════════════════════════
|
|
757
|
+
|
|
516
758
|
/**
|
|
517
|
-
*
|
|
759
|
+
* processLists — line walker for ordered, unordered, and task lists
|
|
760
|
+
*
|
|
761
|
+
* Scans each line for list markers (-, *, +, 1., 2., etc.) with
|
|
762
|
+
* optional leading indentation for nesting. Non-list lines close
|
|
763
|
+
* any open lists and pass through unchanged.
|
|
764
|
+
*
|
|
765
|
+
* Task lists (- [ ] / - [x]) are detected and rendered with
|
|
766
|
+
* checkbox inputs.
|
|
767
|
+
*
|
|
768
|
+
* @param {string} text Full document text
|
|
769
|
+
* @param {Function} getAttr Attribute factory
|
|
770
|
+
* @param {boolean} inline_styles Whether to use inline styles
|
|
771
|
+
* @param {boolean} bidirectional Whether to add data-qd markers
|
|
772
|
+
* @returns {string} Text with lists rendered
|
|
518
773
|
*/
|
|
519
774
|
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
520
|
-
|
|
521
775
|
const lines = text.split('\n');
|
|
522
776
|
const result = [];
|
|
523
|
-
const listStack = [];
|
|
524
|
-
|
|
777
|
+
const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
|
|
778
|
+
|
|
525
779
|
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
|
|
526
780
|
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
|
|
527
781
|
// callback is defensive-only and never actually fires in practice.
|
|
782
|
+
/* istanbul ignore next - defensive: list markers never trigger escaping */
|
|
528
783
|
const escapeHtml = (text) => text.replace(/[&<>"']/g,
|
|
529
784
|
/* istanbul ignore next - defensive: list markers never contain HTML specials */
|
|
530
785
|
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
531
786
|
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
|
|
532
787
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
533
|
-
|
|
788
|
+
|
|
534
789
|
for (let i = 0; i < lines.length; i++) {
|
|
535
790
|
const line = lines[i];
|
|
536
791
|
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
537
|
-
|
|
792
|
+
|
|
538
793
|
if (match) {
|
|
539
794
|
const [, indent, marker, content] = match;
|
|
540
795
|
const level = Math.floor(indent.length / 2);
|
|
541
796
|
const isOrdered = /^\d+\./.test(marker);
|
|
542
797
|
const listType = isOrdered ? 'ol' : 'ul';
|
|
543
|
-
|
|
544
|
-
//
|
|
798
|
+
|
|
799
|
+
// Task list detection (only in unordered lists)
|
|
545
800
|
let listItemContent = content;
|
|
546
801
|
let taskListClass = '';
|
|
547
802
|
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
|
|
548
803
|
if (taskMatch && !isOrdered) {
|
|
549
804
|
const [, checked, taskContent] = taskMatch;
|
|
550
805
|
const isChecked = checked.toLowerCase() === 'x';
|
|
551
|
-
const checkboxAttr = inline_styles
|
|
552
|
-
? ' style="margin-right:.5em"'
|
|
806
|
+
const checkboxAttr = inline_styles
|
|
807
|
+
? ' style="margin-right:.5em"'
|
|
553
808
|
: ` class="${CLASS_PREFIX}task-checkbox"`;
|
|
554
809
|
listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
|
|
555
810
|
taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
|
|
556
811
|
}
|
|
557
|
-
|
|
558
|
-
// Close deeper levels
|
|
812
|
+
|
|
813
|
+
// Close deeper nesting levels
|
|
559
814
|
while (listStack.length > level + 1) {
|
|
560
815
|
const list = listStack.pop();
|
|
561
816
|
result.push(`</${list.type}>`);
|
|
562
817
|
}
|
|
563
|
-
|
|
564
|
-
// Open new
|
|
818
|
+
|
|
819
|
+
// Open new list or switch type at current level
|
|
565
820
|
if (listStack.length === level) {
|
|
566
|
-
// Need to open a new list
|
|
567
821
|
listStack.push({ type: listType, level });
|
|
568
822
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
569
823
|
} else if (listStack.length === level + 1) {
|
|
570
|
-
// Check if we need to switch list type
|
|
571
824
|
const currentList = listStack[listStack.length - 1];
|
|
572
825
|
if (currentList.type !== listType) {
|
|
573
826
|
result.push(`</${currentList.type}>`);
|
|
@@ -576,11 +829,11 @@
|
|
|
576
829
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
577
830
|
}
|
|
578
831
|
}
|
|
579
|
-
|
|
832
|
+
|
|
580
833
|
const liAttr = taskListClass || getAttr('li');
|
|
581
834
|
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
582
835
|
} else {
|
|
583
|
-
// Not a list item
|
|
836
|
+
// Not a list item — close all open lists
|
|
584
837
|
while (listStack.length > 0) {
|
|
585
838
|
const list = listStack.pop();
|
|
586
839
|
result.push(`</${list.type}>`);
|
|
@@ -588,76 +841,76 @@
|
|
|
588
841
|
result.push(line);
|
|
589
842
|
}
|
|
590
843
|
}
|
|
591
|
-
|
|
592
|
-
// Close any remaining lists
|
|
844
|
+
|
|
845
|
+
// Close any remaining open lists
|
|
593
846
|
while (listStack.length > 0) {
|
|
594
847
|
const list = listStack.pop();
|
|
595
848
|
result.push(`</${list.type}>`);
|
|
596
849
|
}
|
|
597
|
-
|
|
850
|
+
|
|
598
851
|
return result.join('\n');
|
|
599
852
|
}
|
|
600
853
|
|
|
854
|
+
// ════════════════════════════════════════════════════════════════════
|
|
855
|
+
// Static API
|
|
856
|
+
// ════════════════════════════════════════════════════════════════════
|
|
857
|
+
|
|
601
858
|
/**
|
|
602
|
-
* Emit CSS
|
|
603
|
-
*
|
|
604
|
-
* @param {string}
|
|
605
|
-
* @
|
|
859
|
+
* Emit CSS rules for all quikdown elements.
|
|
860
|
+
*
|
|
861
|
+
* @param {string} prefix Class prefix (default: 'quikdown-')
|
|
862
|
+
* @param {string} theme 'light' (default) or 'dark'
|
|
863
|
+
* @returns {string} CSS text
|
|
606
864
|
*/
|
|
607
865
|
quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
608
866
|
const styles = QUIKDOWN_STYLES;
|
|
609
|
-
|
|
610
|
-
// Define theme color overrides
|
|
867
|
+
|
|
611
868
|
const themeOverrides = {
|
|
612
869
|
dark: {
|
|
613
|
-
'#f4f4f4': '#2a2a2a',
|
|
614
|
-
'#f0f0f0': '#2a2a2a',
|
|
615
|
-
'#f2f2f2': '#2a2a2a',
|
|
616
|
-
'#ddd': '#3a3a3a',
|
|
617
|
-
'#06c': '#6db3f2',
|
|
870
|
+
'#f4f4f4': '#2a2a2a', // pre background
|
|
871
|
+
'#f0f0f0': '#2a2a2a', // code background
|
|
872
|
+
'#f2f2f2': '#2a2a2a', // th background
|
|
873
|
+
'#ddd': '#3a3a3a', // borders
|
|
874
|
+
'#06c': '#6db3f2', // links
|
|
618
875
|
_textColor: '#e0e0e0'
|
|
619
876
|
},
|
|
620
877
|
light: {
|
|
621
|
-
_textColor: '#333'
|
|
878
|
+
_textColor: '#333'
|
|
622
879
|
}
|
|
623
880
|
};
|
|
624
|
-
|
|
881
|
+
|
|
625
882
|
let css = '';
|
|
626
883
|
for (const [tag, style] of Object.entries(styles)) {
|
|
627
884
|
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}`;
|
|
885
|
+
|
|
886
|
+
if (theme === 'dark' && themeOverrides.dark) {
|
|
887
|
+
for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
|
|
888
|
+
if (!oldColor.startsWith('_')) {
|
|
889
|
+
themedStyle = themedStyle.replaceAll(oldColor, newColor);
|
|
648
890
|
}
|
|
649
891
|
}
|
|
650
|
-
|
|
892
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
893
|
+
if (needsTextColor.includes(tag)) {
|
|
894
|
+
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
895
|
+
}
|
|
896
|
+
} else if (theme === 'light' && themeOverrides.light) {
|
|
897
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
898
|
+
if (needsTextColor.includes(tag)) {
|
|
899
|
+
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
651
903
|
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
652
904
|
}
|
|
653
|
-
|
|
905
|
+
|
|
654
906
|
return css;
|
|
655
907
|
};
|
|
656
908
|
|
|
657
909
|
/**
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
* @
|
|
910
|
+
* Create a pre-configured parser with baked-in options.
|
|
911
|
+
*
|
|
912
|
+
* @param {Object} options Options to bake in
|
|
913
|
+
* @returns {Function} Configured quikdown(markdown) function
|
|
661
914
|
*/
|
|
662
915
|
quikdown.configure = function(options) {
|
|
663
916
|
return function(markdown) {
|
|
@@ -665,18 +918,18 @@
|
|
|
665
918
|
};
|
|
666
919
|
};
|
|
667
920
|
|
|
668
|
-
/**
|
|
669
|
-
* Version information
|
|
670
|
-
*/
|
|
921
|
+
/** Semantic version (injected at build time) */
|
|
671
922
|
quikdown.version = quikdownVersion;
|
|
672
923
|
|
|
673
|
-
//
|
|
924
|
+
// ════════════════════════════════════════════════════════════════════
|
|
925
|
+
// Exports
|
|
926
|
+
// ════════════════════════════════════════════════════════════════════
|
|
927
|
+
|
|
674
928
|
/* istanbul ignore next */
|
|
675
929
|
if (typeof module !== 'undefined' && module.exports) {
|
|
676
930
|
module.exports = quikdown;
|
|
677
931
|
}
|
|
678
932
|
|
|
679
|
-
// For browser global
|
|
680
933
|
/* istanbul ignore next */
|
|
681
934
|
if (typeof window !== 'undefined') {
|
|
682
935
|
window.quikdown = quikdown;
|