quikdown 1.0.2 → 1.0.4

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