@bernierllc/markdown-parser 1.2.5 → 1.5.0

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.
@@ -1,517 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import { Logger } from '@bernierllc/logger';
10
- import { MarkdownParserConfig, ParseResult, ASTNode, Position } from './types';
11
- import { CacheManager } from './utils/cache';
12
- import { countNodes } from './utils/ast-utils';
13
-
14
- /**
15
- * High-performance markdown parser with caching and plugin support
16
- */
17
- export class MarkdownParser {
18
- private config: Required<MarkdownParserConfig>;
19
- private cacheManager: CacheManager | null;
20
- private logger: Logger;
21
-
22
- constructor(config?: MarkdownParserConfig) {
23
- this.config = {
24
- gfm: config?.gfm ?? true,
25
- commonmark: config?.commonmark ?? true,
26
- plugins: config?.plugins ?? [],
27
- cache: {
28
- enabled: config?.cache?.enabled ?? false,
29
- maxSize: config?.cache?.maxSize ?? 1000,
30
- ttl: config?.cache?.ttl ?? 3600,
31
- },
32
- logger: config?.logger ?? (console as unknown as Logger),
33
- };
34
-
35
- this.cacheManager = this.config.cache.enabled
36
- ? new CacheManager(this.config.cache.maxSize, this.config.cache.ttl)
37
- : null;
38
-
39
- this.logger = this.config.logger as Logger;
40
- }
41
-
42
- /**
43
- * Parse markdown string to AST
44
- */
45
- async parse(markdown: string): Promise<ParseResult> {
46
- const startTime = Date.now();
47
-
48
- // Check cache
49
- if (this.cacheManager) {
50
- const cacheKey = this.cacheManager.generateKey(markdown);
51
- const cached = this.cacheManager.get(cacheKey);
52
-
53
- if (cached) {
54
- this.logger.debug('Markdown parse cache hit', { cacheKey });
55
- return {
56
- success: true,
57
- ast: cached,
58
- metadata: {
59
- parseTime: Date.now() - startTime,
60
- nodeCount: countNodes(cached),
61
- cached: true,
62
- },
63
- };
64
- }
65
- }
66
-
67
- try {
68
- // Parse markdown to AST
69
- let ast = await this.parseToAST(markdown);
70
-
71
- // Apply plugins
72
- for (const plugin of this.config.plugins) {
73
- ast = plugin.transform(ast);
74
- }
75
-
76
- // Cache result
77
- if (this.cacheManager) {
78
- const cacheKey = this.cacheManager.generateKey(markdown);
79
- this.cacheManager.set(cacheKey, ast);
80
- }
81
-
82
- const parseTime = Date.now() - startTime;
83
- this.logger.debug('Markdown parsed successfully', {
84
- parseTime,
85
- nodeCount: countNodes(ast),
86
- });
87
-
88
- return {
89
- success: true,
90
- ast,
91
- metadata: {
92
- parseTime,
93
- nodeCount: countNodes(ast),
94
- cached: false,
95
- },
96
- };
97
- } catch (err) {
98
- const error = err instanceof Error ? err : new Error('Unknown parse error');
99
- this.logger.error('Markdown parse failed', error);
100
- return {
101
- success: false,
102
- error: error.message,
103
- };
104
- }
105
- }
106
-
107
- /**
108
- * Core parsing logic (CommonMark + GFM)
109
- */
110
- private async parseToAST(markdown: string): Promise<ASTNode> {
111
- const lines = markdown.split('\n');
112
- const root: ASTNode = { type: 'root', children: [] };
113
-
114
- let lineNumber = 0;
115
- let i = 0;
116
-
117
- while (i < lines.length) {
118
- const line = lines[i];
119
- lineNumber = i + 1;
120
-
121
- // Skip empty lines
122
- if (!line.trim()) {
123
- i++;
124
- continue;
125
- }
126
-
127
- // Heading
128
- const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
129
- if (headingMatch) {
130
- const level = headingMatch[1].length;
131
- const text = headingMatch[2].trim();
132
- root.children!.push({
133
- type: 'heading',
134
- attributes: { level },
135
- children: this.parseInline(text),
136
- position: this.createPosition(lineNumber, 0, line.length),
137
- });
138
- i++;
139
- continue;
140
- }
141
-
142
- // Horizontal rule
143
- if (line.match(/^(\*{3,}|-{3,}|_{3,})$/)) {
144
- root.children!.push({
145
- type: 'horizontalRule',
146
- position: this.createPosition(lineNumber, 0, line.length),
147
- });
148
- i++;
149
- continue;
150
- }
151
-
152
- // Code block (fenced)
153
- if (line.match(/^```/)) {
154
- const language = line.slice(3).trim();
155
- const codeLines: string[] = [];
156
- const startLine = lineNumber;
157
- i++;
158
-
159
- while (i < lines.length && !lines[i].match(/^```/)) {
160
- codeLines.push(lines[i]);
161
- i++;
162
- }
163
-
164
- root.children!.push({
165
- type: 'codeBlock',
166
- value: codeLines.join('\n'),
167
- attributes: { language: language || 'plaintext' },
168
- position: this.createPosition(startLine, 0, i - startLine),
169
- });
170
- i++; // Skip closing ```
171
- continue;
172
- }
173
-
174
- // Blockquote
175
- if (line.match(/^>\s/)) {
176
- const quoteLines: string[] = [];
177
- const startLine = lineNumber;
178
-
179
- while (i < lines.length && lines[i].match(/^>\s/)) {
180
- quoteLines.push(lines[i].replace(/^>\s/, ''));
181
- i++;
182
- }
183
-
184
- const quoteContent = quoteLines.join('\n');
185
- const quotedAst = await this.parseToAST(quoteContent);
186
-
187
- root.children!.push({
188
- type: 'blockquote',
189
- children: quotedAst.children,
190
- position: this.createPosition(startLine, 0, i - startLine),
191
- });
192
- continue;
193
- }
194
-
195
- // Task list (GFM) - Check BEFORE unordered list since "- [ ]" matches "- " pattern
196
- if (this.config.gfm && line.match(/^[\s]*-\s\[\s*[ xX]\s*\]/)) {
197
- const checked = line.includes('[x]') || line.includes('[X]');
198
- const text = line.replace(/^[\s]*-\s\[\s*[ xX]\s*\]\s*/, '');
199
- root.children!.push({
200
- type: 'taskListItem',
201
- attributes: { checked },
202
- children: this.parseInline(text),
203
- position: this.createPosition(lineNumber, 0, line.length),
204
- });
205
- i++;
206
- continue;
207
- }
208
-
209
- // Unordered list
210
- if (line.match(/^[\s]*[-*+]\s/)) {
211
- const listItems: ASTNode[] = [];
212
- const startLine = lineNumber;
213
-
214
- while (i < lines.length && lines[i].match(/^[\s]*[-*+]\s/)) {
215
- const itemText = lines[i].replace(/^[\s]*[-*+]\s/, '');
216
- listItems.push({
217
- type: 'listItem',
218
- children: this.parseInline(itemText),
219
- });
220
- i++;
221
- }
222
-
223
- root.children!.push({
224
- type: 'list',
225
- attributes: { ordered: false },
226
- children: listItems,
227
- position: this.createPosition(startLine, 0, i - startLine),
228
- });
229
- continue;
230
- }
231
-
232
- // Ordered list
233
- if (line.match(/^[\s]*\d+\.\s/)) {
234
- const listItems: ASTNode[] = [];
235
- const startLine = lineNumber;
236
-
237
- while (i < lines.length && lines[i].match(/^[\s]*\d+\.\s/)) {
238
- const itemText = lines[i].replace(/^[\s]*\d+\.\s/, '');
239
- listItems.push({
240
- type: 'listItem',
241
- children: this.parseInline(itemText),
242
- });
243
- i++;
244
- }
245
-
246
- root.children!.push({
247
- type: 'list',
248
- attributes: { ordered: true },
249
- children: listItems,
250
- position: this.createPosition(startLine, 0, i - startLine),
251
- });
252
- continue;
253
- }
254
-
255
- // Table (GFM)
256
- if (this.config.gfm && i + 1 < lines.length && lines[i + 1].match(/^\|?[\s]*:?-+:?[\s]*\|/)) {
257
- const table = this.parseTable(lines, i);
258
- root.children!.push(table.node);
259
- i = table.nextIndex;
260
- continue;
261
- }
262
-
263
- // Paragraph (default)
264
- const paragraphLines: string[] = [line];
265
- const startLine = lineNumber;
266
- i++;
267
-
268
- // Collect continuation lines
269
- while (
270
- i < lines.length &&
271
- lines[i].trim() &&
272
- !lines[i].match(/^(#{1,6}\s|```|>\s|[-*+]\s|\d+\.\s|\*{3,}|-{3,}|_{3,})/)
273
- ) {
274
- paragraphLines.push(lines[i]);
275
- i++;
276
- }
277
-
278
- const paragraphText = paragraphLines.join(' ');
279
- root.children!.push({
280
- type: 'paragraph',
281
- children: this.parseInline(paragraphText),
282
- position: this.createPosition(startLine, 0, paragraphLines.length),
283
- });
284
- }
285
-
286
- return root;
287
- }
288
-
289
- /**
290
- * Parse inline formatting (bold, italic, code, links, images)
291
- */
292
- private parseInline(text: string): ASTNode[] {
293
- const nodes: ASTNode[] = [];
294
- let remaining = text;
295
-
296
- while (remaining.length > 0) {
297
- // Bold (**text** or __text__)
298
- const boldMatch = remaining.match(/^(\*\*|__)(.+?)\1/);
299
- if (boldMatch) {
300
- nodes.push({
301
- type: 'strong',
302
- children: [{ type: 'text', value: boldMatch[2] }],
303
- });
304
- remaining = remaining.slice(boldMatch[0].length);
305
- continue;
306
- }
307
-
308
- // Italic (*text* or _text_)
309
- const italicMatch = remaining.match(/^(\*|_)(.+?)\1/);
310
- if (italicMatch) {
311
- nodes.push({
312
- type: 'emphasis',
313
- children: [{ type: 'text', value: italicMatch[2] }],
314
- });
315
- remaining = remaining.slice(italicMatch[0].length);
316
- continue;
317
- }
318
-
319
- // Inline code (`code`)
320
- const codeMatch = remaining.match(/^`([^`]+)`/);
321
- if (codeMatch) {
322
- nodes.push({
323
- type: 'code',
324
- value: codeMatch[1],
325
- });
326
- remaining = remaining.slice(codeMatch[0].length);
327
- continue;
328
- }
329
-
330
- // Strikethrough (GFM: ~~text~~)
331
- if (this.config.gfm) {
332
- const strikeMatch = remaining.match(/^~~(.+?)~~/);
333
- if (strikeMatch) {
334
- nodes.push({
335
- type: 'strikethrough',
336
- children: [{ type: 'text', value: strikeMatch[1] }],
337
- });
338
- remaining = remaining.slice(strikeMatch[0].length);
339
- continue;
340
- }
341
- }
342
-
343
- // Image (![alt](url))
344
- const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
345
- if (imageMatch) {
346
- nodes.push({
347
- type: 'image',
348
- attributes: {
349
- alt: imageMatch[1],
350
- src: imageMatch[2],
351
- },
352
- });
353
- remaining = remaining.slice(imageMatch[0].length);
354
- continue;
355
- }
356
-
357
- // Link ([text](url))
358
- const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
359
- if (linkMatch) {
360
- nodes.push({
361
- type: 'link',
362
- attributes: {
363
- href: linkMatch[2],
364
- },
365
- children: [{ type: 'text', value: linkMatch[1] }],
366
- });
367
- remaining = remaining.slice(linkMatch[0].length);
368
- continue;
369
- }
370
-
371
- // Autolink (GFM: https://example.com)
372
- if (this.config.gfm) {
373
- const autolinkMatch = remaining.match(/^(https?:\/\/[^\s]+)/);
374
- if (autolinkMatch) {
375
- nodes.push({
376
- type: 'autolink',
377
- attributes: {
378
- href: autolinkMatch[1],
379
- },
380
- children: [{ type: 'text', value: autolinkMatch[1] }],
381
- });
382
- remaining = remaining.slice(autolinkMatch[0].length);
383
- continue;
384
- }
385
- }
386
-
387
- // Line break (two spaces + newline or backslash + newline)
388
- if (remaining.match(/^( \n|\\\n)/)) {
389
- nodes.push({ type: 'lineBreak' });
390
- remaining = remaining.slice(remaining.match(/^( \n|\\\n)/)![0].length);
391
- continue;
392
- }
393
-
394
- // Plain text (consume until next special character or URL)
395
- const textMatch = remaining.match(/^((?:(?!https?:\/\/)[^*_`~!\[\n])+)/);
396
- if (textMatch) {
397
- nodes.push({
398
- type: 'text',
399
- value: textMatch[1],
400
- });
401
- remaining = remaining.slice(textMatch[1].length);
402
- continue;
403
- }
404
-
405
- // Single character fallback
406
- nodes.push({
407
- type: 'text',
408
- value: remaining[0],
409
- });
410
- remaining = remaining.slice(1);
411
- }
412
-
413
- return nodes;
414
- }
415
-
416
- /**
417
- * Parse GFM table
418
- */
419
- private parseTable(lines: string[], startIndex: number): { node: ASTNode; nextIndex: number } {
420
- const headerLine = lines[startIndex];
421
- const separatorLine = lines[startIndex + 1];
422
-
423
- // Parse header
424
- const headerCells = headerLine.split('|').filter((cell) => cell.trim());
425
- const headerRow: ASTNode = {
426
- type: 'tableRow',
427
- children: headerCells.map((cell) => ({
428
- type: 'tableCell',
429
- attributes: { header: true },
430
- children: this.parseInline(cell.trim()),
431
- })),
432
- };
433
-
434
- // Parse alignment from separator
435
- const alignments = separatorLine
436
- .split('|')
437
- .filter((cell) => cell.trim())
438
- .map((cell) => {
439
- const trimmed = cell.trim();
440
- if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
441
- if (trimmed.endsWith(':')) return 'right';
442
- if (trimmed.startsWith(':')) return 'left';
443
- return 'left';
444
- });
445
-
446
- // Parse body rows
447
- const bodyRows: ASTNode[] = [];
448
- let i = startIndex + 2;
449
-
450
- while (i < lines.length && lines[i].includes('|')) {
451
- const cells = lines[i].split('|').filter((cell) => cell.trim());
452
- bodyRows.push({
453
- type: 'tableRow',
454
- children: cells.map((cell, index) => ({
455
- type: 'tableCell',
456
- attributes: {
457
- header: false,
458
- align: alignments[index] || 'left',
459
- },
460
- children: this.parseInline(cell.trim()),
461
- })),
462
- });
463
- i++;
464
- }
465
-
466
- return {
467
- node: {
468
- type: 'table',
469
- children: [headerRow, ...bodyRows],
470
- position: this.createPosition(startIndex + 1, 0, i - startIndex),
471
- },
472
- nextIndex: i,
473
- };
474
- }
475
-
476
- /**
477
- * Create position information
478
- */
479
- private createPosition(line: number, column: number, length: number): Position {
480
- return {
481
- start: { line, column, offset: 0 },
482
- end: { line: line + length - 1, column: column + length, offset: 0 },
483
- };
484
- }
485
-
486
- /**
487
- * Clear the parse cache
488
- */
489
- clearCache(): void {
490
- if (this.cacheManager) {
491
- this.cacheManager.clear();
492
- this.logger.debug('Markdown parse cache cleared');
493
- }
494
- }
495
-
496
- /**
497
- * Get cache statistics
498
- */
499
- getCacheStats(): { size: number; enabled: boolean } {
500
- return {
501
- size: this.cacheManager?.size() ?? 0,
502
- enabled: this.config.cache.enabled,
503
- };
504
- }
505
-
506
- /**
507
- * Prune expired cache entries
508
- */
509
- pruneCache(): number {
510
- if (this.cacheManager) {
511
- const removed = this.cacheManager.prune();
512
- this.logger.debug('Pruned expired cache entries', { removed });
513
- return removed;
514
- }
515
- return 0;
516
- }
517
- }
package/src/index.ts DELETED
@@ -1,13 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- export { MarkdownParser } from './MarkdownParser';
10
- export * from './types';
11
- export * from './plugins';
12
- export * from './utils/ast-utils';
13
- export { CacheManager } from './utils/cache';
@@ -1,23 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import { MarkdownPlugin, ASTNode } from '../types';
10
-
11
- /**
12
- * CommonMark specification plugin
13
- */
14
- export const commonmarkPlugin: MarkdownPlugin = {
15
- name: 'commonmark',
16
- version: '1.0.0',
17
- transform: (ast: ASTNode): ASTNode => {
18
- // This is a placeholder - CommonMark features are built into the main parser
19
- // when commonmark config is enabled. This plugin can be used for additional
20
- // post-processing transformations specific to CommonMark.
21
- return ast;
22
- },
23
- };
@@ -1,23 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import { MarkdownPlugin, ASTNode } from '../types';
10
-
11
- /**
12
- * GitHub-Flavored Markdown extensions plugin
13
- */
14
- export const gfmPlugin: MarkdownPlugin = {
15
- name: 'gfm',
16
- version: '1.0.0',
17
- transform: (ast: ASTNode): ASTNode => {
18
- // This is a placeholder - GFM features are built into the main parser
19
- // when gfm config is enabled. This plugin can be used for additional
20
- // post-processing transformations specific to GFM.
21
- return ast;
22
- },
23
- };
@@ -1,10 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- export * from './gfm';
10
- export * from './commonmark';
package/src/types.ts DELETED
@@ -1,114 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import { Logger } from '@bernierllc/logger';
10
-
11
- /**
12
- * Markdown Parser Configuration
13
- */
14
- export interface MarkdownParserConfig {
15
- /** Enable GitHub-Flavored Markdown extensions */
16
- gfm?: boolean;
17
-
18
- /** Enable CommonMark strict mode */
19
- commonmark?: boolean;
20
-
21
- /** Custom plugins to apply */
22
- plugins?: MarkdownPlugin[];
23
-
24
- /** Enable caching of parsed AST */
25
- cache?: {
26
- enabled: boolean;
27
- maxSize?: number; // Maximum cache entries
28
- ttl?: number; // Time to live in seconds
29
- };
30
-
31
- /** Logger instance */
32
- logger?: Logger;
33
- }
34
-
35
- /**
36
- * Position information in source document
37
- */
38
- export interface Position {
39
- start: { line: number; column: number; offset: number };
40
- end: { line: number; column: number; offset: number };
41
- }
42
-
43
- /**
44
- * Markdown Abstract Syntax Tree Node
45
- */
46
- export interface ASTNode {
47
- type: NodeType;
48
- value?: string;
49
- children?: ASTNode[];
50
- attributes?: Record<string, unknown>;
51
- position?: Position;
52
- }
53
-
54
- /**
55
- * Node types supported by the parser
56
- */
57
- export type NodeType =
58
- | 'root'
59
- | 'heading'
60
- | 'paragraph'
61
- | 'text'
62
- | 'emphasis'
63
- | 'strong'
64
- | 'link'
65
- | 'image'
66
- | 'code'
67
- | 'codeBlock'
68
- | 'blockquote'
69
- | 'list'
70
- | 'listItem'
71
- | 'table'
72
- | 'tableRow'
73
- | 'tableCell'
74
- | 'html'
75
- | 'horizontalRule'
76
- | 'lineBreak'
77
- // GFM extensions
78
- | 'strikethrough'
79
- | 'taskList'
80
- | 'taskListItem'
81
- | 'autolink'
82
- | 'footnote'
83
- | 'footnoteReference';
84
-
85
- /**
86
- * Parse result with metadata
87
- */
88
- export interface ParseResult {
89
- success: boolean;
90
- ast?: ASTNode;
91
- error?: string;
92
- metadata?: {
93
- parseTime: number; // Milliseconds
94
- nodeCount: number;
95
- cached: boolean;
96
- };
97
- }
98
-
99
- /**
100
- * Plugin interface for custom extensions
101
- */
102
- export interface MarkdownPlugin {
103
- name: string;
104
- version: string;
105
- transform: (ast: ASTNode) => ASTNode;
106
- }
107
-
108
- /**
109
- * Cache entry structure
110
- */
111
- export interface CacheEntry {
112
- ast: ASTNode;
113
- timestamp: number;
114
- }