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