Skip to content

Commit f7c4095

Browse files
committed
Rework and small test
1 parent 9ba7d4b commit f7c4095

2 files changed

Lines changed: 379 additions & 12 deletions

File tree

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
/** @jest-environment @happy-dom/jest-environment */
2+
/// <reference types="jest" />
3+
4+
import createStreamingRenderer from './createStreamingRenderer';
5+
6+
const OPTIONS: Parameters<typeof createStreamingRenderer>[0] = {
7+
markdownRespectCRLF: false
8+
};
9+
10+
const INIT: Parameters<typeof createStreamingRenderer>[1] = {
11+
externalLinkAlt: 'Opens in a new window'
12+
};
13+
14+
function setup() {
15+
const container = document.createElement('div');
16+
17+
document.body.appendChild(container);
18+
19+
const renderer = createStreamingRenderer(OPTIONS, INIT);
20+
21+
const nextOptions = () => ({ container });
22+
23+
return { container, nextOptions, renderer };
24+
}
25+
26+
function getWrapperHTML(container: HTMLElement): string {
27+
const wrapper = container.firstElementChild;
28+
29+
return wrapper ? wrapper.innerHTML : '';
30+
}
31+
32+
// Returns the comment node that separates committed from active content.
33+
function getSentinel(container: HTMLElement): Comment | null {
34+
const wrapper = container.firstElementChild;
35+
36+
if (!wrapper) {
37+
return null;
38+
}
39+
40+
for (const child of Array.from(wrapper.childNodes)) {
41+
if (child.nodeType === Node.COMMENT_NODE) {
42+
return child as Comment;
43+
}
44+
}
45+
46+
return null;
47+
}
48+
49+
// Returns [committedHTML, activeHTML] split by the sentinel comment.
50+
// Uses Range to extract each half into a fragment, then serializes via innerHTML.
51+
function splitBySentinel(container: HTMLElement): [string, string] | null {
52+
const sentinel = getSentinel(container);
53+
54+
if (!sentinel) {
55+
return null;
56+
}
57+
58+
const wrapper = container.firstElementChild!;
59+
60+
const committedRange = document.createRange();
61+
62+
committedRange.setStartBefore(wrapper.firstChild!);
63+
committedRange.setEndBefore(sentinel);
64+
65+
const activeRange = document.createRange();
66+
67+
activeRange.setStartAfter(sentinel);
68+
activeRange.setEndAfter(wrapper.lastChild!);
69+
70+
const committedDiv = document.createElement('div');
71+
const activeDiv = document.createElement('div');
72+
73+
committedDiv.appendChild(committedRange.cloneContents());
74+
activeDiv.appendChild(activeRange.cloneContents());
75+
76+
return [committedDiv.innerHTML.trim(), activeDiv.innerHTML.trim()];
77+
}
78+
79+
describe('createStreamingRenderer', () => {
80+
describe('single block', () => {
81+
test('should render a paragraph without sentinel', () => {
82+
const { container, nextOptions, renderer } = setup();
83+
84+
renderer.next('Hello, World!', nextOptions());
85+
86+
expect(getWrapperHTML(container)).toBe('<p>Hello, World!</p>');
87+
expect(getSentinel(container)).toBeNull();
88+
});
89+
90+
test('should render inline formatting within a single block', () => {
91+
const { container, nextOptions, renderer } = setup();
92+
93+
renderer.next('Hello **bo', nextOptions());
94+
renderer.next('ld** and *ita', nextOptions());
95+
renderer.next('lic*', nextOptions());
96+
97+
expect(getWrapperHTML(container)).toBe('<p>Hello <strong>bold</strong> and <em>italic</em></p>');
98+
expect(getSentinel(container)).toBeNull();
99+
});
100+
});
101+
102+
describe('multi-block split', () => {
103+
test('should split two paragraphs into committed and active', () => {
104+
const { container, nextOptions, renderer } = setup();
105+
106+
renderer.next('First paragraph\n\nSecond paragraph', nextOptions());
107+
108+
const split = splitBySentinel(container);
109+
110+
expect(split).not.toBeNull();
111+
expect(split![0]).toBe('<p>First paragraph</p>');
112+
expect(split![1]).toBe('<p>Second paragraph</p>');
113+
});
114+
115+
test('should preserve both paragraphs in textContent', () => {
116+
const { container, nextOptions, renderer } = setup();
117+
118+
renderer.next('First\n\nSecond', nextOptions());
119+
120+
expect(container.textContent).toContain('First');
121+
expect(container.textContent).toContain('Second');
122+
});
123+
124+
test('should split three paragraphs with two committed and one active', () => {
125+
const { container, nextOptions, renderer } = setup();
126+
127+
renderer.next('Block A\n\nBlock B\n\nBlock C', nextOptions());
128+
129+
const split = splitBySentinel(container);
130+
131+
expect(split).not.toBeNull();
132+
expect(split![0]).toContain('Block A');
133+
expect(split![0]).toContain('Block B');
134+
expect(split![1]).toBe('<p>Block C</p>');
135+
});
136+
});
137+
138+
describe('htmlFlow blocks', () => {
139+
test('should keep multi-element htmlFlow block whole in committed split', () => {
140+
const { container, nextOptions, renderer } = setup();
141+
142+
// htmlFlow that produces two sibling <img> elements, followed by a paragraph.
143+
renderer.next('<img src="a.png">\n<img src="b.png">\n\nTrailing paragraph', nextOptions());
144+
145+
const split = splitBySentinel(container);
146+
147+
expect(split).not.toBeNull();
148+
149+
// Both <img> must be in committed; the paragraph is the active block.
150+
expect(split![0]).toContain('<img src="a.png">');
151+
expect(split![0]).toContain('<img src="b.png">');
152+
expect(split![1]).toBe('<p>Trailing paragraph</p>');
153+
});
154+
155+
test('should render htmlFlow as single block without sentinel', () => {
156+
const { container, nextOptions, renderer } = setup();
157+
158+
renderer.next('<div>Hello</div>', nextOptions());
159+
160+
expect(getWrapperHTML(container)).toBe('<div>Hello</div>');
161+
expect(getSentinel(container)).toBeNull();
162+
});
163+
});
164+
165+
describe('incremental streaming (append-only chunks)', () => {
166+
test('should accumulate single block content without sentinel', () => {
167+
const { container, nextOptions, renderer } = setup();
168+
169+
renderer.next('Hello', nextOptions());
170+
171+
expect(getWrapperHTML(container)).toBe('<p>Hello</p>');
172+
expect(getSentinel(container)).toBeNull();
173+
174+
renderer.next(' World', nextOptions());
175+
176+
expect(getWrapperHTML(container)).toBe('<p>Hello World</p>');
177+
expect(getSentinel(container)).toBeNull();
178+
});
179+
180+
test('should promote first block to committed when new block starts', () => {
181+
const { container, nextOptions, renderer } = setup();
182+
183+
renderer.next('First block', nextOptions());
184+
185+
expect(getSentinel(container)).toBeNull();
186+
187+
renderer.next('\n\nSecond block', nextOptions());
188+
189+
const split = splitBySentinel(container);
190+
191+
expect(split).not.toBeNull();
192+
expect(split![0]).toBe('<p>First block</p>');
193+
expect(split![1]).toBe('<p>Second block</p>');
194+
});
195+
196+
test('should grow active block via incremental path', () => {
197+
const { container, nextOptions, renderer } = setup();
198+
199+
renderer.next('Block 1\n\nPartial', nextOptions());
200+
201+
const split1 = splitBySentinel(container);
202+
203+
expect(split1).not.toBeNull();
204+
expect(split1![1]).toBe('<p>Partial</p>');
205+
206+
renderer.next(' more text', nextOptions());
207+
208+
const split2 = splitBySentinel(container);
209+
210+
expect(split2).not.toBeNull();
211+
expect(split2![0]).toBe('<p>Block 1</p>');
212+
expect(split2![1]).toBe('<p>Partial more text</p>');
213+
});
214+
215+
test('should commit additional blocks during incremental streaming', () => {
216+
const { container, nextOptions, renderer } = setup();
217+
218+
renderer.next('Block A\n\nBlock B', nextOptions());
219+
220+
const split1 = splitBySentinel(container);
221+
222+
expect(split1![0]).toBe('<p>Block A</p>');
223+
expect(split1![1]).toBe('<p>Block B</p>');
224+
225+
// Append a new block boundary — Block B becomes committed, Block C is active.
226+
renderer.next('\n\nBlock C', nextOptions());
227+
228+
const split2 = splitBySentinel(container);
229+
230+
expect(split2).not.toBeNull();
231+
expect(split2![0]).toContain('Block A');
232+
expect(split2![0]).toContain('Block B');
233+
expect(split2![1]).toBe('<p>Block C</p>');
234+
});
235+
});
236+
237+
describe('reset', () => {
238+
test('should clear state and render new content from scratch', () => {
239+
const { container, nextOptions, renderer } = setup();
240+
241+
renderer.next('First\n\nSecond', nextOptions());
242+
243+
expect(getSentinel(container)).not.toBeNull();
244+
245+
renderer.reset();
246+
247+
renderer.next('Fresh start', nextOptions());
248+
249+
expect(getWrapperHTML(container)).toBe('<p>Fresh start</p>');
250+
expect(getSentinel(container)).toBeNull();
251+
});
252+
});
253+
254+
describe('finalize', () => {
255+
test('should do full reparse and remove sentinel', () => {
256+
const { container, nextOptions, renderer } = setup();
257+
258+
renderer.next('Block 1\n\nBlock 2', nextOptions());
259+
260+
expect(getSentinel(container)).not.toBeNull();
261+
262+
renderer.finalize(nextOptions());
263+
264+
expect(getSentinel(container)).toBeNull();
265+
expect(getWrapperHTML(container)).toContain('Block 1');
266+
expect(getWrapperHTML(container)).toContain('Block 2');
267+
});
268+
269+
test('should extract link definitions', () => {
270+
const { nextOptions, renderer } = setup();
271+
272+
renderer.next('See [link][1]\n\n[1]: https://example.com "Example"', nextOptions());
273+
274+
const { definitions } = renderer.finalize(nextOptions());
275+
276+
expect(definitions).toEqual(
277+
expect.arrayContaining([expect.objectContaining({ identifier: '1', url: 'https://example.com' })])
278+
);
279+
});
280+
281+
test('should render empty container when no markdown was passed', () => {
282+
const { container, nextOptions, renderer } = setup();
283+
284+
const { definitions } = renderer.finalize(nextOptions());
285+
286+
expect(getWrapperHTML(container)).toBe('');
287+
expect(definitions).toEqual([]);
288+
});
289+
});
290+
291+
describe('non-append reset and re-render', () => {
292+
test('should fall back to full reparse when new markdown does not start with previous', () => {
293+
const { container, nextOptions, renderer } = setup();
294+
295+
renderer.next('Block 1\n\nBlock 2', nextOptions());
296+
297+
// Simulate non-append-only update (e.g., streaming content replaced entirely).
298+
renderer.reset();
299+
300+
renderer.next('Replaced\n\nContent', nextOptions());
301+
302+
const split = splitBySentinel(container);
303+
304+
expect(split).not.toBeNull();
305+
expect(split![0]).toBe('<p>Replaced</p>');
306+
expect(split![1]).toBe('<p>Content</p>');
307+
});
308+
});
309+
310+
describe('code blocks', () => {
311+
test('should split fenced code block from trailing paragraph', () => {
312+
const { container, nextOptions, renderer } = setup();
313+
314+
renderer.next('```js\nconsole.log("hi");\n```\n\nDone', nextOptions());
315+
316+
const split = splitBySentinel(container);
317+
318+
expect(split).not.toBeNull();
319+
expect(split![0]).toContain('<code');
320+
expect(split![1]).toBe('<p>Done</p>');
321+
});
322+
});
323+
324+
describe('heading and paragraph', () => {
325+
test('should split heading from following paragraph', () => {
326+
const { container, nextOptions, renderer } = setup();
327+
328+
renderer.next('# Title\n\nBody text', nextOptions());
329+
330+
const split = splitBySentinel(container);
331+
332+
expect(split).not.toBeNull();
333+
expect(split![0]).toBe('<h1>Title</h1>');
334+
expect(split![1]).toBe('<p>Body text</p>');
335+
});
336+
});
337+
338+
describe('thematic break', () => {
339+
test('should split at thematic break', () => {
340+
const { container, nextOptions, renderer } = setup();
341+
342+
renderer.next('Above\n\n---\n\nBelow', nextOptions());
343+
344+
const split = splitBySentinel(container);
345+
346+
expect(split).not.toBeNull();
347+
expect(split![0]).toContain('Above');
348+
expect(split![0]).toContain('<hr');
349+
expect(split![1]).toBe('<p>Below</p>');
350+
});
351+
});
352+
});

0 commit comments

Comments
 (0)