diff --git a/README.md b/README.md index 13988f6..984b104 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ https://github.com/user-attachments/assets/57f56b3f-3988-4235-af83-a5f2cfd82121 - Works on both iOS and Android(currently fallback to RN `Text` on Android) - Native iOS rendering with smooth selection. - Nested fragments merge into a single native text view -- Rendering Markdown and HTML (coming soon). - Supports only the New Architecture ## Requirements @@ -84,26 +83,6 @@ export function SelectionExample() { } ``` -## HTML rendering - -NitroText can parse HTML string children and inline CSS when you pass `renderer="html"`. - -```tsx -import { NitroText } from 'react-native-nitro-text' - -export function HtmlExample() { - const html = ` -
-

Renderer demo

-

This text comes from HTML with semantic tags.

-

Inline CSS works too.

-
- ` - - return {html} -} -``` - ## Custom selection menu NitroText supports custom menu items that appear when text is selected. Pass a `menus` prop with an array of menu items, each containing a `title` and `action` callback. diff --git a/example/src/App.tsx b/example/src/App.tsx index 523c6eb..ece2d35 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,7 +3,6 @@ import { Text } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { PlainTextScreen } from './screens/PlainTextScreen'; -import { HtmlScreen } from './screens/HtmlScreen'; import { PerformanceScreen } from './screens/PerformanceScreen'; const Tab = createBottomTabNavigator(); @@ -27,16 +26,6 @@ function MyTabs() { ), }} /> - ( - 🌐 - ), - }} - /> - {/* Header Section */} - - 🌐 HTML Renderer - - Render rich HTML content with NitroText's native performance - - - - {/* Basic HTML Elements */} - - Basic HTML Elements - - Standard HTML tags: headings, paragraphs, and text formatting - - - {`

Heading 1

-

Heading 2

-

Heading 3

-

Heading 4

-
Heading 5
-
Heading 6
- -

Paragraph with bold and italic text.

-

Text with underline and strikethrough.

-

Using b tag and i tag for formatting.

-

Text with highlighted content.

`} -
-
- - {/* Links */} - - Links - - Interactive links that open directly in Safari - - - {` - - - -

Visit Google for search.

-

Learn more at React Native Docs.

-

Links can have custom styling too!

-

Multiple links in one paragraph work perfectly.

- -

Styled with CSS: Primary Link and Success Link.

- -

Links work with bold and italic text.

`} -
-
- - {/* Lists */} - - Lists - - Ordered and unordered lists - - - {`
    -
  • Unordered list item 1
  • -
  • Unordered list item 2
  • -
  • Unordered list item 3
  • -
- -
    -
  1. Ordered list item 1
  2. -
  3. Ordered list item 2
  4. -
  5. Ordered list item 3
  6. -
`} -
-
- - {/* CSS Styling */} - - CSS Styling - - CSS classes, IDs, and inline styles - - - {` - -

Red text from CSS class

-

Blue text from CSS class

-

Yellow background from CSS class

-

Large text from CSS class

- -

Green bold text from inline style

-

Custom font size and line height

-

Centered text

-

uppercase text

-

Purple underline

`} -
-
- - {/* Code Blocks */} - - Code & Preformatted Text - - Preformatted text and inline code - - - {`
Preformatted text
-with multiple lines
-and    extra    spaces
- -

Inline code text in a paragraph.

- -
// Code block example
-function hello() {
-  console.log("Hello, World!");
-}
`} -
-
- - {/* Blockquotes */} - - Blockquotes - - Quoted content blocks - - - {`
- This is a blockquote. It's used to highlight quoted content from another source. -
- -
- Styled blockquote with custom border and padding. -
`} -
-
- - {/* HTML Entities & Unicode */} - - HTML Entities & Unicode - - Special characters and emojis - - - {`

HTML entities: & < > " '   non-breaking space

-

Unicode emojis: 😀 💩 ❤ 👍 🚀

-

Mathematical symbols: ∑ ∞ π α β γ

-

Currency symbols: € £ ¥ ¢

`} -
-
- - {/* Nested Elements */} - - Nested Elements - - Complex nested HTML structures - - - {`
-

Nested div content

- Span element -
-

Deeply nested content

-
    -
  • Nested list item
  • -
-
-
`} -
-
- - {/* Hidden Elements */} - - Hidden Elements - - Elements with display: none should be hidden - - - {` - -

This paragraph is visible.

- -

This should also be hidden (inline style)

-

This paragraph is also visible.

`} -
-
- - {/* Complex Example */} - - Complex Example - - Combining multiple HTML features together - - - {` - -
-
Article Title
-
Published on January 1, 2024
-

This is a complex example combining multiple HTML features:

-
    -
  • CSS classes and styling
  • -
  • Nested elements
  • -
  • Text formatting
  • -
-
- "The best way to predict the future is to create it." -
-

You can use inline code and bold text together.

