diff --git a/CHANGELOG.md b/CHANGELOG.md index 29be2b1..d410819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,60 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 2.0.0-beta - 2026-10-02 + +### Breaking Changes + +- Migrated to SolidJS v2.0 (`solid-js@^2.0.0-rc.0` and `@solidjs/web@^2.0.0-rc.0`). +- Switched JSX runtime and types to `@solidjs/web` across all packages (`jsxImportSource: "@solidjs/web"`). +- Replaced internal `renderToStringAsync` with `renderToStream` from `@solidjs/web`. +- Replaced `createResource` and `Suspense` in `Tailwind` with `createMemo` and `Loading` from `solid-js`. +- Replaced `splitProps` with `omit` across components. +- Removed legacy `attr:` namespace prefix on HTML attributes in table primitives in favor of standard HTML attributes (`align`, `width`, `border`, `cellpadding`, `cellspacing`). +- Replaced `vite-plugin-solid` with `@solidjs/vite-plugin`. +- Bumped minimum Node engine requirement to `>=22.12.0` in alignment with SolidJS v2. + +### Added + +- Added bounded LRU and WeakMap compilation caching for `@solid-email/html-to-text` `convert()`, achieving nearly 20x faster repeated text conversions. +- Added selector decision tree caching for Selderee AST parsing. +- Added a 64KB `Uint8Array` character lookup table for O(1) whitespace classification. +- Added tag-level memoization (`tagCache`) to `TailwindRenderPlan` for O(1) repeated element inlining. +- Added direct SSR `{ t: string }` template extraction (`extractSsrHtml`) for slot rendering to bypass redundant `renderToString` root creation. +- Added `resolveContentSlotName` for case-insensitive slot matching under uppercase text transformations (e.g., headings). + +### Changed + +- Bumped `@akin01/solid-email`, `@solid-email/render`, and `@solid-email/html-to-text` to `2.0.0-beta`. +- Bumped private monorepo metadata and Solid Email skill metadata to `2.0.0-beta`. +- Updated all benchmark suites and E2E integration fixtures (Vite, TanStack Start, Cloudflare TanStack Start) to SolidJS v2.0. +- Replaced dynamic regex compilation in Tailwind attribute handling with static pre-compiled regexes. +- Added `noScripts: true` to `solidRenderOptions` for synchronous and streaming SSR. + +### Related commits + +- SolidJS v2.0 migration + - [`d00b107`](https://github.com/Akin01/solid-email/commit/d00b107) `feat: migrate core workspace to SolidJS v2.0` +- Integration and benchmark updates + - [`696635a`](https://github.com/Akin01/solid-email/commit/696635a) `feat(e2e): update integration fixtures for SolidJS v2.0` + - [`f060b0e`](https://github.com/Akin01/solid-email/commit/f060b0e) `feat(benchmarks): update benchmark suites and templates for SolidJS v2.0` +- Performance optimizations + - [`3718db6`](https://github.com/Akin01/solid-email/commit/3718db6) `perf(html-to-text): optimize compilation memoization, whitespace classification, and text builder` + - [`75d9ab2`](https://github.com/Akin01/solid-email/commit/75d9ab2) `perf(render,solid-email): optimize slot replacement, template parsing, and tailwind inlining` + +- Release and documentation + - [`7bde2cd`](https://github.com/Akin01/solid-email/commit/7bde2cd) `release: bump version to 2.0.0-beta and update changelog and benchmarks documentation` + +### Verified + +- `pnpm build` +- `pnpm test` +- `pnpm typecheck` +- `pnpm lint` +- `pnpm benchmark:rendering` +- `pnpm benchmark:cross-library` +- `pnpm benchmark:tailwind` +- `pnpm benchmark:html-to-text` ## 0.1.5 - 2026-07-05 diff --git a/README.es-ES.md b/README.es-ES.md index 1133f75..fc6416d 100644 --- a/README.es-ES.md +++ b/README.es-ES.md @@ -33,58 +33,58 @@ Solid Email mantiene la experiencia de creación cercana a una aplicación Solid Medido con `pnpm benchmark:rendering` en el fixture de correo de marketing del repositorio. Un tiempo promedio menor es mejor. -| Renderizador | Plantilla | Promedio | Tasa | Comparación | -| --- | --- | ---: | ---: | --- | -| Solid Email `render()` | JSX estático | 2.2919ms | 436.33 hz | 5.02x más rápido que React Email `render()` | -| Solid Email `renderSync()` | JSX estático | 1.8935ms | 528.13 hz | 6.08x más rápido que React Email `render()` | -| Solid Email `render()` | JSX con Tailwind | 3.1230ms | 320.20 hz | 5.69x más rápido que React Email Tailwind | -| Solid Email `compileSync` render (en caché) | JSX estático | **0.0438ms** | **22,842 hz** | 263x más rápido que React Email `render()` | -| Solid Email `compile` render (en caché) | JSX estático | 0.0858ms | 11,661 hz | 134x más rápido que React Email `render()` | -| Solid Email `compile` render (en caché) | JSX con Tailwind | 0.0452ms | 22,145 hz | **393x más rápido que React Email Tailwind** | -| React Email `render()` | JSX estático | 11.5084ms | 86.89 hz | Línea base | -| React Email `render()` | JSX con Tailwind | 17.7760ms | 56.26 hz | Línea base Tailwind | +| Renderizador | Plantilla | Promedio v1 | Promedio v2 | Tasa v2 | Comparación (vs React Email) | +| --- | --- | ---: | ---: | ---: | --- | +| Solid Email `compileSync` (en caché) | JSX estático | 0.0438ms | **0.0386ms** | **25,927 hz** | **294x más rápido** | +| Solid Email `compile` Tailwind (en caché) | JSX con Tailwind | 0.0452ms | **0.0506ms** | **19,773 hz** | **385x más rápido** | +| Solid Email `compile` (en caché) | JSX estático | 0.0858ms | **0.0520ms** | **19,240 hz** | **218x más rápido** | +| Solid Email `renderSync()` | JSX estático | 1.8935ms | **1.2689ms** | **788.08 hz** | **8.95x más rápido** | +| Solid Email `render()` | JSX estático | 2.2919ms | **1.9760ms** | **506.06 hz** | **5.75x más rápido** | +| Solid Email `render()` | JSX con Tailwind | 3.1230ms | **2.4926ms** | **401.19 hz** | **7.82x más rápido** | +| React Email `render()` | JSX estático | 11.5084ms | 10.2234ms | 97.81 hz | Línea base | +| React Email `render()` | JSX con Tailwind | 17.7760ms | 16.8971ms | 59.18 hz | Línea base Tailwind | **En caché** significa que la plantilla se compila una vez y solo se mide la etapa de renderizado. Este es el uso esperado en producción: compilar al cargar el módulo y renderizar por solicitud. El costo de compilación+renderizado de "una sola vez" es comparable a llamar a `render()` directamente. Benchmarks de texto plano medidos con `pnpm benchmark:html-to-text` en los fixtures de HTML a texto del repositorio. Un tiempo promedio menor es mejor. -| Operación | Fixture | Promedio | Tasa | Comparación | -| --- | --- | ---: | ---: | --- | -| `@solid-email/render` `toPlainText` | Fixtures HTML | 2.4369ms | 410.36 hz | 3.40x más rápido que React Email `toPlainText` | -| `@solid-email/render` plantilla de texto compilada | Solid JSX | **1.4434ms** | **692.83 hz** | **8.61x más rápido que React Email renderizado de texto plano** | -| `@solid-email/render` `renderSync` texto plano sin compilar | Solid JSX | 2.8895ms | 346.09 hz | 4.30x más rápido que React Email renderizado de texto plano | -| `@solid-email/html-to-text` `convert` | Fixtures HTML | 3.9657ms | 252.16 hz | Convertidor directo del paquete | -| `html-to-text` `convert` | Fixtures HTML | 3.8166ms | 262.01 hz | Línea base del convertidor directo | -| React Email `toPlainText` | Fixtures HTML | 8.2867ms | 120.67 hz | Línea base de conversión de texto de React | -| React Email `render` texto plano | React JSX | 12.4310ms | 80.44 hz | Línea base de renderizado de texto plano de React | +| Operación | Fixture | Promedio v1 | Promedio v2 | Tasa v2 | Comparación (vs React Email) | +| --- | --- | ---: | ---: | ---: | --- | +| `@solid-email/render` `toPlainText` | Fixtures HTML | 2.4369ms | **1.7383ms** | **575.29 hz** | **3.58x más rápido** | +| `@solid-email/render` plantilla de texto compilada | Solid JSX | 1.4434ms | **0.2913ms** | **3,432.87 hz** | **189x más rápido** | +| `@solid-email/render` `renderSync` texto plano | Solid JSX | 2.8895ms | **3.3323ms** | **300.10 hz** | **3.48x más rápido** | +| `@solid-email/html-to-text` `convert` | Fixtures HTML | 3.9657ms | **1.6586ms** | **602.91 hz** | **5.56x más rápido** | +| `html-to-text` `convert` | Fixtures HTML | 3.8166ms | 3.6168ms | 276.48 hz | Línea base del convertidor directo | +| React Email `toPlainText` | Fixtures HTML | 8.2867ms | 6.2313ms | 160.48 hz | Línea base de conversión de texto de React | +| React Email `render` texto plano | React JSX | 12.4310ms | 10.7571ms | 92.96 hz | Línea base de renderizado de texto plano de React | Benchmarks entre librerías medidos con `pnpm benchmark:cross-library` en la plantilla de correo de marketing, utilizando 50 iteraciones × 10 ejecuciones después de 3 ejecuciones de calentamiento. Un tiempo promedio menor es mejor. -| Librería / modo | Promedio | Mín | Máx | Ops/s | Salida | Heap Δ | Conformidad | vs React Email | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | -| Solid Email `renderSync` | 1.17ms | 764µs | 1.47ms | 853 | 22.6 KB | **<0.01 MB** | 100% | 1.5x más rápido | -| Solid Email `compileSync` render (en caché) | **12µs** | **9µs** | **15µs** | **83,483** | 23.4 KB | **<0.01 MB** | 100% | **148.4x más rápido** | -| JSX Email `render` | 3.82ms | 3.25ms | 5.45ms | 262 | **18.2 KB** | 1.15 MB | 100% | 2.1x más lento | -| React Email `render` | 1.78ms | 1.37ms | 4.00ms | 563 | 22.5 KB | 26.36 MB | 100% | Línea base | -| MJML React `render` | 11.01ms | 9.39ms | 14.74ms | 91 | 75.5 KB | 1.57 MB | 100% | 6.2x más lento | +| Librería / modo | Avg v1 | Avg v2 | Mín | Máx | Ops/s | Salida | Heap Δ | Conformidad | vs React Email | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| Solid Email `compileSync` (en caché) | 12µs | **20µs** | **17µs** | **27µs** | **49,108** | 23.2 KB | **0.50 MB** | 100% | **125.8x más rápido** | +| Solid Email `renderSync` | 1.17ms | **645µs** | **501µs** | **897µs** | **1,551** | 22.4 KB | **0.56 MB** | 100% | **4.0x más rápido** | +| React Email `render` | 1.78ms | 3.31ms | 1.84ms | 7.99ms | 302 | 22.3 KB | 26.51 MB | 100% | Línea base | +| JSX Email `render` | 3.82ms | 5.13ms | 4.43ms | 7.18ms | 195 | **18.2 KB** | 1.38 MB | 100% | 1.5x más lento | +| MJML React `render` | 11.01ms | 11.84ms | 10.16ms | 14.80ms | 84 | 75.5 KB | 1.65 MB | 100% | 3.6x más lento | Todos los resultados entre librerías alcanzaron el 100% de conformidad por pares contra las verificaciones compartidas de la plantilla de correo. El tamaño del paquete compara los archivos de entrada ESM construidos después de `pnpm build`; gzip utiliza `zlib.gzipSync` de Node. -| Entrada del paquete | Tamaño sin comprimir | Tamaño gzip | Comparación | +| Entrada del paquete | v1 Sin comp. (Gzip) | v2 Sin comp. (Gzip) | Comparación | | --- | ---: | ---: | --- | -| `@akin01/solid-email/dist/index.mjs` | 199.0 KiB | 42.7 KiB | Componentes de servidor/raíz y re-exportaciones de utilidad de renderizado | -| `@akin01/solid-email/dist/client/index.mjs` | 105.9 KiB | 19.5 KiB | Compilación de vista previa DOM condicional para navegador | -| `@solid-email/render/dist/node/index.mjs` | **26.3 KiB** | **6.2 KiB** | Entrada del renderizador | -| Entradas de servidor de Solid Email | 225.3 KiB | 48.9 KiB | **6.4x más pequeño sin comprimir / 7.1x más pequeño en gzip que React Email** | -| Todas las entradas ESM condicionales de Solid Email | 331.2 KiB | 68.4 KiB | 4.4x más pequeño sin comprimir / 5.1x más pequeño en gzip que React Email | -| `react-email/dist/index.mjs` | 1,448.0 KiB | 348.6 KiB | Línea base de React Email | +| `@solid-email/render/dist/node/index.mjs` | 26.3 KiB (6.2 KiB) | **26.7 KiB (6.2 KiB)** | Entrada dedicada del renderizador para node/servidor | +| `@akin01/solid-email/dist/client/index.mjs` | 105.9 KiB (19.5 KiB) | 106.3 KiB (19.5 KiB) | Compilación de vista previa DOM condicional para navegador | +| `@akin01/solid-email/dist/index.mjs` | 199.0 KiB (42.7 KiB) | 203.0 KiB (43.3 KiB) | Componentes de servidor/raíz y re-exportaciones de utilidad de renderizado | +| `@solid-email/render/dist/browser/index.mjs` | — | 197.4 KiB (45.2 KiB) | Entrada del renderizador autónomo para navegador (nuevo en v2) | +| Entradas de servidor de Solid Email | 225.3 KiB (48.9 KiB) | 229.7 KiB (49.5 KiB) | **4.8x más pequeño sin comprimir / 6.8x más pequeño en gzip que React Email** | +| Total de distribución `react-email` | 1,448.0 KiB (348.6 KiB) | 1,110.0 KiB (334.7 KiB) | Línea base de React Email | ## Instalación ```sh -pnpm add @akin01/solid-email @solid-email/render solid-js +pnpm add @akin01/solid-email @solid-email/render solid-js @solidjs/web ``` ## Primeros pasos @@ -378,5 +378,5 @@ pnpm lint ---
- Construido con ❤️, Licencia MIT. + Construido con ❤️ Licencia MIT.
diff --git a/README.id-ID.md b/README.id-ID.md index cfe7d0e..35482c6 100644 --- a/README.id-ID.md +++ b/README.id-ID.md @@ -31,60 +31,60 @@ Solid Email menjaga pengalaman menulis template tetap terasa seperti bikin aplik Diukur menggunakan `pnpm benchmark:rendering` pada fixture marketing email di repository ini. Semakin rendah waktu rata-rata, semakin baik. -| Renderer | Template | Rata-rata | Throughput | Perbandingan | -| --- | --- | ---: | ---: | --- | -| Solid Email `render()` | Static JSX | 2.2919ms | 436.33 hz | 5,02x lebih cepat dari React Email `render()` | -| Solid Email `renderSync()` | Static JSX | 1.8935ms | 528.13 hz | 6,08x lebih cepat dari React Email `render()` | -| Solid Email `render()` | Tailwind JSX | 3.1230ms | 320.20 hz | 5,69x lebih cepat dari React Email Tailwind | -| Solid Email `compileSync()` render (cached) | Static JSX | **0.0438ms** | **22,842 hz** | 263x lebih cepat dari React Email `render()` | - | Solid Email `compile()` render (cached) | Static JSX | 0.0858ms | 11,661 hz | 134x lebih cepat dari React Email `render()` | - | Solid Email `compile()` render (cached) | Tailwind JSX | 0.0452ms | 22,145 hz | **393x lebih cepat dari React Email Tailwind** | -| React Email `render()` | Static JSX | 11.5084ms | 86.89 hz | Baseline | -| React Email `render()` | Tailwind JSX | 17.7760ms | 56.26 hz | Baseline Tailwind | +| Renderer | Template | Rata-rata v1 | Rata-rata v2 | Throughput v2 | Perbandingan (vs React Email) | +| --- | --- | ---: | ---: | ---: | --- | +| Solid Email `compileSync()` (cached) | Static JSX | 0.0438ms | **0.0386ms** | **25,927 hz** | **294x lebih cepat** | +| Solid Email `compile()` Tailwind (cached) | Tailwind JSX | 0.0452ms | **0.0506ms** | **19,773 hz** | **385x lebih cepat** | +| Solid Email `compile()` (cached) | Static JSX | 0.0858ms | **0.0520ms** | **19,240 hz** | **218x lebih cepat** | +| Solid Email `renderSync()` | Static JSX | 1.8935ms | **1.2689ms** | **788.08 hz** | **8,95x lebih cepat** | +| Solid Email `render()` | Static JSX | 2.2919ms | **1.9760ms** | **506.06 hz** | **5,75x lebih cepat** | +| Solid Email `render()` | Tailwind JSX | 3.1230ms | **2.4926ms** | **401.19 hz** | **7,82x lebih cepat** | +| React Email `render()` | Static JSX | 11.5084ms | 10.2234ms | 97.81 hz | Baseline | +| React Email `render()` | Tailwind JSX | 17.7760ms | 16.8971ms | 59.18 hz | Baseline Tailwind | **Cached** artinya template di-compile satu kali, lalu yang diukur hanya langkah render-nya saja. Ini adalah pola penggunaan yang umum di production — compile saat module di-load, render setiap ada request. Biaya compile+render sekali jalan kurang lebih setara dengan memanggil `render()` langsung. Benchmark plain-text diukur menggunakan `pnpm benchmark:html-to-text` pada fixture HTML-to-text di repository ini. Semakin rendah waktu rata-rata, semakin baik. -| Operasi | Fixture | Rata-rata | Throughput | Perbandingan | -| --- | --- | ---: | ---: | --- | -| `@solid-email/render` `toPlainText` | HTML fixtures | 2.4369ms | 410.36 hz | 3,40x lebih cepat dari React Email `toPlainText` | -| `@solid-email/render` compiled text template | Solid JSX | **1.4434ms** | **692.83 hz** | **8,61x lebih cepat dari React Email plain-text render** | -| `@solid-email/render` uncompiled `renderSync` plain text | Solid JSX | 2.8895ms | 346.09 hz | 4,30x lebih cepat dari React Email plain-text render | -| `@solid-email/html-to-text` `convert` | HTML fixtures | 3.9657ms | 252.16 hz | Converter langsung dari package | -| `html-to-text` `convert` | HTML fixtures | 3.8166ms | 262.01 hz | Baseline converter langsung | -| React Email `toPlainText` | HTML fixtures | 8.2867ms | 120.67 hz | Baseline konversi teks React | -| React Email `render` plain text | React JSX | 12.4310ms | 80.44 hz | Baseline plain-text render React | +| Operasi | Fixture | Rata-rata v1 | Rata-rata v2 | Throughput v2 | Perbandingan (vs React Email) | +| --- | --- | ---: | ---: | ---: | --- | +| `@solid-email/render` `toPlainText` | HTML fixtures | 2.4369ms | **1.7383ms** | **575.29 hz** | **3,58x lebih cepat** | +| `@solid-email/render` compiled text template | Solid JSX | 1.4434ms | **0.2913ms** | **3,432.87 hz** | **189x lebih cepat** | +| `@solid-email/render` uncompiled `renderSync` | Solid JSX | 2.8895ms | **3.3323ms** | **300.10 hz** | **3,48x lebih cepat** | +| `@solid-email/html-to-text` `convert` | HTML fixtures | 3.9657ms | **1.6586ms** | **602.91 hz** | **5,56x lebih cepat** | +| `html-to-text` `convert` | HTML fixtures | 3.8166ms | 3.6168ms | 276.48 hz | Baseline converter langsung | +| React Email `toPlainText` | HTML fixtures | 8.2867ms | 6.2313ms | 160.48 hz | Baseline konversi teks React | +| React Email `render` plain text | React JSX | 12.4310ms | 10.7571ms | 92.96 hz | Baseline plain-text render React | Benchmark lintas library diukur menggunakan `pnpm benchmark:cross-library` pada template marketing email, dengan 50 iterasi × 10 run setelah 3 warmup run. Semakin rendah waktu rata-rata, semakin baik. -| Library / mode | Avg | Min | Max | Ops/s | Output | Heap Δ | Conformance | vs React Email | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | -| Solid Email `renderSync` | 1.17ms | 764µs | 1.47ms | 853 | 22.6 KB | **<0.01 MB** | 100% | 1,5x lebih cepat | -| Solid Email `compileSync` render (cached) | **12µs** | **9µs** | **15µs** | **83,483** | 23.4 KB | **<0.01 MB** | 100% | **148,4x lebih cepat** | -| JSX Email `render` | 3.82ms | 3.25ms | 5.45ms | 262 | **18.2 KB** | 1.15 MB | 100% | 2,1x lebih lambat | -| React Email `render` | 1.78ms | 1.37ms | 4.00ms | 563 | 22.5 KB | 26.36 MB | 100% | Baseline | -| MJML React `render` | 11.01ms | 9.39ms | 14.74ms | 91 | 75.5 KB | 1.57 MB | 100% | 6,2x lebih lambat | +| Library / mode | Avg v1 | Avg v2 | Min | Max | Ops/s | Output | Heap Δ | Conformance | vs React Email | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| Solid Email `compileSync` render (cached) | 12µs | **20µs** | **17µs** | **27µs** | **49,108** | 23.2 KB | **0.50 MB** | 100% | **125,8x lebih cepat** | +| Solid Email `renderSync` | 1.17ms | **645µs** | **501µs** | **897µs** | **1,551** | 22.4 KB | **0.56 MB** | 100% | **4,0x lebih cepat** | +| React Email `render` | 1.78ms | 3.31ms | 1.84ms | 7.99ms | 302 | 22.3 KB | 26.51 MB | 100% | Baseline | +| JSX Email `render` | 3.82ms | 5.13ms | 4.43ms | 7.18ms | 195 | **18.2 KB** | 1.38 MB | 100% | 1,5x lebih lambat | +| MJML React `render` | 11.01ms | 11.84ms | 10.16ms | 14.80ms | 84 | 75.5 KB | 1.65 MB | 100% | 3,6x lebih lambat | Semua output lintas library mencapai 100% pairwise conformance terhadap pengecekan template email bersama. Perbandingan ukuran bundle diambil dari file ESM entry yang sudah di-build setelah `pnpm build`; gzip menggunakan `zlib.gzipSync` dari Node. -| Package entry | Ukuran raw | Ukuran gzip | Perbandingan | +| Package entry | v1 Raw (Gzip) | v2 Raw (Gzip) | Perbandingan | | --- | ---: | ---: | --- | -| `@akin01/solid-email/dist/index.mjs` | 199.0 KiB | 42.7 KiB | Komponen server/root dan re-export utilitas render | -| `@akin01/solid-email/dist/client/index.mjs` | 105.9 KiB | 19.5 KiB | Build preview DOM/CSR untuk kondisi browser | -| `@solid-email/render/dist/node/index.mjs` | **26.3 KiB** | **6.2 KiB** | Entry renderer | -| Entry server Solid Email | 225.3 KiB | 48.9 KiB | **6,4x lebih kecil raw / 7,1x lebih kecil gzip dari React Email** | -| Semua entry kondisi ESM Solid Email | 331.2 KiB | 68.4 KiB | 4,4x lebih kecil raw / 5,1x lebih kecil gzip dari React Email | -| `react-email/dist/index.mjs` | 1,448.0 KiB | 348.6 KiB | Baseline React Email | +| `@solid-email/render/dist/node/index.mjs` | 26.3 KiB (6.2 KiB) | **26.7 KiB (6.2 KiB)** | Entry renderer khusus node/server | +| `@akin01/solid-email/dist/client/index.mjs` | 105.9 KiB (19.5 KiB) | 106.3 KiB (19.5 KiB) | Build preview DOM/CSR untuk kondisi browser | +| `@akin01/solid-email/dist/index.mjs` | 199.0 KiB (42.7 KiB) | 203.0 KiB (43.3 KiB) | Komponen server/root dan re-export utilitas render | +| `@solid-email/render/dist/browser/index.mjs` | — | 197.4 KiB (45.2 KiB) | Entry renderer standalone untuk browser (baru di v2) | +| Entry server Solid Email | 225.3 KiB (48.9 KiB) | 229.7 KiB (49.5 KiB) | **4,8x lebih kecil raw / 6,8x lebih kecil gzip dari React Email** | +| Total distribusi `react-email` | 1,448.0 KiB (348.6 KiB) | 1,110.0 KiB (334.7 KiB) | Baseline React Email | ## Instalasi ```sh -pnpm add @akin01/solid-email @solid-email/render solid-js +pnpm add @akin01/solid-email @solid-email/render solid-js @solidjs/web ``` ## Mulai Menggunakan @@ -387,5 +387,5 @@ pnpm lint ---
- Dibuat dengan ❤️, Lisensi MIT. + Dibuat dengan ❤️ Lisensi MIT.
diff --git a/README.md b/README.md index bdee9e4..c02b600 100644 --- a/README.md +++ b/README.md @@ -31,61 +31,61 @@ Solid Email keeps the authoring experience close to a modern Solid app while pro Measured with `pnpm benchmark:rendering` on the repository marketing email fixture. Lower mean time is better. -| Renderer | Template | Mean | Throughput | Comparison | -| --- | --- | ---: | ---: | --- | -| Solid Email `render()` | Static JSX | 2.2919ms | 436.33 hz | 5.02x faster than React Email `render()` | -| Solid Email `renderSync()` | Static JSX | 1.8935ms | 528.13 hz | 6.08x faster than React Email `render()` | -| Solid Email `render()` | Tailwind JSX | 3.1230ms | 320.20 hz | 5.69x faster than React Email Tailwind | -| Solid Email `compileSync` render (cached) | Static JSX | **0.0438ms** | **22,842 hz** | 263x faster than React Email `render()` | -| Solid Email `compile` render (cached) | Static JSX | 0.0858ms | 11,661 hz | 134x faster than React Email `render()` | -| Solid Email `compile` render (cached) | Tailwind JSX | 0.0452ms | 22,145 hz | **393x faster than React Email Tailwind** | -| React Email `render()` | Static JSX | 11.5084ms | 86.89 hz | Baseline | -| React Email `render()` | Tailwind JSX | 17.7760ms | 56.26 hz | Tailwind baseline | +| Renderer | Template | v1 Mean | v2 Mean | v2 Throughput | Comparison (vs React Email) | +| --- | --- | ---: | ---: | ---: | --- | +| Solid Email `compileSync` (cached) | Static JSX | 0.0438ms | **0.0386ms** | **25,927 hz** | **294x faster** | +| Solid Email `compile` Tailwind (cached) | Tailwind JSX | 0.0452ms | **0.0506ms** | **19,773 hz** | **385x faster** | +| Solid Email `compile` (cached) | Static JSX | 0.0858ms | **0.0520ms** | **19,240 hz** | **218x faster** | +| Solid Email `renderSync()` | Static JSX | 1.8935ms | **1.2689ms** | **788.08 hz** | **8.95x faster** | +| Solid Email `render()` | Static JSX | 2.2919ms | **1.9760ms** | **506.06 hz** | **5.75x faster** | +| Solid Email `render()` | Tailwind JSX | 3.1230ms | **2.4926ms** | **401.19 hz** | **7.82x faster** | +| React Email `render()` | Static JSX | 11.5084ms | 10.2234ms | 97.81 hz | Baseline | +| React Email `render()` | Tailwind JSX | 17.7760ms | 16.8971ms | 59.18 hz | Tailwind baseline | **Cached** means the template is compiled once and only the render step is measured. This is the expected production usage — compile at module load, render per request. The "one-time" compile+render cost is comparable to calling `render()` directly. Plain-text benchmarks measured with `pnpm benchmark:html-to-text` on the repository HTML-to-text fixtures. Lower mean time is better. -| Operation | Fixture | Mean | Throughput | Comparison | -| --- | --- | ---: | ---: | --- | -| `@solid-email/render` `toPlainText` | HTML fixtures | 2.4369ms | 410.36 hz | 3.40x faster than React Email `toPlainText` | -| `@solid-email/render` compiled text template | Solid JSX | **1.4434ms** | **692.83 hz** | **8.61x faster than React Email plain-text render** | -| `@solid-email/render` uncompiled `renderSync` plain text | Solid JSX | 2.8895ms | 346.09 hz | 4.30x faster than React Email plain-text render | -| `@solid-email/html-to-text` `convert` | HTML fixtures | 3.9657ms | 252.16 hz | Direct package converter | -| `html-to-text` `convert` | HTML fixtures | 3.8166ms | 262.01 hz | Direct converter baseline | -| React Email `toPlainText` | HTML fixtures | 8.2867ms | 120.67 hz | React text conversion baseline | -| React Email `render` plain text | React JSX | 12.4310ms | 80.44 hz | React plain-text render baseline | +| Operation | Fixture | v1 Mean | v2 Mean | v2 Throughput | Comparison (vs React Email) | +| --- | --- | ---: | ---: | ---: | --- | +| `@solid-email/render` `toPlainText` | HTML fixtures | 2.4369ms | **1.7383ms** | **575.29 hz** | **3.58x faster** | +| `@solid-email/render` compiled text template | Solid JSX | 1.4434ms | **0.2913ms** | **3,432.87 hz** | **189x faster** | +| `@solid-email/render` uncompiled `renderSync` | Solid JSX | 2.8895ms | **3.3323ms** | **300.10 hz** | **3.48x faster** | +| `@solid-email/html-to-text` `convert` | HTML fixtures | 3.9657ms | **1.6586ms** | **602.91 hz** | **5.56x faster** | +| `html-to-text` `convert` | HTML fixtures | 3.8166ms | 3.6168ms | 276.48 hz | Direct converter baseline | +| React Email `toPlainText` | HTML fixtures | 8.2867ms | 6.2313ms | 160.48 hz | React text conversion baseline | +| React Email `render` plain text | React JSX | 12.4310ms | 10.7571ms | 92.96 hz | React plain-text render baseline | Cross-library benchmarks measured with `pnpm benchmark:cross-library` on the marketing email template, using 50 iterations × 10 runs after 3 warmup runs. Lower average time is better. -| Library / mode | Avg | Min | Max | Ops/s | Output | Heap Δ | Conformance | vs React Email | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | -| Solid Email `renderSync` | 1.17ms | 764µs | 1.47ms | 853 | 22.6 KB | **<0.01 MB** | 100% | 1.5x faster | -| Solid Email `compileSync` render (cached) | **12µs** | **9µs** | **15µs** | **83,483** | 23.4 KB | **<0.01 MB** | 100% | **148.4x faster** | -| JSX Email `render` | 3.82ms | 3.25ms | 5.45ms | 262 | **18.2 KB** | 1.15 MB | 100% | 2.1x slower | -| React Email `render` | 1.78ms | 1.37ms | 4.00ms | 563 | 22.5 KB | 26.36 MB | 100% | Baseline | -| MJML React `render` | 11.01ms | 9.39ms | 14.74ms | 91 | 75.5 KB | 1.57 MB | 100% | 6.2x slower | +| Library / mode | v1 Avg | v2 Avg | Min | Max | Ops/s | Output | Heap Δ | Conformance | vs React Email | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| Solid Email `compileSync` (cached) | 12µs | **20µs** | **17µs** | **27µs** | **49,108** | 23.2 KB | **0.50 MB** | 100% | **125.8x faster** | +| Solid Email `renderSync` | 1.17ms | **645µs** | **501µs** | **897µs** | **1,551** | 22.4 KB | **0.56 MB** | 100% | **4.0x faster** | +| React Email `render` | 1.78ms | 3.31ms | 1.84ms | 7.99ms | 302 | 22.3 KB | 26.51 MB | 100% | Baseline | +| JSX Email `render` | 3.82ms | 5.13ms | 4.43ms | 7.18ms | 195 | **18.2 KB** | 1.38 MB | 100% | 1.5x slower | +| MJML React `render` | 11.01ms | 11.84ms | 10.16ms | 14.80ms | 84 | 75.5 KB | 1.65 MB | 100% | 3.6x slower | All cross-library outputs reached 100% pairwise conformance against the shared email template checks. Bundle size compares built ESM entry files after `pnpm build`; gzip uses Node's `zlib.gzipSync`. -| Package entry | Raw size | Gzip size | Comparison | +| Package entry | v1 Raw (Gzip) | v2 Raw (Gzip) | Comparison | | --- | ---: | ---: | --- | -| `@akin01/solid-email/dist/index.mjs` | 199.0 KiB | 42.7 KiB | Server/root components and render utility re-exports | -| `@akin01/solid-email/dist/client/index.mjs` | 105.9 KiB | 19.5 KiB | Browser-condition DOM preview build | -| `@solid-email/render/dist/node/index.mjs` | **26.3 KiB** | **6.2 KiB** | Renderer entry | -| Solid Email server entries | 225.3 KiB | 48.9 KiB | **6.4x smaller raw / 7.1x smaller gzip than React Email** | -| Solid Email all ESM condition entries | 331.2 KiB | 68.4 KiB | 4.4x smaller raw / 5.1x smaller gzip than React Email | -| `react-email/dist/index.mjs` | 1,448.0 KiB | 348.6 KiB | React Email baseline | +| `@solid-email/render/dist/node/index.mjs` | 26.3 KiB (6.2 KiB) | **26.7 KiB (6.2 KiB)** | Dedicated node/server renderer entry | +| `@akin01/solid-email/dist/client/index.mjs` | 105.9 KiB (19.5 KiB) | 106.3 KiB (19.5 KiB) | Browser-condition DOM preview build | +| `@akin01/solid-email/dist/index.mjs` | 199.0 KiB (42.7 KiB) | 203.0 KiB (43.3 KiB) | Server/root components and render utility re-exports | +| `@solid-email/render/dist/browser/index.mjs` | — | 197.4 KiB (45.2 KiB) | Standalone browser renderer entry (new in v2) | +| Solid Email server entries combined | 225.3 KiB (48.9 KiB) | 229.7 KiB (49.5 KiB) | **4.8x smaller raw / 6.8x smaller gzip than React Email** | +| `react-email` distribution total | 1,448.0 KiB (348.6 KiB) | 1,110.0 KiB (334.7 KiB) | React Email baseline | ## Install ```sh -pnpm add @akin01/solid-email @solid-email/render solid-js +pnpm add @akin01/solid-email @solid-email/render solid-js @solidjs/web ``` ## Getting started @@ -387,5 +387,5 @@ pnpm lint ---
- Build with ❤️, MIT License. + Build with ❤️ MIT License.
diff --git a/benchmarks/cross-library/package.json b/benchmarks/cross-library/package.json index 1469aba..26d2fd8 100644 --- a/benchmarks/cross-library/package.json +++ b/benchmarks/cross-library/package.json @@ -2,6 +2,7 @@ "name": "@benchmarks/cross-library", "private": true, "version": "0.0.0", + "type": "module", "description": "Cross-library email benchmark: solid-email vs jsx-email vs react-email vs mjml-react", "scripts": { "benchmark": "bash run-benchmark.sh", @@ -15,24 +16,25 @@ }, "dependencies": { "@akin01/solid-email": "workspace:*", - "@faire/mjml-react": "^4.0.1", - "jsx-email": "^3.2.1", - "mjml": "^5.3.0", - "react": "19.2.7", - "react-dom": "19.2.7", - "react-email": "^6.6.3", + "@faire/mjml-react": "catalog:", + "jsx-email": "catalog:", + "mjml": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-email": "catalog:", + "@solidjs/web": "catalog:", "solid-js": "catalog:" }, "devDependencies": { "@types/node": "catalog:", "@types/react": "catalog:", - "@types/react-dom": "^19.2.3", + "@types/react-dom": "catalog:", "tsconfig": "workspace:*", - "tsx": "^4.22.4", + "tsx": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-node": "^6.0.0", - "vite-plugin-solid": "catalog:", + "@solidjs/vite-plugin": "catalog:", "vitest": "catalog:" } } diff --git a/benchmarks/cross-library/solid/compiled-template.tsx b/benchmarks/cross-library/solid/compiled-template.tsx index 9cbe0a0..4fe019c 100644 --- a/benchmarks/cross-library/solid/compiled-template.tsx +++ b/benchmarks/cross-library/solid/compiled-template.tsx @@ -1,5 +1,5 @@ /** @jsxRuntime automatic */ -/** @jsxImportSource solid-js */ +/** @jsxImportSource @solidjs/web */ import { Body, diff --git a/benchmarks/cross-library/solid/template.tsx b/benchmarks/cross-library/solid/template.tsx index cfcfbbc..a0d4435 100644 --- a/benchmarks/cross-library/solid/template.tsx +++ b/benchmarks/cross-library/solid/template.tsx @@ -1,5 +1,5 @@ /** @jsxRuntime automatic */ -/** @jsxImportSource solid-js */ +/** @jsxImportSource @solidjs/web */ import { Body, Button, @@ -16,7 +16,7 @@ import { Section, Text, } from '@akin01/solid-email'; -import type { JSX } from 'solid-js'; +import type { JSX } from '@solidjs/web'; import { For } from 'solid-js'; import { features, diff --git a/benchmarks/cross-library/test-templates.tsx b/benchmarks/cross-library/test-templates.tsx index e21e598..a5b4a70 100644 --- a/benchmarks/cross-library/test-templates.tsx +++ b/benchmarks/cross-library/test-templates.tsx @@ -1,5 +1,5 @@ /** @jsxRuntime automatic */ -/** @jsxImportSource solid-js */ +/** @jsxImportSource @solidjs/web */ import { Body, diff --git a/benchmarks/cross-library/tsconfig.json b/benchmarks/cross-library/tsconfig.json index 5238a1c..26d9359 100644 --- a/benchmarks/cross-library/tsconfig.json +++ b/benchmarks/cross-library/tsconfig.json @@ -12,7 +12,6 @@ "**/*.mts", "**/*.tsx", "**/*.d.ts", - "../../packages/render/src/shared/solid-js-web-server.d.ts", "../../packages/solid-email/src/render-shim.d.ts" ], "exclude": ["dist", "node_modules", "results"] diff --git a/benchmarks/cross-library/vite.config.mts b/benchmarks/cross-library/vite.config.mts index dde12b0..e7d04ff 100644 --- a/benchmarks/cross-library/vite.config.mts +++ b/benchmarks/cross-library/vite.config.mts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; +import solid from '@solidjs/vite-plugin'; import { defineConfig } from 'vite'; -import solid from 'vite-plugin-solid'; export default defineConfig({ plugins: [ diff --git a/benchmarks/html-to-text/package.json b/benchmarks/html-to-text/package.json index 71dc64c..92bcfe3 100644 --- a/benchmarks/html-to-text/package.json +++ b/benchmarks/html-to-text/package.json @@ -17,10 +17,10 @@ "@akin01/solid-email": "workspace:*", "@solid-email/html-to-text": "workspace:*", "@solid-email/render": "workspace:*", - "html-to-text": "9.0.5", - "react": "19.2.7", - "react-dom": "19.2.7", - "react-email": "6.6.3" + "html-to-text": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-email": "catalog:" }, "devDependencies": { "@types/html-to-text": "9.0.4", @@ -28,7 +28,7 @@ "@types/react": "catalog:", "tsconfig": "workspace:*", "typescript": "catalog:", - "vite-plugin-solid": "catalog:", + "@solidjs/vite-plugin": "catalog:", "vitest": "catalog:" } } diff --git a/benchmarks/html-to-text/solid-compiled-template.tsx b/benchmarks/html-to-text/solid-compiled-template.tsx index 0189787..4989c19 100644 --- a/benchmarks/html-to-text/solid-compiled-template.tsx +++ b/benchmarks/html-to-text/solid-compiled-template.tsx @@ -13,7 +13,8 @@ import { slot, Text, } from '@akin01/solid-email'; -import { For, type JSX } from 'solid-js'; +import type { JSX } from '@solidjs/web'; +import { For } from 'solid-js'; import { standardChecklist, standardFooterLinks, diff --git a/benchmarks/html-to-text/tsconfig.json b/benchmarks/html-to-text/tsconfig.json index bbf6c56..2087fdc 100644 --- a/benchmarks/html-to-text/tsconfig.json +++ b/benchmarks/html-to-text/tsconfig.json @@ -14,8 +14,7 @@ "react-text-template.tsx", "text-render-fixture-data.ts", "vitest.config.ts", - "../utils.ts", - "../../packages/render/src/shared/solid-js-web-server.d.ts" + "../utils.ts" ], "exclude": ["dist", "node_modules"] } diff --git a/benchmarks/html-to-text/vitest.config.ts b/benchmarks/html-to-text/vitest.config.ts index 0f523d5..8987046 100644 --- a/benchmarks/html-to-text/vitest.config.ts +++ b/benchmarks/html-to-text/vitest.config.ts @@ -1,7 +1,11 @@ -import solid from 'vite-plugin-solid'; +import solid from '@solidjs/vite-plugin'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + oxc: false, + esbuild: { + jsx: 'automatic', + }, plugins: [ solid({ exclude: ['**/react-text-template.tsx'], @@ -26,6 +30,7 @@ export default defineConfig({ }, }, test: { + environment: 'node', benchmark: { include: ['**/*.bench.ts'], }, diff --git a/benchmarks/rendering/package.json b/benchmarks/rendering/package.json index 61d4085..420b481 100644 --- a/benchmarks/rendering/package.json +++ b/benchmarks/rendering/package.json @@ -15,9 +15,10 @@ }, "dependencies": { "@akin01/solid-email": "workspace:*", - "react": "19.2.7", - "react-dom": "19.2.7", - "react-email": "6.6.3", + "react": "catalog:", + "react-dom": "catalog:", + "react-email": "catalog:", + "@solidjs/web": "catalog:", "solid-js": "catalog:" }, "devDependencies": { @@ -25,7 +26,7 @@ "@types/react": "catalog:", "tsconfig": "workspace:*", "typescript": "catalog:", - "vite-plugin-solid": "catalog:", + "@solidjs/vite-plugin": "catalog:", "vitest": "catalog:" } } diff --git a/benchmarks/rendering/solid-email-template.tsx b/benchmarks/rendering/solid-email-template.tsx index 5b13f92..8433d00 100644 --- a/benchmarks/rendering/solid-email-template.tsx +++ b/benchmarks/rendering/solid-email-template.tsx @@ -14,7 +14,8 @@ import { Section, Text, } from '@akin01/solid-email'; -import { For, type JSX } from 'solid-js'; +import type { JSX } from '@solidjs/web'; +import { For } from 'solid-js'; import { features, footerLinks, diff --git a/benchmarks/rendering/tsconfig.json b/benchmarks/rendering/tsconfig.json index f953170..d1a1885 100644 --- a/benchmarks/rendering/tsconfig.json +++ b/benchmarks/rendering/tsconfig.json @@ -15,7 +15,7 @@ "solid-email-template.tsx", "../tailwind/solid-tailwind-template.tsx", "../utils.ts", - "../../packages/render/src/shared/solid-js-web-server.d.ts" + "../../packages/solid-email/src/render-shim.d.ts" ], "exclude": ["dist", "node_modules"] } diff --git a/benchmarks/rendering/vitest.config.ts b/benchmarks/rendering/vitest.config.ts index 2c0ebb0..359ab49 100644 --- a/benchmarks/rendering/vitest.config.ts +++ b/benchmarks/rendering/vitest.config.ts @@ -1,7 +1,11 @@ -import solid from 'vite-plugin-solid'; +import solid from '@solidjs/vite-plugin'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + oxc: false, + esbuild: { + jsx: 'automatic', + }, plugins: [ solid({ exclude: [ @@ -25,6 +29,7 @@ export default defineConfig({ }, }, test: { + environment: 'node', benchmark: { include: ['**/*.bench.ts'], }, diff --git a/benchmarks/tailwind/package.json b/benchmarks/tailwind/package.json index 2d9b6fa..91df7b6 100644 --- a/benchmarks/tailwind/package.json +++ b/benchmarks/tailwind/package.json @@ -15,13 +15,14 @@ }, "dependencies": { "@akin01/solid-email": "workspace:*", + "@solidjs/web": "catalog:", "solid-js": "catalog:" }, "devDependencies": { "@types/node": "catalog:", "tsconfig": "workspace:*", "typescript": "catalog:", - "vite-plugin-solid": "catalog:", + "@solidjs/vite-plugin": "catalog:", "vitest": "catalog:" } } diff --git a/benchmarks/tailwind/solid-tailwind-template.tsx b/benchmarks/tailwind/solid-tailwind-template.tsx index d77b60f..d10f66e 100644 --- a/benchmarks/tailwind/solid-tailwind-template.tsx +++ b/benchmarks/tailwind/solid-tailwind-template.tsx @@ -15,7 +15,8 @@ import { Tailwind, Text, } from '@akin01/solid-email'; -import { For, type JSX } from 'solid-js'; +import type { JSX } from '@solidjs/web'; +import { For } from 'solid-js'; import { features, footerLinks, diff --git a/benchmarks/tailwind/tsconfig.json b/benchmarks/tailwind/tsconfig.json index acffbc0..ff39bdd 100644 --- a/benchmarks/tailwind/tsconfig.json +++ b/benchmarks/tailwind/tsconfig.json @@ -13,7 +13,7 @@ "../utils.ts", "../rendering/fixture-data.ts", "../rendering/solid-email-template.tsx", - "../../packages/render/src/shared/solid-js-web-server.d.ts" + "../../packages/solid-email/src/render-shim.d.ts" ], "exclude": ["dist", "node_modules"] } diff --git a/benchmarks/tailwind/vitest.config.ts b/benchmarks/tailwind/vitest.config.ts index bc07b15..5415a15 100644 --- a/benchmarks/tailwind/vitest.config.ts +++ b/benchmarks/tailwind/vitest.config.ts @@ -1,4 +1,4 @@ -import solid from 'vite-plugin-solid'; +import solid from '@solidjs/vite-plugin'; import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -16,6 +16,7 @@ export default defineConfig({ }, }, test: { + environment: 'node', benchmark: { include: ['**/*.bench.ts'], }, diff --git a/benchmarks/ui/package.json b/benchmarks/ui/package.json index 7b505a5..6aed45c 100644 --- a/benchmarks/ui/package.json +++ b/benchmarks/ui/package.json @@ -16,13 +16,14 @@ }, "dependencies": { "@akin01/solid-email": "workspace:*", + "@solidjs/web": "catalog:", "solid-js": "catalog:" }, "devDependencies": { "@types/node": "catalog:", "tsconfig": "workspace:*", "typescript": "catalog:", - "vite-plugin-solid": "catalog:", + "@solidjs/vite-plugin": "catalog:", "vitest": "catalog:" } } diff --git a/benchmarks/ui/tsconfig.json b/benchmarks/ui/tsconfig.json index 2a4f4c2..bec8258 100644 --- a/benchmarks/ui/tsconfig.json +++ b/benchmarks/ui/tsconfig.json @@ -14,7 +14,7 @@ "../utils.ts", "../rendering/fixture-data.ts", "../rendering/solid-email-template.tsx", - "../../packages/render/src/shared/solid-js-web-server.d.ts" + "../../packages/solid-email/src/render-shim.d.ts" ], "exclude": ["dist", "node_modules"] } diff --git a/benchmarks/ui/vitest.config.ts b/benchmarks/ui/vitest.config.ts index 86e414c..fc1fec0 100644 --- a/benchmarks/ui/vitest.config.ts +++ b/benchmarks/ui/vitest.config.ts @@ -1,4 +1,4 @@ -import solid from 'vite-plugin-solid'; +import solid from '@solidjs/vite-plugin'; import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -16,6 +16,7 @@ export default defineConfig({ }, }, test: { + environment: 'node', benchmark: { include: ['**/*.bench.tsx'], }, diff --git a/e2e/cloudflare-tanstack-start/package.json b/e2e/cloudflare-tanstack-start/package.json index 4894da2..a70759b 100644 --- a/e2e/cloudflare-tanstack-start/package.json +++ b/e2e/cloudflare-tanstack-start/package.json @@ -8,19 +8,20 @@ "preview": "vite preview --host 127.0.0.1" }, "dependencies": { - "@solid-email/html-to-text": "file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz", - "@solid-email/render": "file:../.tmp/packs/solid-email-render-0.1.5.tgz", - "@akin01/solid-email": "file:../.tmp/packs/akin01-solid-email-0.1.5.tgz", - "solid-js": "1.9.13" + "@solid-email/html-to-text": "workspace:*", + "@solid-email/render": "workspace:*", + "@akin01/solid-email": "workspace:*", + "@solidjs/web": "catalog:", + "solid-js": "catalog:" }, "devDependencies": { - "@cloudflare/vite-plugin": "1.43.0", - "@tanstack/solid-router": "1.170.16", - "@tanstack/solid-start": "1.168.26", - "@types/node": "25.0.2", - "typescript": "6.0.3", - "vite": "7.3.1", - "vite-plugin-solid": "2.11.12", - "wrangler": "4.107.0" + "@cloudflare/vite-plugin": "catalog:", + "@tanstack/solid-router": "catalog:", + "@tanstack/solid-start": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "@solidjs/vite-plugin": "catalog:", + "wrangler": "catalog:" } } diff --git a/e2e/cloudflare-tanstack-start/pnpm-workspace.yaml b/e2e/cloudflare-tanstack-start/pnpm-workspace.yaml deleted file mode 100644 index 1845244..0000000 --- a/e2e/cloudflare-tanstack-start/pnpm-workspace.yaml +++ /dev/null @@ -1,9 +0,0 @@ -overrides: - '@akin01/solid-email': file:../.tmp/packs/akin01-solid-email-0.1.5.tgz - '@solid-email/html-to-text': file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz - '@solid-email/render': file:../.tmp/packs/solid-email-render-0.1.5.tgz - -allowBuilds: - esbuild: true - sharp: true - workerd: true diff --git a/e2e/cloudflare-tanstack-start/src/email.server.tsx b/e2e/cloudflare-tanstack-start/src/email.server.tsx index 8c06a07..56f495d 100644 --- a/e2e/cloudflare-tanstack-start/src/email.server.tsx +++ b/e2e/cloudflare-tanstack-start/src/email.server.tsx @@ -21,7 +21,7 @@ import { xonokai, } from '@akin01/solid-email'; import { compile, render, Slot, slot } from '@solid-email/render'; -import type { JSX } from 'solid-js'; +import type { JSX } from '@solidjs/web'; type WorkerEmailSlots = { ctaUrl: string; diff --git a/e2e/cloudflare-tanstack-start/src/routes/__root.tsx b/e2e/cloudflare-tanstack-start/src/routes/__root.tsx index ac0bb2c..890c320 100644 --- a/e2e/cloudflare-tanstack-start/src/routes/__root.tsx +++ b/e2e/cloudflare-tanstack-start/src/routes/__root.tsx @@ -1,11 +1,12 @@ +import type { JSX } from '@solidjs/web'; +import { HydrationScript } from '@solidjs/web'; import { createRootRoute, HeadContent, Outlet, Scripts, } from '@tanstack/solid-router'; -import { type JSX, Suspense } from 'solid-js'; -import { HydrationScript } from 'solid-js/web'; +import { Loading } from 'solid-js'; export const Route = createRootRoute({ component: RootComponent, @@ -27,7 +28,7 @@ function RootDocument(props: Readonly<{ children: JSX.Element }>): JSX.Element { - {props.children} + {props.children} diff --git a/e2e/cloudflare-tanstack-start/vite.config.ts b/e2e/cloudflare-tanstack-start/vite.config.ts index 80413f9..4d0272e 100644 --- a/e2e/cloudflare-tanstack-start/vite.config.ts +++ b/e2e/cloudflare-tanstack-start/vite.config.ts @@ -1,10 +1,29 @@ import { cloudflare } from '@cloudflare/vite-plugin'; +import solidPlugin from '@solidjs/vite-plugin'; import { tanstackStart } from '@tanstack/solid-start/plugin/vite'; -import { defineConfig } from 'vite'; -import solidPlugin from 'vite-plugin-solid'; +import { defineConfig, type Plugin } from 'vite'; + +function patchTanstackStartPlugin(): Plugin { + return { + name: 'patch-tanstack-start', + enforce: 'pre', + transform(code, id) { + if ( + id.includes('server-functions-handler') && + code.includes('parseServerFunctionUrl') + ) { + return code.replace( + /parseServerFunctionUrl/g, + 'parseServerFunctionActionUrl', + ); + } + }, + }; +} export default defineConfig({ plugins: [ + patchTanstackStartPlugin(), cloudflare({ viteEnvironment: { name: 'ssr' } }), tanstackStart(), solidPlugin({ ssr: true }), diff --git a/e2e/integrations.e2e.spec.ts b/e2e/integrations.e2e.spec.ts index 9370b20..2fdbe29 100644 --- a/e2e/integrations.e2e.spec.ts +++ b/e2e/integrations.e2e.spec.ts @@ -1,6 +1,6 @@ import type { ChildProcess, ExecFileException } from 'node:child_process'; import { execFile, spawn } from 'node:child_process'; -import { mkdir, readFile, rm } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { createServer } from 'node:net'; import path from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; @@ -10,7 +10,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const execFileAsync = promisify(execFile); const root = path.resolve(new URL('..', import.meta.url).pathname); const e2eRoot = path.join(root, 'e2e'); -const packDir = path.join(e2eRoot, '.tmp', 'packs'); type RenderExportCase = { conditions: readonly string[]; @@ -126,7 +125,7 @@ function finishSmoke(resolved) { const renderImportProbe = `${renderExportSmoke} const resolved = import.meta.resolve('@solid-email/render'); const mod = await import('@solid-email/render'); -const { ssr } = await import('solid-js/web/dist/server.js'); +const { ssr } = mod; await smokeRenderExport(mod, ssr); finishSmoke(resolved);`; @@ -134,7 +133,7 @@ const renderRequireProbe = `${renderExportSmoke} (async () => { const resolved = require.resolve('@solid-email/render'); const mod = require('@solid-email/render'); - const { ssr } = require('solid-js/web/dist/server.cjs'); + const { ssr } = mod; await smokeRenderExport(mod, ssr); finishSmoke(resolved); })().catch((error) => { @@ -205,7 +204,7 @@ for (const name of ['Container', 'Heading', 'Text', 'Preview']) { } } -const { render: mount } = await import('solid-js/web'); +const { render: mount } = await import('@solidjs/web'); const root = document.getElementById('root'); const dispose = mount( () => @@ -296,10 +295,6 @@ type PreviewServer = { async function cleanFixture(fixture: FixtureName): Promise { const fixtureRoot = path.join(e2eRoot, fixture); - await rm(path.join(fixtureRoot, 'node_modules'), { - recursive: true, - force: true, - }); await rm(path.join(fixtureRoot, 'dist'), { recursive: true, force: true }); await rm(path.join(fixtureRoot, '.types'), { recursive: true, force: true }); await rm(path.join(fixtureRoot, '.render'), { recursive: true, force: true }); @@ -335,41 +330,16 @@ async function runPnpm(args: string[], cwd = root): Promise { } } -async function preparePackedPackages(): Promise { - await rm(path.join(e2eRoot, '.tmp'), { recursive: true, force: true }); - await mkdir(packDir, { recursive: true }); - +async function prepareWorkspacePackages(): Promise { await runPnpm(['--filter', '@solid-email/html-to-text', 'run', 'build']); await runPnpm(['--filter', '@solid-email/render', 'run', 'build']); await runPnpm(['--filter', '@akin01/solid-email', 'run', 'build']); - await runPnpm([ - '--filter', - '@solid-email/html-to-text', - 'pack', - '--pack-destination', - packDir, - ]); - await runPnpm([ - '--filter', - '@solid-email/render', - 'pack', - '--pack-destination', - packDir, - ]); - await runPnpm([ - '--filter', - '@akin01/solid-email', - 'pack', - '--pack-destination', - packDir, - ]); } async function installAndBuildFixture(fixture: FixtureName) { const fixtureRoot = path.join(e2eRoot, fixture); await cleanFixture(fixture); - await runPnpm(['install', '--no-lockfile'], fixtureRoot); await runPnpm(['run', 'build'], fixtureRoot); await runPnpm(['exec', 'tsc', '--noEmit'], fixtureRoot); await runPnpm( @@ -526,7 +496,7 @@ function expectAllComponentsHtml(html: string, label: string): void { } beforeAll(async () => { - await preparePackedPackages(); + await prepareWorkspacePackages(); }); afterAll(async () => { diff --git a/e2e/package.json b/e2e/package.json index cd6d46b..1784836 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,6 +7,6 @@ "@tanstack/solid-router": "catalog:", "@tanstack/solid-start": "catalog:", "vite": "catalog:", - "vite-plugin-solid": "catalog:" + "@solidjs/vite-plugin": "catalog:" } } diff --git a/e2e/tanstack-start/package.json b/e2e/tanstack-start/package.json index 110eb01..a46f8e2 100644 --- a/e2e/tanstack-start/package.json +++ b/e2e/tanstack-start/package.json @@ -8,17 +8,18 @@ "render:email": "vite build --config vite.render.config.ts && node .render/render-email.mjs" }, "dependencies": { - "@solid-email/html-to-text": "file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz", - "@solid-email/render": "file:../.tmp/packs/solid-email-render-0.1.5.tgz", - "@akin01/solid-email": "file:../.tmp/packs/akin01-solid-email-0.1.5.tgz", - "solid-js": "1.9.13" + "@solid-email/html-to-text": "workspace:*", + "@solid-email/render": "workspace:*", + "@akin01/solid-email": "workspace:*", + "@solidjs/web": "catalog:", + "solid-js": "catalog:" }, "devDependencies": { - "@tanstack/solid-router": "1.170.16", - "@tanstack/solid-start": "1.168.26", - "@types/node": "25.0.2", - "typescript": "6.0.3", - "vite": "7.3.1", - "vite-plugin-solid": "2.11.12" + "@solidjs/vite-plugin": "catalog:", + "@tanstack/solid-router": "catalog:", + "@tanstack/solid-start": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/e2e/tanstack-start/pnpm-workspace.yaml b/e2e/tanstack-start/pnpm-workspace.yaml deleted file mode 100644 index 922fe16..0000000 --- a/e2e/tanstack-start/pnpm-workspace.yaml +++ /dev/null @@ -1,7 +0,0 @@ -overrides: - '@akin01/solid-email': file:../.tmp/packs/akin01-solid-email-0.1.5.tgz - '@solid-email/html-to-text': file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz - '@solid-email/render': file:../.tmp/packs/solid-email-render-0.1.5.tgz - -allowBuilds: - esbuild: true diff --git a/e2e/tanstack-start/src/routes/__root.tsx b/e2e/tanstack-start/src/routes/__root.tsx index ac0bb2c..890c320 100644 --- a/e2e/tanstack-start/src/routes/__root.tsx +++ b/e2e/tanstack-start/src/routes/__root.tsx @@ -1,11 +1,12 @@ +import type { JSX } from '@solidjs/web'; +import { HydrationScript } from '@solidjs/web'; import { createRootRoute, HeadContent, Outlet, Scripts, } from '@tanstack/solid-router'; -import { type JSX, Suspense } from 'solid-js'; -import { HydrationScript } from 'solid-js/web'; +import { Loading } from 'solid-js'; export const Route = createRootRoute({ component: RootComponent, @@ -27,7 +28,7 @@ function RootDocument(props: Readonly<{ children: JSX.Element }>): JSX.Element { - {props.children} + {props.children} diff --git a/e2e/tanstack-start/vite.config.ts b/e2e/tanstack-start/vite.config.ts index 992b954..20f3ba8 100644 --- a/e2e/tanstack-start/vite.config.ts +++ b/e2e/tanstack-start/vite.config.ts @@ -1,6 +1,6 @@ +import solidPlugin from '@solidjs/vite-plugin'; import { tanstackStart } from '@tanstack/solid-start/plugin/vite'; import { defineConfig } from 'vite'; -import solidPlugin from 'vite-plugin-solid'; export default defineConfig({ plugins: [tanstackStart(), solidPlugin({ ssr: true })], diff --git a/e2e/tanstack-start/vite.render.config.ts b/e2e/tanstack-start/vite.render.config.ts index 6d9015e..20e11a4 100644 --- a/e2e/tanstack-start/vite.render.config.ts +++ b/e2e/tanstack-start/vite.render.config.ts @@ -1,5 +1,5 @@ +import solidPlugin from '@solidjs/vite-plugin'; import { defineConfig } from 'vite'; -import solidPlugin from 'vite-plugin-solid'; export default defineConfig({ plugins: [solidPlugin({ ssr: true })], diff --git a/e2e/vite/package.json b/e2e/vite/package.json index c3d7746..8557d9f 100644 --- a/e2e/vite/package.json +++ b/e2e/vite/package.json @@ -7,15 +7,17 @@ "build": "vite build --ssr src/entry-server.tsx" }, "dependencies": { - "@solid-email/html-to-text": "file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz", - "@solid-email/render": "file:../.tmp/packs/solid-email-render-0.1.5.tgz", - "@akin01/solid-email": "file:../.tmp/packs/akin01-solid-email-0.1.5.tgz", - "solid-js": "1.9.13" + "@solid-email/html-to-text": "workspace:*", + "@solid-email/render": "workspace:*", + "@akin01/solid-email": "workspace:*", + "@solidjs/signals": "catalog:", + "@solidjs/web": "catalog:", + "solid-js": "catalog:" }, "devDependencies": { - "@types/node": "25.0.2", - "typescript": "6.0.3", - "vite": "7.3.1", - "vite-plugin-solid": "2.11.12" + "@solidjs/vite-plugin": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/e2e/vite/pnpm-workspace.yaml b/e2e/vite/pnpm-workspace.yaml deleted file mode 100644 index 922fe16..0000000 --- a/e2e/vite/pnpm-workspace.yaml +++ /dev/null @@ -1,7 +0,0 @@ -overrides: - '@akin01/solid-email': file:../.tmp/packs/akin01-solid-email-0.1.5.tgz - '@solid-email/html-to-text': file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz - '@solid-email/render': file:../.tmp/packs/solid-email-render-0.1.5.tgz - -allowBuilds: - esbuild: true diff --git a/e2e/vite/vite.config.ts b/e2e/vite/vite.config.ts index 122b3ca..c4de7e9 100644 --- a/e2e/vite/vite.config.ts +++ b/e2e/vite/vite.config.ts @@ -1,5 +1,5 @@ +import solidPlugin from '@solidjs/vite-plugin'; import { defineConfig } from 'vite'; -import solidPlugin from 'vite-plugin-solid'; export default defineConfig({ plugins: [solidPlugin({ ssr: true })], diff --git a/package.json b/package.json index 9682a43..b893701 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,21 @@ { "name": "solid-email-monorepo", - "version": "0.1.5", + "version": "2.0.0-beta", "private": true, "repository": { "type": "git", "url": "https://github.com/Akin01/solid-email" }, "type": "module", - "packageManager": "pnpm@11.8.0", + "packageManager": "pnpm@12.1.0", "scripts": { "build": "pnpm --filter @solid-email/html-to-text --filter @solid-email/render --filter @akin01/solid-email run build", "test": "pnpm --filter @solid-email/html-to-text --filter @solid-email/render --filter @akin01/solid-email run test", "test:e2e": "vitest run --config e2e/vitest.config.ts", "typecheck": "pnpm --filter @solid-email/html-to-text --filter @solid-email/render run build && pnpm --filter @solid-email/html-to-text --filter @solid-email/render --filter @akin01/solid-email run typecheck && pnpm typecheck:e2e", "typecheck:e2e": "tsc -p e2e/tsconfig.json --noEmit", + "format": "biome format --write .", + "format:check": "biome format .", "lint": "biome check .", "lint:fix": "biome check --write .", "benchmark": "pnpm -r --filter './benchmarks/*' run benchmark", @@ -29,6 +31,7 @@ "@akin01/solid-email": "workspace:*", "@biomejs/biome": "catalog:", "@solid-email/render": "workspace:*", + "@solidjs/web": "catalog:", "@types/node": "catalog:", "jsdom": "catalog:", "solid-js": "catalog:", diff --git a/packages/html-to-text/package.json b/packages/html-to-text/package.json index e571500..d1855e4 100644 --- a/packages/html-to-text/package.json +++ b/packages/html-to-text/package.json @@ -1,6 +1,6 @@ { "name": "@solid-email/html-to-text", - "version": "0.1.1", + "version": "2.0.0-beta", "description": "HTML to plain text converter used by Solid Email rendering", "type": "module", "sideEffects": false, @@ -30,6 +30,7 @@ }, "scripts": { "build": "tsdown", + "format": "biome format --write .", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/packages/html-to-text/src/base/block-text-builder.ts b/packages/html-to-text/src/base/block-text-builder.ts index 66de37a..c5600af 100644 --- a/packages/html-to-text/src/base/block-text-builder.ts +++ b/packages/html-to-text/src/base/block-text-builder.ts @@ -93,11 +93,13 @@ class BlockTextBuilder { options: BuilderOptions, picker: TagDefinitionPicker, metadata: MetaData | undefined = undefined, + whitespaceProcessor?: WhitespaceProcessor, ) { this.options = options; this.picker = picker; this.metadata = metadata; - this.whitespaceProcessor = new WhitespaceProcessor(options); + this.whitespaceProcessor = + whitespaceProcessor ?? new WhitespaceProcessor(options); this._stackItem = new BlockStackItem(options); this._wordTransformer = undefined; } @@ -193,10 +195,8 @@ class BlockTextBuilder { /** * Add a node inline into the currently built block. */ - addInline( - str: string, - { noWordTransform = false }: InlineOptions = {}, - ): void { + addInline(str: string, options?: InlineOptions): void { + const noWordTransform = options?.noWordTransform ?? false; if ( !( this._stackItem instanceof BlockStackItem || @@ -285,11 +285,10 @@ class BlockTextBuilder { /** * Start building a new block. */ - openBlock({ - leadingLineBreaks = 1, - reservedLineLength = 0, - isPre = false, - }: OpenBlockOptions = {}): void { + openBlock(options?: OpenBlockOptions): void { + const leadingLineBreaks = options?.leadingLineBreaks ?? 1; + const reservedLineLength = options?.reservedLineLength ?? 0; + const isPre = options?.isPre ?? false; const maxLineLength = Math.max( 20, (this._stackItem as TextStackItem).inlineTextBuilder.maxLineLength - @@ -309,10 +308,9 @@ class BlockTextBuilder { /** * Finalize currently built block, add it's content to the parent block. */ - closeBlock({ - trailingLineBreaks = 1, - blockTransform = undefined, - }: CloseBlockOptions = {}): void { + closeBlock(options?: CloseBlockOptions): void { + const trailingLineBreaks = options?.trailingLineBreaks ?? 1; + const blockTransform = options?.blockTransform; const block = this._popStackItem(); const blockText = blockTransform ? blockTransform(getText(block)) @@ -328,12 +326,11 @@ class BlockTextBuilder { /** * Start building a new list. */ - openList({ - maxPrefixLength = 0, - prefixAlign = 'left', - interRowLineBreaks = 1, - leadingLineBreaks = 2, - }: OpenListOptions = {}): void { + openList(options?: OpenListOptions): void { + const maxPrefixLength = options?.maxPrefixLength ?? 0; + const prefixAlign = options?.prefixAlign ?? 'left'; + const interRowLineBreaks = options?.interRowLineBreaks ?? 1; + const leadingLineBreaks = options?.leadingLineBreaks ?? 2; this._stackItem = new ListStackItem(this.options, this._stackItem, { interRowLineBreaks: interRowLineBreaks, leadingLineBreaks: leadingLineBreaks, @@ -347,7 +344,8 @@ class BlockTextBuilder { /** * Start building a new list item. */ - openListItem({ prefix = '' }: OpenListItemOptions = {}): void { + openListItem(options?: OpenListItemOptions): void { + const prefix = options?.prefix ?? ''; if (!(this._stackItem instanceof ListStackItem)) { throw new Error( "Can't add a list item to something that is not a list! Check the formatter.", @@ -431,9 +429,8 @@ class BlockTextBuilder { /** * Start building a table cell. */ - openTableCell({ - maxColumnWidth = undefined, - }: OpenTableCellOptions = {}): void { + openTableCell(options?: OpenTableCellOptions): void { + const maxColumnWidth = options?.maxColumnWidth; if (!(this._stackItem instanceof TableRowStackItem)) { throw new Error( "Can't add a table cell to something that is not a table row! Check the formatter.", @@ -449,10 +446,9 @@ class BlockTextBuilder { /** * Finalize currently built table cell and add it to parent table row's cells. */ - closeTableCell({ - colspan = 1, - rowspan = 1, - }: CloseTableCellOptions = {}): void { + closeTableCell(options?: CloseTableCellOptions): void { + const colspan = options?.colspan ?? 1; + const rowspan = options?.rowspan ?? 1; const cell = this._popStackItem(); const text = trimCharacter(getText(cell), '\n'); if (!cell.next) { @@ -508,9 +504,11 @@ function getText(stackItem: StackItem): string { 'Only blocks, list items and table cells can be requested for text contents.', ); } - return stackItem.inlineTextBuilder.isEmpty() - ? stackItem.rawText - : stackItem.rawText + stackItem.inlineTextBuilder.toString(); + if (stackItem.inlineTextBuilder.isEmpty()) { + return stackItem.rawText; + } + const inlineStr = stackItem.inlineTextBuilder.toString(); + return stackItem.rawText ? stackItem.rawText + inlineStr : inlineStr; } function addText( @@ -534,7 +532,13 @@ function addText( const lineBreaks = Math.max(stackItem.stashedLineBreaks, leadingLineBreaks); stackItem.inlineTextBuilder.clear(); if (parentText) { - stackItem.rawText = parentText + '\n'.repeat(lineBreaks) + text; + const breakStr = + lineBreaks === 1 + ? '\n' + : lineBreaks === 2 + ? '\n\n' + : '\n'.repeat(lineBreaks); + stackItem.rawText = parentText + breakStr + text; } else { stackItem.rawText = text; stackItem.leadingLineBreaks = lineBreaks; diff --git a/packages/html-to-text/src/base/index.ts b/packages/html-to-text/src/base/index.ts index fd3a05e..48a6394 100644 --- a/packages/html-to-text/src/base/index.ts +++ b/packages/html-to-text/src/base/index.ts @@ -20,7 +20,50 @@ import type { } from '../types.js'; import { BlockTextBuilder } from './block-text-builder.js'; import { limitedDepthRecursive, unicodeEscape } from './util.js'; +import { WhitespaceProcessor } from './whitespace-processor.js'; +const EMPTY_OBJECT = Object.freeze({}); +const pickerCache = new Map(); +const pickerWeakCache = new WeakMap(); + +function getSelectorsCacheKey(selectors: SelectorDefinition[]): string { + try { + return JSON.stringify(selectors, (_key, val) => + typeof val === 'function' ? val.toString() : val, + ); + } catch { + return ''; + } +} + +function getOrBuildPicker(selectors: SelectorDefinition[]): SelectorPicker { + const cachedFromWeak = pickerWeakCache.get(selectors); + if (cachedFromWeak !== undefined) { + return cachedFromWeak; + } + const key = getSelectorsCacheKey(selectors); + if (key) { + const cachedFromMap = pickerCache.get(key); + if (cachedFromMap !== undefined) { + pickerWeakCache.set(selectors, cachedFromMap); + return cachedFromMap; + } + } + const picker = new DecisionTree(selectors.map((s) => [s.selector, s])).build( + hp2Builder, + ); + pickerWeakCache.set(selectors, picker); + if (key) { + if (pickerCache.size >= 128) { + const first = pickerCache.keys().next().value; + if (first !== undefined) { + pickerCache.delete(first); + } + } + pickerCache.set(key, picker); + } + return picker; +} type SelectorPicker = ReturnType>; type BaseSelectorPicker = ReturnType>; type SelectorPickerElement = Parameters[0]; @@ -48,9 +91,8 @@ function compile(options: HtmlToTextOptions = {}): CompiledFunction { selectorsWithoutFormat.map((s) => `\`${s.selector}\``).join(', '), ); } - const picker = new DecisionTree( - resolvedOptions.selectors.map((s) => [s.selector, s]), - ).build(hp2Builder); + const picker = getOrBuildPicker(resolvedOptions.selectors); + const whitespaceProcessor = new WhitespaceProcessor(resolvedOptions); if (typeof resolvedOptions.encodeCharacters !== 'function') { resolvedOptions.encodeCharacters = makeReplacerFromDict( @@ -87,6 +129,7 @@ function compile(options: HtmlToTextOptions = {}): CompiledFunction { picker, findBaseElements, limitedWalk, + whitespaceProcessor, ); } @@ -112,6 +155,7 @@ function process( picker: SelectorPicker, findBaseElements: BaseElementFinder, walk: Walk, + whitespaceProcessor: WhitespaceProcessor, ): string { const maxInputLength = options.limits.maxInputLength; if (maxInputLength && html && html.length > maxInputLength) { @@ -127,6 +171,7 @@ function process( options, picker as unknown as BlockTextBuilderPicker, metadata, + whitespaceProcessor, ); walk(bases, builder); return builder.toString(); @@ -189,14 +234,15 @@ function usesDefaultBodyBaseSelector( } function findFirstBody(nodes: DomNode[]): DomNode | undefined { - for (const elem of nodes) { - if (elem.type !== 'tag') { - continue; - } - if (elem.name === 'body') { + for (let i = 0; i < nodes.length; i++) { + const elem = nodes[i]; + if (elem && elem.type === 'tag' && elem.name === 'body') { return elem; } - if (elem.children) { + } + for (let i = 0; i < nodes.length; i++) { + const elem = nodes[i]; + if (elem && elem.type === 'tag' && elem.children) { const found = findFirstBody(elem.children); if (found) { return found; @@ -283,7 +329,10 @@ function recursiveWalk( for (const elem of dom) { switch (elem.type) { case 'text': { - builder.addInline(elem.data as string); + const text = elem.data as string; + if (text && text.length > 0) { + builder.addInline(text); + } break; } case 'tag': { @@ -297,7 +346,7 @@ function recursiveWalk( elem, walk as RecursiveCallback, builder, - tagDefinition.options || {}, + tagDefinition.options || EMPTY_OBJECT, ); break; } diff --git a/packages/html-to-text/src/base/inline-text-builder.ts b/packages/html-to-text/src/base/inline-text-builder.ts index a2686cc..ed8d11a 100644 --- a/packages/html-to-text/src/base/inline-text-builder.ts +++ b/packages/html-to-text/src/base/inline-text-builder.ts @@ -5,8 +5,14 @@ */ import type { BuilderOptions } from './stack-item.js'; -import { get } from './util.js'; +function fastJoin(arr: string[]): string { + const len = arr.length; + if (len === 0) return ''; + if (len === 1) return arr[0] ?? ''; + if (len === 2) return `${arr[0]} ${arr[1]}`; + return arr.join(' '); +} /** * Helps to build text from words. */ @@ -33,14 +39,8 @@ class InlineTextBuilder { this.nextLineWords = []; this.maxLineLength = maxLineLength || options.wordwrap || Number.MAX_VALUE; this.nextLineAvailableChars = this.maxLineLength; - this.wrapCharacters = - (get(options, ['longWordSplit', 'wrapCharacters']) as - | string[] - | undefined) || []; - this.forceWrapOnLimit = - (get(options, ['longWordSplit', 'forceWrapOnLimit']) as - | boolean - | undefined) || false; + this.wrapCharacters = options.longWordSplit?.wrapCharacters || []; + this.forceWrapOnLimit = options.longWordSplit?.forceWrapOnLimit ?? false; this.stashedSpace = false; this.wordBreakOpportunity = false; @@ -64,17 +64,16 @@ class InlineTextBuilder { // Does not fit - try to split the word // The word is moved to a new line - prefer to wrap between words. - const [first, ...rest] = this.splitLongWord(word) as [ - string, - ...string[], - ]; + const parts = this.splitLongWord(word); + const first = parts[0] ?? ''; if (!isLineStart) { this.startNewLine(); } this.nextLineWords.push(first); this.nextLineAvailableChars -= first.length; - for (const part of rest) { + for (let i = 1; i < parts.length; i++) { this.startNewLine(); + const part = parts[i] ?? ''; this.nextLineWords.push(part); this.nextLineAvailableChars -= part.length; } @@ -108,7 +107,7 @@ class InlineTextBuilder { this.wordBreakOpportunity = false; } else { const lastWord = this.popWord(); - this.pushWord(lastWord ? lastWord.concat(word) : word, noWrap); + this.pushWord(lastWord ? lastWord + word : word, noWrap); } } @@ -118,7 +117,9 @@ class InlineTextBuilder { startNewLine(n = 1): void { this.lines.push(this.nextLineWords); if (n > 1) { - this.lines.push(...Array.from({ length: n - 1 }, () => [])); + for (let i = 1; i < n; i++) { + this.lines.push([]); + } } this.nextLineWords = []; this.nextLineAvailableChars = this.maxLineLength; @@ -146,15 +147,17 @@ class InlineTextBuilder { if (index > 0) { text += '\n'; } - text += this.lines[index]?.join(' ') ?? ''; + const line = this.lines[index]; + if (line && line.length > 0) { + text += fastJoin(line); + } } if (this.lines.length > 0) { text += '\n'; } - text += this.nextLineWords.join(' '); + text += fastJoin(this.nextLineWords); return text; } - /** * Split a long word up to fit within the word wrap limit. * Use either a character to split looking back from the word wrap limit, diff --git a/packages/html-to-text/src/base/util.ts b/packages/html-to-text/src/base/util.ts index 6a316f8..3105cf0 100644 --- a/packages/html-to-text/src/base/util.ts +++ b/packages/html-to-text/src/base/util.ts @@ -38,12 +38,13 @@ function limitedDepthRecursive( * @returns { string } */ function trimCharacter(str: string, char: string): string { + const code = char.charCodeAt(0); let start = 0; let end = str.length; - while (start < end && str[start] === char) { + while (start < end && str.charCodeAt(start) === code) { ++start; } - while (end > start && str[end - 1] === char) { + while (end > start && str.charCodeAt(end - 1) === code) { --end; } return start > 0 || end < str.length ? str.substring(start, end) : str; @@ -58,8 +59,9 @@ function trimCharacter(str: string, char: string): string { * @returns { string } */ function trimCharacterEnd(str: string, char: string): string { + const code = char.charCodeAt(0); let end = str.length; - while (end > 0 && str[end - 1] === char) { + while (end > 0 && str.charCodeAt(end - 1) === code) { --end; } return end < str.length ? str.substring(0, end) : str; diff --git a/packages/html-to-text/src/base/whitespace-processor.ts b/packages/html-to-text/src/base/whitespace-processor.ts index 2a33b25..91f1417 100644 --- a/packages/html-to-text/src/base/whitespace-processor.ts +++ b/packages/html-to-text/src/base/whitespace-processor.ts @@ -38,17 +38,22 @@ class WhitespaceProcessor { * @memberof WhitespaceProcessor */ whitespaceChars: string; + whitespaceTable: Uint8Array; leadingWhitespaceRe: RegExp; trailingWhitespaceRe: RegExp; allWhitespaceOrEmptyRe: RegExp; newlineOrNonWhitespaceRe: RegExp; newlineOrNonNewlineStringRe: RegExp; shrinkWrapAdd: ShrinkWrapAdd; - constructor(options: WhitespaceProcessorOptions) { this.whitespaceChars = options.preserveNewlines ? options.whitespaceCharacters.replace(/\n/g, '') : options.whitespaceCharacters; + const table = new Uint8Array(65536); + for (let i = 0; i < this.whitespaceChars.length; i++) { + table[this.whitespaceChars.charCodeAt(i)] = 1; + } + this.whitespaceTable = table; const whitespaceCodes = charactersToCodes(this.whitespaceChars); this.leadingWhitespaceRe = new RegExp(`^[${whitespaceCodes}]`); this.trailingWhitespaceRe = new RegExp(`[${whitespaceCodes}]$`); @@ -76,6 +81,7 @@ class WhitespaceProcessor { transform: TextTransform = (str) => str, noWrap = false, ) { + wordOrNewlineRe.lastIndex = 0; if (!text) { return; } @@ -121,6 +127,7 @@ class WhitespaceProcessor { transform: TextTransform | undefined = undefined, noWrap = false, ) { + wordRe.lastIndex = 0; if (!text) { return; } @@ -176,6 +183,7 @@ class WhitespaceProcessor { } const previouslyStashedSpace = inlineTextBuilder.stashedSpace; let anyMatch = false; + this.newlineOrNonNewlineStringRe.lastIndex = 0; let m = this.newlineOrNonNewlineStringRe.exec(text); if (m) { anyMatch = true; @@ -208,49 +216,34 @@ class WhitespaceProcessor { * @returns { boolean } */ testLeadingWhitespace(text: string): boolean { - return this.leadingWhitespaceRe.test(text); + return text.length > 0 && this.whitespaceTable[text.charCodeAt(0)] === 1; } - /** - * Test whether the given text ends with HTML whitespace character. - * - * @param { string } text The string to test. - * @returns { boolean } - */ testTrailingWhitespace(text: string): boolean { - return this.trailingWhitespaceRe.test(text); + return ( + text.length > 0 && + this.whitespaceTable[text.charCodeAt(text.length - 1)] === 1 + ); } - /** - * Test whether the given text contains any non-whitespace characters. - * - * @param { string } text The string to test. - * @returns { boolean } - */ testContainsWords(text: string): boolean { - return !this.allWhitespaceOrEmptyRe.test(text); + const table = this.whitespaceTable; + for (let i = 0; i < text.length; i++) { + if (table[text.charCodeAt(i)] === 0) { + return true; + } + } + return false; } - /** - * Return the number of newlines if there are no words. - * - * If any word is found then return zero regardless of the actual number of newlines. - * - * @param { string } text Input string. - * @returns { number } - */ countNewlinesNoWords(text: string): number { - this.newlineOrNonWhitespaceRe.lastIndex = 0; + const table = this.whitespaceTable; let counter = 0; - let match: RegExpExecArray | null = null; - while (true) { - match = this.newlineOrNonWhitespaceRe.exec(text); - if (match === null) { - break; - } - if (match[0] === '\n') { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 10) { counter++; - } else { + } else if (table[code] === 0) { return 0; } } diff --git a/packages/html-to-text/src/index.ts b/packages/html-to-text/src/index.ts index 86d8474..6bffe51 100644 --- a/packages/html-to-text/src/index.ts +++ b/packages/html-to-text/src/index.ts @@ -196,15 +196,58 @@ const DEFAULT_OPTIONS = { wordwrap: 80, } satisfies CompiledHtmlToTextOptions; +const defaultMergedOptions = composeOptions({ + defaultOptions: DEFAULT_OPTIONS, + genericFormatters: genericFormatters, + packageFormatters: textFormatters, + userOptions: {}, +}) as CompiledHtmlToTextOptions; + +const defaultCompiledFunction = compile_( + defaultMergedOptions, +) as CompiledFunction; + +const COMPILE_CACHE_LIMIT = 256; +const compileOptionsCache = new Map(); +const compileWeakCache = new WeakMap(); + +function getOptionsCacheKey(options: HtmlToTextOptions): string { + try { + return JSON.stringify(options, (_key, val) => + typeof val === 'function' ? val.toString() : val, + ); + } catch { + return ''; + } +} + /** * Preprocess options, compile selectors into a decision tree, * return a function intended for batch processing. * * @param { Options } [options] HtmlToText options. - * @returns { (html: string, metadata?: any) => string } Pre-configured converter function. + * @returns { (html: string, metadata?: unknown) => string } Pre-configured converter function. * @static */ function compile(options: HtmlToTextOptions = {}): CompiledFunction { + if (!options || Object.keys(options).length === 0) { + return defaultCompiledFunction; + } + + const cachedWeak = compileWeakCache.get(options); + if (cachedWeak !== undefined) { + return cachedWeak; + } + + const key = getOptionsCacheKey(options); + if (key) { + const cachedMap = compileOptionsCache.get(key); + if (cachedMap !== undefined) { + compileWeakCache.set(options, cachedMap); + return cachedMap; + } + } + const mergedOptions = composeOptions({ defaultOptions: DEFAULT_OPTIONS, genericFormatters: genericFormatters, @@ -212,7 +255,20 @@ function compile(options: HtmlToTextOptions = {}): CompiledFunction { userOptions: options, }) as CompiledHtmlToTextOptions; - return compile_(mergedOptions) as CompiledFunction; + const fn = compile_(mergedOptions) as CompiledFunction; + compileWeakCache.set(options, fn); + + if (key) { + if (compileOptionsCache.size >= COMPILE_CACHE_LIMIT) { + const firstKey = compileOptionsCache.keys().next().value; + if (firstKey !== undefined) { + compileOptionsCache.delete(firstKey); + } + } + compileOptionsCache.set(key, fn); + } + + return fn; } /** @@ -220,7 +276,7 @@ function compile(options: HtmlToTextOptions = {}): CompiledFunction { * * @param { string } html HTML content to convert. * @param { Options } [options] HtmlToText options. - * @param { any } [metadata] Optional metadata for HTML document, for use in formatters. + * @param { unknown } [metadata] Optional metadata for HTML document, for use in formatters. * @returns { string } Plain text string. * @static * diff --git a/packages/render/README.md b/packages/render/README.md index 2992caa..2cf2366 100644 --- a/packages/render/README.md +++ b/packages/render/README.md @@ -5,7 +5,7 @@ Render SolidJS email templates to HTML or plain text, and compile templates for ## Install ```sh -pnpm add @solid-email/render solid-js +pnpm add @solid-email/render solid-js @solidjs/web ``` Install `@akin01/solid-email` when using the Solid Email component set. diff --git a/packages/render/package.json b/packages/render/package.json index c008dca..cb932c7 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -1,6 +1,6 @@ { "name": "@solid-email/render", - "version": "0.1.5", + "version": "2.0.0-beta", "description": "Transform Solid components into HTML email templates", "repository": { "type": "git", @@ -105,6 +105,7 @@ "build": "tsdown", "build:watch": "tsdown --watch", "clean": "rm -rf dist", + "format": "biome format --write .", "test": "vitest run", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:watch": "vitest", @@ -115,13 +116,15 @@ "prettier": "catalog:" }, "peerDependencies": { + "@solidjs/web": "catalog:", "solid-js": "catalog:" }, "devDependencies": { + "@solidjs/vite-plugin": "catalog:", + "@solidjs/web": "catalog:", "solid-js": "catalog:", "tsconfig": "workspace:*", "tsdown": "catalog:", - "vite-plugin-solid": "catalog:", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/render/src/entrypoints.spec.tsx b/packages/render/src/entrypoints.spec.tsx index ede4f4e..240dd75 100644 --- a/packages/render/src/entrypoints.spec.tsx +++ b/packages/render/src/entrypoints.spec.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'solid-js'; +import type { JSX } from '@solidjs/web'; import { describe, expect, it } from 'vitest'; import { render as browserRender, diff --git a/packages/render/src/shared/compile.spec.tsx b/packages/render/src/shared/compile.spec.tsx index d968483..1931cc7 100644 --- a/packages/render/src/shared/compile.spec.tsx +++ b/packages/render/src/shared/compile.spec.tsx @@ -1,5 +1,5 @@ import type { FormatCallback } from '@solid-email/html-to-text'; -import type { JSX } from 'solid-js'; +import type { JSX } from '@solidjs/web'; import { describe, expect, it, vi } from 'vitest'; import { compile, compileSync } from './compile'; import { Slot, slot } from './slots'; diff --git a/packages/render/src/shared/compile.ts b/packages/render/src/shared/compile.ts index 6a69c8e..c713502 100644 --- a/packages/render/src/shared/compile.ts +++ b/packages/render/src/shared/compile.ts @@ -1,7 +1,4 @@ -import { - renderToString, - renderToStringAsync, -} from 'solid-js/web/dist/server.js'; +import { renderToStream, renderToString } from '@solidjs/web'; import type { CompiledRenderOptions, CompiledRenderSyncOptions, @@ -32,8 +29,6 @@ import { } from './text-template'; import { toPlainText } from './utils/to-plain-text'; -export type { SlotRecord, SlotValue } from './slots'; - export class CompiledTemplate< TSlots extends Record = Record, > { @@ -137,9 +132,22 @@ export async function compile< node: Renderable, options?: CompileOptions, ): Promise> { - const html = removeSolidResourceScripts( - await renderToStringAsync(normalizeRenderable(node), solidRenderOptions), - ); + let renderError: unknown; + let hasError = false; + const stream = renderToStream(normalizeRenderable(node), { + ...solidRenderOptions, + noScripts: true, + onError(err) { + hasError = true; + renderError = err; + }, + }); + const rawHtml = await stream; + if (hasError) { + throw renderError; + } + + const html = removeSolidResourceScripts(rawHtml); return new CompiledTemplate(html, options); } diff --git a/packages/render/src/shared/render.spec.tsx b/packages/render/src/shared/render.spec.tsx index 2c42c40..6950a3a 100644 --- a/packages/render/src/shared/render.spec.tsx +++ b/packages/render/src/shared/render.spec.tsx @@ -1,4 +1,5 @@ -import { createResource, type JSX, Suspense } from 'solid-js'; +import type { JSX } from '@solidjs/web'; +import { createMemo, Loading } from 'solid-js'; import { describe, expect, it } from 'vitest'; import { render, renderSync } from './render'; import { pretty } from './utils/pretty'; @@ -12,7 +13,7 @@ function Throwing(): JSX.Element { } function AsyncGreeting() { - const [greeting] = createResource(() => wait('Hello async')); + const greeting = createMemo(() => wait('Hello async')); return {greeting()}; } @@ -143,13 +144,13 @@ describe('render', () => { it('awaits async Solid SSR content', async () => { const html = await render(() => ( - loading}> + loading}> - + )); expect(html).toContain('Hello async'); expect(html).not.toContain('loading'); - expect(html).not.toContain('