Unclosed')
- expect(result.text).toBe('Unclosed')
- })
-
- it('should handle DOCTYPE declarations', () => {
- const html = '
Content'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('Content')
- })
-
- it('should ignore script tags', () => {
- const html = 'Text'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('Text')
- })
-
- it('should ignore style tag content as text', () => {
- const html = 'Text'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('Text')
- })
-
- it('should ignore head tag content', () => {
- const html = '
TitleBody'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('Body')
- })
-
- it('should handle HTML comments', () => {
- const html = 'TextMore'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('TextMore')
- })
-
- it('should handle CDATA sections', () => {
- const html = 'TextMore'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('TextMore')
- })
-
- it('should handle empty elements', () => {
- const result = HTMLRenderer.render('
')
- expect(result.text).toBe('')
- })
-
- it('should handle void tags', () => {
- const html = '
![]()
'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('')
- })
-
- it('should handle self-closing tags', () => {
- const html = '
Text
More
'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('Text\nMore')
- })
-
- it('should handle mixed content', () => {
- const html = `
-
-
Title
-
Paragraph with bold and italic.
-
-
- `
- const result = HTMLRenderer.render(html)
- expect(result.text).toContain('Title')
- expect(result.text).toContain('Paragraph with bold and italic.')
- expect(result.text).toContain('• Item 1')
- expect(result.text).toContain('• Item 2')
- })
- })
-
- describe('background color handling', () => {
- it('should apply background color from inline style', () => {
- const result = HTMLRenderer.render(
- '
Highlighted'
- )
- expect(result.text).toBe('Highlighted')
- expect(result.fragments[0]).toBeDefined()
- })
-
- it('should not apply background color to newlines after block elements', () => {
- const result = HTMLRenderer.render(
- '
Text
'
- )
- expect(result.text).toBe('Text')
- expect(result.fragments[0]?.text).toBe('Text')
- })
-
- it('should not inherit background color to children', () => {
- const result = HTMLRenderer.render(
- '
Text
'
- )
- expect(result.text).toBe('Text')
- expect(result.fragments[0]?.text).toBe('Text')
- })
- })
-
- describe('complex HTML structures', () => {
- it('should handle deeply nested elements', () => {
- const html =
- '
'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('Nested')
- const fragment = result.fragments[0]
- expect(fragment?.fontWeight).toBe('bold')
- expect(fragment?.fontStyle).toBe('italic')
- })
-
- it('should handle sibling elements with different styles', () => {
- const html = '
Bold Italic Normal
'
- const result = HTMLRenderer.render(html)
- expect(result.text).toBe('Bold Italic Normal')
-
- const boldFragment = result.fragments.find(
- (f) => f.fontWeight === 'bold'
- )
- const italicFragment = result.fragments.find(
- (f) => f.fontStyle === 'italic'
- )
- expect(boldFragment?.text).toBe('Bold')
- expect(italicFragment?.text).toBe('Italic')
- })
-
- it('should handle article structure', () => {
- const html = `
-
-
-
- First paragraph.
- Second paragraph.
-
-
-
- `
- const result = HTMLRenderer.render(html)
- expect(result.text).toContain('Article Title')
- expect(result.text).toContain('First paragraph.')
- expect(result.text).toContain('Second paragraph.')
- expect(result.text).toContain('Footer text')
- })
- })
-
- describe('links', () => {
- it('should capture link href and render text', () => {
- const result = HTMLRenderer.render(
- '
Click'
- )
-
- expect(result.text).toBe('Click')
- expect(result.fragments).toHaveLength(1)
- expect(result.fragments[0]?.text).toBe('Click')
- expect(result.fragments[0]?.linkUrl).toBe('https://example.com')
- expect(result.fragments[0]?.fontColor).toBeUndefined()
- })
-
- it('should ignore target attribute on links', () => {
- const result = HTMLRenderer.render(
- '
New Tab'
- )
-
- const frag = result.fragments[0]
- expect(result.text).toBe('New Tab')
- expect(frag?.linkUrl).toBe('https://example.com')
- })
-
- it('should support inline styles on
', () => {
- const result = HTMLRenderer.render(
- 'Link'
- )
-
- const frag = result.fragments[0]
- expect(result.text).toBe('Link')
- expect(frag?.linkUrl).toBe('#')
- expect(frag?.fontColor).toBe('red')
- expect(frag?.fontWeight).toBe('bold')
- })
-
- it('should inherit semantic styles inside
', () => {
- const result = HTMLRenderer.render(
- 'Bold and Italic'
- )
-
- expect(result.text).toBe('Bold and Italic')
-
- const boldFrag = result.fragments.find(
- (f) => f.fontWeight === 'bold'
- )
- const italicFrag = result.fragments.find(
- (f) => f.fontStyle === 'italic'
- )
-
- expect(boldFrag?.text).toBe('Bold')
- expect(boldFrag?.fontWeight).toBe('bold')
- expect(boldFrag?.linkUrl).toBe('/path')
- expect(italicFrag?.text).toBe('Italic')
- expect(italicFrag?.fontStyle).toBe('italic')
- expect(italicFrag?.linkUrl).toBe('/path')
- })
-
- it('should handle links without href', () => {
- const result = HTMLRenderer.render('
No Link')
-
- expect(result.text).toBe('No Link')
- expect(result.fragments[0]?.linkUrl).toBeUndefined()
- })
-
- it('should ignore empty links', () => {
- const result = HTMLRenderer.render('
')
- expect(result.text).toBe('')
- expect(result.fragments).toHaveLength(0)
- })
-
- it('should handle whitespace around links', () => {
- const result = HTMLRenderer.render(
- 'Hello
there friend'
- )
- expect(result.text).toBe('Hello there friend')
- const linkFrag = result.fragments.find((f) => f.linkUrl)
- expect(linkFrag?.text).toBe('there')
- expect(linkFrag?.linkUrl).toBe('/link')
- })
-
- it('should not inherit color from parent paragraph', () => {
- const result = HTMLRenderer.render(
- '
Visit Google for search.
'
- )
-
- const linkFrag = result.fragments.find(
- (f) => f.linkUrl === 'https://google.com'
- )
- expect(linkFrag).toBeDefined()
- expect(linkFrag?.text).toBe('Google')
- expect(linkFrag?.linkUrl).toBe('https://google.com')
- expect(linkFrag?.fontColor).toBeUndefined()
- })
- })
- })
-})
diff --git a/src/renderers/html.ts b/src/renderers/html.ts
deleted file mode 100644
index 57db910..0000000
--- a/src/renderers/html.ts
+++ /dev/null
@@ -1,693 +0,0 @@
-import type { Fragment } from '../types'
-import type {
- ElementNode,
- ListStackItem,
- Node,
- RenderResult,
- WalkContext,
- WalkState,
-} from './types'
-import {
- BLOCK_TAGS,
- HEADING_SIZES,
- PRE_TAGS,
- VOID_TAGS,
-} from '../constants/html'
-import { CSSProcessor } from './css-processor'
-import {
- appendText,
- collapseWhitespace,
- createState,
- decodeEntities,
- finalizeState,
- mergeStyles,
- trimLeadingWhitespace,
- trimTrailingWhitespace,
-} from './utils'
-
-/**
- * HTML renderer with optimized parsing and rendering.
- */
-export class HTMLRenderer {
- /**
- * Renders HTML string to fragments with optimized parsing.
- */
- static render(
- html: string,
- baseFragment: Partial
= {}
- ): RenderResult {
- const root = this.parseHtmlTree(html)
- const stylesheetBlocks: string[] = []
- this.stripStyleNodes(root, stylesheetBlocks)
- const stylesheet = CSSProcessor.buildStylesheet(stylesheetBlocks)
- const state = createState()
- const walkState: WalkState = {
- stylesheet,
- state,
- listStack: [],
- }
- this.walkNodes(
- root.children,
- { style: baseFragment, preformatted: false },
- walkState
- )
- trimLeadingWhitespace(state)
- trimTrailingWhitespace(state)
- return finalizeState(state)
- }
-
- /**
- * Walks the node tree and renders content with optimized traversal.
- */
- private static walkNodes(
- nodes: Node[],
- context: WalkContext,
- walkState: WalkState
- ) {
- const nodesLength = nodes.length
- for (let i = 0; i < nodesLength; i++) {
- const node = nodes[i]
- if (!node) continue
- if (node.type === 'text') {
- const decoded = decodeEntities(node.content)
- const content = context.preformatted
- ? decoded
- : collapseWhitespace(decoded)
- if (content.length > 0) {
- appendText(walkState.state, content, context.style)
- }
- continue
- }
-
- const tag = node.tag
- if (tag === 'head' || tag === 'style' || tag === 'script') {
- continue
- }
-
- let nextStyle = context.style
- const applied = CSSProcessor.applyStylesFromSheet(
- node,
- walkState.stylesheet
- )
- if (applied.hidden) {
- continue
- }
-
- const inlineApplied = CSSProcessor.applyInlineStyles(node.attrs.style)
- if (inlineApplied.hidden) {
- continue
- }
-
- if (applied.fragment) {
- nextStyle = mergeStyles(nextStyle, applied.fragment)
- }
- if (inlineApplied.fragment) {
- nextStyle = mergeStyles(nextStyle, inlineApplied.fragment)
- }
-
- const suppressNewlines =
- applied.suppressNewlines || inlineApplied.suppressNewlines
-
- const semantic = this.semanticStyleForTag(tag)
- if (semantic) {
- nextStyle = mergeStyles(nextStyle, semantic)
- }
-
- if (tag === 'a') {
- const href = node.attrs.href
- if (href) {
- nextStyle = mergeStyles(nextStyle, { linkUrl: href })
- }
- }
-
- const styleForChildren = { ...nextStyle }
- if (styleForChildren.fragmentBackgroundColor) {
- delete styleForChildren.fragmentBackgroundColor
- }
-
- if (tag === 'br') {
- appendText(walkState.state, '\n', nextStyle)
- continue
- }
-
- if (tag === 'img') {
- // Handle whitespace before img alt text (same as other inline elements)
- const hasContent =
- walkState.state.plainText &&
- walkState.state.plainText.trim().length > 0
- if (hasContent) {
- const trimmed = walkState.state.plainText.trimEnd()
- if (trimmed.length < walkState.state.plainText.length) {
- const trailingWhitespace = walkState.state.plainText.slice(
- trimmed.length
- )
- const newlineCount = (trailingWhitespace.match(/\n/g) || [])
- .length
-
- if (newlineCount > 0) {
- // Remove all trailing whitespace, then add back exactly one newline
- const diff =
- walkState.state.plainText.length - trimmed.length
- let removed = 0
- for (
- let i = walkState.state.fragments.length - 1;
- i >= 0 && removed < diff;
- i--
- ) {
- const frag = walkState.state.fragments[i]
- if (frag && frag.text) {
- const fragLen = frag.text.length
- const toRemove = Math.min(fragLen, diff - removed)
- frag.text = frag.text.slice(0, fragLen - toRemove)
- removed += toRemove
- if (!frag.text) {
- walkState.state.fragments.pop()
- }
- }
- }
- walkState.state.plainText = trimmed
- // Add back exactly one newline so img alt text appears on new line
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- } else {
- // No newlines, just spaces - remove them all
- const diff =
- walkState.state.plainText.length - trimmed.length
- let removed = 0
- for (
- let i = walkState.state.fragments.length - 1;
- i >= 0 && removed < diff;
- i--
- ) {
- const frag = walkState.state.fragments[i]
- if (frag && frag.text) {
- const fragLen = frag.text.length
- const toRemove = Math.min(fragLen, diff - removed)
- frag.text = frag.text.slice(0, fragLen - toRemove)
- removed += toRemove
- if (!frag.text) {
- walkState.state.fragments.pop()
- }
- }
- }
- walkState.state.plainText = trimmed
- }
- }
- }
- const altText = node.attrs.alt
- if (altText) {
- appendText(walkState.state, altText, nextStyle)
- }
- continue
- }
-
- const preformatted = context.preformatted || PRE_TAGS.has(tag)
- const isBlock = BLOCK_TAGS.has(tag)
-
- // Handle inline elements (like span) that come after block elements
- if (!isBlock && tag !== 'br' && tag !== 'img') {
- const hasContent =
- walkState.state.plainText &&
- walkState.state.plainText.trim().length > 0
- if (hasContent) {
- // Check if we have trailing whitespace with newlines (from a block element)
- const trimmed = walkState.state.plainText.trimEnd()
- if (trimmed.length < walkState.state.plainText.length) {
- const trailingWhitespace = walkState.state.plainText.slice(
- trimmed.length
- )
- const newlineCount = (trailingWhitespace.match(/\n/g) || [])
- .length
-
- // Only process if we have newlines (block element just finished)
- // If there are only spaces (no newlines), preserve them for normal inline flow
- if (newlineCount > 0) {
- // Remove all trailing whitespace, then add back exactly one newline
- const diff =
- walkState.state.plainText.length - trimmed.length
- let removed = 0
- for (
- let i = walkState.state.fragments.length - 1;
- i >= 0 && removed < diff;
- i--
- ) {
- const frag = walkState.state.fragments[i]
- if (frag && frag.text) {
- const fragLen = frag.text.length
- const toRemove = Math.min(fragLen, diff - removed)
- frag.text = frag.text.slice(0, fragLen - toRemove)
- removed += toRemove
- if (!frag.text) {
- walkState.state.fragments.pop()
- }
- }
- }
- walkState.state.plainText = trimmed
- // Add back exactly one newline so inline elements appear on new line
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- }
- }
- }
-
- if (tag === 'ul' || tag === 'ol') {
- const listItem: ListStackItem = {
- type: tag as 'ul' | 'ol',
- index: tag === 'ol' ? 1 : 0,
- }
- // Always ensure list containers start on a new line when there's existing content
- // Check for actual non-whitespace content, not just any text
- const hasContent =
- walkState.state.plainText &&
- walkState.state.plainText.trim().length > 0
- if (hasContent) {
- // Trim any trailing whitespace and ensure we end with a newline
- const trimmed = walkState.state.plainText.trimEnd()
- if (trimmed.length < walkState.state.plainText.length) {
- const diff = walkState.state.plainText.length - trimmed.length
- let removed = 0
- for (
- let i = walkState.state.fragments.length - 1;
- i >= 0 && removed < diff;
- i--
- ) {
- const frag = walkState.state.fragments[i]
- if (frag && frag.text) {
- const fragLen = frag.text.length
- const toRemove = Math.min(fragLen, diff - removed)
- frag.text = frag.text.slice(0, fragLen - toRemove)
- removed += toRemove
- if (!frag.text) {
- walkState.state.fragments.pop()
- }
- }
- }
- walkState.state.plainText = trimmed
- }
- if (!walkState.state.plainText.endsWith('\n')) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- // Add extra spacing for non-zero margins
- if (
- !suppressNewlines &&
- walkState.state.plainText.endsWith('\n')
- ) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- }
- walkState.listStack.push(listItem)
- this.walkNodes(
- node.children,
- { style: nextStyle, preformatted },
- walkState
- )
- walkState.listStack.pop()
- // Always ensure list containers end with a newline for proper block structure
- if (!walkState.state.plainText.endsWith('\n')) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- // Add extra spacing for non-zero margins
- if (!suppressNewlines && walkState.state.plainText.endsWith('\n')) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- continue
- }
-
- if (tag === 'li') {
- const activeList =
- walkState.listStack[walkState.listStack.length - 1]
- // Always ensure list items start on a new line when there's existing content
- // Check for actual non-whitespace content, not just any text
- const hasContent =
- walkState.state.plainText &&
- walkState.state.plainText.trim().length > 0
- if (hasContent) {
- // Trim any trailing whitespace and ensure we end with a newline
- const trimmed = walkState.state.plainText.trimEnd()
- if (trimmed.length < walkState.state.plainText.length) {
- const diff = walkState.state.plainText.length - trimmed.length
- let removed = 0
- for (
- let i = walkState.state.fragments.length - 1;
- i >= 0 && removed < diff;
- i--
- ) {
- const frag = walkState.state.fragments[i]
- if (frag && frag.text) {
- const fragLen = frag.text.length
- const toRemove = Math.min(fragLen, diff - removed)
- frag.text = frag.text.slice(0, fragLen - toRemove)
- removed += toRemove
- if (!frag.text) {
- walkState.state.fragments.pop()
- }
- }
- }
- walkState.state.plainText = trimmed
- }
- if (!walkState.state.plainText.endsWith('\n')) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- // Add extra spacing for non-zero margins
- if (
- !suppressNewlines &&
- walkState.state.plainText.endsWith('\n')
- ) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- }
- if (activeList) {
- const bullet =
- activeList.type === 'ol' ? `${activeList.index}. ` : '• '
- appendText(walkState.state, bullet, nextStyle)
- activeList.index += 1
- } else {
- appendText(walkState.state, '• ', nextStyle)
- }
- this.walkNodes(
- node.children,
- { style: nextStyle, preformatted },
- walkState
- )
- // Always ensure list items end with a newline for proper block structure
- if (!walkState.state.plainText.endsWith('\n')) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- // Add extra spacing for non-zero margins
- if (!suppressNewlines && walkState.state.plainText.endsWith('\n')) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- continue
- }
-
- // Handle spacing BEFORE block elements (margin-top)
- if (isBlock) {
- // Always ensure block elements start on a new line when there's existing content
- // Check for actual non-whitespace content, not just any text
- const hasContent =
- walkState.state.plainText &&
- walkState.state.plainText.trim().length > 0
- if (hasContent) {
- // Trim any trailing whitespace and ensure we end with a newline
- // This handles the case where whitespace text nodes between blocks
- // might have collapsed newlines to spaces
- const trimmed = walkState.state.plainText.trimEnd()
- if (trimmed.length < walkState.state.plainText.length) {
- // Remove trailing whitespace from fragments
- const diff = walkState.state.plainText.length - trimmed.length
- let removed = 0
- for (
- let i = walkState.state.fragments.length - 1;
- i >= 0 && removed < diff;
- i--
- ) {
- const frag = walkState.state.fragments[i]
- if (frag && frag.text) {
- const fragLen = frag.text.length
- const toRemove = Math.min(fragLen, diff - removed)
- frag.text = frag.text.slice(0, fragLen - toRemove)
- removed += toRemove
- if (!frag.text) {
- walkState.state.fragments.pop()
- }
- }
- }
- walkState.state.plainText = trimmed
- }
- // Ensure we end with a newline (blocks always maintain structure)
- if (!walkState.state.plainText.endsWith('\n')) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- // Add margin-top spacing for all block elements (unless suppressed)
- // Default margin-top: add extra newline for spacing
- if (!suppressNewlines) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- }
- }
-
- // Track content before processing children to detect empty blocks
- const contentBefore = walkState.state.plainText.trim().length
- this.walkNodes(
- node.children,
- { style: styleForChildren, preformatted },
- walkState
- )
- const contentAfter = walkState.state.plainText.trim().length
- const hasContent = contentAfter > contentBefore
-
- // Handle spacing AFTER block elements (margin-bottom)
- if (isBlock) {
- // Only add spacing if the block element actually produced content
- // This prevents empty fragments with background colors for nested empty divs
- if (hasContent) {
- // Always ensure block elements end with a newline for proper block structure
- if (!walkState.state.plainText.endsWith('\n')) {
- // Use nextStyle but without background color to preserve alignment
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- // Add margin-bottom spacing for all block elements (unless suppressed)
- // Default margin-bottom: add extra newline for spacing
- if (
- !suppressNewlines &&
- walkState.state.plainText.endsWith('\n')
- ) {
- const newlineStyle = { ...nextStyle }
- delete newlineStyle.fragmentBackgroundColor
- appendText(walkState.state, '\n', newlineStyle)
- }
- }
- }
- }
- }
-
- /**
- * Strips style nodes and extracts CSS content.
- */
- private static stripStyleNodes(node: ElementNode, styles: string[]) {
- const filtered: Node[] = []
- const childrenLength = node.children.length
- for (let i = 0; i < childrenLength; i++) {
- const child = node.children[i]
- if (!child) continue
- if (child.type === 'element') {
- if (child.tag === 'style') {
- const cssText = this.extractNodeText(child)
- if (cssText.trim()) {
- styles.push(cssText)
- }
- continue
- }
- this.stripStyleNodes(child, styles)
- }
- filtered.push(child)
- }
- node.children = filtered
- }
-
- /**
- * Extracts text content from a node tree.
- */
- private static extractNodeText(node: Node): string {
- if (node.type === 'text') {
- return node.content
- }
- const parts: string[] = []
- const childrenLength = node.children.length
- for (let i = 0; i < childrenLength; i++) {
- const child = node.children[i]
- if (child) {
- parts.push(this.extractNodeText(child))
- }
- }
- return parts.join('')
- }
-
- /**
- * Parses HTML into a tree structure with optimized regex matching.
- */
- private static parseHtmlTree(html: string): ElementNode {
- const root: ElementNode = {
- type: 'element',
- tag: '#root',
- attrs: {},
- children: [],
- }
- const stack: ElementNode[] = [root]
- const sanitized = html.replace(//gi, '')
- const lower = sanitized.toLowerCase()
- const tagRegex = /||<[^>]+>/g
- let lastIndex = 0
- let match: RegExpExecArray | null
- while ((match = tagRegex.exec(sanitized))) {
- const index = match.index
- if (index > lastIndex) {
- const text = sanitized.slice(lastIndex, index)
- if (text) {
- const parent = stack[stack.length - 1]
- parent?.children.push({
- type: 'text',
- content: text,
- })
- }
- }
-
- const token = match[0]
- lastIndex = tagRegex.lastIndex
-
- if (token.startsWith('