-
`} -
-
- - ); -} - diff --git a/example/src/screens/styles.ts b/example/src/screens/styles.ts index 02ccb6f..1134114 100644 --- a/example/src/screens/styles.ts +++ b/example/src/screens/styles.ts @@ -13,15 +13,6 @@ export const styles = StyleSheet.create({ section: { marginBottom: 32, }, - htmlSection: { - backgroundColor: 'white', - padding: 16, - borderRadius: 8, - borderWidth: 1, - borderColor: '#dee2e6', - marginTop: 8, - }, - // Headers mainTitle: { fontSize: 25, @@ -418,4 +409,3 @@ export const styles = StyleSheet.create({ lineHeight: 20, }, }); - diff --git a/ios/HybridNitroText.swift b/ios/HybridNitroText.swift index ae0f70a..d55bb29 100755 --- a/ios/HybridNitroText.swift +++ b/ios/HybridNitroText.swift @@ -26,12 +26,6 @@ class HybridNitroText: HybridNitroTextSpec, NitroTextViewDelegate { didSet { markNeedsApply() } } - var renderer: Renderer? { - didSet { - markNeedsApply() - } - } - var selectable: Bool? { didSet { nitroTextImpl.setSelectable(selectable) diff --git a/nitrogen/generated/ios/NitroText-Swift-Cxx-Bridge.hpp b/nitrogen/generated/ios/NitroText-Swift-Cxx-Bridge.hpp index a86b35a..2bb7a6c 100644 --- a/nitrogen/generated/ios/NitroText-Swift-Cxx-Bridge.hpp +++ b/nitrogen/generated/ios/NitroText-Swift-Cxx-Bridge.hpp @@ -2,7 +2,7 @@ /// NitroText-Swift-Cxx-Bridge.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #pragma once @@ -24,8 +24,6 @@ namespace margelo::nitro::nitrotext { class HybridNitroTextSpec; } namespace margelo::nitro::nitrotext { enum class LineBreakStrategyIOS; } // Forward declaration of `MenuItem` to properly resolve imports. namespace margelo::nitro::nitrotext { struct MenuItem; } -// Forward declaration of `Renderer` to properly resolve imports. -namespace margelo::nitro::nitrotext { enum class Renderer; } // Forward declaration of `TextAlign` to properly resolve imports. namespace margelo::nitro::nitrotext { enum class TextAlign; } // Forward declaration of `TextDecorationLine` to properly resolve imports. @@ -52,7 +50,6 @@ namespace NitroText { class HybridNitroTextSpec_cxx; } #include "HybridNitroTextSpec.hpp" #include "LineBreakStrategyIOS.hpp" #include "MenuItem.hpp" -#include "Renderer.hpp" #include "TextAlign.hpp" #include "TextDecorationLine.hpp" #include "TextDecorationStyle.hpp" @@ -217,21 +214,6 @@ namespace margelo::nitro::nitrotext::bridge::swift { return *optional; } - // pragma MARK: std::optional - /** - * Specialized version of `std::optional`. - */ - using std__optional_Renderer_ = std::optional; - inline std::optional create_std__optional_Renderer_(const Renderer& value) noexcept { - return std::optional(value); - } - inline bool has_value_std__optional_Renderer_(const std::optional& optional) noexcept { - return optional.has_value(); - } - inline Renderer get_std__optional_Renderer_(const std::optional& optional) noexcept { - return *optional; - } - // pragma MARK: std::optional /** * Specialized version of `std::optional`. diff --git a/nitrogen/generated/ios/NitroText-Swift-Cxx-Umbrella.hpp b/nitrogen/generated/ios/NitroText-Swift-Cxx-Umbrella.hpp index 90e5ca9..dd88c98 100644 --- a/nitrogen/generated/ios/NitroText-Swift-Cxx-Umbrella.hpp +++ b/nitrogen/generated/ios/NitroText-Swift-Cxx-Umbrella.hpp @@ -2,7 +2,7 @@ /// NitroText-Swift-Cxx-Umbrella.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #pragma once @@ -24,8 +24,6 @@ namespace margelo::nitro::nitrotext { class HybridNitroTextSpec; } namespace margelo::nitro::nitrotext { enum class LineBreakStrategyIOS; } // Forward declaration of `MenuItem` to properly resolve imports. namespace margelo::nitro::nitrotext { struct MenuItem; } -// Forward declaration of `Renderer` to properly resolve imports. -namespace margelo::nitro::nitrotext { enum class Renderer; } // Forward declaration of `TextAlign` to properly resolve imports. namespace margelo::nitro::nitrotext { enum class TextAlign; } // Forward declaration of `TextDecorationLine` to properly resolve imports. @@ -48,7 +46,6 @@ namespace margelo::nitro::nitrotext { enum class TextTransform; } #include "HybridNitroTextSpec.hpp" #include "LineBreakStrategyIOS.hpp" #include "MenuItem.hpp" -#include "Renderer.hpp" #include "TextAlign.hpp" #include "TextDecorationLine.hpp" #include "TextDecorationStyle.hpp" diff --git a/nitrogen/generated/ios/c++/HybridNitroTextSpecSwift.hpp b/nitrogen/generated/ios/c++/HybridNitroTextSpecSwift.hpp index 5e042de..bdd2283 100644 --- a/nitrogen/generated/ios/c++/HybridNitroTextSpecSwift.hpp +++ b/nitrogen/generated/ios/c++/HybridNitroTextSpecSwift.hpp @@ -2,7 +2,7 @@ /// HybridNitroTextSpecSwift.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #pragma once @@ -26,8 +26,6 @@ namespace margelo::nitro::nitrotext { enum class TextTransform; } namespace margelo::nitro::nitrotext { enum class TextDecorationLine; } // Forward declaration of `TextDecorationStyle` to properly resolve imports. namespace margelo::nitro::nitrotext { enum class TextDecorationStyle; } -// Forward declaration of `Renderer` to properly resolve imports. -namespace margelo::nitro::nitrotext { enum class Renderer; } // Forward declaration of `EllipsizeMode` to properly resolve imports. namespace margelo::nitro::nitrotext { enum class EllipsizeMode; } // Forward declaration of `LineBreakStrategyIOS` to properly resolve imports. @@ -51,7 +49,6 @@ namespace margelo::nitro::nitrotext { struct TextLayout; } #include "TextTransform.hpp" #include "TextDecorationLine.hpp" #include "TextDecorationStyle.hpp" -#include "Renderer.hpp" #include "EllipsizeMode.hpp" #include "LineBreakStrategyIOS.hpp" #include "DynamicTypeRamp.hpp" @@ -107,13 +104,6 @@ namespace margelo::nitro::nitrotext { inline void setFragments(const std::optional>& fragments) noexcept override { _swiftPart.setFragments(fragments); } - inline std::optional getRenderer() noexcept override { - auto __result = _swiftPart.getRenderer(); - return __result; - } - inline void setRenderer(std::optional renderer) noexcept override { - _swiftPart.setRenderer(renderer); - } inline std::optional getSelectable() noexcept override { auto __result = _swiftPart.getSelectable(); return __result; diff --git a/nitrogen/generated/ios/c++/views/HybridNitroTextComponent.mm b/nitrogen/generated/ios/c++/views/HybridNitroTextComponent.mm index 87c9c29..75801fb 100644 --- a/nitrogen/generated/ios/c++/views/HybridNitroTextComponent.mm +++ b/nitrogen/generated/ios/c++/views/HybridNitroTextComponent.mm @@ -2,7 +2,7 @@ /// HybridNitroTextComponent.mm /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #import "HybridNitroTextComponent.hpp" @@ -76,11 +76,6 @@ - (void) updateProps:(const std::shared_ptr&)props swiftPart.setFragments(newViewProps.fragments.value); newViewProps.fragments.isDirty = false; } - // renderer: optional - if (newViewProps.renderer.isDirty) { - swiftPart.setRenderer(newViewProps.renderer.value); - newViewProps.renderer.isDirty = false; - } // selectable: optional if (newViewProps.selectable.isDirty) { swiftPart.setSelectable(newViewProps.selectable.value); diff --git a/nitrogen/generated/ios/swift/HybridNitroTextSpec.swift b/nitrogen/generated/ios/swift/HybridNitroTextSpec.swift index b8ff038..fe7b110 100644 --- a/nitrogen/generated/ios/swift/HybridNitroTextSpec.swift +++ b/nitrogen/generated/ios/swift/HybridNitroTextSpec.swift @@ -2,7 +2,7 @@ /// HybridNitroTextSpec.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// import Foundation @@ -12,7 +12,6 @@ import NitroModules public protocol HybridNitroTextSpec_protocol: HybridObject, HybridView { // Properties var fragments: [Fragment]? { get set } - var renderer: Renderer? { get set } var selectable: Bool? { get set } var allowFontScaling: Bool? { get set } var ellipsizeMode: EllipsizeMode? { get set } diff --git a/nitrogen/generated/ios/swift/HybridNitroTextSpec_cxx.swift b/nitrogen/generated/ios/swift/HybridNitroTextSpec_cxx.swift index bba6f86..2b3139c 100644 --- a/nitrogen/generated/ios/swift/HybridNitroTextSpec_cxx.swift +++ b/nitrogen/generated/ios/swift/HybridNitroTextSpec_cxx.swift @@ -2,7 +2,7 @@ /// HybridNitroTextSpec_cxx.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// import Foundation @@ -144,23 +144,6 @@ open class HybridNitroTextSpec_cxx { } } - public final var renderer: bridge.std__optional_Renderer_ { - @inline(__always) - get { - return { () -> bridge.std__optional_Renderer_ in - if let __unwrappedValue = self.__implementation.renderer { - return bridge.create_std__optional_Renderer_(__unwrappedValue) - } else { - return .init() - } - }() - } - @inline(__always) - set { - self.__implementation.renderer = newValue.value - } - } - public final var selectable: bridge.std__optional_bool_ { @inline(__always) get { diff --git a/nitrogen/generated/ios/swift/Renderer.swift b/nitrogen/generated/ios/swift/Renderer.swift deleted file mode 100644 index 4de4c7e..0000000 --- a/nitrogen/generated/ios/swift/Renderer.swift +++ /dev/null @@ -1,40 +0,0 @@ -/// -/// Renderer.swift -/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. -/// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo -/// - -/** - * Represents the JS union `Renderer`, backed by a C++ enum. - */ -public typealias Renderer = margelo.nitro.nitrotext.Renderer - -public extension Renderer { - /** - * Get a Renderer for the given String value, or - * return `nil` if the given value was invalid/unknown. - */ - init?(fromString string: String) { - switch string { - case "html": - self = .html - case "plaintext": - self = .plaintext - default: - return nil - } - } - - /** - * Get the String value this Renderer represents. - */ - var stringValue: String { - switch self { - case .html: - return "html" - case .plaintext: - return "plaintext" - } - } -} diff --git a/nitrogen/generated/shared/c++/HybridNitroTextSpec.cpp b/nitrogen/generated/shared/c++/HybridNitroTextSpec.cpp index f166c06..d829a32 100644 --- a/nitrogen/generated/shared/c++/HybridNitroTextSpec.cpp +++ b/nitrogen/generated/shared/c++/HybridNitroTextSpec.cpp @@ -2,7 +2,7 @@ /// HybridNitroTextSpec.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #include "HybridNitroTextSpec.hpp" @@ -16,8 +16,6 @@ namespace margelo::nitro::nitrotext { registerHybrids(this, [](Prototype& prototype) { prototype.registerHybridGetter("fragments", &HybridNitroTextSpec::getFragments); prototype.registerHybridSetter("fragments", &HybridNitroTextSpec::setFragments); - prototype.registerHybridGetter("renderer", &HybridNitroTextSpec::getRenderer); - prototype.registerHybridSetter("renderer", &HybridNitroTextSpec::setRenderer); prototype.registerHybridGetter("selectable", &HybridNitroTextSpec::getSelectable); prototype.registerHybridSetter("selectable", &HybridNitroTextSpec::setSelectable); prototype.registerHybridGetter("allowFontScaling", &HybridNitroTextSpec::getAllowFontScaling); diff --git a/nitrogen/generated/shared/c++/HybridNitroTextSpec.hpp b/nitrogen/generated/shared/c++/HybridNitroTextSpec.hpp index 1dd624f..2b222bf 100644 --- a/nitrogen/generated/shared/c++/HybridNitroTextSpec.hpp +++ b/nitrogen/generated/shared/c++/HybridNitroTextSpec.hpp @@ -2,7 +2,7 @@ /// HybridNitroTextSpec.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #pragma once @@ -15,8 +15,6 @@ // Forward declaration of `Fragment` to properly resolve imports. namespace margelo::nitro::nitrotext { struct Fragment; } -// Forward declaration of `Renderer` to properly resolve imports. -namespace margelo::nitro::nitrotext { enum class Renderer; } // Forward declaration of `EllipsizeMode` to properly resolve imports. namespace margelo::nitro::nitrotext { enum class EllipsizeMode; } // Forward declaration of `LineBreakStrategyIOS` to properly resolve imports. @@ -43,7 +41,6 @@ namespace margelo::nitro::nitrotext { enum class TextDecorationStyle; } #include "Fragment.hpp" #include #include -#include "Renderer.hpp" #include "EllipsizeMode.hpp" #include "LineBreakStrategyIOS.hpp" #include "DynamicTypeRamp.hpp" @@ -87,8 +84,6 @@ namespace margelo::nitro::nitrotext { // Properties virtual std::optional> getFragments() = 0; virtual void setFragments(const std::optional>& fragments) = 0; - virtual std::optional getRenderer() = 0; - virtual void setRenderer(std::optional renderer) = 0; virtual std::optional getSelectable() = 0; virtual void setSelectable(std::optional selectable) = 0; virtual std::optional getAllowFontScaling() = 0; diff --git a/nitrogen/generated/shared/c++/Renderer.hpp b/nitrogen/generated/shared/c++/Renderer.hpp deleted file mode 100644 index ac3eaa4..0000000 --- a/nitrogen/generated/shared/c++/Renderer.hpp +++ /dev/null @@ -1,76 +0,0 @@ -/// -/// Renderer.hpp -/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. -/// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo -/// - -#pragma once - -#if __has_include() -#include -#else -#error NitroModules cannot be found! Are you sure you installed NitroModules properly? -#endif -#if __has_include() -#include -#else -#error NitroModules cannot be found! Are you sure you installed NitroModules properly? -#endif -#if __has_include() -#include -#else -#error NitroModules cannot be found! Are you sure you installed NitroModules properly? -#endif - -namespace margelo::nitro::nitrotext { - - /** - * An enum which can be represented as a JavaScript union (Renderer). - */ - enum class Renderer { - HTML SWIFT_NAME(html) = 0, - PLAINTEXT SWIFT_NAME(plaintext) = 1, - } CLOSED_ENUM; - -} // namespace margelo::nitro::nitrotext - -namespace margelo::nitro { - - // C++ Renderer <> JS Renderer (union) - template <> - struct JSIConverter final { - static inline margelo::nitro::nitrotext::Renderer fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { - std::string unionValue = JSIConverter::fromJSI(runtime, arg); - switch (hashString(unionValue.c_str(), unionValue.size())) { - case hashString("html"): return margelo::nitro::nitrotext::Renderer::HTML; - case hashString("plaintext"): return margelo::nitro::nitrotext::Renderer::PLAINTEXT; - default: [[unlikely]] - throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum Renderer - invalid value!"); - } - } - static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::nitrotext::Renderer arg) { - switch (arg) { - case margelo::nitro::nitrotext::Renderer::HTML: return JSIConverter::toJSI(runtime, "html"); - case margelo::nitro::nitrotext::Renderer::PLAINTEXT: return JSIConverter::toJSI(runtime, "plaintext"); - default: [[unlikely]] - throw std::invalid_argument("Cannot convert Renderer to JS - invalid value: " - + std::to_string(static_cast(arg)) + "!"); - } - } - static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { - if (!value.isString()) { - return false; - } - std::string unionValue = JSIConverter::fromJSI(runtime, value); - switch (hashString(unionValue.c_str(), unionValue.size())) { - case hashString("html"): - case hashString("plaintext"): - return true; - default: - return false; - } - } - }; - -} // namespace margelo::nitro diff --git a/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.cpp b/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.cpp index 2e185f3..b59fd5a 100644 --- a/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.cpp +++ b/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.cpp @@ -2,7 +2,7 @@ /// HybridNitroTextComponent.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #include "HybridNitroTextComponent.hpp" @@ -35,16 +35,6 @@ namespace margelo::nitro::nitrotext::views { throw std::runtime_error(std::string("NitroText.fragments: ") + exc.what()); } }()), - renderer([&]() -> CachedProp> { - try { - const react::RawValue* rawValue = rawProps.at("renderer", nullptr, nullptr); - if (rawValue == nullptr) return sourceProps.renderer; - const auto& [runtime, value] = (std::pair)*rawValue; - return CachedProp>::fromRawValue(*runtime, value, sourceProps.renderer); - } catch (const std::exception& exc) { - throw std::runtime_error(std::string("NitroText.renderer: ") + exc.what()); - } - }()), selectable([&]() -> CachedProp> { try { const react::RawValue* rawValue = rawProps.at("selectable", nullptr, nullptr); @@ -349,7 +339,6 @@ namespace margelo::nitro::nitrotext::views { HybridNitroTextProps::HybridNitroTextProps(const HybridNitroTextProps& other): react::ViewProps(), fragments(other.fragments), - renderer(other.renderer), selectable(other.selectable), allowFontScaling(other.allowFontScaling), ellipsizeMode(other.ellipsizeMode), @@ -384,7 +373,6 @@ namespace margelo::nitro::nitrotext::views { bool HybridNitroTextProps::filterObjectKeys(const std::string& propName) { switch (hashString(propName)) { case hashString("fragments"): return true; - case hashString("renderer"): return true; case hashString("selectable"): return true; case hashString("allowFontScaling"): return true; case hashString("ellipsizeMode"): return true; diff --git a/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.hpp b/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.hpp index 067ee97..c2ca66d 100644 --- a/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.hpp +++ b/nitrogen/generated/shared/c++/views/HybridNitroTextComponent.hpp @@ -2,7 +2,7 @@ /// HybridNitroTextComponent.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright © 2025 Marc Rousavy @ Margelo +/// Copyright © 2026 Marc Rousavy @ Margelo /// #pragma once @@ -19,7 +19,6 @@ #include "Fragment.hpp" #include #include -#include "Renderer.hpp" #include "EllipsizeMode.hpp" #include "LineBreakStrategyIOS.hpp" #include "DynamicTypeRamp.hpp" @@ -58,7 +57,6 @@ namespace margelo::nitro::nitrotext::views { public: CachedProp>> fragments; - CachedProp> renderer; CachedProp> selectable; CachedProp> allowFontScaling; CachedProp> ellipsizeMode; diff --git a/nitrogen/generated/shared/json/NitroTextConfig.json b/nitrogen/generated/shared/json/NitroTextConfig.json index f850c62..2370e76 100644 --- a/nitrogen/generated/shared/json/NitroTextConfig.json +++ b/nitrogen/generated/shared/json/NitroTextConfig.json @@ -5,7 +5,6 @@ "directEventTypes": {}, "validAttributes": { "fragments": true, - "renderer": true, "selectable": true, "allowFontScaling": true, "ellipsizeMode": true, diff --git a/package.json b/package.json index 9ff9e6d..25a0b0b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "react-native": "src/index", "source": "src/index", "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && bun run typecheck:api", + "typecheck:api": "tsc --project tsconfig.type-tests.json", "lint": "eslint src", "lint:fix": "eslint src --fix", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,md}\"", diff --git a/src/constants/css.ts b/src/constants/css.ts deleted file mode 100644 index f3426ec..0000000 --- a/src/constants/css.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Fragment } from '../types' - -export const FRAGMENT_STYLE_KEYS: (keyof Fragment)[] = [ - 'selectionColor', - 'fontSize', - 'fontWeight', - 'fontColor', - 'fragmentBackgroundColor', - 'fontStyle', - 'fontFamily', - 'lineHeight', - 'letterSpacing', - 'textAlign', - 'textTransform', - 'textDecorationLine', - 'textDecorationColor', - 'textDecorationStyle', - 'linkUrl', -] diff --git a/src/constants/html.ts b/src/constants/html.ts deleted file mode 100644 index b82c8f9..0000000 --- a/src/constants/html.ts +++ /dev/null @@ -1,361 +0,0 @@ -export const BLOCK_TAGS = new Set([ - 'p', - 'div', - 'section', - 'article', - 'header', - 'footer', - 'aside', - 'main', - 'nav', - 'figure', - 'figcaption', - 'ul', - 'ol', - 'li', - 'pre', - 'blockquote', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', -]) - -export const PRE_TAGS = new Set(['pre', 'code']) - -export const VOID_TAGS = new Set([ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -]) - -export const HEADING_SIZES: Record = { - h1: 30, - h2: 26, - h3: 22, - h4: 20, - h5: 18, - h6: 16, -} - -export const HTML_ENTITY_MAP: Record = { - // Basic HTML entities - amp: '&', - lt: '<', - gt: '>', - quot: '"', - apos: "'", - nbsp: '\u00a0', - ensp: '\u2002', - emsp: '\u2003', - thinsp: '\u2009', - zwnj: '\u200c', - zwj: '\u200d', - lrm: '\u200e', - rlm: '\u200f', - - // Currency symbols - euro: '\u20ac', - pound: '\u00a3', - yen: '\u00a5', - cent: '\u00a2', - dollar: '$', - rupee: '\u20b9', - rouble: '\u20bd', - naira: '\u20a6', - won: '\u20a9', - lira: '\u20a4', - - // Mathematical operators and symbols - sum: '\u2211', - infin: '\u221e', - prod: '\u220f', - int: '\u222b', - part: '\u2202', - forall: '\u2200', - exist: '\u2203', - empty: '\u2205', - nabla: '\u2207', - isin: '\u2208', - notin: '\u2209', - ni: '\u220b', - minus: '\u2212', - lowast: '\u2217', - radic: '\u221a', - prop: '\u221d', - ang: '\u2220', - and: '\u2227', - or: '\u2228', - cap: '\u2229', - cup: '\u222a', - there4: '\u2234', - sim: '\u223c', - cong: '\u2245', - asymp: '\u2248', - ne: '\u2260', - equiv: '\u2261', - le: '\u2264', - ge: '\u2265', - sub: '\u2282', - sup: '\u2283', - nsub: '\u2284', - sube: '\u2286', - supe: '\u2287', - oplus: '\u2295', - otimes: '\u2297', - perp: '\u22a5', - sdot: '\u22c5', - lceil: '\u2308', - rceil: '\u2309', - lfloor: '\u230a', - rfloor: '\u230b', - - // Greek letters (lowercase) - alpha: '\u03b1', - beta: '\u03b2', - gamma: '\u03b3', - delta: '\u03b4', - epsilon: '\u03b5', - zeta: '\u03b6', - eta: '\u03b7', - theta: '\u03b8', - iota: '\u03b9', - kappa: '\u03ba', - lambda: '\u03bb', - mu: '\u03bc', - nu: '\u03bd', - xi: '\u03be', - omicron: '\u03bf', - pi: '\u03c0', - rho: '\u03c1', - sigmaf: '\u03c2', - sigma: '\u03c3', - tau: '\u03c4', - upsilon: '\u03c5', - phi: '\u03c6', - chi: '\u03c7', - psi: '\u03c8', - omega: '\u03c9', - thetasym: '\u03d1', - upsih: '\u03d2', - piv: '\u03d6', - - // Greek letters (uppercase) - Alpha: '\u0391', - Beta: '\u0392', - Gamma: '\u0393', - Delta: '\u0394', - Epsilon: '\u0395', - Zeta: '\u0396', - Eta: '\u0397', - Theta: '\u0398', - Iota: '\u0399', - Kappa: '\u039a', - Lambda: '\u039b', - Mu: '\u039c', - Nu: '\u039d', - Xi: '\u039e', - Omicron: '\u039f', - Pi: '\u03a0', - Rho: '\u03a1', - Sigma: '\u03a3', - Tau: '\u03a4', - Upsilon: '\u03a5', - Phi: '\u03a6', - Chi: '\u03a7', - Psi: '\u03a8', - Omega: '\u03a9', - - // Arrows - larr: '\u2190', - uarr: '\u2191', - rarr: '\u2192', - darr: '\u2193', - harr: '\u2194', - crarr: '\u21b5', - lArr: '\u21d0', - uArr: '\u21d1', - rArr: '\u21d2', - dArr: '\u21d3', - hArr: '\u21d4', - rlarr: '\u21c4', - rlArr: '\u21cc', - - // Common punctuation and symbols - copy: '\u00a9', - reg: '\u00ae', - trade: '\u2122', - deg: '\u00b0', - plusmn: '\u00b1', - sup2: '\u00b2', - sup3: '\u00b3', - acute: '\u00b4', - micro: '\u00b5', - para: '\u00b6', - middot: '\u00b7', - cedil: '\u00b8', - sup1: '\u00b9', - ordm: '\u00ba', - raquo: '\u00bb', - frac14: '\u00bc', - frac12: '\u00bd', - frac34: '\u00be', - iquest: '\u00bf', - Agrave: '\u00c0', - Aacute: '\u00c1', - Acirc: '\u00c2', - Atilde: '\u00c3', - Auml: '\u00c4', - Aring: '\u00c5', - AElig: '\u00c6', - Ccedil: '\u00c7', - Egrave: '\u00c8', - Eacute: '\u00c9', - Ecirc: '\u00ca', - Euml: '\u00cb', - Igrave: '\u00cc', - Iacute: '\u00cd', - Icirc: '\u00ce', - Iuml: '\u00cf', - ETH: '\u00d0', - Ntilde: '\u00d1', - Ograve: '\u00d2', - Oacute: '\u00d3', - Ocirc: '\u00d4', - Otilde: '\u00d5', - Ouml: '\u00d6', - times: '\u00d7', - Oslash: '\u00d8', - Ugrave: '\u00d9', - Uacute: '\u00da', - Ucirc: '\u00db', - Uuml: '\u00dc', - Yacute: '\u00dd', - THORN: '\u00de', - szlig: '\u00df', - agrave: '\u00e0', - aacute: '\u00e1', - acirc: '\u00e2', - atilde: '\u00e3', - auml: '\u00e4', - aring: '\u00e5', - aelig: '\u00e6', - ccedil: '\u00e7', - egrave: '\u00e8', - eacute: '\u00e9', - ecirc: '\u00ea', - euml: '\u00eb', - igrave: '\u00ec', - iacute: '\u00ed', - icirc: '\u00ee', - iuml: '\u00ef', - eth: '\u00f0', - ntilde: '\u00f1', - ograve: '\u00f2', - oacute: '\u00f3', - ocirc: '\u00f4', - otilde: '\u00f5', - ouml: '\u00f6', - divide: '\u00f7', - oslash: '\u00f8', - ugrave: '\u00f9', - uacute: '\u00fa', - ucirc: '\u00fb', - uuml: '\u00fc', - yacute: '\u00fd', - thorn: '\u00fe', - yuml: '\u00ff', - - // Additional common symbols - lsquo: '\u2018', - rsquo: '\u2019', - sbquo: '\u201a', - ldquo: '\u201c', - rdquo: '\u201d', - bdquo: '\u201e', - dagger: '\u2020', - Dagger: '\u2021', - bull: '\u2022', - hellip: '\u2026', - permil: '\u2030', - prime: '\u2032', - Prime: '\u2033', - lsaquo: '\u2039', - rsaquo: '\u203a', - oline: '\u203e', - frasl: '\u2044', - image: '\u2111', - weierp: '\u2118', - real: '\u211c', - alefsym: '\u2135', - lang: '\u2329', - rang: '\u232a', - loz: '\u25ca', - spades: '\u2660', - clubs: '\u2663', - hearts: '\u2665', - diams: '\u2666', - - // More arrows - lAarr: '\u21da', - rAarr: '\u21db', - xarr: '\u2197', - xArr: '\u21d7', - xlarr: '\u2196', - xlArr: '\u21d6', - xrarr: '\u2198', - xrArr: '\u21d8', - xharr: '\u2199', - xhArr: '\u21d9', - vArr: '\u21d5', - varr: '\u2195', - lrarr: '\u21c6', - udarr: '\u21c5', - llarr: '\u21c7', - rrarr: '\u21c9', - ddarr: '\u21ca', - uuarr: '\u21c8', - rarrw: '\u219d', - larrtl: '\u21a2', - rarrtl: '\u21a3', - map: '\u21a6', - larrhk: '\u21a9', - rarrhk: '\u21aa', - larrlp: '\u21ab', - rarrlp: '\u21ac', - harrw: '\u21ad', - nharr: '\u21ae', - lsh: '\u21b0', - rsh: '\u21b1', - ldsh: '\u21b2', - rdsh: '\u21b3', - - // Additional punctuation and symbols - iexcl: '\u00a1', - brvbar: '\u00a6', - sect: '\u00a7', - uml: '\u00a8', - laquo: '\u00ab', - not: '\u00ac', - shy: '\u00ad', - macr: '\u00af', - ndash: '\u2013', - mdash: '\u2014', - fnof: '\u0192', - circ: '\u02c6', - tilde: '\u02dc', - Yuml: '\u0178', -} diff --git a/src/constants/index.ts b/src/constants/index.ts deleted file mode 100644 index 3bcc4c4..0000000 --- a/src/constants/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './html' -export * from './css' diff --git a/src/nitro-text.tsx b/src/nitro-text.tsx index d48dab4..e5d5c70 100644 --- a/src/nitro-text.tsx +++ b/src/nitro-text.tsx @@ -19,7 +19,6 @@ import { getStyleProps, styleToFragment, } from './utils' -import { renderStringChildren } from './renderers' export type NitroTextRef = HybridRef @@ -35,7 +34,6 @@ type NitroTextPropsWithEvents = Pick< | 'onPressIn' | 'onPressOut' | 'menus' - | 'renderer' | 'maxFontSizeMultiplier' > & Omit @@ -51,7 +49,6 @@ export const NitroText = (props: NitroTextPropsWithEvents) => { const isInsideRNText = useContext(TextAncestorContext) const { style, - renderer, children, selectable, selectionColor, @@ -72,17 +69,10 @@ export const NitroText = (props: NitroTextPropsWithEvents) => { return styleToFragment(style) }, [style]) - const parsedFragments = useMemo(() => { - if (!renderer || !isStringChildren) return undefined - const result = renderStringChildren(children, renderer, topStyles) - return result.fragments - }, [renderer, children, isStringChildren, topStyles]) - const fragments = useMemo(() => { - if (parsedFragments !== undefined) return parsedFragments if (isSimpleText) return [] return flattenChildrenToFragments(children, style) - }, [parsedFragments, children, style, isSimpleText]) + }, [children, style, isSimpleText]) const styleProps = useMemo(() => getStyleProps(topStyles), [topStyles]) @@ -98,7 +88,6 @@ export const NitroText = (props: NitroTextPropsWithEvents) => { ...rest, selectable: selectable || false, maxFontSizeMultiplier: maxFontSizeMultiplier || undefined, - fragments: parsedFragments || undefined, selectionColor: (selectionColor as string) || undefined, onPress: callback(onPress) || undefined, onPressIn: callback(onPressIn) || undefined, @@ -113,7 +102,6 @@ export const NitroText = (props: NitroTextPropsWithEvents) => { styleProps, selectable, maxFontSizeMultiplier, - parsedFragments, selectionColor, onPress, onPressIn, @@ -139,10 +127,6 @@ export const NitroText = (props: NitroTextPropsWithEvents) => { ) } - if (renderer && isStringChildren) { - return - } - if (isSimpleText) { return } diff --git a/src/renderers.ts b/src/renderers.ts deleted file mode 100644 index b6cda7e..0000000 --- a/src/renderers.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { renderStringChildren } from './renderers/index' -export type { RenderResult } from './renderers/index' diff --git a/src/renderers/css-processor.test.ts b/src/renderers/css-processor.test.ts deleted file mode 100644 index 98a1aee..0000000 --- a/src/renderers/css-processor.test.ts +++ /dev/null @@ -1,726 +0,0 @@ -import { CSSProcessor } from './css-processor' -import type { ElementNode, Stylesheet } from './types' - -describe('CSSProcessor', () => { - describe('buildStylesheet', () => { - it('should build an empty stylesheet from empty blocks', () => { - const sheet = CSSProcessor.buildStylesheet([]) - expect(sheet.tag.size).toBe(0) - expect(sheet.className.size).toBe(0) - expect(sheet.id.size).toBe(0) - }) - - it('should parse tag selectors', () => { - const sheet = CSSProcessor.buildStylesheet([ - 'p { color: red; font-size: 16px; }', - ]) - const pStyles = sheet.tag.get('p') - expect(pStyles).toBeDefined() - expect(pStyles?.[0]).toEqual({ - 'color': 'red', - 'font-size': '16px', - }) - }) - - it('should parse class selectors', () => { - const sheet = CSSProcessor.buildStylesheet([ - '.my-class { color: blue; }', - ]) - const classStyles = sheet.className.get('my-class') - expect(classStyles).toBeDefined() - expect(classStyles?.[0]).toEqual({ color: 'blue' }) - }) - - it('should parse ID selectors', () => { - const sheet = CSSProcessor.buildStylesheet([ - '#my-id { color: green; }', - ]) - const idStyles = sheet.id.get('my-id') - expect(idStyles).toBeDefined() - expect(idStyles?.[0]).toEqual({ color: 'green' }) - }) - - it('should parse universal selector (*)', () => { - const sheet = CSSProcessor.buildStylesheet([ - '* { color: blue; font-size: 14px; }', - ]) - const universalStyles = sheet.tag.get('*') - expect(universalStyles).toBeDefined() - expect(universalStyles?.[0]).toEqual({ - 'color': 'blue', - 'font-size': '14px', - }) - }) - - it('should handle multiple selectors (comma-separated)', () => { - const sheet = CSSProcessor.buildStylesheet([ - 'h1, h2, h3 { font-weight: bold; }', - ]) - expect(sheet.tag.get('h1')?.[0]).toEqual({ 'font-weight': 'bold' }) - expect(sheet.tag.get('h2')?.[0]).toEqual({ 'font-weight': 'bold' }) - expect(sheet.tag.get('h3')?.[0]).toEqual({ 'font-weight': 'bold' }) - }) - - it('should handle universal selector in comma-separated selectors', () => { - const sheet = CSSProcessor.buildStylesheet(['*, p { color: red; }']) - expect(sheet.tag.get('*')?.[0]).toEqual({ color: 'red' }) - expect(sheet.tag.get('p')?.[0]).toEqual({ color: 'red' }) - }) - - it('should handle multiple CSS blocks', () => { - const sheet = CSSProcessor.buildStylesheet([ - 'p { color: red; }', - '.class { color: blue; }', - '#id { color: green; }', - ]) - expect(sheet.tag.get('p')?.[0]).toEqual({ color: 'red' }) - expect(sheet.className.get('class')?.[0]).toEqual({ color: 'blue' }) - expect(sheet.id.get('id')?.[0]).toEqual({ color: 'green' }) - }) - - it('should merge multiple rules for the same selector', () => { - const sheet = CSSProcessor.buildStylesheet([ - 'p { color: red; }', - 'p { font-size: 16px; }', - ]) - const pStyles = sheet.tag.get('p') - expect(pStyles).toBeDefined() - expect(pStyles?.length).toBe(2) - expect(pStyles?.[0]).toEqual({ color: 'red' }) - expect(pStyles?.[1]).toEqual({ 'font-size': '16px' }) - }) - - it('should remove CSS comments', () => { - const sheet = CSSProcessor.buildStylesheet([ - '/* This is a comment */ p { color: red; }', - '.class { /* inline comment */ color: blue; }', - ]) - expect(sheet.tag.get('p')?.[0]).toEqual({ color: 'red' }) - expect(sheet.className.get('class')?.[0]).toEqual({ color: 'blue' }) - }) - - it('should handle multi-line comments', () => { - const sheet = CSSProcessor.buildStylesheet([ - '/*\n * Multi-line\n * comment\n */ p { color: red; }', - ]) - expect(sheet.tag.get('p')?.[0]).toEqual({ color: 'red' }) - }) - - it('should ignore @ rules', () => { - const sheet = CSSProcessor.buildStylesheet([ - '@media screen { p { color: red; } }', - '@keyframes fade { from { opacity: 0; } }', - ]) - expect(sheet.tag.size).toBe(0) - expect(sheet.className.size).toBe(0) - expect(sheet.id.size).toBe(0) - }) - - it('should handle empty declarations', () => { - const sheet = CSSProcessor.buildStylesheet(['p { }']) - expect(sheet.tag.size).toBe(0) - }) - - it('should handle whitespace in selectors', () => { - const sheet = CSSProcessor.buildStylesheet([ - ' p { color: red; }', - ' .class { color: blue; }', - ' #id { color: green; }', - ]) - expect(sheet.tag.get('p')?.[0]).toEqual({ color: 'red' }) - expect(sheet.className.get('class')?.[0]).toEqual({ color: 'blue' }) - expect(sheet.id.get('id')?.[0]).toEqual({ color: 'green' }) - }) - - it('should handle nested braces correctly', () => { - const sheet = CSSProcessor.buildStylesheet([ - 'div { color: red; }', - 'span { color: blue; }', - ]) - expect(sheet.tag.get('div')?.[0]).toEqual({ color: 'red' }) - expect(sheet.tag.get('span')?.[0]).toEqual({ color: 'blue' }) - }) - }) - - describe('parseCssDeclarations', () => { - it('should parse simple declarations', () => { - const decls = CSSProcessor.parseCssDeclarations( - 'color: red; font-size: 16px;' - ) - expect(decls).toEqual({ - 'color': 'red', - 'font-size': '16px', - }) - }) - - it('should handle declarations without semicolon at end', () => { - const decls = CSSProcessor.parseCssDeclarations('color: red') - expect(decls).toEqual({ color: 'red' }) - }) - - it('should normalize property names to lowercase', () => { - const decls = CSSProcessor.parseCssDeclarations( - 'Color: red; Font-Size: 16px;' - ) - expect(decls).toEqual({ - 'color': 'red', - 'font-size': '16px', - }) - }) - - it('should handle whitespace', () => { - const decls = CSSProcessor.parseCssDeclarations( - ' color : red ; font-size : 16px ; ' - ) - expect(decls).toEqual({ - 'color': 'red', - 'font-size': '16px', - }) - }) - - it('should handle empty input', () => { - const decls = CSSProcessor.parseCssDeclarations('') - expect(decls).toEqual({}) - }) - - it('should handle multiple semicolons', () => { - const decls = CSSProcessor.parseCssDeclarations( - 'color: red;; font-size: 16px;' - ) - expect(decls).toEqual({ - 'color': 'red', - 'font-size': '16px', - }) - }) - - it('should skip invalid declarations', () => { - const decls = CSSProcessor.parseCssDeclarations( - 'color: red; invalid; font-size: 16px;' - ) - expect(decls).toEqual({ - 'color': 'red', - 'font-size': '16px', - }) - }) - - it('should preserve value whitespace', () => { - const decls = CSSProcessor.parseCssDeclarations( - 'font-family: Arial, sans-serif;' - ) - expect(decls).toEqual({ - 'font-family': 'Arial, sans-serif', - }) - }) - }) - - describe('applyStylesFromSheet', () => { - let sheet: Stylesheet - - beforeEach(() => { - sheet = CSSProcessor.buildStylesheet([ - 'p { color: red; font-size: 16px; }', - '.highlight { background-color: yellow; }', - '#title { font-weight: bold; }', - ]) - }) - - it('should apply tag styles', () => { - const node: ElementNode = { - type: 'element', - tag: 'p', - attrs: {}, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ - fontColor: 'red', - fontSize: 16, - }) - expect(result.hidden).toBe(false) - expect(result.suppressNewlines).toBe(false) - }) - - it('should apply class styles', () => { - const node: ElementNode = { - type: 'element', - tag: 'span', - attrs: { class: 'highlight' }, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ - fragmentBackgroundColor: 'yellow', - }) - }) - - it('should apply ID styles', () => { - const node: ElementNode = { - type: 'element', - tag: 'h1', - attrs: { id: 'title' }, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ - fontWeight: 'bold', - }) - }) - - it('should apply multiple class styles', () => { - sheet = CSSProcessor.buildStylesheet([ - '.class1 { color: red; }', - '.class2 { font-size: 20px; }', - ]) - const node: ElementNode = { - type: 'element', - tag: 'div', - attrs: { class: 'class1 class2' }, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ - fontColor: 'red', - fontSize: 20, - }) - }) - - it('should handle classname attribute', () => { - const node: ElementNode = { - type: 'element', - tag: 'span', - attrs: { classname: 'highlight' }, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ - fragmentBackgroundColor: 'yellow', - }) - }) - - it('should combine tag, class, and ID styles', () => { - sheet = CSSProcessor.buildStylesheet([ - 'div { color: blue; }', - '.container { font-size: 18px; }', - '#main { font-weight: bold; }', - ]) - const node: ElementNode = { - type: 'element', - tag: 'div', - attrs: { class: 'container', id: 'main' }, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ - fontColor: 'blue', - fontSize: 18, - fontWeight: 'bold', - }) - }) - - it('should handle display: none', () => { - sheet = CSSProcessor.buildStylesheet(['.hidden { display: none; }']) - const node: ElementNode = { - type: 'element', - tag: 'div', - attrs: { class: 'hidden' }, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.hidden).toBe(true) - expect(result.fragment).toBeUndefined() - }) - - it('should handle zero margin', () => { - sheet = CSSProcessor.buildStylesheet(['p { margin: 0; }']) - const node: ElementNode = { - type: 'element', - tag: 'p', - attrs: {}, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.suppressNewlines).toBe(true) - }) - - it('should handle margin-top: 0', () => { - sheet = CSSProcessor.buildStylesheet(['p { margin-top: 0; }']) - const node: ElementNode = { - type: 'element', - tag: 'p', - attrs: {}, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.suppressNewlines).toBe(true) - }) - - it('should handle margin-bottom: 0', () => { - sheet = CSSProcessor.buildStylesheet(['p { margin-bottom: 0; }']) - const node: ElementNode = { - type: 'element', - tag: 'p', - attrs: {}, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.suppressNewlines).toBe(true) - }) - - it('should return empty result when no styles match', () => { - const node: ElementNode = { - type: 'element', - tag: 'span', - attrs: {}, - children: [], - } - const emptySheet: Stylesheet = { - tag: new Map(), - className: new Map(), - id: new Map(), - } - const result = CSSProcessor.applyStylesFromSheet(node, emptySheet) - expect(result.fragment).toBeUndefined() - expect(result.hidden).toBe(false) - expect(result.suppressNewlines).toBe(false) - }) - - it('should apply universal selector styles to all elements', () => { - sheet = CSSProcessor.buildStylesheet(['* { color: gray; }']) - const node: ElementNode = { - type: 'element', - tag: 'div', - attrs: {}, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ fontColor: 'gray' }) - }) - - it('should apply universal selector before tag-specific styles', () => { - sheet = CSSProcessor.buildStylesheet([ - '* { color: gray; }', - 'p { color: red; }', - ]) - const node: ElementNode = { - type: 'element', - tag: 'p', - attrs: {}, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - // Tag-specific style should override universal selector - expect(result.fragment).toEqual({ fontColor: 'red' }) - }) - - it('should apply universal selector before class styles', () => { - sheet = CSSProcessor.buildStylesheet([ - '* { font-size: 12px; }', - '.highlight { font-size: 18px; }', - ]) - const node: ElementNode = { - type: 'element', - tag: 'span', - attrs: { class: 'highlight' }, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - // Class style should override universal selector - expect(result.fragment).toEqual({ fontSize: 18 }) - }) - - it('should apply universal selector to elements with any tag', () => { - sheet = CSSProcessor.buildStylesheet(['* { font-weight: bold; }']) - const tags = ['div', 'span', 'p', 'h1', 'a'] - for (const tag of tags) { - const node: ElementNode = { - type: 'element', - tag, - attrs: {}, - children: [], - } - const result = CSSProcessor.applyStylesFromSheet(node, sheet) - expect(result.fragment).toEqual({ fontWeight: 'bold' }) - } - }) - }) - - describe('applyInlineStyles', () => { - it('should parse and apply inline styles', () => { - const result = CSSProcessor.applyInlineStyles( - 'color: red; font-size: 16px;' - ) - expect(result.fragment).toEqual({ - fontColor: 'red', - fontSize: 16, - }) - expect(result.hidden).toBe(false) - expect(result.suppressNewlines).toBe(false) - }) - - it('should handle empty style attribute', () => { - const result = CSSProcessor.applyInlineStyles(undefined) - expect(result.fragment).toBeUndefined() - expect(result.hidden).toBe(false) - expect(result.suppressNewlines).toBe(false) - }) - - it('should handle display: none', () => { - const result = CSSProcessor.applyInlineStyles( - 'display: none; color: red;' - ) - expect(result.hidden).toBe(true) - expect(result.fragment).toBeUndefined() - }) - - it('should handle zero margin', () => { - const result = CSSProcessor.applyInlineStyles('margin: 0; color: red;') - expect(result.suppressNewlines).toBe(true) - expect(result.fragment).toEqual({ - fontColor: 'red', - }) - }) - - it('should handle margin-top: 0', () => { - const result = CSSProcessor.applyInlineStyles('margin-top: 0;') - expect(result.suppressNewlines).toBe(true) - }) - - it('should handle margin-bottom: 0', () => { - const result = CSSProcessor.applyInlineStyles('margin-bottom: 0;') - expect(result.suppressNewlines).toBe(true) - }) - }) - - describe('cssDeclarationsToFragment', () => { - it('should convert color to fontColor', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - color: 'red', - }) - expect(fragment).toEqual({ fontColor: 'red' }) - }) - - it('should convert background-color to fragmentBackgroundColor', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'background-color': 'yellow', - }) - expect(fragment).toEqual({ fragmentBackgroundColor: 'yellow' }) - }) - - it('should convert background to fragmentBackgroundColor', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - background: 'blue', - }) - expect(fragment).toEqual({ fragmentBackgroundColor: 'blue' }) - }) - - it('should convert font-size to fontSize', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-size': '16px', - }) - expect(fragment).toEqual({ fontSize: 16 }) - }) - - it('should handle font-size with different units', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-size': '1.5em', - }) - expect(fragment).toEqual({ fontSize: 1.5 }) - }) - - it('should convert line-height to lineHeight', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'line-height': '1.5', - }) - expect(fragment).toEqual({ lineHeight: 1.5 }) - }) - - it('should convert letter-spacing to letterSpacing', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'letter-spacing': '2px', - }) - expect(fragment).toEqual({ letterSpacing: 2 }) - }) - - it('should convert font-weight to fontWeight', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-weight': 'bold', - }) - expect(fragment).toEqual({ fontWeight: 'bold' }) - }) - - it('should normalize numeric font-weight', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-weight': '700', - }) - expect(fragment).toEqual({ fontWeight: 'bold' }) - }) - - it('should convert font-style to fontStyle', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-style': 'italic', - }) - expect(fragment).toEqual({ fontStyle: 'italic' }) - }) - - it('should handle font-style: normal', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-style': 'normal', - }) - expect(fragment).toEqual({ fontStyle: 'normal' }) - }) - - it('should handle font-style: oblique', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-style': 'oblique', - }) - expect(fragment).toEqual({ fontStyle: 'oblique' }) - }) - - it('should convert font-family to fontFamily', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-family': 'Arial, sans-serif', - }) - expect(fragment).toEqual({ fontFamily: 'Arial, sans-serif' }) - }) - - it('should strip quotes from font-family', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-family': '"Arial", sans-serif', - }) - expect(fragment).toEqual({ fontFamily: 'Arial, sans-serif' }) - }) - - it('should convert text-align to textAlign', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-align': 'center', - }) - expect(fragment).toEqual({ textAlign: 'center' }) - }) - - it('should convert text-transform to textTransform', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-transform': 'uppercase', - }) - expect(fragment).toEqual({ textTransform: 'uppercase' }) - }) - - it('should convert text-decoration-line to textDecorationLine', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-decoration-line': 'underline', - }) - expect(fragment).toEqual({ textDecorationLine: 'underline' }) - }) - - it('should convert text-decoration to textDecorationLine', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-decoration': 'underline', - }) - expect(fragment).toEqual({ textDecorationLine: 'underline' }) - }) - - it('should handle multiple text decorations', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-decoration': 'underline line-through', - }) - expect(fragment).toEqual({ - textDecorationLine: 'underline line-through', - }) - }) - - it('should filter unsupported text decorations', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-decoration': 'overline', - }) - expect(fragment.textDecorationLine).toBeUndefined() - }) - - it('should convert text-decoration-color to textDecorationColor', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-decoration-color': 'red', - }) - expect(fragment).toEqual({ textDecorationColor: 'red' }) - }) - - it('should convert text-decoration-style to textDecorationStyle', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'text-decoration-style': 'dashed', - }) - expect(fragment).toEqual({ textDecorationStyle: 'dashed' }) - }) - - it('should parse font shorthand', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - font: 'bold italic 16px/1.5 Arial', - }) - expect(fragment).toEqual({ - fontWeight: 'bold', - fontStyle: 'italic', - fontSize: 16, - lineHeight: 1.5, - fontFamily: 'Arial', - }) - }) - - it('should parse font shorthand without line-height', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - font: 'bold 16px Arial', - }) - expect(fragment).toEqual({ - fontWeight: 'bold', - fontSize: 16, - fontFamily: 'Arial', - }) - }) - - it('should parse font shorthand with quoted family', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - font: '16px "Times New Roman"', - }) - expect(fragment).toEqual({ - fontSize: 16, - fontFamily: 'Times New Roman', - }) - }) - - it('should handle multiple declarations', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'color': 'red', - 'font-size': '16px', - 'font-weight': 'bold', - 'text-align': 'center', - }) - expect(fragment).toEqual({ - fontColor: 'red', - fontSize: 16, - fontWeight: 'bold', - textAlign: 'center', - }) - }) - - it('should ignore empty values', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'color': '', - 'font-size': '16px', - }) - expect(fragment).toEqual({ fontSize: 16 }) - }) - - it('should ignore unsupported properties', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'border-width': '1px', - 'padding': '10px', - 'color': 'red', - }) - expect(fragment).toEqual({ fontColor: 'red' }) - }) - - it('should handle invalid numeric values', () => { - const fragment = CSSProcessor.cssDeclarationsToFragment({ - 'font-size': 'invalid', - 'line-height': 'not-a-number', - }) - expect(fragment.fontSize).toBeUndefined() - expect(fragment.lineHeight).toBeUndefined() - }) - }) -}) diff --git a/src/renderers/css-processor.ts b/src/renderers/css-processor.ts deleted file mode 100644 index 35d618b..0000000 --- a/src/renderers/css-processor.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { Fragment } from '../types' -import { normalizeWeight } from '../utils' -import type { AppliedStyle, ElementNode, Stylesheet } from './types' -import { isZeroMargin, parseNumeric, stripQuotes } from './utils' - -/** - * High-performance CSS processor for parsing and applying CSS styles. - * Optimized for minimal allocations and fast lookups. - */ -export class CSSProcessor { - /** - * Builds a stylesheet from CSS blocks with optimized parsing. - */ - static buildStylesheet(blocks: string[]): Stylesheet { - const sheet: Stylesheet = { - tag: new Map(), - className: new Map(), - id: new Map(), - } - - for (const block of blocks) { - // Remove comments efficiently - const cleaned = block.replace(/\/\*[\s\S]*?\*\//g, '') - this.parseCssRules(cleaned, (selector, declarations) => { - if (!selector || selector.startsWith('@')) { - return - } - const parsedDecls = this.parseCssDeclarations(declarations) - if (!Object.keys(parsedDecls).length) { - return - } - // Handle multiple selectors (comma-separated) - const targets = selector.split(',') - for (const raw of targets) { - const sel = raw.trim() - if (!sel) continue - if (sel === '*') { - this.addStylesheetEntry(sheet.tag, '*', parsedDecls) - } else if (sel.startsWith('.')) { - this.addStylesheetEntry( - sheet.className, - sel.slice(1), - parsedDecls - ) - } else if (sel.startsWith('#')) { - this.addStylesheetEntry(sheet.id, sel.slice(1), parsedDecls) - } else { - this.addStylesheetEntry(sheet.tag, sel, parsedDecls) - } - } - }) - } - return sheet - } - - /** - * Parses CSS rules efficiently using indexOf for better performance. - */ - private static parseCssRules( - css: string, - cb: (selector: string, body: string) => void - ) { - let index = 0 - while (index < css.length) { - const braceIndex = css.indexOf('{', index) - if (braceIndex === -1) break - const selector = css.slice(index, braceIndex).trim() - let depth = 1 - let cursor = braceIndex + 1 - while (cursor < css.length && depth > 0) { - const char = css[cursor] - if (char === '{') depth += 1 - else if (char === '}') depth -= 1 - cursor += 1 - } - const body = css.slice(braceIndex + 1, cursor - 1) - cb(selector, body) - index = cursor - } - } - - /** - * Adds a stylesheet entry with minimal allocations. - */ - private static addStylesheetEntry( - map: Map>>, - key: string, - declarations: Record - ) { - const existing = map.get(key) - if (existing) { - existing.push({ ...declarations }) - } else { - map.set(key, [{ ...declarations }]) - } - } - - /** - * Applies styles from stylesheet to a node with optimized lookups. - */ - static applyStylesFromSheet( - node: ElementNode, - sheet: Stylesheet - ): AppliedStyle { - const merged: Record = {} - const result: AppliedStyle = { hidden: false, suppressNewlines: false } - - const applyDecls = (decls?: Array>) => { - if (!decls) return - for (const entry of decls) { - for (const [key, value] of Object.entries(entry)) { - if (key === 'display') { - const displayValue = value.trim().toLowerCase() - if (displayValue === 'none') { - result.hidden = true - } else { - merged[key] = value - } - } else if ( - (key === 'margin' || - key === 'margin-top' || - key === 'margin-bottom') && - isZeroMargin(value) - ) { - // Suppress extra spacing when margin is 0 - // Block elements will still maintain minimal structure - result.suppressNewlines = true - // Still add margin to merged so it can be used elsewhere if needed - merged[key] = value - } else { - merged[key] = value - } - } - } - } - - // Apply universal selector styles first (lowest specificity) - applyDecls(sheet.tag.get('*')) - - // Apply tag styles - applyDecls(sheet.tag.get(node.tag)) - - // Apply class styles (handle multiple classes) - const classAttr = node.attrs.class || node.attrs.classname - if (classAttr) { - const classes = classAttr.split(/\s+/) - for (let i = 0; i < classes.length; i++) { - const className = classes[i] - if (className) { - applyDecls(sheet.className.get(className)) - } - } - } - - // Apply ID styles - const idAttr = node.attrs.id - if (idAttr) { - applyDecls(sheet.id.get(idAttr)) - } - - if (Object.keys(merged).length) { - result.fragment = this.cssDeclarationsToFragment(merged) - } - return result - } - - /** - * Applies inline styles from style attribute. - */ - static applyInlineStyles(styleAttr?: string): AppliedStyle { - if (!styleAttr) { - return { hidden: false, suppressNewlines: false } - } - const declarations = this.parseCssDeclarations(styleAttr) - const applied: AppliedStyle = { hidden: false, suppressNewlines: false } - - // Check for display: none first - if present, don't process other styles - for (const [key, value] of Object.entries(declarations)) { - if (key === 'display' && value.trim().toLowerCase() === 'none') { - applied.hidden = true - return applied - } - } - - for (const [key, value] of Object.entries(declarations)) { - if ( - (key === 'margin' || - key === 'margin-top' || - key === 'margin-bottom') && - isZeroMargin(value) - ) { - // Suppress extra spacing when margin is 0 - applied.suppressNewlines = true - } - } - if (Object.keys(declarations).length) { - applied.fragment = this.cssDeclarationsToFragment(declarations) - } - return applied - } - - /** - * Parses CSS declarations efficiently. - */ - static parseCssDeclarations(input: string): Record { - const decls: Record = {} - const parts = input.split(';') - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - if (!part) continue - const trimmed = part.trim() - if (!trimmed) continue - const colonIndex = trimmed.indexOf(':') - if (colonIndex === -1) continue - const prop = trimmed.slice(0, colonIndex).trim().toLowerCase() - const value = trimmed.slice(colonIndex + 1).trim() - if (prop) { - decls[prop] = value - } - } - return decls - } - - /** - * Converts CSS declarations to fragment styles. - * Optimized with early returns and minimal object creation. - */ - static cssDeclarationsToFragment( - decls: Record - ): Partial { - const fragment: Partial = {} - for (const [prop, value] of Object.entries(decls)) { - if (!value) continue - switch (prop) { - case 'color': - fragment.fontColor = value - break - case 'background': - case 'background-color': - fragment.fragmentBackgroundColor = value - break - case 'font-size': { - const size = parseNumeric(value) - if (size !== undefined) fragment.fontSize = size - break - } - case 'line-height': { - const lh = parseNumeric(value) - if (lh !== undefined) fragment.lineHeight = lh - break - } - case 'letter-spacing': { - const ls = parseNumeric(value) - if (ls !== undefined) fragment.letterSpacing = ls - break - } - case 'font-weight': { - const weight = this.normalizeCssFontWeight(value) - if (weight) fragment.fontWeight = weight - break - } - case 'font-style': - if (value === 'italic' || value === 'oblique') { - fragment.fontStyle = value - } else if (value === 'normal') { - fragment.fontStyle = 'normal' - } - break - case 'font-family': - fragment.fontFamily = stripQuotes(value) - break - case 'text-align': - fragment.textAlign = value as Fragment['textAlign'] - break - case 'text-transform': - fragment.textTransform = value as Fragment['textTransform'] - break - case 'text-decoration': - case 'text-decoration-line': - fragment.textDecorationLine = this.normalizeDecoration(value) - break - case 'text-decoration-color': - fragment.textDecorationColor = value - break - case 'text-decoration-style': - fragment.textDecorationStyle = - value as Fragment['textDecorationStyle'] - break - case 'font': { - const parsedFont = this.parseFontShorthand(value) - Object.assign(fragment, parsedFont) - break - } - default: - break - } - } - return fragment - } - - /** - * Parses font shorthand property. - */ - private static parseFontShorthand(value: string): Partial { - const out: Partial = {} - const tokens = value.split(/\s+/) - let sizeIndex = -1 - for (let i = 0; i < tokens.length; i += 1) { - const token = tokens[i] - if (!token) continue - if (token.includes('/')) { - const [sizePart = '', linePart = ''] = token.split('/') - const size = parseNumeric(sizePart) - if (size !== undefined) out.fontSize = size - const lh = parseNumeric(linePart) - if (lh !== undefined) out.lineHeight = lh - sizeIndex = i - break - } - if (/[0-9]/.test(token)) { - const size = parseNumeric(token) - if (size !== undefined) out.fontSize = size - sizeIndex = i - break - } - } - if (sizeIndex === -1) { - sizeIndex = tokens.length - } - for (let i = 0; i < sizeIndex; i += 1) { - const token = tokens[i] - if (!token) continue - if (token === 'italic' || token === 'oblique') { - out.fontStyle = token - } else { - const weight = this.normalizeCssFontWeight(token) - if (weight) out.fontWeight = weight - } - } - const family = tokens.slice(sizeIndex + 1).join(' ') - if (family) { - out.fontFamily = stripQuotes(family) - } - return out - } - - /** - * Normalizes CSS font weight values. - */ - private static normalizeCssFontWeight(value: string) { - const trimmed = value.trim() - if (!trimmed) return undefined - return normalizeWeight(trimmed as Fragment['fontWeight']) - } - - /** - * Normalizes text decoration values. - */ - private static normalizeDecoration(value: string) { - const parts = value - .split(/\s+/) - .filter(Boolean) - .map((token) => token.toLowerCase()) - const supported = parts.filter( - (token) => token === 'underline' || token === 'line-through' - ) - if (!supported.length) { - return undefined - } - if (supported.length === 2) { - return 'underline line-through' - } - return supported[0] as Fragment['textDecorationLine'] - } -} diff --git a/src/renderers/html.test.ts b/src/renderers/html.test.ts deleted file mode 100644 index b50dd39..0000000 --- a/src/renderers/html.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { HTMLRenderer } from './html' -import type { Fragment } from '../types' - -describe('HTMLRenderer', () => { - describe('render', () => { - describe('basic text rendering', () => { - it('should render plain text', () => { - const result = HTMLRenderer.render('Hello World') - expect(result.text).toBe('Hello World') - expect(result.fragments).toHaveLength(1) - expect(result.fragments[0]?.text).toBe('Hello World') - }) - - it('should render empty string', () => { - const result = HTMLRenderer.render('') - expect(result.text).toBe('') - expect(result.fragments).toHaveLength(0) - }) - - it('should trim leading and trailing whitespace', () => { - const result = HTMLRenderer.render(' Hello ') - expect(result.text).toBe('Hello') - }) - - it('should collapse multiple spaces to single space', () => { - const result = HTMLRenderer.render('Hello World') - expect(result.text).toBe('Hello World') - }) - }) - - describe('semantic tags', () => { - it('should render bold text with ', () => { - const result = HTMLRenderer.render('Bold') - expect(result.text).toBe('Bold') - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - - it('should render bold text with ', () => { - const result = HTMLRenderer.render('Bold') - expect(result.text).toBe('Bold') - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - - it('should render italic text with ', () => { - const result = HTMLRenderer.render('Italic') - expect(result.text).toBe('Italic') - expect(result.fragments[0]?.fontStyle).toBe('italic') - }) - - it('should render italic text with ', () => { - const result = HTMLRenderer.render('Italic') - expect(result.text).toBe('Italic') - expect(result.fragments[0]?.fontStyle).toBe('italic') - }) - - it('should render underlined text with ', () => { - const result = HTMLRenderer.render('Underlined') - expect(result.text).toBe('Underlined') - expect(result.fragments[0]?.textDecorationLine).toBe('underline') - }) - - it('should render strikethrough with ', () => { - const result = HTMLRenderer.render('Strikethrough') - expect(result.text).toBe('Strikethrough') - expect(result.fragments[0]?.textDecorationLine).toBe('line-through') - }) - - it('should render strikethrough with ', () => { - const result = HTMLRenderer.render('Deleted') - expect(result.text).toBe('Deleted') - expect(result.fragments[0]?.textDecorationLine).toBe('line-through') - }) - - it('should render strikethrough with ', () => { - const result = HTMLRenderer.render('Strike') - expect(result.text).toBe('Strike') - expect(result.fragments[0]?.textDecorationLine).toBe('line-through') - }) - - it('should render marked text with background color', () => { - const result = HTMLRenderer.render('Highlighted') - expect(result.text).toBe('Highlighted') - expect(result.fragments[0]).toBeDefined() - }) - - it('should combine multiple semantic styles', () => { - const result = HTMLRenderer.render( - 'Bold and Italic' - ) - expect(result.text).toBe('Bold and Italic') - const fragment = result.fragments[0] - expect(fragment?.fontWeight).toBe('bold') - expect(fragment?.fontStyle).toBe('italic') - }) - }) - - describe('headings', () => { - it('should render h1 with correct size', () => { - const result = HTMLRenderer.render('

Heading 1

') - expect(result.text).toBe('Heading 1') - expect(result.fragments[0]?.fontSize).toBe(30) - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - - it('should render h2 with correct size', () => { - const result = HTMLRenderer.render('

Heading 2

') - expect(result.text).toBe('Heading 2') - expect(result.fragments[0]?.fontSize).toBe(26) - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - - it('should render h3 with correct size', () => { - const result = HTMLRenderer.render('

Heading 3

') - expect(result.text).toBe('Heading 3') - expect(result.fragments[0]?.fontSize).toBe(22) - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - - it('should render h4 with correct size', () => { - const result = HTMLRenderer.render('

Heading 4

') - expect(result.text).toBe('Heading 4') - expect(result.fragments[0]?.fontSize).toBe(20) - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - - it('should render h5 with correct size', () => { - const result = HTMLRenderer.render('
Heading 5
') - expect(result.text).toBe('Heading 5') - expect(result.fragments[0]?.fontSize).toBe(18) - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - - it('should render h6 with correct size', () => { - const result = HTMLRenderer.render('
Heading 6
') - expect(result.text).toBe('Heading 6') - expect(result.fragments[0]?.fontSize).toBe(16) - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - }) - - describe('block elements', () => { - it('should add newlines around paragraphs', () => { - const result = HTMLRenderer.render('

First

Second

') - expect(result.text).toBe('First\n\nSecond') - }) - - it('should add newlines around divs', () => { - const result = HTMLRenderer.render( - '
First
Second
' - ) - expect(result.text).toBe('First\n\nSecond') - }) - - it('should handle nested block elements', () => { - const result = HTMLRenderer.render('

Nested

') - expect(result.text).toBe('Nested') - }) - - it('should handle block elements after text', () => { - const result = HTMLRenderer.render('Text

Paragraph

') - expect(result.text).toBe('Text\n\nParagraph') - }) - }) - - describe('inline elements', () => { - it('should handle span elements', () => { - const result = HTMLRenderer.render('Inline') - expect(result.text).toBe('Inline') - }) - - it('should handle multiple inline elements', () => { - const result = HTMLRenderer.render( - 'First Second' - ) - expect(result.text).toBe('First Second') - }) - - it('should handle inline elements after block elements', () => { - const result = HTMLRenderer.render( - '

Block

Inline' - ) - expect(result.text).toBe('Block\nInline') - }) - }) - - describe('line breaks', () => { - it('should render br tags as newlines', () => { - const result = HTMLRenderer.render('Line 1
Line 2') - expect(result.text).toBe('Line 1\nLine 2') - }) - - it('should handle multiple br tags', () => { - const result = HTMLRenderer.render('Line 1

Line 2') - expect(result.text).toBe('Line 1\n\nLine 2') - }) - - it('should handle br tags with attributes', () => { - const result = HTMLRenderer.render('Line 1
Line 2') - expect(result.text).toBe('Line 1\nLine 2') - }) - }) - - describe('lists', () => { - it('should render unordered list with bullets', () => { - const result = HTMLRenderer.render( - '
  • Item 1
  • Item 2
' - ) - expect(result.text).toBe('• Item 1\n\n• Item 2') - }) - - it('should render ordered list with numbers', () => { - const result = HTMLRenderer.render( - '
  1. First
  2. Second
' - ) - expect(result.text).toBe('1. First\n\n2. Second') - }) - - it('should handle nested lists', () => { - const result = HTMLRenderer.render( - '
  • Item 1
    • Sub 1
' - ) - expect(result.text).toContain('• Item 1') - expect(result.text).toContain('• Sub 1') - }) - - it('should handle mixed ordered and unordered lists', () => { - const result = HTMLRenderer.render( - '
  1. First
    • Bullet
' - ) - expect(result.text).toContain('1. First') - expect(result.text).toContain('• Bullet') - }) - - it('should handle list with text before it', () => { - const result = HTMLRenderer.render('Text
  • Item
') - expect(result.text).toBe('Text\n\n• Item') - }) - }) - - describe('images', () => { - it('should render image alt text', () => { - const result = HTMLRenderer.render( - 'Image description' - ) - expect(result.text).toBe('Image description') - }) - - it('should ignore image without alt text', () => { - const result = HTMLRenderer.render('') - expect(result.text).toBe('') - }) - - it('should handle image after text', () => { - const result = HTMLRenderer.render('TextImage') - expect(result.text).toBe('TextImage') - }) - - it('should handle image in block element', () => { - const result = HTMLRenderer.render('

Image

') - expect(result.text).toBe('Image') - }) - }) - - describe('HTML entities', () => { - it('should decode common HTML entities', () => { - const result = HTMLRenderer.render('<div>') - expect(result.text).toBe('
') - }) - - it('should decode ampersand', () => { - const result = HTMLRenderer.render('Tom & Jerry') - expect(result.text).toBe('Tom & Jerry') - }) - - it('should decode quotes', () => { - const result = HTMLRenderer.render('"Hello"') - expect(result.text).toBe('"Hello"') - }) - - it('should decode non-breaking space', () => { - const result = HTMLRenderer.render('Word Word') - expect(result.text).toBe('Word Word') - }) - - it('should decode numeric entities', () => { - const result = HTMLRenderer.render('A A') - expect(result.text).toBe('A A') - }) - - it('should decode greek letters', () => { - const result = HTMLRenderer.render('α β γ') - expect(result.text).toBe('\u03b1 \u03b2 \u03b3') - }) - - it('should decode copyright and other symbols', () => { - const result = HTMLRenderer.render('© ® ™') - expect(result.text).toBe('\u00a9 \u00ae \u2122') - }) - }) - - describe('CSS integration', () => { - it('should apply inline styles', () => { - const result = HTMLRenderer.render( - 'Styled' - ) - expect(result.text).toBe('Styled') - expect(result.fragments[0]?.fontColor).toBe('red') - expect(result.fragments[0]?.fontSize).toBe(20) - }) - - it('should apply styles from style tag', () => { - const html = ` - - Red Text - ` - const result = HTMLRenderer.render(html) - expect(result.text).toBe('Red Text') - expect(result.fragments[0]?.fontColor).toBe('red') - }) - - it('should apply tag selector styles', () => { - const html = ` - -

