quikdown 1.0.2 → 1.0.3

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.
@@ -0,0 +1,808 @@
1
+ /**
2
+ * quikdown-lex - Lightweight Markdown Parser (Lexer Implementation)
3
+ * @version 1.0.3dev4
4
+ * @license BSD-2-Clause
5
+ * @copyright DeftIO 2025
6
+ */
7
+ /**
8
+ * quikdown-lex - Hand-coded lexer/parser implementation
9
+ *
10
+ * This is a state-machine based markdown parser that processes input
11
+ * line-by-line with explicit state tracking. The approach trades regex
12
+ * complexity for hand-coded state transitions, resulting in smaller
13
+ * minified size and more predictable performance.
14
+ *
15
+ * Architecture:
16
+ * 1. Line-by-line processing with lookahead
17
+ * 2. Explicit state tracking (NORMAL, FENCE, TABLE, LIST, BLOCKQUOTE)
18
+ * 3. Single-pass inline processing
19
+ * 4. Direct HTML generation (no intermediate AST)
20
+ *
21
+ * @version __QUIKDOWN_VERSION__
22
+ */
23
+
24
+ // ===========================================================================
25
+ // CONSTANTS & CONFIGURATION
26
+ // ===========================================================================
27
+
28
+ // Compact style map - keys match HTML tags, values are CSS strings
29
+ // Optimized: no spaces after colons, decimal values shortened
30
+ const STYLES = {
31
+ h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
32
+ h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
33
+ h3: 'font-size:1.25em;font-weight:600;margin:1em 0',
34
+ h4: 'font-size:1em;font-weight:600;margin:1.33em 0',
35
+ h5: 'font-size:.875em;font-weight:600;margin:1.67em 0',
36
+ h6: 'font-size:.85em;font-weight:600;margin:2em 0',
37
+ pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
38
+ code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
39
+ blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
40
+ table: 'border-collapse:collapse;width:100%;margin:1em 0',
41
+ th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
42
+ td: 'border:1px solid #ddd;padding:8px;text-align:left',
43
+ hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
44
+ img: 'max-width:100%;height:auto',
45
+ a: 'color:#06c;text-decoration:underline',
46
+ strong: 'font-weight:bold',
47
+ em: 'font-style:italic',
48
+ del: 'text-decoration:line-through',
49
+ ul: 'margin:.5em 0;padding-left:2em',
50
+ ol: 'margin:.5em 0;padding-left:2em',
51
+ li: 'margin:.25em 0',
52
+ 'task-item': 'list-style:none',
53
+ 'task-checkbox': 'margin-right:.5em'
54
+ };
55
+
56
+ // HTML escape map for XSS prevention
57
+ const ESC_MAP = {
58
+ '&': '&',
59
+ '<': '&lt;',
60
+ '>': '&gt;',
61
+ '"': '&quot;',
62
+ "'": '&#39;'
63
+ };
64
+
65
+ // Line type constants for state machine
66
+ const LINE_BLANK = 0;
67
+ const LINE_HEADING = 1;
68
+ const LINE_HR = 2;
69
+ const LINE_FENCE = 3;
70
+ const LINE_BLOCKQUOTE = 4;
71
+ const LINE_LIST_UNORDERED = 5;
72
+ const LINE_LIST_ORDERED = 6;
73
+ const LINE_TABLE = 7;
74
+ const LINE_TABLE_SEP = 8;
75
+ const LINE_TEXT = 9;
76
+
77
+ // Parser states
78
+ const STATE_NORMAL = 0;
79
+ const STATE_FENCE = 1;
80
+ const STATE_BLOCKQUOTE = 4;
81
+ const STATE_PARAGRAPH = 5;
82
+
83
+ // ===========================================================================
84
+ // MAIN PARSER FUNCTION
85
+ // ===========================================================================
86
+
87
+ function quikdown(markdown, options = {}) {
88
+ // Early return for invalid input
89
+ if (!markdown || typeof markdown !== 'string') return '';
90
+
91
+ // Parse options with defaults
92
+ const opts = {
93
+ inline_styles: options.inline_styles || false,
94
+ class_prefix: options.class_prefix || 'quikdown-',
95
+ allow_unsafe_urls: options.allow_unsafe_urls || false,
96
+ fence_plugin: options.fence_plugin || null
97
+ };
98
+
99
+ // Split into lines for processing
100
+ const lines = markdown.split('\n');
101
+ const output = [];
102
+
103
+ // Parser state
104
+ let state = STATE_NORMAL;
105
+ let stateData = null; // Holds state-specific data
106
+
107
+ // Buffers for accumulating content
108
+ let paragraphBuffer = [];
109
+ let blockquoteBuffer = [];
110
+
111
+ // ===========================================================================
112
+ // HELPER FUNCTIONS
113
+ // ===========================================================================
114
+
115
+ /**
116
+ * Generate HTML attribute (class or inline style)
117
+ * @param {string} tag - HTML tag name
118
+ * @param {string} extraStyle - Additional inline styles
119
+ * @returns {string} HTML attribute string
120
+ */
121
+ const getAttr = (tag, extraStyle = '') => {
122
+ if (opts.inline_styles) {
123
+ const baseStyle = STYLES[tag] || '';
124
+ const combined = extraStyle
125
+ ? (baseStyle ? `${baseStyle};${extraStyle}` : extraStyle)
126
+ : baseStyle;
127
+ return combined ? ` style="${combined}"` : '';
128
+ }
129
+ return ` class="${opts.class_prefix}${tag}"`;
130
+ };
131
+
132
+ /**
133
+ * Escape HTML entities to prevent XSS
134
+ * @param {string} str - Input string
135
+ * @returns {string} Escaped string
136
+ */
137
+ const escapeHtml = (str) => {
138
+ return str.replace(/[&<>"']/g, m => ESC_MAP[m]);
139
+ };
140
+
141
+ /**
142
+ * Sanitize URLs to prevent XSS attacks
143
+ * @param {string} url - Input URL
144
+ * @returns {string} Sanitized URL or '#' if dangerous
145
+ */
146
+ const sanitizeUrl = (url) => {
147
+ if (!url) return '';
148
+ if (opts.allow_unsafe_urls) return url;
149
+
150
+ const trimmed = url.trim();
151
+ const lower = trimmed.toLowerCase();
152
+
153
+ // Block dangerous protocols except data:image
154
+ if (/^(javascript|vbscript|data):/i.test(lower)) {
155
+ if (/^data:image\//i.test(lower)) return trimmed;
156
+ return '#';
157
+ }
158
+
159
+ return trimmed;
160
+ };
161
+
162
+ /**
163
+ * Process inline markdown elements (bold, italic, links, etc.)
164
+ * Single-pass processing with minimal allocations
165
+ * @param {string} text - Input text
166
+ * @returns {string} HTML with inline formatting
167
+ */
168
+ const processInline = (text) => {
169
+ if (!text) return '';
170
+
171
+ // Step 1: Protect inline code by extracting it
172
+ const codes = [];
173
+ text = text.replace(/`([^`]+)`/g, (_, code) => {
174
+ codes.push(escapeHtml(code));
175
+ return `\x01${codes.length - 1}\x02`; // Use control chars as markers
176
+ });
177
+
178
+ // Step 2: Escape HTML entities
179
+ text = escapeHtml(text);
180
+
181
+ // Step 3: Process images (must come before links)
182
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
183
+ return `<img${getAttr('img')} src="${sanitizeUrl(src)}" alt="${alt}">`;
184
+ });
185
+
186
+ // Step 4: Process links
187
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
188
+ const url = sanitizeUrl(href);
189
+ const isExternal = /^https?:\/\//i.test(url);
190
+ const rel = isExternal ? ' rel="noopener noreferrer"' : '';
191
+ return `<a${getAttr('a')} href="${url}"${rel}>${label}</a>`;
192
+ });
193
+
194
+ // Step 5: Process autolinks
195
+ text = text.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (_, prefix, url) => {
196
+ return `${prefix}<a${getAttr('a')} href="${sanitizeUrl(url)}" rel="noopener noreferrer">${url}</a>`;
197
+ });
198
+
199
+ // Step 6: Process bold (** and __)
200
+ text = text.replace(/\*\*(.+?)\*\*/g, `<strong${getAttr('strong')}>$1</strong>`);
201
+ text = text.replace(/__(.+?)__/g, `<strong${getAttr('strong')}>$1</strong>`);
202
+
203
+ // Step 7: Process italic (* and _) - using lookahead/behind
204
+ text = text.replace(/(?<!\*)\*(?!\*)([^*]+)\*(?!\*)/g, `<em${getAttr('em')}>$1</em>`);
205
+ text = text.replace(/(?<!_)_(?!_)([^_]+)_(?!_)/g, `<em${getAttr('em')}>$1</em>`);
206
+
207
+ // Step 8: Process strikethrough
208
+ text = text.replace(/~~(.+?)~~/g, `<del${getAttr('del')}>$1</del>`);
209
+
210
+ // Step 9: Process line breaks (two spaces at end of line)
211
+ text = text.replace(/ $/gm, `<br${getAttr('br')}>`);
212
+
213
+ // Step 10: Restore inline code
214
+ text = text.replace(/\x01(\d+)\x02/g, (_, idx) => {
215
+ return `<code${getAttr('code')}>${codes[idx]}</code>`;
216
+ });
217
+
218
+ return text;
219
+ };
220
+
221
+ /**
222
+ * Identify line type using optimized checks
223
+ * @param {string} line - Input line
224
+ * @returns {number} Line type constant
225
+ */
226
+ const getLineType = (line) => {
227
+ const trimmed = line.trim();
228
+
229
+ // Empty line
230
+ if (!trimmed) return LINE_BLANK;
231
+
232
+ // Use first character for quick discrimination
233
+ const firstChar = trimmed[0];
234
+
235
+ switch (firstChar) {
236
+ case '#':
237
+ // Heading: # through ######
238
+ if (/^#{1,6}\s+/.test(trimmed)) return LINE_HEADING;
239
+ break;
240
+
241
+ case '-':
242
+ case '*':
243
+ case '_':
244
+ // Could be HR or list
245
+ if (/^[-*_](\s*[-*_]){2,}$/.test(trimmed)) return LINE_HR;
246
+ if (/^[*+-]\s+/.test(trimmed)) return LINE_LIST_UNORDERED;
247
+ break;
248
+
249
+ case '+':
250
+ // Unordered list with +
251
+ if (/^\+\s+/.test(trimmed)) return LINE_LIST_UNORDERED;
252
+ break;
253
+
254
+ case '`':
255
+ case '~':
256
+ // Fence marker (3+ backticks or tildes)
257
+ if (/^[`~]{3,}/.test(trimmed)) return LINE_FENCE;
258
+ break;
259
+
260
+ case '>':
261
+ // Blockquote
262
+ return LINE_BLOCKQUOTE;
263
+
264
+ case '|':
265
+ // Table (starts with pipe)
266
+ if (/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/.test(trimmed)) {
267
+ return LINE_TABLE_SEP;
268
+ }
269
+ return LINE_TABLE;
270
+
271
+ default:
272
+ // Check for ordered list (digit)
273
+ if (/^\d+\.\s+/.test(trimmed)) return LINE_LIST_ORDERED;
274
+
275
+ // Check for table without leading pipe
276
+ if (trimmed.includes('|')) {
277
+ if (/^\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\s*$/.test(trimmed)) {
278
+ return LINE_TABLE_SEP;
279
+ }
280
+ return LINE_TABLE;
281
+ }
282
+ }
283
+
284
+ // Check indented list items
285
+ if (/^\s+[*+-]\s+/.test(line)) return LINE_LIST_UNORDERED;
286
+ if (/^\s+\d+\.\s+/.test(line)) return LINE_LIST_ORDERED;
287
+
288
+ return LINE_TEXT;
289
+ };
290
+
291
+ /**
292
+ * Flush accumulated paragraph buffer to output
293
+ */
294
+ const flushParagraph = () => {
295
+ if (paragraphBuffer.length > 0) {
296
+ const content = paragraphBuffer.join('\n');
297
+ output.push(`<p>${processInline(content)}</p>`);
298
+ paragraphBuffer = [];
299
+ }
300
+ };
301
+
302
+ /**
303
+ * Flush accumulated blockquote buffer to output
304
+ */
305
+ const flushBlockquote = () => {
306
+ if (blockquoteBuffer.length > 0) {
307
+ const innerContent = blockquoteBuffer.join('\n').trim();
308
+
309
+ // Check if it's a simple single-line blockquote without block elements
310
+ if (blockquoteBuffer.length === 1 && !innerContent.includes('\n')) {
311
+ // Simple blockquote - just process inline
312
+ output.push(`<blockquote${getAttr('blockquote')}>${processInline(innerContent)}</blockquote>`);
313
+ } else if (blockquoteBuffer.length === 2 && blockquoteBuffer[1] === '' && !blockquoteBuffer[0].includes('\n')) {
314
+ // Two lines but second is empty - treat as single line
315
+ output.push(`<blockquote${getAttr('blockquote')}>${processInline(blockquoteBuffer[0])}</blockquote>`);
316
+ } else {
317
+ // Multi-line blockquote - treat all lines as single block
318
+ const lines = blockquoteBuffer.filter(line => line !== '');
319
+ if (lines.length === 0) ; else if (lines.length === 2 && lines.every(line => !line.includes('\n') && line.trim().length > 0)) {
320
+ // Two consecutive lines - keep them separate as two blockquotes
321
+ output.push(`<blockquote${getAttr('blockquote')}>${processInline(lines[0])}</blockquote>`);
322
+ output.push(`<blockquote${getAttr('blockquote')}>${processInline(lines[1])}</blockquote>`);
323
+ } else {
324
+ // Complex content - recursively parse
325
+ const innerHtml = quikdown(innerContent, opts);
326
+ output.push(`<blockquote${getAttr('blockquote')}>${innerHtml}</blockquote>`);
327
+ }
328
+ }
329
+ blockquoteBuffer = [];
330
+ }
331
+ };
332
+
333
+ /**
334
+ * Process a list starting at current position
335
+ * @param {number} startIdx - Starting line index
336
+ * @returns {number} Next line index to process
337
+ */
338
+ const processList = (startIdx) => {
339
+ const listStack = []; // Stack of { type, indent, items }
340
+ let i = startIdx;
341
+
342
+ while (i < lines.length) {
343
+ const line = lines[i];
344
+ const match = line.match(/^(\s*)([*+-]|\d+\.)\s+(.+)$/);
345
+
346
+ if (!match) {
347
+ // Not a list item, end list processing
348
+ break;
349
+ }
350
+
351
+ const [, spaces, marker, content] = match;
352
+ const indent = Math.floor(spaces.length / 2);
353
+ const isOrdered = /^\d+\./.test(marker);
354
+ const listType = isOrdered ? 'ol' : 'ul';
355
+
356
+ // Process task list syntax
357
+ let itemContent = content;
358
+ let itemAttr = getAttr('li');
359
+
360
+ if (!isOrdered) {
361
+ const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
362
+ if (taskMatch) {
363
+ const checked = taskMatch[1].toLowerCase() === 'x';
364
+ const checkboxAttr = opts.inline_styles
365
+ ? ' style="margin-right:.5em"'
366
+ : ` class="${opts.class_prefix}task-checkbox"`;
367
+ itemAttr = opts.inline_styles
368
+ ? ' style="list-style:none"'
369
+ : ` class="${opts.class_prefix}task-item"`;
370
+ itemContent = `<input type="checkbox"${checkboxAttr}${checked ? ' checked' : ''} disabled> ${taskMatch[2]}`;
371
+ }
372
+ }
373
+
374
+ // Manage list stack based on indentation
375
+ while (listStack.length > 0 && indent < listStack[listStack.length - 1].indent) {
376
+ // Close deeper lists
377
+ const closed = listStack.pop();
378
+ closed.items.push(`\n</${closed.type}>`);
379
+ }
380
+
381
+ if (listStack.length === 0 || indent > listStack[listStack.length - 1].indent) {
382
+ // Start new list level
383
+ const newList = {
384
+ type: listType,
385
+ indent: indent,
386
+ items: indent > 0 ? [`\n<${listType}${getAttr(listType)}>`] : [`<${listType}${getAttr(listType)}>`]
387
+ };
388
+ if (indent > 0 && listStack.length > 0) {
389
+ // Nested list - add to parent's items
390
+ listStack[listStack.length - 1].items.push(newList.items[0]);
391
+ newList.items = [];
392
+ }
393
+ listStack.push(newList);
394
+ } else if (listStack[listStack.length - 1].type !== listType) {
395
+ // Different list type at same level - close and open new
396
+ const closed = listStack.pop();
397
+ closed.items.push(`\n</${closed.type}>`);
398
+ if (listStack.length > 0) {
399
+ listStack[listStack.length - 1].items.push(closed.items.join(''));
400
+ } else {
401
+ output.push(closed.items.join(''));
402
+ }
403
+ const newList = {
404
+ type: listType,
405
+ indent: indent,
406
+ items: [`<${listType}${getAttr(listType)}>`]
407
+ };
408
+ listStack.push(newList);
409
+ }
410
+
411
+ // Add list item to current list
412
+ listStack[listStack.length - 1].items.push(
413
+ `\n<li${itemAttr}>${processInline(itemContent)}</li>`
414
+ );
415
+
416
+ i++;
417
+ }
418
+
419
+ // Close all open lists
420
+ while (listStack.length > 1) {
421
+ const closed = listStack.pop();
422
+ closed.items.push(`\n</${closed.type}>`);
423
+ listStack[listStack.length - 1].items.push(closed.items.join(''));
424
+ }
425
+
426
+ if (listStack.length > 0) {
427
+ const finalList = listStack[0];
428
+ finalList.items.push(`\n</${finalList.type}>`);
429
+ output.push(finalList.items.join(''));
430
+ }
431
+
432
+ return i;
433
+ };
434
+
435
+ /**
436
+ * Process a table starting at current position
437
+ * @param {number} startIdx - Starting line index
438
+ * @returns {number} Next line index to process
439
+ */
440
+ const processTable = (startIdx) => {
441
+ let i = startIdx;
442
+ const headerCells = [];
443
+ let alignments = null;
444
+ const bodyRows = [];
445
+
446
+ // Parse first row as potential header
447
+ const firstLine = lines[i].trim();
448
+ const firstCells = firstLine.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
449
+ headerCells.push(...firstCells);
450
+ i++;
451
+
452
+ // Check for separator line - REQUIRED for valid table
453
+ if (i < lines.length) {
454
+ const lineType = getLineType(lines[i]);
455
+ if (lineType === LINE_TABLE_SEP) {
456
+ // Parse alignments from separator
457
+ const sepLine = lines[i].trim();
458
+ const sepCells = sepLine.replace(/^\|/, '').replace(/\|$/, '').split('|');
459
+ alignments = sepCells.map(cell => {
460
+ const trimmed = cell.trim();
461
+ if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
462
+ if (trimmed.endsWith(':')) return 'right';
463
+ return 'left';
464
+ });
465
+ i++;
466
+ } else {
467
+ // No separator, not a valid table - return without processing
468
+ return startIdx;
469
+ }
470
+ } else {
471
+ // End of input without separator - not a valid table
472
+ return startIdx;
473
+ }
474
+
475
+ // Parse body rows
476
+ while (i < lines.length) {
477
+ const lineType = getLineType(lines[i]);
478
+ if (lineType !== LINE_TABLE && lineType !== LINE_TABLE_SEP) break;
479
+
480
+ const line = lines[i].trim();
481
+ const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
482
+ bodyRows.push(cells);
483
+ i++;
484
+ }
485
+
486
+ // Generate table HTML
487
+ let html = `<table${getAttr('table')}>`;
488
+
489
+ // Add header if present
490
+ if (headerCells.length > 0) {
491
+ html += `\n<thead${getAttr('thead')}>\n<tr${getAttr('tr')}>\n`;
492
+ headerCells.forEach((cell, idx) => {
493
+ const align = alignments && alignments[idx] !== 'left'
494
+ ? `text-align:${alignments[idx]}`
495
+ : '';
496
+ html += `<th${getAttr('th', align)}>${processInline(cell)}</th>\n`;
497
+ });
498
+ html += `</tr>\n</thead>`;
499
+ }
500
+
501
+ // Add body rows
502
+ if (bodyRows.length > 0) {
503
+ html += `\n<tbody${getAttr('tbody')}>\n`;
504
+ bodyRows.forEach(row => {
505
+ html += `<tr${getAttr('tr')}>\n`;
506
+ row.forEach((cell, idx) => {
507
+ const align = alignments && alignments[idx] !== 'left'
508
+ ? `text-align:${alignments[idx]}`
509
+ : '';
510
+ html += `<td${getAttr('td', align)}>${processInline(cell)}</td>\n`;
511
+ });
512
+ html += `</tr>\n`;
513
+ });
514
+ html += `</tbody>`;
515
+ }
516
+
517
+ html += `\n</table>`;
518
+ output.push(html);
519
+
520
+ return i;
521
+ };
522
+
523
+ // ===========================================================================
524
+ // MAIN PARSING LOOP - STATE MACHINE
525
+ // ===========================================================================
526
+
527
+ let i = 0;
528
+ while (i < lines.length) {
529
+ const line = lines[i];
530
+ const lineType = getLineType(line);
531
+
532
+ // STATE MACHINE - Handle current state and line type
533
+ switch (state) {
534
+
535
+ // -----------------------------------------------------------------------
536
+ // NORMAL STATE - Can transition to any other state
537
+ // -----------------------------------------------------------------------
538
+ case STATE_NORMAL:
539
+ switch (lineType) {
540
+ case LINE_BLANK:
541
+ // Blank line - just skip
542
+ i++;
543
+ break;
544
+
545
+ case LINE_HEADING:
546
+ // Parse heading
547
+ const headingMatch = line.trim().match(/^(#{1,6})\s+(.+?)(?:\s*#*)?$/);
548
+ if (headingMatch) {
549
+ const level = headingMatch[1].length;
550
+ const text = headingMatch[2];
551
+ output.push(`<h${level}${getAttr('h' + level)}>${processInline(text)}</h${level}>`);
552
+ }
553
+ i++;
554
+ break;
555
+
556
+ case LINE_HR:
557
+ // Horizontal rule
558
+ output.push(`<hr${getAttr('hr')}>`);
559
+ i++;
560
+ break;
561
+
562
+ case LINE_FENCE:
563
+ // Start fence block
564
+ const fenceMatch = line.trim().match(/^([`~]{3,})(.*)$/);
565
+ if (fenceMatch) {
566
+ state = STATE_FENCE;
567
+ stateData = {
568
+ marker: fenceMatch[1][0],
569
+ count: fenceMatch[1].length,
570
+ lang: (fenceMatch[2] || '').trim(),
571
+ lines: []
572
+ };
573
+ }
574
+ i++;
575
+ break;
576
+
577
+ case LINE_BLOCKQUOTE:
578
+ // Start blockquote
579
+ state = STATE_BLOCKQUOTE;
580
+ blockquoteBuffer = [line.replace(/^\s*>\s?/, '')];
581
+ i++;
582
+ break;
583
+
584
+ case LINE_LIST_UNORDERED:
585
+ case LINE_LIST_ORDERED:
586
+ // Process entire list
587
+ i = processList(i);
588
+ break;
589
+
590
+ case LINE_TABLE:
591
+ case LINE_TABLE_SEP:
592
+ // Process entire table
593
+ const newIdx = processTable(i);
594
+ if (newIdx === i) {
595
+ // Not a valid table, treat as text
596
+ state = STATE_PARAGRAPH;
597
+ paragraphBuffer = [line];
598
+ i++;
599
+ } else {
600
+ i = newIdx;
601
+ }
602
+ break;
603
+
604
+ case LINE_TEXT:
605
+ default:
606
+ // Start paragraph
607
+ state = STATE_PARAGRAPH;
608
+ paragraphBuffer = [line];
609
+ i++;
610
+ break;
611
+ }
612
+ break;
613
+
614
+ // -----------------------------------------------------------------------
615
+ // FENCE STATE - Inside a code fence
616
+ // -----------------------------------------------------------------------
617
+ case STATE_FENCE:
618
+ // Check for closing fence
619
+ const trimmed = line.trim();
620
+ const closePattern = new RegExp(`^${stateData.marker}{${stateData.count},}\\s*$`);
621
+
622
+ if (closePattern.test(trimmed)) {
623
+ // End fence - output code block
624
+ const code = stateData.lines.join('\n');
625
+ let output_html = '';
626
+
627
+ // Try fence plugin first
628
+ if (opts.fence_plugin) {
629
+ output_html = opts.fence_plugin(code, stateData.lang);
630
+ }
631
+
632
+ // Fall back to default rendering
633
+ if (!output_html || output_html === undefined) {
634
+ const langAttr = !opts.inline_styles && stateData.lang
635
+ ? ` class="language-${stateData.lang}"`
636
+ : '';
637
+ const codeAttr = opts.inline_styles ? getAttr('code') : langAttr;
638
+ output_html = `<pre${getAttr('pre')}><code${codeAttr}>${escapeHtml(code)}</code></pre>`;
639
+ }
640
+
641
+ output.push(output_html);
642
+ state = STATE_NORMAL;
643
+ stateData = null;
644
+ } else {
645
+ // Continue accumulating fence content
646
+ stateData.lines.push(line);
647
+ }
648
+ i++;
649
+ break;
650
+
651
+ // -----------------------------------------------------------------------
652
+ // PARAGRAPH STATE - Accumulating paragraph lines
653
+ // -----------------------------------------------------------------------
654
+ case STATE_PARAGRAPH:
655
+ switch (lineType) {
656
+ case LINE_BLANK:
657
+ // End paragraph
658
+ flushParagraph();
659
+ state = STATE_NORMAL;
660
+ i++;
661
+ break;
662
+
663
+ case LINE_HEADING:
664
+ case LINE_HR:
665
+ case LINE_FENCE:
666
+ case LINE_BLOCKQUOTE:
667
+ case LINE_LIST_UNORDERED:
668
+ case LINE_LIST_ORDERED:
669
+ case LINE_TABLE:
670
+ case LINE_TABLE_SEP:
671
+ // End paragraph and process new block
672
+ flushParagraph();
673
+ state = STATE_NORMAL;
674
+ // Don't increment i - reprocess this line in NORMAL state
675
+ break;
676
+
677
+ case LINE_TEXT:
678
+ default:
679
+ // Continue paragraph
680
+ paragraphBuffer.push(line);
681
+ i++;
682
+ break;
683
+ }
684
+ break;
685
+
686
+ // -----------------------------------------------------------------------
687
+ // BLOCKQUOTE STATE - Accumulating blockquote lines
688
+ // -----------------------------------------------------------------------
689
+ case STATE_BLOCKQUOTE:
690
+ if (lineType === LINE_BLOCKQUOTE) {
691
+ // Continue blockquote
692
+ blockquoteBuffer.push(line.replace(/^\s*>\s?/, ''));
693
+ i++;
694
+ } else if (lineType === LINE_BLANK && i + 1 < lines.length &&
695
+ getLineType(lines[i + 1]) === LINE_BLOCKQUOTE) {
696
+ // Blank line within blockquote
697
+ blockquoteBuffer.push('');
698
+ i++;
699
+ } else {
700
+ // End blockquote
701
+ flushBlockquote();
702
+ state = STATE_NORMAL;
703
+ // Don't increment i - reprocess this line
704
+ }
705
+ break;
706
+ }
707
+ }
708
+
709
+ // Flush any remaining content
710
+ flushParagraph();
711
+ flushBlockquote();
712
+
713
+ return output.join('').trim();
714
+ }
715
+
716
+ // ===========================================================================
717
+ // STATIC METHODS
718
+ // ===========================================================================
719
+
720
+ /**
721
+ * Emit CSS styles for all quikdown elements
722
+ * @param {string} prefix - Class prefix (default: 'quikdown-')
723
+ * @param {string} theme - Optional theme: 'light' (default) or 'dark'
724
+ * @returns {string} CSS stylesheet
725
+ */
726
+ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
727
+ const styles = STYLES;
728
+
729
+ // Define theme color overrides
730
+ const themeOverrides = {
731
+ dark: {
732
+ '#f4f4f4': '#2a2a2a', // pre background
733
+ '#f0f0f0': '#2a2a2a', // code background
734
+ '#f2f2f2': '#2a2a2a', // th background
735
+ '#ddd': '#3a3a3a', // borders
736
+ '#06c': '#6db3f2', // links
737
+ _textColor: '#e0e0e0'
738
+ },
739
+ light: {
740
+ _textColor: '#333' // Explicit text color for light theme
741
+ }
742
+ };
743
+
744
+ let css = '';
745
+ for (const [tag, style] of Object.entries(styles)) {
746
+ if (style) {
747
+ let themedStyle = style;
748
+
749
+ // Apply theme overrides if dark theme
750
+ if (theme === 'dark' && themeOverrides.dark) {
751
+ // Replace colors
752
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
753
+ if (!oldColor.startsWith('_')) {
754
+ themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
755
+ }
756
+ }
757
+
758
+ // Add text color for certain elements in dark theme
759
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
760
+ if (needsTextColor.includes(tag)) {
761
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
762
+ }
763
+ } else if (theme === 'light' && themeOverrides.light) {
764
+ // Add explicit text color for light theme elements too
765
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
766
+ if (needsTextColor.includes(tag)) {
767
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
768
+ }
769
+ }
770
+
771
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
772
+ }
773
+ }
774
+
775
+ return css;
776
+ };
777
+
778
+ /**
779
+ * Create a configured parser function
780
+ * @param {Object} options - Parser options
781
+ * @returns {Function} Configured parser
782
+ */
783
+ quikdown.configure = function(options) {
784
+ return function(markdown) {
785
+ return quikdown(markdown, options);
786
+ };
787
+ };
788
+
789
+ /**
790
+ * Version string
791
+ */
792
+ quikdown.version = '1.0.3dev4';
793
+
794
+ // ===========================================================================
795
+ // EXPORTS
796
+ // ===========================================================================
797
+
798
+ // CommonJS
799
+ if (typeof module !== 'undefined' && module.exports) {
800
+ module.exports = quikdown;
801
+ }
802
+
803
+ // Browser global
804
+ if (typeof window !== 'undefined') {
805
+ window.quikdown = quikdown;
806
+ }
807
+
808
+ export { quikdown as default };