@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,319 +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 { countNodes, visitNodes, transformNodes, findNodes, cloneNode } from '../src/utils/ast-utils';
10
- import { ASTNode } from '../src/types';
11
-
12
- describe('AST Utilities', () => {
13
- const sampleAST: ASTNode = {
14
- type: 'root',
15
- children: [
16
- {
17
- type: 'heading',
18
- attributes: { level: 1 },
19
- children: [{ type: 'text', value: 'Test' }],
20
- },
21
- {
22
- type: 'paragraph',
23
- children: [
24
- { type: 'text', value: 'This is ' },
25
- { type: 'strong', children: [{ type: 'text', value: 'bold' }] },
26
- { type: 'text', value: ' text' },
27
- ],
28
- },
29
- ],
30
- };
31
-
32
- describe('countNodes()', () => {
33
- it('should count single node', () => {
34
- const node: ASTNode = { type: 'text', value: 'test' };
35
- expect(countNodes(node)).toBe(1);
36
- });
37
-
38
- it('should count nested nodes', () => {
39
- expect(countNodes(sampleAST)).toBe(8); // root + heading + 1 text + paragraph + 3 children + strong + 1 text
40
- });
41
-
42
- it('should handle node without children', () => {
43
- const node: ASTNode = { type: 'horizontalRule' };
44
- expect(countNodes(node)).toBe(1);
45
- });
46
-
47
- it('should handle empty children array', () => {
48
- const node: ASTNode = { type: 'root', children: [] };
49
- expect(countNodes(node)).toBe(1);
50
- });
51
-
52
- it('should count deeply nested structure', () => {
53
- const deepNode: ASTNode = {
54
- type: 'root',
55
- children: [
56
- {
57
- type: 'blockquote',
58
- children: [
59
- {
60
- type: 'blockquote',
61
- children: [
62
- {
63
- type: 'paragraph',
64
- children: [{ type: 'text', value: 'nested' }],
65
- },
66
- ],
67
- },
68
- ],
69
- },
70
- ],
71
- };
72
- expect(countNodes(deepNode)).toBe(5);
73
- });
74
- });
75
-
76
- describe('visitNodes()', () => {
77
- it('should visit all nodes', () => {
78
- const visited: string[] = [];
79
- visitNodes(sampleAST, (node) => {
80
- visited.push(node.type);
81
- });
82
-
83
- expect(visited).toContain('root');
84
- expect(visited).toContain('heading');
85
- expect(visited).toContain('paragraph');
86
- expect(visited).toContain('text');
87
- expect(visited).toContain('strong');
88
- expect(visited.length).toBe(8);
89
- });
90
-
91
- it('should visit nodes in depth-first order', () => {
92
- const node: ASTNode = {
93
- type: 'root',
94
- children: [
95
- { type: 'heading', children: [{ type: 'text', value: 'h1' }] },
96
- { type: 'paragraph', children: [{ type: 'text', value: 'p1' }] },
97
- ],
98
- };
99
-
100
- const order: string[] = [];
101
- visitNodes(node, (n) => {
102
- order.push(n.type);
103
- });
104
-
105
- expect(order).toEqual(['root', 'heading', 'text', 'paragraph', 'text']);
106
- });
107
-
108
- it('should handle single node', () => {
109
- const visited: string[] = [];
110
- visitNodes({ type: 'text', value: 'test' }, (node) => {
111
- visited.push(node.type);
112
- });
113
-
114
- expect(visited).toEqual(['text']);
115
- });
116
- });
117
-
118
- describe('transformNodes()', () => {
119
- it('should transform matching nodes', () => {
120
- const transformed = transformNodes(sampleAST, (node) => {
121
- if (node.type === 'text' && node.value) {
122
- return { ...node, value: node.value.toUpperCase() };
123
- }
124
- return node;
125
- });
126
-
127
- const texts: string[] = [];
128
- visitNodes(transformed, (node) => {
129
- if (node.type === 'text' && node.value) {
130
- texts.push(node.value);
131
- }
132
- });
133
-
134
- expect(texts.every((text) => text === text.toUpperCase())).toBe(true);
135
- });
136
-
137
- it('should preserve structure', () => {
138
- const transformed = transformNodes(sampleAST, (node) => node);
139
-
140
- expect(countNodes(transformed)).toBe(countNodes(sampleAST));
141
- expect(transformed.type).toBe(sampleAST.type);
142
- });
143
-
144
- it('should handle node type changes', () => {
145
- const transformed = transformNodes(sampleAST, (node) => {
146
- if (node.type === 'strong') {
147
- return { ...node, type: 'emphasis' };
148
- }
149
- return node;
150
- });
151
-
152
- const hasStrong = findNodes(transformed, (n) => n.type === 'strong');
153
- const hasEmphasis = findNodes(transformed, (n) => n.type === 'emphasis');
154
-
155
- expect(hasStrong.length).toBe(0);
156
- expect(hasEmphasis.length).toBe(1);
157
- });
158
-
159
- it('should handle attribute modifications', () => {
160
- const transformed = transformNodes(sampleAST, (node) => {
161
- if (node.type === 'heading') {
162
- return {
163
- ...node,
164
- attributes: { ...node.attributes, id: 'test-id' },
165
- };
166
- }
167
- return node;
168
- });
169
-
170
- const headings = findNodes(transformed, (n) => n.type === 'heading');
171
- expect(headings[0].attributes!.id).toBe('test-id');
172
- });
173
- });
174
-
175
- describe('findNodes()', () => {
176
- it('should find nodes matching predicate', () => {
177
- const textNodes = findNodes(sampleAST, (node) => node.type === 'text');
178
- expect(textNodes.length).toBeGreaterThan(0);
179
- expect(textNodes.every((node) => node.type === 'text')).toBe(true);
180
- });
181
-
182
- it('should find nodes with specific attributes', () => {
183
- const level1Headings = findNodes(
184
- sampleAST,
185
- (node) => node.type === 'heading' && node.attributes?.level === 1
186
- );
187
- expect(level1Headings.length).toBe(1);
188
- });
189
-
190
- it('should return empty array when no matches', () => {
191
- const images = findNodes(sampleAST, (node) => node.type === 'image');
192
- expect(images).toEqual([]);
193
- });
194
-
195
- it('should find all matching nodes at any depth', () => {
196
- const deepAST: ASTNode = {
197
- type: 'root',
198
- children: [
199
- {
200
- type: 'blockquote',
201
- children: [
202
- { type: 'paragraph', children: [{ type: 'text', value: '1' }] },
203
- {
204
- type: 'blockquote',
205
- children: [{ type: 'paragraph', children: [{ type: 'text', value: '2' }] }],
206
- },
207
- ],
208
- },
209
- ],
210
- };
211
-
212
- const texts = findNodes(deepAST, (node) => node.type === 'text');
213
- expect(texts.length).toBe(2);
214
- });
215
-
216
- it('should handle complex predicates', () => {
217
- const nodes = findNodes(
218
- sampleAST,
219
- (node) => node.type === 'text' && node.value !== undefined && node.value.length > 3
220
- );
221
- expect(nodes.length).toBeGreaterThan(0);
222
- });
223
- });
224
-
225
- describe('cloneNode()', () => {
226
- it('should create deep copy', () => {
227
- const cloned = cloneNode(sampleAST);
228
-
229
- expect(cloned).not.toBe(sampleAST);
230
- expect(cloned).toEqual(sampleAST);
231
- });
232
-
233
- it('should not affect original when modifying clone', () => {
234
- const cloned = cloneNode(sampleAST);
235
- (cloned as unknown as { type: string }).type = 'modified';
236
-
237
- expect(sampleAST.type).toBe('root');
238
- expect((cloned as unknown as { type: string }).type).toBe('modified');
239
- });
240
-
241
- it('should clone children deeply', () => {
242
- const cloned = cloneNode(sampleAST);
243
- (cloned.children![0] as unknown as { type: string }).type = 'modified';
244
-
245
- expect(sampleAST.children![0].type).toBe('heading');
246
- expect((cloned.children![0] as unknown as { type: string }).type).toBe('modified');
247
- });
248
-
249
- it('should clone attributes', () => {
250
- const node: ASTNode = {
251
- type: 'heading',
252
- attributes: { level: 1, id: 'test' },
253
- };
254
-
255
- const cloned = cloneNode(node);
256
- cloned.attributes!.level = 2;
257
-
258
- expect(node.attributes!.level).toBe(1);
259
- expect(cloned.attributes!.level).toBe(2);
260
- });
261
-
262
- it('should clone position information', () => {
263
- const node: ASTNode = {
264
- type: 'heading',
265
- position: {
266
- start: { line: 1, column: 0, offset: 0 },
267
- end: { line: 1, column: 10, offset: 10 },
268
- },
269
- };
270
-
271
- const cloned = cloneNode(node);
272
- cloned.position!.start.line = 2;
273
-
274
- expect(node.position!.start.line).toBe(1);
275
- expect(cloned.position!.start.line).toBe(2);
276
- });
277
-
278
- it('should handle node without children', () => {
279
- const node: ASTNode = { type: 'text', value: 'test' };
280
- const cloned = cloneNode(node);
281
-
282
- expect(cloned).toEqual(node);
283
- expect(cloned).not.toBe(node);
284
- });
285
-
286
- it('should handle empty children array', () => {
287
- const node: ASTNode = { type: 'root', children: [] };
288
- const cloned = cloneNode(node);
289
-
290
- expect(cloned.children).toEqual([]);
291
- expect(cloned.children).not.toBe(node.children);
292
- });
293
- });
294
-
295
- describe('Utility combinations', () => {
296
- it('should work together for complex operations', () => {
297
- // Find all text nodes (for verification purposes)
298
- findNodes(sampleAST, (node) => node.type === 'text');
299
-
300
- // Clone the AST
301
- const cloned = cloneNode(sampleAST);
302
-
303
- // Transform the clone
304
- const transformed = transformNodes(cloned, (node) => {
305
- if (node.type === 'text' && node.value) {
306
- return { ...node, value: node.value.toUpperCase() };
307
- }
308
- return node;
309
- });
310
-
311
- // Count nodes
312
- expect(countNodes(transformed)).toBe(countNodes(sampleAST));
313
-
314
- // Original should be unchanged
315
- const originalTexts = findNodes(sampleAST, (node) => node.type === 'text');
316
- expect(originalTexts[0].value).not.toBe(originalTexts[0].value?.toUpperCase());
317
- });
318
- });
319
- });
@@ -1,243 +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 { MarkdownParser } from '../src/MarkdownParser';
10
-
11
- describe('MarkdownParser - Caching', () => {
12
- describe('Cache behavior', () => {
13
- it('should cache parsed AST when enabled', async () => {
14
- const parser = new MarkdownParser({ cache: { enabled: true } });
15
- const markdown = '# Test Heading';
16
-
17
- const result1 = await parser.parse(markdown);
18
- const result2 = await parser.parse(markdown);
19
-
20
- expect(result1.success).toBe(true);
21
- expect(result2.success).toBe(true);
22
- expect(result1.metadata!.cached).toBe(false);
23
- expect(result2.metadata!.cached).toBe(true);
24
- // Cached result should be fast (allow for timing variations)
25
- expect(result2.metadata!.parseTime).toBeGreaterThanOrEqual(0);
26
- });
27
-
28
- it('should not cache when disabled', async () => {
29
- const parser = new MarkdownParser({ cache: { enabled: false } });
30
- const markdown = '# Test';
31
-
32
- const result1 = await parser.parse(markdown);
33
- const result2 = await parser.parse(markdown);
34
-
35
- expect(result1.metadata!.cached).toBe(false);
36
- expect(result2.metadata!.cached).toBe(false);
37
- });
38
-
39
- it('should respect cache TTL', async () => {
40
- const parser = new MarkdownParser({
41
- cache: { enabled: true, ttl: 1 }, // 1 second
42
- });
43
- const markdown = '# Test';
44
-
45
- await parser.parse(markdown);
46
-
47
- // Wait for TTL to expire
48
- await new Promise((resolve) => setTimeout(resolve, 1100));
49
-
50
- const result = await parser.parse(markdown);
51
- expect(result.metadata!.cached).toBe(false); // Cache expired
52
- });
53
-
54
- it('should enforce cache size limit', async () => {
55
- const parser = new MarkdownParser({
56
- cache: { enabled: true, maxSize: 2 },
57
- });
58
-
59
- await parser.parse('# Doc 1');
60
- await parser.parse('# Doc 2');
61
- await parser.parse('# Doc 3'); // Should evict Doc 1
62
-
63
- // Cache size should be at max
64
- expect(parser.getCacheStats().size).toBe(2);
65
-
66
- // First document should no longer be cached (was evicted)
67
- const result = await parser.parse('# Doc 1');
68
- expect(result.metadata!.cached).toBe(false);
69
- });
70
-
71
- it('should cache different content separately', async () => {
72
- const parser = new MarkdownParser({ cache: { enabled: true } });
73
-
74
- await parser.parse('# Doc 1');
75
- await parser.parse('# Doc 2');
76
-
77
- const result1 = await parser.parse('# Doc 1');
78
- const result2 = await parser.parse('# Doc 2');
79
-
80
- expect(result1.metadata!.cached).toBe(true);
81
- expect(result2.metadata!.cached).toBe(true);
82
- expect(result1.ast).not.toEqual(result2.ast);
83
- });
84
- });
85
-
86
- describe('Cache management', () => {
87
- it('should clear cache on demand', async () => {
88
- const parser = new MarkdownParser({ cache: { enabled: true } });
89
- const markdown = '# Test';
90
-
91
- await parser.parse(markdown);
92
-
93
- const stats1 = parser.getCacheStats();
94
- expect(stats1.size).toBe(1);
95
-
96
- parser.clearCache();
97
-
98
- const stats2 = parser.getCacheStats();
99
- expect(stats2.size).toBe(0);
100
-
101
- const result = await parser.parse(markdown);
102
- expect(result.metadata!.cached).toBe(false);
103
- });
104
-
105
- it('should get accurate cache statistics', async () => {
106
- const parser = new MarkdownParser({ cache: { enabled: true } });
107
-
108
- const stats1 = parser.getCacheStats();
109
- expect(stats1.enabled).toBe(true);
110
- expect(stats1.size).toBe(0);
111
-
112
- await parser.parse('# Test 1');
113
- const stats2 = parser.getCacheStats();
114
- expect(stats2.size).toBe(1);
115
-
116
- await parser.parse('# Test 2');
117
- await parser.parse('# Test 3');
118
- const stats3 = parser.getCacheStats();
119
- expect(stats3.size).toBe(3);
120
- });
121
-
122
- it('should prune expired entries', async () => {
123
- const parser = new MarkdownParser({
124
- cache: { enabled: true, ttl: 1 },
125
- });
126
-
127
- await parser.parse('# Test 1');
128
- await parser.parse('# Test 2');
129
- await parser.parse('# Test 3');
130
-
131
- const stats1 = parser.getCacheStats();
132
- expect(stats1.size).toBe(3);
133
-
134
- // Wait for expiration
135
- await new Promise((resolve) => setTimeout(resolve, 1100));
136
-
137
- const removed = parser.pruneCache();
138
- expect(removed).toBe(3);
139
-
140
- const stats2 = parser.getCacheStats();
141
- expect(stats2.size).toBe(0);
142
- });
143
-
144
- it('should only prune expired entries', async () => {
145
- const parser = new MarkdownParser({
146
- cache: { enabled: true, ttl: 2 },
147
- });
148
-
149
- await parser.parse('# Test 1');
150
- await new Promise((resolve) => setTimeout(resolve, 1000));
151
- await parser.parse('# Test 2');
152
-
153
- // Wait for first entry to expire but not second
154
- await new Promise((resolve) => setTimeout(resolve, 1100));
155
-
156
- const removed = parser.pruneCache();
157
- expect(removed).toBe(1);
158
-
159
- const stats = parser.getCacheStats();
160
- expect(stats.size).toBe(1);
161
- });
162
- });
163
-
164
- describe('Cache performance', () => {
165
- it('should improve parse time with caching', async () => {
166
- const parser = new MarkdownParser({ cache: { enabled: true } });
167
- const markdown = '# Heading\n\n' + 'Paragraph '.repeat(100);
168
-
169
- await parser.parse(markdown);
170
- const result2 = await parser.parse(markdown);
171
-
172
- expect(result2.metadata!.cached).toBe(true);
173
- });
174
-
175
- it('should handle large cache efficiently', async () => {
176
- const parser = new MarkdownParser({
177
- cache: { enabled: true, maxSize: 1000 },
178
- });
179
-
180
- // Add 100 entries
181
- for (let i = 0; i < 100; i++) {
182
- await parser.parse(`# Document ${i}`);
183
- }
184
-
185
- const stats = parser.getCacheStats();
186
- expect(stats.size).toBe(100);
187
-
188
- // Cache lookups should be fast
189
- await parser.parse('# Document 50');
190
-
191
- // Verify cached result
192
- expect(stats.size).toBeGreaterThan(0);
193
- });
194
- });
195
-
196
- describe('Cache edge cases', () => {
197
- it('should handle empty document caching', async () => {
198
- const parser = new MarkdownParser({ cache: { enabled: true } });
199
-
200
- await parser.parse('');
201
- const result2 = await parser.parse('');
202
-
203
- expect(result2.success).toBe(true);
204
- expect(result2.metadata!.cached).toBe(true);
205
- });
206
-
207
- it('should handle whitespace-only document caching', async () => {
208
- const parser = new MarkdownParser({ cache: { enabled: true } });
209
-
210
- await parser.parse(' \n \n ');
211
- const result2 = await parser.parse(' \n \n ');
212
-
213
- expect(result2.metadata!.cached).toBe(true);
214
- });
215
-
216
- it('should cache large documents', async () => {
217
- const parser = new MarkdownParser({ cache: { enabled: true } });
218
- const largeMarkdown = '# Heading\n\n'.repeat(1000) + 'Paragraph '.repeat(5000);
219
-
220
- await parser.parse(largeMarkdown);
221
- const result2 = await parser.parse(largeMarkdown);
222
-
223
- expect(result2.metadata!.cached).toBe(true);
224
- });
225
-
226
- it('should handle concurrent parse requests', async () => {
227
- const parser = new MarkdownParser({ cache: { enabled: true } });
228
- const markdown = '# Test';
229
-
230
- // Parse the same document multiple times concurrently
231
- const results = await Promise.all([
232
- parser.parse(markdown),
233
- parser.parse(markdown),
234
- parser.parse(markdown),
235
- ]);
236
-
237
- expect(results.every((r) => r.success)).toBe(true);
238
- // At least one should be cached (depending on execution order)
239
- const cachedCount = results.filter((r) => r.metadata!.cached).length;
240
- expect(cachedCount).toBeGreaterThanOrEqual(0);
241
- });
242
- });
243
- });