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