@byteatatime/mdstream 0.0.1

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.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # mdstream
2
+
3
+ To install dependencies:
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ To run:
10
+
11
+ ```bash
12
+ bun run src/index.ts
13
+ ```
14
+
15
+ This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@byteatatime/mdstream",
3
+ "version": "0.0.1",
4
+ "module": "src/index.ts",
5
+ "type": "module",
6
+ "devDependencies": {
7
+ "@types/bun": "latest",
8
+ "@types/mdast": "^4.0.4"
9
+ },
10
+ "peerDependencies": {
11
+ "typescript": "^5"
12
+ },
13
+ "dependencies": {
14
+ "mdast": "^3.0.0",
15
+ "remark": "^15.0.1"
16
+ }
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+ import type { Node, Root } from "mdast";
4
+ import type { PluggableList } from "unified";
5
+
6
+ type Block = {
7
+ readonly ast: Node;
8
+ readonly start: number;
9
+ readonly end: number;
10
+ readonly source: string;
11
+ };
12
+
13
+ type ParserState = {
14
+ buffer: string;
15
+ finalizedBlocks: Block[];
16
+ totalOffset: number;
17
+ };
18
+
19
+ type ParserOptions = {
20
+ plugins?: PluggableList;
21
+ };
22
+
23
+ const createStreamingParser = (options: ParserOptions = {}) => {
24
+ const { plugins = [] } = options;
25
+ const state: ParserState = {
26
+ buffer: "",
27
+ finalizedBlocks: [],
28
+ totalOffset: 0,
29
+ };
30
+
31
+ let processor = unified().use(remarkParse);
32
+ if (plugins.length > 0) {
33
+ processor = processor.use(plugins);
34
+ }
35
+
36
+ const processBuffer = (): void => {
37
+ if (!state.buffer) return;
38
+
39
+ const ast = processor.parse(state.buffer) as Root;
40
+ const tree = processor.runSync(ast) as Root;
41
+ const children = tree.children;
42
+
43
+ if (children.length < 2) {
44
+ return;
45
+ }
46
+
47
+ const lastNode = children[children.length - 1];
48
+ let finalizableNodes = children.slice(0, -1);
49
+
50
+ const lastNodeSource = lastNode?.position
51
+ ? state.buffer.slice(
52
+ lastNode.position.start.offset!,
53
+ lastNode.position.end.offset!,
54
+ )
55
+ : "";
56
+ const isSetextUnderline = /^(=+|-+)\s*$/.test(lastNodeSource);
57
+
58
+ if (isSetextUnderline && finalizableNodes.length > 0) {
59
+ const secondToLastNode = finalizableNodes[finalizableNodes.length - 1];
60
+ const secondToLastPosition = secondToLastNode?.position;
61
+ const lastPosition = lastNode?.position;
62
+
63
+ if (secondToLastNode?.type === "paragraph" &&
64
+ secondToLastPosition &&
65
+ lastPosition &&
66
+ secondToLastPosition.end.offset === lastPosition.start.offset) {
67
+ finalizableNodes = finalizableNodes.slice(0, -1);
68
+ }
69
+ }
70
+
71
+ for (const node of finalizableNodes) {
72
+ const position = node?.position;
73
+ if (node && position) {
74
+ const start = state.totalOffset + position.start.offset!;
75
+ const end = state.totalOffset + position.end.offset!;
76
+ state.finalizedBlocks.push({
77
+ ast: node,
78
+ start,
79
+ end,
80
+ source: state.buffer.slice(
81
+ position.start.offset!,
82
+ position.end.offset!,
83
+ ),
84
+ });
85
+ }
86
+ }
87
+
88
+ const lastPosition = lastNode?.position;
89
+ if (lastPosition) {
90
+ const newBufferStart = lastPosition.start.offset!;
91
+ state.buffer = state.buffer.slice(newBufferStart);
92
+ state.totalOffset += newBufferStart;
93
+ }
94
+ };
95
+
96
+ const append = (chunk: string): void => {
97
+ state.buffer += chunk;
98
+ processBuffer();
99
+ };
100
+
101
+ const getPendingBlock = (): Block | undefined => {
102
+ if (!state.buffer) return undefined;
103
+
104
+ const ast = processor.parse(state.buffer) as Root;
105
+ const tree = processor.runSync(ast) as Root;
106
+ const children = tree.children;
107
+
108
+ if (children.length === 0) return undefined;
109
+
110
+ const firstNode = children[0];
111
+ const position = firstNode?.position;
112
+
113
+ if (!firstNode || !position) return undefined;
114
+
115
+ const start = state.totalOffset + position.start.offset!;
116
+ const end = state.totalOffset + position.end.offset!;
117
+
118
+ return {
119
+ ast: firstNode,
120
+ start,
121
+ end,
122
+ source: state.buffer.slice(position.start.offset!, position.end.offset!),
123
+ };
124
+ };
125
+
126
+ return {
127
+ append,
128
+ get blocks(): readonly Block[] {
129
+ const pending = getPendingBlock();
130
+ return pending
131
+ ? [...state.finalizedBlocks, pending]
132
+ : state.finalizedBlocks;
133
+ },
134
+ };
135
+ };
136
+
137
+ export { createStreamingParser };
138
+ export type { Block, ParserOptions };
@@ -0,0 +1,115 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { createStreamingParser } from '../src/index.ts';
3
+
4
+ describe('Block Finalization', () => {
5
+ test('finalizes blocks when 2+ top-level nodes exist', () => {
6
+ const parser = createStreamingParser();
7
+ parser.append('# Hello\n\nThis is a paragraph.\n\n');
8
+ expect(parser.blocks.length).toBe(2);
9
+ expect(parser.blocks.map(b => b.ast.type)).toEqual(['heading', 'paragraph']);
10
+
11
+ const headingRef = parser.blocks[0];
12
+ parser.append('Another paragraph');
13
+
14
+ expect(parser.blocks.length).toBe(3);
15
+ expect(parser.blocks[0]).toBe(headingRef);
16
+ expect(parser.blocks.map(b => b.ast.type)).toEqual(['heading', 'paragraph', 'paragraph']);
17
+ });
18
+ });
19
+
20
+ describe('Block Stability', () => {
21
+ test('finalized blocks maintain stable object references across multiple appends', () => {
22
+ const parser = createStreamingParser();
23
+
24
+ parser.append('# First\n\nPara 1\n\nPara 2\n\n');
25
+ const block0 = parser.blocks[0];
26
+ const block1 = parser.blocks[1];
27
+
28
+ expect(parser.blocks.length).toBe(3);
29
+
30
+ parser.append('Para 3');
31
+ const newBlock2 = parser.blocks[2]!;
32
+
33
+ expect(parser.blocks.length).toBe(4);
34
+ expect(parser.blocks[0]).toBe(block0);
35
+ expect(parser.blocks[1]).toBe(block1);
36
+ expect(newBlock2.ast.type).toBe('paragraph');
37
+
38
+ parser.append('\n\nPara 4');
39
+ expect(parser.blocks.length).toBe(5);
40
+ expect(parser.blocks[0]).toBe(block0);
41
+ expect(parser.blocks[1]).toBe(block1);
42
+ expect(parser.blocks[2]).toBe(newBlock2);
43
+ });
44
+
45
+ test('pending block gets new reference each cycle while finalized blocks stay stable', () => {
46
+ const parser = createStreamingParser();
47
+
48
+ parser.append('# Heading\n\nPara 1');
49
+ const block0 = parser.blocks[0]!;
50
+ const pending1 = parser.blocks[1]!;
51
+
52
+ expect(parser.blocks.length).toBe(2);
53
+
54
+ parser.append(' continues');
55
+ const pending2 = parser.blocks[1]!;
56
+
57
+ expect(parser.blocks.length).toBe(2);
58
+ expect(parser.blocks[0]).toBe(block0);
59
+ expect(pending1).not.toBe(pending2);
60
+ expect(pending1.source).toBe('Para 1');
61
+ expect(pending2.source).toBe('Para 1 continues');
62
+ });
63
+
64
+ test('blocks array can be recreated but finalized block references remain stable', () => {
65
+ const parser = createStreamingParser();
66
+
67
+ parser.append('# First\n\nSecond\n\nThird\n\n');
68
+ const firstRef = parser.blocks[0];
69
+ const secondRef = parser.blocks[1];
70
+
71
+ const firstArray = parser.blocks;
72
+ parser.append('Fourth');
73
+
74
+ const secondArray = parser.blocks;
75
+
76
+ expect(firstArray).not.toBe(secondArray);
77
+ expect(secondArray[0]).toBe(firstRef);
78
+ expect(secondArray[1]).toBe(secondRef);
79
+ expect(secondArray.length).toBe(4);
80
+ });
81
+
82
+ test('all finalized blocks keep references when many blocks accumulate', () => {
83
+ const parser = createStreamingParser();
84
+ const refs: any[] = [];
85
+
86
+ parser.append('# 1\n\n2\n\n3\n\n4\n\n5\n\n6\n\n');
87
+ for (let i = 0; i < 5; i++) {
88
+ refs.push(parser.blocks[i]);
89
+ }
90
+
91
+ parser.append('7\n\n8\n\n9\n\n');
92
+
93
+ for (let i = 0; i < 5; i++) {
94
+ expect(parser.blocks[i]).toBe(refs[i]);
95
+ }
96
+
97
+ expect(parser.blocks.length).toBe(9);
98
+ });
99
+
100
+ test('block stability with different markdown types', () => {
101
+ const parser = createStreamingParser();
102
+
103
+ parser.append('# Heading\n\n- List item\n\n```\ncode\n```\n\nParagraph\n\n');
104
+ const heading = parser.blocks[0];
105
+ const list = parser.blocks[1];
106
+ const code = parser.blocks[2];
107
+
108
+ parser.append('More text');
109
+
110
+ expect(parser.blocks[0]).toBe(heading);
111
+ expect(parser.blocks[1]).toBe(list);
112
+ expect(parser.blocks[2]).toBe(code);
113
+ expect(parser.blocks[3]!.ast.type).toBe('paragraph');
114
+ });
115
+ });
@@ -0,0 +1,48 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { createStreamingParser } from '../src/index.ts';
3
+
4
+ describe('Initialization', () => {
5
+ test('creates empty blocks initially', () => {
6
+ const parser = createStreamingParser();
7
+ expect(parser.blocks).toEqual([]);
8
+ });
9
+ });
10
+
11
+ describe('Basic Parsing', () => {
12
+ test('parses heading', () => {
13
+ const parser = createStreamingParser();
14
+ parser.append('# Hello');
15
+
16
+ const block = parser.blocks[0];
17
+ expect(block?.ast.type).toBe('heading');
18
+ expect(block?.source).toBe('# Hello');
19
+ });
20
+
21
+ test('parses multiple blocks', () => {
22
+ const parser = createStreamingParser();
23
+ parser.append('# Hello\n\nParagraph one\n\nParagraph two');
24
+
25
+ const types = parser.blocks.map((b) => b.ast.type);
26
+ expect(types).toEqual(['heading', 'paragraph', 'paragraph']);
27
+ });
28
+
29
+ test('calculates correct block offsets', () => {
30
+ const parser = createStreamingParser();
31
+ parser.append('# Hello\n\nWorld');
32
+
33
+ const [heading, paragraph] = parser.blocks;
34
+ expect(heading?.start).toBe(0);
35
+ expect(heading?.end).toBe(7);
36
+ expect(paragraph?.start).toBe(9);
37
+ expect(paragraph?.end).toBe(14);
38
+ });
39
+
40
+ test('captures heading depth', () => {
41
+ const parser = createStreamingParser();
42
+ parser.append('# Heading');
43
+
44
+ const heading = parser.blocks[0]?.ast as any;
45
+ expect(heading?.type).toBe('heading');
46
+ expect(heading?.depth).toBe(1);
47
+ });
48
+ });
@@ -0,0 +1,46 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { createStreamingParser } from "../src/index.ts";
3
+
4
+ describe("Custom Plugin Support", () => {
5
+ test("accepts plugins in options", () => {
6
+ const customPlugin = () => (tree: any) => {
7
+ for (const child of tree.children) {
8
+ (child as any).custom = true;
9
+ }
10
+ };
11
+
12
+ const parser = createStreamingParser({
13
+ plugins: [customPlugin],
14
+ });
15
+
16
+ parser.append("# Hello");
17
+
18
+ const block = parser.blocks[0];
19
+ expect(block?.ast.type).toBe("heading");
20
+ expect((block?.ast as any).custom).toBe(true);
21
+ });
22
+
23
+ test("accepts multiple plugins", () => {
24
+ const plugin1 = () => (tree: any) => {
25
+ for (const child of tree.children) {
26
+ (child as any).plugin1Applied = true;
27
+ }
28
+ };
29
+
30
+ const plugin2 = () => (tree: any) => {
31
+ for (const child of tree.children) {
32
+ (child as any).plugin2Applied = true;
33
+ }
34
+ };
35
+
36
+ const parser = createStreamingParser({
37
+ plugins: [plugin1, plugin2],
38
+ });
39
+
40
+ parser.append("# Hello");
41
+
42
+ const block = parser.blocks[0];
43
+ expect((block?.ast as any).plugin1Applied).toBe(true);
44
+ expect((block?.ast as any).plugin2Applied).toBe(true);
45
+ });
46
+ });
@@ -0,0 +1,103 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { createStreamingParser } from '../src/index.ts';
3
+
4
+ describe('Setext Headings', () => {
5
+ test('setext heading with === creates h1', () => {
6
+ const parser = createStreamingParser();
7
+ parser.append('Hello World\n===');
8
+
9
+ const block = parser.blocks[0];
10
+ expect(block?.ast.type).toBe('heading');
11
+ const heading = block?.ast as any;
12
+ expect(heading.depth).toBe(1);
13
+ });
14
+
15
+ test('setext heading with --- creates h2', () => {
16
+ const parser = createStreamingParser();
17
+ parser.append('Hello World\n---');
18
+
19
+ const block = parser.blocks[0];
20
+ expect(block?.ast.type).toBe('heading');
21
+ const heading = block?.ast as any;
22
+ expect(heading.depth).toBe(2);
23
+ });
24
+
25
+ test('setext heading does not prematurely finalize paragraph when underline arrives', () => {
26
+ const parser = createStreamingParser();
27
+ parser.append('Hello World\n');
28
+
29
+ expect(parser.blocks[0]?.ast.type).toBe('paragraph');
30
+
31
+ parser.append('===');
32
+
33
+ const block = parser.blocks[0];
34
+ expect(block?.ast.type).toBe('heading');
35
+ const heading = block?.ast as any;
36
+ expect(heading.depth).toBe(1);
37
+ });
38
+
39
+ test('setext heading with preceding content finalizes correctly', () => {
40
+ const parser = createStreamingParser();
41
+ parser.append('# First\n\nHello World\n');
42
+
43
+ expect(parser.blocks[0]?.ast.type).toBe('heading');
44
+ expect(parser.blocks[1]?.ast.type).toBe('paragraph');
45
+
46
+ parser.append('===\n\nSecond paragraph');
47
+
48
+ expect(parser.blocks.length).toBe(3);
49
+ expect(parser.blocks[0]?.ast.type).toBe('heading');
50
+ expect(parser.blocks[1]?.ast.type).toBe('heading');
51
+ expect(parser.blocks[2]?.ast.type).toBe('paragraph');
52
+ });
53
+
54
+ test('setext underline only affects preceding paragraph', () => {
55
+ const parser = createStreamingParser();
56
+ parser.append('First paragraph\n\nSecond paragraph\n===\n\nThird paragraph');
57
+
58
+ expect(parser.blocks.length).toBe(3);
59
+ expect(parser.blocks[0]?.ast.type).toBe('paragraph');
60
+ expect(parser.blocks[1]?.ast.type).toBe('heading');
61
+ expect(parser.blocks[2]?.ast.type).toBe('paragraph');
62
+ });
63
+
64
+ test('setext underline after blank line is separate paragraph', () => {
65
+ const parser = createStreamingParser();
66
+ parser.append('Paragraph 1\n\n');
67
+
68
+ expect(parser.blocks.length).toBe(1);
69
+ expect(parser.blocks[0]?.ast.type).toBe('paragraph');
70
+ expect(parser.blocks[0]?.source).toBe('Paragraph 1');
71
+
72
+ parser.append('===');
73
+
74
+ expect(parser.blocks.length).toBe(2);
75
+ expect(parser.blocks[0]?.ast.type).toBe('paragraph');
76
+ expect(parser.blocks[0]?.source).toBe('Paragraph 1');
77
+ expect(parser.blocks[1]?.ast.type).toBe('paragraph');
78
+ expect(parser.blocks[1]?.source).toBe('===');
79
+ });
80
+
81
+ test('setext underline after blank line does not cause data loss', () => {
82
+ const parser = createStreamingParser();
83
+ parser.append('First paragraph\n\n');
84
+ parser.append('===');
85
+
86
+ const blocks = parser.blocks;
87
+ expect(blocks.length).toBe(2);
88
+
89
+ const hasFirstPara = blocks.some((b) => b.source.includes('First paragraph'));
90
+ expect(hasFirstPara).toBe(true);
91
+ });
92
+
93
+ test('setext underline with multiple blank lines', () => {
94
+ const parser = createStreamingParser();
95
+ parser.append('First\n\n\n===');
96
+
97
+ expect(parser.blocks.length).toBe(2);
98
+ expect(parser.blocks[0]?.ast.type).toBe('paragraph');
99
+ expect(parser.blocks[0]?.source).toBe('First');
100
+ expect(parser.blocks[1]?.ast.type).toBe('paragraph');
101
+ expect(parser.blocks[1]?.source).toBe('===');
102
+ });
103
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }