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