Blue paragraph

- ` - const result = HTMLRenderer.render(html) - expect(result.text).toBe('Blue paragraph') - expect(result.fragments[0]?.fontColor).toBe('blue') - }) - - it('should apply ID selector styles', () => { - const html = ` - - Special - ` - const result = HTMLRenderer.render(html) - expect(result.text).toBe('Special') - expect(result.fragments[0]?.fontColor).toBe('green') - }) - - it('should give priority to inline styles over CSS', () => { - const html = ` - - Text - ` - const result = HTMLRenderer.render(html) - expect(result.text).toBe('Text') - expect(result.fragments[0]?.fontColor).toBe('red') - }) - - it('should handle display:none to hide elements', () => { - const html = 'HiddenVisible' - const result = HTMLRenderer.render(html) - expect(result.text).toBe('Visible') - }) - }) - - describe('baseFragment', () => { - it('should apply base fragment styles', () => { - const base: Partial = { - fontSize: 14, - fontColor: 'blue', - } - const result = HTMLRenderer.render('Text', base) - expect(result.fragments[0]?.fontSize).toBe(14) - expect(result.fragments[0]?.fontColor).toBe('blue') - }) - - it('should allow HTML to override base fragment', () => { - const base: Partial = { - fontSize: 14, - } - const result = HTMLRenderer.render( - 'Text', - base - ) - expect(result.fragments[0]?.fontSize).toBe(20) - }) - - it('should combine base fragment with semantic styles', () => { - const base: Partial = { - fontColor: 'blue', - } - const result = HTMLRenderer.render('Bold', base) - expect(result.fragments[0]?.fontColor).toBe('blue') - expect(result.fragments[0]?.fontWeight).toBe('bold') - }) - }) - - describe('whitespace handling', () => { - it('should preserve whitespace in pre tags', () => { - const result = HTMLRenderer.render( - '
Line 1\n  Line 2\n    Line 3
' - ) - expect(result.text).toBe('Line 1\n Line 2\n Line 3') - }) - - it('should collapse whitespace outside pre tags', () => { - const result = HTMLRenderer.render('Text with spaces') - expect(result.text).toBe('Text with spaces') - }) - - it('should collapse newlines outside pre tags', () => { - const result = HTMLRenderer.render('Line 1\n\n\nLine 2') - expect(result.text).toBe('Line 1 Line 2') - }) - - it('should trim trailing newlines', () => { - const result = HTMLRenderer.render('

Text

\n\n') - expect(result.text).toBe('Text') - }) - }) - - describe('edge cases', () => { - it('should handle malformed HTML gracefully', () => { - const result = HTMLRenderer.render('

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.

-
    -
  • Item 1
  • -
  • Item 2
  • -
-
- ` - 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 = - '

Nested

' - 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 = ` -
-
-

Article Title

-
-
-

First paragraph.

-

Second paragraph.

-
-
Footer text
-
- ` - 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('