diff --git a/CLAUDE.md b/CLAUDE.md index 8e44942..5bfee79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,23 +81,42 @@ These make the declarative syntax work; check them before changing signatures: - Three render paths on every `HtmlItem`: `ToString(indent)`, `AppendTo(ref StringBuilder)`, and `WriteTo(ref TextWriter)` (the streaming path used by the web integration). New node types must implement all three. For `HtmlNode` the first two funnel into `WriteTo` (one buffer for the whole subtree — `ToString` builds a `StringBuilder` then calls `AppendTo`, which wraps a `StringWriter`); keep the three outputs byte-identical (pinned by `GeneralRenderingTests.WriteTo_Should_ProduceTheSameOutputAsToString`). Indentation is emitted via the non-allocating `Indentation` helper, not `new string(' ', n)`. - Lowest-allocation render: `node.WriteTo(IBufferWriter)` (extension in `HtmlNodeExtensions`) encodes UTF-8 straight into the buffer via `Utf8HtmlWriter`, streaming in pooled chunks — render allocation is O(1) regardless of page size (no large string, no LOH/Gen2). `CC.CSX.Web.HtmlResult` uses this against the response `PipeWriter`. The browser path is different: `BrowserApp.Refresh()` calls `view().ToString()` and hands the HTML string to the JS `morph`, so the browser benefits from the `ToString` fast path (not the `IBufferWriter` path). - `HtmlNode.Attributes`/`Children` backing lists are allocated lazily (leaf nodes allocate neither). Render and traversal read the internal `RawAttributes`/`RawChildren` (nullable) so they never force a list allocation; the public getters allocate on demand for mutation. +- Fragment caching: `Raw(string)` emits pre-rendered HTML verbatim (caching its UTF-8 bytes for the `Utf8HtmlWriter` path); `node.Cache()` wraps a static subtree so it renders once and reuses the bytes (hold the result in a `static readonly`); `FragmentCache.GetOrAdd(key, factory)` is the keyed variant. Gated by the `FragmentCache.Enabled` flag, which **defaults to true in Release, false in Debug** (`#if DEBUG`) so dev sees fresh output. Caching only takes effect when `RenderOptions.Indent == 0` (indented output depends on nesting depth); otherwise the source renders live. Only cache genuinely-static fragments — a cached fragment holding per-request data serves stale content. Tests mutate this global flag + `RenderOptions`, so `CC.CSX.Tests` disables xUnit parallelization (`AssemblyInfo.cs`). - **RenderOptions** is global static state (`Indent`, `TextNodeOnNewLine`) that affects formatting everywhere — including test expectations. - `CC.CSX.Web.HtmlResult` streams via `WriteTo` to the response `BodyWriter` with `Content-Type: text/html`; `Render(...)` extension methods are in `CC.CSX.Web/HtmlNodeExtensions.cs`. ### Usage pattern (what user code looks like) +**Preferred styling: author CSS in a `.css` file and use the generated typed classes** — not raw +class strings. Register the file as `` and reference the +`CC.CSX.Css.Generator` (NuGet consumers get it with the `CC.CSX.Css` package; in-repo add the +analyzer ProjectReference). The generator emits `Css..` constants plus a +`Bundle`, so classes are compile-checked, refactor-safe, and discoverable. Serve the styles with +`CssImports.Inline(...)`/`StyleSheet(...)`. Use `CssProperties` for typed inline styles and +`CC.CSX.Css.Tailwind` (`Tw.*`) for typed Tailwind utilities. Reserve plain class strings for one-off +or third-party class names only. + ```csharp using static CC.CSX.HtmlElements; using static CC.CSX.HtmlAttributes; -using static CC.CSX.Htmx.HtmxAttributes; // if using HTMX - -Div(@class("container"), id("main"), - H1("Title"), - P("Content here"), - ("data-custom", "value") // tuple → attribute +using static CC.CSX.Css.CssImports; // Inline(...) / StyleSheet(...) +using static CC.CSX.Htmx.HtmxAttributes; // if using HTMX +using static MyApp.Css; // generated from your styles/*.css (e.g. site.css -> Site) + +Html( + Head(Inline(Site.Bundle)), // serve the generated stylesheet + Body( + Div(@class(Site.container), id("main"), // typed class constant, not a raw "container" + H1(@class(Site.title), "Title"), + P("Content here"), + ("data-custom", "value")) // tuple → attribute + ) ) ``` +See `samples/Web` (and `samples/CalendarSample`) for the end-to-end `.css` source-generator setup; +`CssProperties` for typed inline `style(...)`; `Tw.*` for typed Tailwind classes. + ### Target frameworks - All five runtime packages (CC.CSX, CC.CSX.Web, CC.CSX.Htmx, CC.CSX.Css, CC.CSX.Css.Tailwind, CC.CSX.Browser) multi-target `net8.0;net9.0;net10.0`. The source generator (CC.CSX.Css.Generator) stays `netstandard2.0` so it loads in every Roslyn host. diff --git a/README.md b/README.md index 116815c..e9b79a4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ static HtmlNode Master(string title, params HtmlNode[] content) Meta(charset("utf-8")), HtmxImports), Body( - H1(@class("text-center"), title), + H1(@class(Site.title), title), // prefer typed classes from the CSS generator (see "Styling" below) content, // this is the content that will be rendered as a child of the body Hr() ) @@ -116,6 +116,35 @@ This will generate the following HTML: ``` For existing HTML elements and attributes, you can use the static methods provided by the `HtmlElements` and `HtmlAttributes` classes, if you need to create custom elements you can use the new HtmlNode constructor, and tuple for attributes. +## Styling and CSS classes + +**The preferred way to author styles and classes is with `CC.CSX.Css` and its source generator — +not raw class strings.** Write your CSS in a `.css` file, register it as an additional file, and the +generator turns it into compile-checked, refactor-safe, discoverable typed members: + +```cs +// site.css is registered via and the +// CC.CSX.Css.Generator (shipped with the CC.CSX.Css package). +using static CC.CSX.Css.CssImports; // Inline(...) / StyleSheet(...) +using static MyApp.Css; // generated: site.css -> Site. + Site.Bundle + +Html( + Head(Inline(Site.Bundle)), // serve the generated stylesheet + Body( + Div(@class(Site.container), // typed class constant, not the raw string "container" + H1(@class(Site.title), "Title")) + ) +); +``` + +- **Typed CSS classes** from your `.css` files via the generator — the recommended default. +- **`CssProperties`** for typed inline styles: `style(background("silver"), padding(8.px()))`. +- **`CC.CSX.Css.Tailwind`** (`Tw.*`) for typed Tailwind utility classes. +- Plain class strings still work everywhere (`CssClass` ⇄ `string`), but reserve them for one-off or + third-party class names. + +See `samples/Web` and `samples/CalendarSample` for the end-to-end `.css` generator setup. + ## How it compares to Blazor Both let you build web UIs in C#, but htnet treats HTML as a plain C# *value* (a function returning `HtmlNode`) while Blazor runs a full component framework. The repo ships a head-to-head benchmark (`tests/CC.CSX.Benchmarks/BlazorComparison.cs`) rendering the same page through both — the Blazor side uses `RenderTreeBuilder` + `HtmlRenderer`, exactly what `.razor` SSR compiles down to: diff --git a/docs/production-comparison-htnet-vs-blazor.md b/docs/production-comparison-htnet-vs-blazor.md new file mode 100644 index 0000000..80b7f54 --- /dev/null +++ b/docs/production-comparison-htnet-vs-blazor.md @@ -0,0 +1,104 @@ +# htnet vs Blazor — production SSR comparison + +A production-grade, apples-to-apples comparison of the two **optimized** server-side rendering paths, +across two scenarios: a **static-heavy data table** and a **dynamic-heavy product catalog** (loop of +cards with a conditional class, a computed price, a structural conditional badge, and a nested tag +loop). The two ends of the spectrum: + +- **htnet (optimized)** — a `[RenderOptimized]` view compiled to a render plan: static markup baked to + `byte[]` and written by memcpy, only the dynamic values written per request. +- **Blazor (optimized)** — the same page as a Blazor component rendered via `HtmlRenderer.WriteHtmlTo` + (streams to a `TextWriter`, no output string) — Blazor's fastest SSR configuration. + +A **hand-written** byte writer is included as the theoretical floor. The unoptimized "build a tree +then render" path is intentionally excluded — this is a best-vs-best, production comparison. + +> The page: `

Report

…` + +> *N* rows of ``. Both engines +> emit identical HTML. +> +> Full multi-way numbers (including the unoptimized path and Blazor's `AddMarkupContent` variant): +> see [`render-plan-benchmarks.md`](./render-plan-benchmarks.md). + +## Conclusions + +**In production-grade configuration, across both a static-heavy and a dynamic-heavy page, htnet's +render plan is roughly 5–14× faster than the best-optimized Blazor SSR, and uses on the order of +17–240× less memory per render.** The advantage is largest on static-heavy pages and narrows — but +stays a multiple — as pages get more dynamic. + +In plain terms: + +- **Big page (1,000 items).** Data table: htnet **~66 µs / ~23 KB** vs Blazor **~471 µs / ~376 KB** + (~7× faster, ~17× less memory). Dynamic catalog: htnet **~134 µs / ~40 KB** vs Blazor + **~704 µs / ~697 KB** (~5× faster, ~18× less memory). +- **Medium page (100 items).** Table: ~8× faster, ~240× less memory. Catalog: ~7× faster, ~24× less + memory. +- **Small page (10 items).** Table: ~14× faster, ~34× less memory. Catalog: ~10× faster, ~23× less + memory. The advantage is there on tiny pages too, not just big lists. + +**Why it matters in production:** htnet's per-render allocation stays small and grows slowly +(~0.2–40 KB), while Blazor's grows with page size into hundreds of KB. Under load that's the +difference between being CPU-bound and being GC-bound — fewer/cheaper garbage collections, flatter +tail latencies, and a higher requests-per-second ceiling on the same hardware. + +**And it tracks the hand-written floor.** On the table the render plan renders in essentially the +same time as a hand-written byte writer (66 µs vs 67 µs at 1,000 rows) — there's effectively no +overhead left to remove — while you still write plain, declarative C# views. + +## Numbers + +`net10.0` · AMD Ryzen 9 5900X · BenchmarkDotNet (ShortRun) · lower is better. +"htnet advantage" = optimized Blazor ÷ htnet. "Blazor (optimized)" = `HtmlRenderer.WriteHtmlTo(TextWriter)`. + +### Scenario 1 — data table (static-heavy) + +| Rows | htnet (optimized) | Blazor (optimized) | hand-written (floor) | htnet advantage | +|---:|---|---|---|---| +| 10 | **0.61 µs · 184 B** | 8.56 µs · 6,288 B | 0.72 µs · 64 B | **14× faster · 34× less mem** | +| 100 | **6.13 µs · 184 B** | 48.78 µs · 44,288 B | 6.67 µs · 64 B | **8× faster · 240× less mem** | +| 1000 | **66.0 µs · 22,584 B** | 470.6 µs · 376,002 B | 67.1 µs · 64 B | **7× faster · 17× less mem** | + +### Scenario 2 — product catalog (dynamic-heavy: conditional class, computed price, structural conditional badge, nested tag loop) + +| Items | htnet (optimized) | Blazor (optimized) | htnet advantage | +|---:|---|---|---| +| 10 | **1.32 µs · 504 B** | 13.75 µs · 11,488 B | **10× faster · 23× less mem** | +| 100 | **12.99 µs · 3,648 B** | 94.75 µs · 86,312 B | **7× faster · 24× less mem** | +| 1000 | **133.8 µs · 39,648 B** | 704.4 µs · 696,711 B | **5× faster · 18× less mem** | + +Typical Blazor via `.ToHtmlString()` is slower still (e.g. table 598 µs / 695 KB, catalog 1,043 µs / +1,231 KB at the largest size). One honest note from the dynamic scenario: optimized Blazor's flat +frame array actually edges out htnet's *unoptimized live tree* path there (704 µs vs 780 µs at 1,000 +items) — which is exactly why the compiled render plan, not the tree, is the production path. + +## How htnet's render plan works (one paragraph) + +A view marked `[RenderOptimized]` is analyzed at compile time. Because the CC.CSX element/attribute +factories are *pure functions of their arguments*, a Roslyn source generator recursively splits the +view into **static chunks** (baked to `static readonly byte[]`, written via memcpy) and **dynamic +holes** (the values that read parameters). `Select`/`foreach` become real loops with the per-row +scaffold baked; node-producing conditionals become `if`/`else` with per-branch sub-plans. A C# +**interceptor** transparently redirects each call site to the generated builder — existing call sites +get the speedup with no code change. Anything not provably static falls back to rendering live, so +correctness is never traded for speed. Blazor, by contrast, always builds and walks a +`RenderTreeFrame[]` at render time — there is no SSR path that skips the tree the way a compiled plan +does, which is why even optimized Blazor stays several times behind. + +## Methodology & caveats + +- **BenchmarkDotNet ShortRun** (3 iterations) — solid for the order-of-magnitude ratios here; re-run + without `--job short` for publication-grade confidence intervals. +- Both engines render to a discarding output (htnet → discarding `IBufferWriter`; Blazor → + `TextWriter.Null`), so the numbers reflect the *renderer*, not output storage. In production htnet + writes straight to the response `PipeWriter`. +- `RenderOptions.Indent = 0` (production shape). Render plans apply only at `Indent = 0`. +- The render-plan generator is a spike (`CC.CSX.RenderPlan.Generator`, branch `feature/render-plan`). + The numbers only improve as codegen polish lands — e.g. `TryFormat` for numeric holes removes htnet's + last remaining allocation (the per-row `int.ToString()`). + +## Reproduce + +```bash +dotnet run -c Release --project tests/CC.CSX.Benchmarks -- --filter "*RealisticBenchmarks*" --job short +``` diff --git a/docs/render-plan-benchmarks.md b/docs/render-plan-benchmarks.md new file mode 100644 index 0000000..11fa1a6 --- /dev/null +++ b/docs/render-plan-benchmarks.md @@ -0,0 +1,143 @@ +# CC.CSX render-plan benchmarks + +How fast can a C# HTML library render a realistic, data-heavy page — and what does compiling +the view into a **static/dynamic render plan** buy you, versus building an object tree, versus +Blazor SSR (including Blazor's own optimizations)? + +All approaches render the **same page** (a report with a table of *N* rows), writing to a +discarding output at `Indent = 0`: + +| Approach | What it is | +|---|---| +| **HandWritten** | Direct byte writing — the theoretical floor | +| **Htnet · RenderPlan** | The generated `[RenderOptimized]` builder: static markup baked to `byte[]`, only dynamic values written | +| **Htnet · Live** | Building the CC.CSX `HtmlNode` tree, then `WriteTo` (the current default path) | +| **Blazor · ToString** | The page as a Blazor component via `HtmlRenderer`, `.ToHtmlString()` — typical SSR | +| **Blazor · WriteTo** | Same component, but `WriteHtmlTo(TextWriter)` — Blazor opt #1 (no output string) | +| **Blazor · Markup+WriteTo** | Static HTML as raw markup (`AddMarkupContent`) + `WriteHtmlTo` — Blazor opt #2 (the hand-tuned analog of baked chunks) | + +> The page: `

Report

{id}{name}{email}
…` + +> *N* rows of ``. The htnet +> approaches emit byte-identical HTML (pinned by golden tests). + +## Headline (1,000-row table) + +| Approach | Time | Allocated | vs Live | +|---|--:|--:|--:| +| HandWritten (floor) | **67.1 µs** | **64 B** | 5.5× faster | +| **Htnet · RenderPlan** | **66.0 µs** | **22.6 KB** | **5.6× faster, 49× less mem** | +| Htnet · Live | 370.9 µs | 1,097 KB | baseline | +| Blazor · ToString | 598.4 µs | 695 KB | 0.62× (1.6× slower) | +| **Blazor · WriteTo** *(best Blazor)* | 470.6 µs | 376 KB | 0.79× (1.3× slower) | +| Blazor · Markup+WriteTo | 612.4 µs | 704 KB | 0.61× | + +**The render plan matches the hand-written floor** while producing identical HTML from a plain, +declarative C# view. Versus the **best** Blazor configuration it is **~7× faster** and allocates +**~17× less**. + +## Scenario 1 — data table (static-heavy) + +`net10.0` · AMD Ryzen 9 5900X · BenchmarkDotNet (ShortRun) · lower is better. + +### 10 rows (small page) + +| Method | Mean | Allocated | +|---|--:|--:| +| HandWritten | 718 ns | 64 B | +| **Htnet · RenderPlan** | **613 ns** | **184 B** | +| Htnet · Live | 3,905 ns | 13,096 B | +| Blazor · ToString | 10,768 ns | 10,760 B | +| Blazor · WriteTo | 8,561 ns | 6,288 B | +| Blazor · Markup+WriteTo | 9,033 ns | 6,192 B | + +### 100 rows + +| Method | Mean | Allocated | +|---|--:|--:| +| HandWritten | 6,670 ns | 64 B | +| **Htnet · RenderPlan** | **6,130 ns** | **184 B** | +| Htnet · Live | 32,343 ns | 110,000 B | +| Blazor · ToString | 61,537 ns | 76,904 B | +| Blazor · WriteTo | 48,784 ns | 44,288 B | +| Blazor · Markup+WriteTo | 59,264 ns | 44,192 B | + +### 1,000 rows + +| Method | Mean | Allocated | +|---|--:|--:| +| HandWritten | 67,147 ns | 64 B | +| **Htnet · RenderPlan** | **65,975 ns** | **22,584 B** | +| Htnet · Live | 370,909 ns | 1,097,208 B | +| Blazor · ToString | 598,432 ns | 694,536 B | +| Blazor · WriteTo | 470,617 ns | 376,002 B | +| Blazor · Markup+WriteTo | 612,449 ns | 703,552 B | + +## Scenario 2 — product catalog (dynamic-heavy) + +A loop of product cards, each with a conditional CSS class, a computed price string, a **structural +conditional** (the SALE badge → `if`/`else`), and a **nested loop** (tags), plus an inlined +`[HtmlPure]` component. A much higher dynamic:static ratio than the table — a more honest test. + +| Method | 10 items | 100 items | 1,000 items | +|---|--:|--:|--:| +| **Htnet · RenderPlan** | **1.32 µs · 504 B** | **12.99 µs · 3,648 B** | **133.8 µs · 39,648 B** | +| Htnet · Live | 7.62 µs · 22,232 B | 71.6 µs · 207,720 B | 779.9 µs · 2,064,129 B | +| Blazor · ToString | 17.1 µs · 19,040 B | 121.1 µs · 146,504 B | 1,042.8 µs · 1,230,811 B | +| Blazor · WriteTo *(best Blazor)* | 13.75 µs · 11,488 B | 94.75 µs · 86,312 B | 704.4 µs · 696,711 B | + +The render plan's lead **narrows** vs the static table (the holes now do real work — string +interpolation for price, nested tag lists) but holds: **~5–10× faster and ~18–24× less memory than +the best Blazor**. Note that here optimized Blazor (`WriteTo`) actually edges out htnet's *live tree* +path on time (704 µs vs 780 µs at 1,000 items) — Blazor's flat `RenderTreeFrame[]` handles a deep, +dynamic page well — so the compiled render plan, not the tree, is what keeps htnet ahead in production. + +## Interesting observations + +- **The render plan tracks the hand-written floor at every size** (66.0 µs vs 67.1 µs at 1,000 rows). + The cost of building/walking an object tree is gone; what's left is the actual byte writing. +- **Allocation collapses to near-constant.** Live rendering allocates ~1.1 KB **per row**; the plan + allocates a flat ~184 B regardless of size, rising only to ~22.6 KB at 1,000 rows — and that + residual is entirely `int.ToString()` on the id cell (the hand-written version avoids it with + `TryFormat`; emitting that in codegen would close the last gap). +- **Blazor's own optimizations help — partly.** Rendering to a `TextWriter` (`WriteHtmlTo`) instead of + `ToHtmlString()` is a real win: ~21% faster and ~46% less allocation at 1,000 rows (598→471 µs, + 695→376 KB), because it skips materializing the output string. **Using `AddMarkupContent` for the + static HTML did *not* help** — it was slightly slower and allocated *more* than the plain element + component (612 µs / 704 KB vs 471 µs / 376 KB). Blazor's renderer still builds and walks a + `RenderTreeFrame[]` either way; there's no SSR path that skips the tree the way a compiled plan does. +- **htnet's plain tree path already beats even optimized Blazor on time** (1.3× faster than + `Blazor · WriteTo`), though optimized Blazor allocates less than htnet's object tree. The render plan + then beats *everything* — ~7× faster and ~17× less memory than the best Blazor across the table. +- **This is a throughput story.** ~1.1 MB/request (Live) vs ~22 KB/request (RenderPlan) is the + difference between being GC-bound and CPU-bound under load. +- **The win holds at small sizes too** — a 10-row page is still ~14× faster and ~34× lighter than the + best Blazor. + +## How it works (one paragraph) + +A view marked `[RenderOptimized]` is analyzed at compile time. Because the CC.CSX element/attribute +factories are *pure functions of their arguments*, a Roslyn source generator recursively splits the +view into **static chunks** (baked to `static readonly byte[]`, written via memcpy) and **dynamic +holes** (the values that read parameters). `Select`/`foreach` become real loops with the per-row +scaffold baked; node-producing conditionals become `if`/`else` with per-branch sub-plans. A C# +**interceptor** then transparently redirects each call site to the generated builder, which returns a +lightweight `PlanNode` — so existing call sites get the speedup with no code change. Anything the +analyzer can't prove static falls back to rendering live, so correctness is never traded for speed. + +## Methodology & caveats + +- Results are **BenchmarkDotNet ShortRun** (3 iterations) — fine for the order-of-magnitude ratios + here; re-run without `--job short` for publication-grade confidence intervals. +- htnet renders UTF-8 bytes to a reused, discarding `IBufferWriter`; Blazor `WriteTo` variants + render to `TextWriter.Null`; `ToString` builds the string. So the numbers reflect the *renderer*, + not output storage. In production the htnet path writes straight to the response `PipeWriter`. +- `RenderOptions.Indent = 0` (production shape). Render plans apply only at `Indent = 0`. +- The render-plan generator is a spike (`CC.CSX.RenderPlan.Generator`, branch `feature/render-plan`). + Numbers only improve as codegen polish lands (e.g. `TryFormat` for numeric holes removes the last + allocation). + +## Reproduce + +```bash +dotnet run -c Release --project tests/CC.CSX.Benchmarks -- --filter "*RealisticBenchmarks*" --job short +``` diff --git a/htnet.sln b/htnet.sln index 85ec621..c0f25c5 100644 --- a/htnet.sln +++ b/htnet.sln @@ -47,6 +47,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CC.CSX.Components.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComponentsSample", "samples\ComponentsSample\ComponentsSample.csproj", "{6A4B7947-F06A-4A08-8F0F-04199B556D21}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CC.CSX.RenderPlan.Generator", "src\CC.CSX.RenderPlan.Generator\CC.CSX.RenderPlan.Generator.csproj", "{05782005-655E-4A95-B07F-FEE16BCBBA1D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RenderPlanSpike", "samples\RenderPlanSpike\RenderPlanSpike.csproj", "{E47D2024-3800-4C65-A915-F36AEFEDA866}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CC.CSX.RenderPlan.Tests", "tests\CC.CSX.RenderPlan.Tests\CC.CSX.RenderPlan.Tests.csproj", "{3D3D1680-158E-4FA5-A59D-9C03A10E671C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -285,6 +291,42 @@ Global {6A4B7947-F06A-4A08-8F0F-04199B556D21}.Release|x64.Build.0 = Release|Any CPU {6A4B7947-F06A-4A08-8F0F-04199B556D21}.Release|x86.ActiveCfg = Release|Any CPU {6A4B7947-F06A-4A08-8F0F-04199B556D21}.Release|x86.Build.0 = Release|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Debug|x64.ActiveCfg = Debug|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Debug|x64.Build.0 = Debug|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Debug|x86.ActiveCfg = Debug|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Debug|x86.Build.0 = Debug|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Release|Any CPU.Build.0 = Release|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Release|x64.ActiveCfg = Release|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Release|x64.Build.0 = Release|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Release|x86.ActiveCfg = Release|Any CPU + {05782005-655E-4A95-B07F-FEE16BCBBA1D}.Release|x86.Build.0 = Release|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Debug|x64.ActiveCfg = Debug|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Debug|x64.Build.0 = Debug|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Debug|x86.ActiveCfg = Debug|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Debug|x86.Build.0 = Debug|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Release|Any CPU.Build.0 = Release|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Release|x64.ActiveCfg = Release|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Release|x64.Build.0 = Release|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Release|x86.ActiveCfg = Release|Any CPU + {E47D2024-3800-4C65-A915-F36AEFEDA866}.Release|x86.Build.0 = Release|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Debug|x64.ActiveCfg = Debug|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Debug|x64.Build.0 = Debug|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Debug|x86.ActiveCfg = Debug|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Debug|x86.Build.0 = Debug|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Release|Any CPU.Build.0 = Release|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Release|x64.ActiveCfg = Release|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Release|x64.Build.0 = Release|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Release|x86.ActiveCfg = Release|Any CPU + {3D3D1680-158E-4FA5-A59D-9C03A10E671C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -309,5 +351,8 @@ Global {8AD55B53-F81F-4EA0-A761-078BB514C058} = {5676F3F9-F3B8-4E72-9330-1AB43E380F3E} {520A519A-4342-4B3D-89CF-C8C770627283} = {FF95AD8E-2294-4184-BB0C-499C5929550C} {6A4B7947-F06A-4A08-8F0F-04199B556D21} = {DFFD6EAD-BDF1-4BA3-A49C-D9FC700013CD} + {05782005-655E-4A95-B07F-FEE16BCBBA1D} = {5676F3F9-F3B8-4E72-9330-1AB43E380F3E} + {E47D2024-3800-4C65-A915-F36AEFEDA866} = {DFFD6EAD-BDF1-4BA3-A49C-D9FC700013CD} + {3D3D1680-158E-4FA5-A59D-9C03A10E671C} = {FF95AD8E-2294-4184-BB0C-499C5929550C} EndGlobalSection EndGlobal diff --git a/samples/RenderPlanSpike/.gitignore b/samples/RenderPlanSpike/.gitignore new file mode 100644 index 0000000..9ab870d --- /dev/null +++ b/samples/RenderPlanSpike/.gitignore @@ -0,0 +1 @@ +generated/ diff --git a/samples/RenderPlanSpike/AssemblyInfo.cs b/samples/RenderPlanSpike/AssemblyInfo.cs new file mode 100644 index 0000000..7308f1d --- /dev/null +++ b/samples/RenderPlanSpike/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +// let the golden tests + benchmarks reach the generated internal Views__Optimized class +[assembly: InternalsVisibleTo("CC.CSX.RenderPlan.Tests")] +[assembly: InternalsVisibleTo("CC.CSX.Benchmarks")] diff --git a/samples/RenderPlanSpike/CatalogViews.cs b/samples/RenderPlanSpike/CatalogViews.cs new file mode 100644 index 0000000..08fb515 --- /dev/null +++ b/samples/RenderPlanSpike/CatalogViews.cs @@ -0,0 +1,30 @@ +using CC.CSX; + +using static CC.CSX.HtmlElements; +using static CC.CSX.HtmlAttributes; + +namespace RenderPlanSpike; + +public record Product(string Name, decimal Price, bool InStock, bool OnSale, string[] Tags); + +// A deliberately "dynamic-heavy" page: a loop of product cards, each with a value-level conditional +// class, a computed price string, a STRUCTURAL conditional (the SALE badge), and a NESTED loop +// (tags) — plus an inlined [HtmlPure] component. Exercises far more of the dynamic path than a +// plain data table, so it's a more realistic perf setup. +public static class CatalogViews +{ + [HtmlPure] + public static HtmlNode Card(Product p) => + Div(@class(p.InStock ? "card in-stock" : "card out-of-stock"), + H3(p.Name), + P(@class("price"), $"${p.Price}"), + p.OnSale ? Span(@class("badge"), "SALE") : None, + Ul(@class("tags"), p.Tags.Select(t => Li(t)).ToArray())); + + [RenderOptimized] + public static HtmlNode Catalog(IEnumerable products) => + Div(@class("catalog"), + H1("Products"), + Div(@class("grid"), + products.Select(p => Card(p)).ToArray())); +} diff --git a/samples/RenderPlanSpike/Demo.cs b/samples/RenderPlanSpike/Demo.cs new file mode 100644 index 0000000..c6b6528 --- /dev/null +++ b/samples/RenderPlanSpike/Demo.cs @@ -0,0 +1,15 @@ +using CC.CSX; + +namespace RenderPlanSpike; + +// Call sites of the [RenderOptimized] views. Because this project enables interceptors for the +// CC.CSX.Generated namespace, the generator rewrites these `Views.X(...)` calls to the optimized +// builder — so each returns a PlanNode instead of building a real HtmlNode tree. +public static class Demo +{ + public static HtmlNode UserRow() => Views.UserRow(1, "Ann", "ann@example.com"); + + public static HtmlNode Status(bool ok) => Views.Status(ok); + + public static HtmlNode Report(IEnumerable<(int id, string name, string email)> rows) => Views.Report(rows); +} diff --git a/samples/RenderPlanSpike/RenderPlanSpike.csproj b/samples/RenderPlanSpike/RenderPlanSpike.csproj new file mode 100644 index 0000000..a1e9128 --- /dev/null +++ b/samples/RenderPlanSpike/RenderPlanSpike.csproj @@ -0,0 +1,26 @@ + + + net10.0 + enable + enable + preview + false + + $(InterceptorsNamespaces);CC.CSX.Generated + + true + generated + + + + + + + + + + + + + diff --git a/samples/RenderPlanSpike/Views.cs b/samples/RenderPlanSpike/Views.cs new file mode 100644 index 0000000..b923d8b --- /dev/null +++ b/samples/RenderPlanSpike/Views.cs @@ -0,0 +1,61 @@ +using CC.CSX; + +using static CC.CSX.HtmlElements; +using static CC.CSX.HtmlAttributes; + +namespace RenderPlanSpike; + +// Plain views — NO Dyn/Each markers. The generator infers static vs dynamic purely from the fact +// that the CC.CSX factories are pure: a factory call with static args is static; an arg that reads +// a parameter is a hole. Build this project and read samples/RenderPlanSpike/generated/.../*.g.cs. +public static class Views +{ + // flat view: static /
{id}{name}{email}
scaffold, dynamic class + 3 cell values + [RenderOptimized] + public static HtmlNode UserRow(int id, string name, string email) => + Tr(@class(id % 2 == 0 ? "even" : "odd"), + Td(id), + Td("Name:"), // literal -> static + Td(name), + Td(email)); + + // all-static subtree: should collapse to a single baked segment + [RenderOptimized] + public static HtmlNode TableHeader() => + Thead(Tr(Th("Id"), Th("Name"), Th("Email"))); + + // a loop: static chrome baked, the Select body decomposed per item + [RenderOptimized] + public static HtmlNode Report(IEnumerable<(int id, string name, string email)> rows) => + Div(@class("uk-container"), + H1("Report"), + Table(@class("uk-table"), + Thead(Tr(Th("Id"), Th("Name"), Th("Email"))), + Tbody(rows.Select(r => Tr(@class(r.id % 2 == 0 ? "even" : "odd"), + Td(r.id), + Td(r.name), + Td(r.email))).ToArray()))); + + // inlining: Page calls another pure component (UserRow) — the generator should recurse into it + [HtmlPure] + public static HtmlNode Badge(string text) => Span(@class("badge"), text); + + [RenderOptimized] + public static HtmlNode Profile(string userName) => + Div(@class("profile"), + H1(userName), + Badge("online"), // pure, all-static arg -> fully static + Badge(userName)); // pure, dynamic arg -> static span scaffold + hole + + // structural conditional: the branches produce different subtrees -> per-branch sub-plans + [RenderOptimized] + public static HtmlNode Status(bool ok) => + Div(@class("status"), + ok ? Span(@class("ok"), "Online") + : Span(@class("err"), "Offline")); + + // a boundary: calls an unknown/impure helper -> should become an opaque hole + [RenderOptimized] + public static HtmlNode WithUnknown(string s) => + Div(P(s), P(System.DateTime.Now.ToString())); +} diff --git a/src/CC.CSX.RenderPlan.Generator/CC.CSX.RenderPlan.Generator.csproj b/src/CC.CSX.RenderPlan.Generator/CC.CSX.RenderPlan.Generator.csproj new file mode 100644 index 0000000..45ee6b9 --- /dev/null +++ b/src/CC.CSX.RenderPlan.Generator/CC.CSX.RenderPlan.Generator.csproj @@ -0,0 +1,25 @@ + + + + + netstandard2.0 + latest + enable + enable + true + true + false + + $(NoWarn);RSEXPERIMENTAL002 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/src/CC.CSX.RenderPlan.Generator/CodeEmitter.cs b/src/CC.CSX.RenderPlan.Generator/CodeEmitter.cs new file mode 100644 index 0000000..79fa3b2 --- /dev/null +++ b/src/CC.CSX.RenderPlan.Generator/CodeEmitter.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Text; + +namespace CC.CSX.RenderPlan.Generator; + +/// +/// Emits an optimized builder per [RenderOptimized] method: <Type>__Optimized.<Method>(args) +/// returns a whose render writes baked static byte segments (memcpy) plus the +/// dynamic holes/loops/conditionals. The signature matches the original (returns HtmlNode), so an +/// interceptor can redirect call sites to it. +/// +internal static class CodeEmitter +{ + public static string EmitType(string ns, string typeName, List methods) + { + var consts = new StringBuilder(); + var body = new StringBuilder(); + int constId = 0; + + foreach (var m in methods) + { + if (m.Plan is null) continue; + var pars = string.Join(", ", m.Params.ConvertAll(p => $"{p.Type} {p.Name}")); + + body.AppendLine($" /// Optimized render plan for {Escape(m.Signature)}."); + body.AppendLine($" public static global::CC.CSX.HtmlNode {m.MethodName}({pars})"); + body.AppendLine(" => new global::CC.CSX.PlanNode(__tw =>"); + body.AppendLine(" {"); + EmitSegs(m.Plan, m.MethodName, body, consts, ref constId, " "); + body.AppendLine(" });"); + body.AppendLine(); + } + + var sb = new StringBuilder(); + sb.AppendLine("// CC.CSX.RenderPlan.Generator"); + sb.AppendLine("#nullable enable"); + // replicate the view files' usings so hole expressions (unqualified factory calls) resolve + var usings = new System.Collections.Generic.HashSet(); + foreach (var m in methods) + foreach (var u in m.Usings) + usings.Add(u); + foreach (var u in usings) sb.AppendLine(u); + bool hasNs = !string.IsNullOrEmpty(ns); + if (hasNs) sb.AppendLine($"namespace {ns}").AppendLine("{"); + sb.AppendLine($"internal static class {typeName}__Optimized"); + sb.AppendLine("{"); + sb.Append(consts); + if (consts.Length > 0) sb.AppendLine(); + sb.Append(body); + sb.AppendLine("}"); + if (hasNs) sb.AppendLine("}"); + return sb.ToString(); + } + + static void EmitSegs(List segs, string method, StringBuilder body, StringBuilder consts, ref int id, string indent) + { + foreach (var seg in segs) + { + switch (seg) + { + case StaticSeg st: + string sField = $"_{method}_S{id}"; + string bField = $"_{method}_B{id}"; + id++; + consts.AppendLine($" private static readonly string {sField} = \"{Escape(st.Text)}\";"); + consts.AppendLine($" private static readonly byte[] {bField} = global::System.Text.Encoding.UTF8.GetBytes({sField});"); + body.AppendLine($"{indent}global::CC.CSX.PlanStatics.WriteStatic(__tw, {bField}, {sField});"); + break; + case HoleSeg h: EmitWrite(h.Expr, h.Kind, body, indent); break; + case OpaqueSeg op: EmitWrite(op.Expr, op.Kind, body, indent); break; + case LoopSeg loop: + body.AppendLine($"{indent}foreach (var {loop.ItemVar} in ({loop.Items}))"); + body.AppendLine($"{indent}{{"); + EmitSegs(loop.Body, method, body, consts, ref id, indent + " "); + body.AppendLine($"{indent}}}"); + break; + case CondSeg cond: + body.AppendLine($"{indent}if ({cond.Cond})"); + body.AppendLine($"{indent}{{"); + EmitSegs(cond.Then, method, body, consts, ref id, indent + " "); + body.AppendLine($"{indent}}}"); + body.AppendLine($"{indent}else"); + body.AppendLine($"{indent}{{"); + EmitSegs(cond.Else, method, body, consts, ref id, indent + " "); + body.AppendLine($"{indent}}}"); + break; + } + } + } + + static void EmitWrite(string expr, WriteKind kind, StringBuilder body, string indent) + { + switch (kind) + { + case WriteKind.Text: + body.AppendLine($"{indent}__tw.Write({expr});"); + break; + case WriteKind.Value: + body.AppendLine($"{indent}__tw.Write(({expr}).ToString());"); + break; + case WriteKind.Node: + body.AppendLine($"{indent}{{ var __n = ({expr}); if (__n is not null) {{ global::System.IO.TextWriter __t = __tw; ((global::CC.CSX.HtmlItem)__n).WriteTo(ref __t); }} }}"); + break; + } + } + + static string Escape(string s) + { + var sb = new StringBuilder(s.Length + 8); + foreach (char c in s) + sb.Append(c switch + { + '\\' => "\\\\", + '"' => "\\\"", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + _ => c.ToString(), + }); + return sb.ToString(); + } +} diff --git a/src/CC.CSX.RenderPlan.Generator/InterceptorEmitter.cs b/src/CC.CSX.RenderPlan.Generator/InterceptorEmitter.cs new file mode 100644 index 0000000..31cba53 --- /dev/null +++ b/src/CC.CSX.RenderPlan.Generator/InterceptorEmitter.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace CC.CSX.RenderPlan.Generator; + +internal sealed class CallSite(string attr, string ns, string typeName, string method, string returnType, List paramTypes) +{ + public string Attr { get; } = attr; + public string Namespace { get; } = ns; + public string TypeName { get; } = typeName; + public string Method { get; } = method; + public string ReturnType { get; } = returnType; + public List ParamTypes { get; } = paramTypes; + public string Key => $"{Namespace}|{TypeName}|{Method}|{string.Join(",", ParamTypes)}"; +} + +/// +/// Emits C# interceptors that redirect each call site of a [RenderOptimized] method to the matching +/// <Type>__Optimized builder. The interceptor shares the original signature (returns HtmlNode), +/// so the swap is transparent to callers. Requires the consumer to enable interceptors for the +/// "CC.CSX.Generated" namespace. +/// +internal static class InterceptorEmitter +{ + public static void Emit(SourceProductionContext spc, ImmutableArray sites) + { + var groups = sites.Where(s => s is not null).Select(s => s!).GroupBy(s => s.Key).ToList(); + if (groups.Count == 0) return; + + var sb = new StringBuilder(); + sb.AppendLine("// CC.CSX.RenderPlan.Generator — interceptors"); + sb.AppendLine("#nullable enable"); + sb.AppendLine("namespace CC.CSX.Generated"); + sb.AppendLine("{"); + sb.AppendLine(" file static class RenderPlanInterceptors"); + sb.AppendLine(" {"); + + int n = 0; + foreach (var group in groups) + { + var s = group.First(); + string optTarget = string.IsNullOrEmpty(s.Namespace) + ? $"global::{s.TypeName}__Optimized" + : $"global::{s.Namespace}.{s.TypeName}__Optimized"; + string pars = string.Join(", ", s.ParamTypes.Select((t, i) => $"{t} p{i}")); + string args = string.Join(", ", Enumerable.Range(0, s.ParamTypes.Count).Select(i => $"p{i}")); + + foreach (var site in group) sb.AppendLine($" {site.Attr}"); + sb.AppendLine($" public static {s.ReturnType} Intercept_{s.Method}_{n}({pars})"); + sb.AppendLine($" => {optTarget}.{s.Method}({args});"); + sb.AppendLine(); + n++; + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + // the version-based InterceptsLocationAttribute is recognized by full name; define it here + // since the targeted framework does not ship it. + sb.AppendLine("namespace System.Runtime.CompilerServices"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)]"); + sb.AppendLine(" file sealed class InterceptsLocationAttribute : global::System.Attribute"); + sb.AppendLine(" {"); + sb.AppendLine(" public InterceptsLocationAttribute(int version, string data) { }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + spc.AddSource("RenderPlan.Interceptors.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } +} diff --git a/src/CC.CSX.RenderPlan.Generator/RenderPlanGenerator.cs b/src/CC.CSX.RenderPlan.Generator/RenderPlanGenerator.cs new file mode 100644 index 0000000..8cbad9d --- /dev/null +++ b/src/CC.CSX.RenderPlan.Generator/RenderPlanGenerator.cs @@ -0,0 +1,327 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CC.CSX.RenderPlan.Generator; + +/// +/// Finds [RenderOptimized] methods, infers a static/dynamic render plan, and (a) emits a readable +/// decomposition report and (b) generates an optimized writer that bakes static runs to bytes and +/// writes only the dynamic holes/loops. (Spike stage: standalone optimized methods, no interceptors.) +/// +[Generator(LanguageNames.CSharp)] +public sealed class RenderPlanGenerator : IIncrementalGenerator +{ + const string Trigger = "CC.CSX.RenderOptimizedAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var plans = context.SyntaxProvider.ForAttributeWithMetadataName( + Trigger, + predicate: static (n, _) => n is MethodDeclarationSyntax, + transform: static (ctx, _) => Analyze(ctx)) + .Where(static p => p is not null) + .Collect(); + + // (a) one combined decomposition report + context.RegisterSourceOutput(plans, static (spc, all) => + { + var sb = new StringBuilder(); + sb.AppendLine("// CC.CSX.RenderPlan.Generator — decomposition report"); + sb.AppendLine("/*"); + foreach (var p in all) sb.AppendLine(Report.Render(p!)); + sb.AppendLine("*/"); + spc.AddSource("RenderPlan.Decomposition.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + }); + + // (b) generated optimized writers, grouped by containing type + context.RegisterSourceOutput(plans, static (spc, all) => + { + foreach (var group in all.Where(p => p!.Plan is not null) + .GroupBy(p => (p!.Namespace, p.TypeName))) + { + var src = CodeEmitter.EmitType(group.Key.Namespace, group.Key.TypeName, group.ToList()!); + spc.AddSource($"{group.Key.TypeName}__Optimized.g.cs", SourceText.From(src, Encoding.UTF8)); + } + }); + + // (c) interceptors: redirect call sites of [RenderOptimized] methods to the optimized builder + var sites = context.SyntaxProvider.CreateSyntaxProvider( + static (n, _) => n is InvocationExpressionSyntax, + static (ctx, _) => AnalyzeCallSite(ctx)) + .Where(static s => s is not null) + .Collect(); + + context.RegisterSourceOutput(sites, static (spc, all) => InterceptorEmitter.Emit(spc, all!)); + } + + static readonly SymbolDisplayFormat Fq = SymbolDisplayFormat.FullyQualifiedFormat; + + static CallSite? AnalyzeCallSite(GeneratorSyntaxContext ctx) + { + var inv = (InvocationExpressionSyntax)ctx.Node; + if (ctx.SemanticModel.GetSymbolInfo(inv).Symbol is not IMethodSymbol m) return null; + if (!m.IsStatic) return null; // spike: static views only + if (!m.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == "CC.CSX.RenderOptimizedAttribute")) + return null; + var loc = ctx.SemanticModel.GetInterceptableLocation(inv); + if (loc is null) return null; + + return new CallSite( + loc.GetInterceptsLocationAttributeSyntax(), + m.ContainingNamespace.IsGlobalNamespace ? "" : m.ContainingNamespace.ToDisplayString(), + m.ContainingType.Name, + m.Name, + m.ReturnType.ToDisplayString(Fq), + m.Parameters.Select(p => p.Type.ToDisplayString(Fq)).ToList()); + } + + static MethodPlan? Analyze(GeneratorAttributeSyntaxContext ctx) + { + if (ctx.TargetSymbol is not IMethodSymbol method) return null; + if (ctx.TargetNode is not MethodDeclarationSyntax decl) return null; + + var ns = method.ContainingNamespace.IsGlobalNamespace ? "" : method.ContainingNamespace.ToDisplayString(); + var typeName = method.ContainingType.Name; + var pars = method.Parameters + .Select(p => (Type: p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), p.Name)) + .ToList(); + + // replicate the view file's using directives so hole expressions (unqualified factory calls + // like Span(...) / @class(...) / None) resolve in the generated file + var usings = decl.SyntaxTree.GetRoot().DescendantNodes() + .OfType().Select(u => u.ToString()).ToList(); + + var ret = ReturnExpression(decl); + if (ret is null) + return new MethodPlan(ns, typeName, method.Name, pars, null, usings); + + var planner = new Planner(ctx.SemanticModel); + var segs = Planner.Consolidate(planner.Plan(ret, ImmutableDictionary.Empty, 0)); + return new MethodPlan(ns, typeName, method.Name, pars, segs, usings); + } + + static ExpressionSyntax? ReturnExpression(MethodDeclarationSyntax decl) + { + if (decl.ExpressionBody is { } eb) return eb.Expression; + var returns = decl.Body?.Statements.OfType().ToList(); + return returns is { Count: 1 } ? returns[0].Expression : null; + } +} + +internal sealed class MethodPlan(string ns, string typeName, string methodName, + List<(string Type, string Name)> pars, List? plan, List usings) +{ + public string Namespace { get; } = ns; + public string TypeName { get; } = typeName; + public string MethodName { get; } = methodName; + public List<(string Type, string Name)> Params { get; } = pars; + public List? Plan { get; } = plan; + public List Usings { get; } = usings; + public string Signature => $"{MethodName}({string.Join(", ", Params.Select(p => $"{Short(p.Type)} {p.Name}"))})"; + static string Short(string t) { int i = t.LastIndexOf('.'); return i < 0 ? t : t.Substring(i + 1); } +} + +// ---- segment model ---------------------------------------------------------- + +internal enum WriteKind { Text, Value, Node } + +internal abstract class Segment; +internal sealed class StaticSeg(string text) : Segment { public string Text = text; } +internal sealed class HoleSeg(string expr, WriteKind kind) : Segment { public string Expr = expr; public WriteKind Kind = kind; } +internal sealed class OpaqueSeg(string expr, WriteKind kind) : Segment { public string Expr = expr; public WriteKind Kind = kind; } +internal sealed class LoopSeg(string items, string itemVar, List body) : Segment +{ public string Items = items; public string ItemVar = itemVar; public List Body = body; } +internal sealed class CondSeg(string cond, List then, List els) : Segment +{ public string Cond = cond; public List Then = then; public List Else = els; } + +// ---- recursive planner ------------------------------------------------------ + +internal sealed class Planner(SemanticModel model) +{ + const int MaxDepth = 48; + static readonly HashSet AttributeTypes = ["CC.CSX.HtmlAttributes", "CC.CSX.Css.CssAttributes", "CC.CSX.Css.CssProperties"]; + static readonly HashSet NonElementFactories = ["Each", "Dyn", "Raw", "Fragment", "None"]; + + readonly SemanticModel _model = model; + + public List Plan(ExpressionSyntax expr, ImmutableDictionary subst, int depth) + { + expr = Unwrap(expr); + if (depth > MaxDepth) return [new OpaqueSeg(Sub(expr, subst), KindOf(expr))]; + + var cv = _model.GetConstantValue(expr); + if (cv.HasValue) return [new StaticSeg(cv.Value?.ToString() ?? "")]; + + switch (expr) + { + case InvocationExpressionSyntax inv: return PlanInvocation(inv, subst, depth); + // structural conditional: branches produce nodes -> decompose each branch into a sub-plan + case ConditionalExpressionSyntax cond when KindOf(cond) == WriteKind.Node: + return [new CondSeg( + Sub(cond.Condition, subst), + Consolidate(Plan(cond.WhenTrue, subst, depth + 1)), + Consolidate(Plan(cond.WhenFalse, subst, depth + 1)))]; + case IdentifierNameSyntax or MemberAccessExpressionSyntax: return PlanLeaf(expr, subst); + default: return [new HoleSeg(Sub(expr, subst), KindOf(expr))]; // value ternaries land here + } + } + + List PlanLeaf(ExpressionSyntax expr, ImmutableDictionary subst) + { + if (_model.GetSymbolInfo(expr).Symbol is IFieldSymbol { IsStatic: true, IsReadOnly: true }) + return [new StaticSeg($"«{expr}»")]; // invariant; value not known at gen time (report-only) + return [new HoleSeg(Sub(expr, subst), KindOf(expr))]; + } + + List PlanInvocation(InvocationExpressionSyntax inv, ImmutableDictionary subst, int depth) + { + if (_model.GetSymbolInfo(inv).Symbol is not IMethodSymbol sym) + return [new OpaqueSeg(Sub(inv, subst), WriteKind.Node)]; + + string type = sym.ContainingType?.ToDisplayString() ?? ""; + + if (type == "System.Linq.Enumerable" && sym.Name is "ToArray" or "ToList" or "AsEnumerable" + && inv.Expression is MemberAccessExpressionSyntax mat) + return Plan(mat.Expression, subst, depth); + + if (type == "System.Linq.Enumerable" && sym.Name == "Select" && inv.Expression is MemberAccessExpressionSyntax sel) + return PlanLoop(sel.Expression, LastArgLambda(inv), subst, depth); + if (type == "CC.CSX.HtmlElements" && sym.Name == "Each" && inv.ArgumentList.Arguments.Count == 2) + return PlanLoop(inv.ArgumentList.Arguments[0].Expression, inv.ArgumentList.Arguments[1].Expression, subst, depth); + + if (type == "CC.CSX.HtmlElements" && !NonElementFactories.Contains(sym.Name) && ReturnsNode(sym)) + return PlanElement(sym, inv, subst, depth); + + if ((AttributeTypes.Contains(type) || IsHtmlPure(sym)) && ReturnsAttribute(sym)) + return PlanAttribute(sym, inv, subst, depth); + + if ((IsHtmlPure(sym) || HasAttr(sym, "CC.CSX.RenderOptimizedAttribute")) && ReturnsNode(sym) + && TryInline(sym, inv, subst, depth, out var inlined)) + return inlined; + + return [new OpaqueSeg(Sub(inv, subst), KindOf(inv))]; + } + + List PlanElement(IMethodSymbol sym, InvocationExpressionSyntax inv, ImmutableDictionary subst, int depth) + { + string tag = sym.Name.ToLowerInvariant(); + var attrs = new List(); + var children = new List(); + foreach (var arg in inv.ArgumentList.Arguments) + { + var segs = Plan(arg.Expression, subst, depth + 1); + (IsAttributeArg(arg.Expression) ? attrs : children).AddRange(segs); + } + var result = new List { new StaticSeg($"<{tag}") }; + result.AddRange(attrs); + result.Add(new StaticSeg(">")); + result.AddRange(children); + result.Add(new StaticSeg($"")); + return result; + } + + List PlanAttribute(IMethodSymbol sym, InvocationExpressionSyntax inv, ImmutableDictionary subst, int depth) + { + var result = new List { new StaticSeg($" {sym.Name}=\"") }; + if (inv.ArgumentList.Arguments.Count == 1) + result.AddRange(Plan(inv.ArgumentList.Arguments[0].Expression, subst, depth + 1)); + else + result.Add(new HoleSeg(Sub(inv, subst), WriteKind.Text)); + result.Add(new StaticSeg("\"")); + return result; + } + + List PlanLoop(ExpressionSyntax items, ExpressionSyntax? lambda, ImmutableDictionary subst, int depth) + { + if (lambda is null) return [new OpaqueSeg(Sub(items, subst), WriteKind.Node)]; + var (param, body) = ExtractLambda(lambda); + if (body is null) return [new OpaqueSeg(Sub(lambda, subst), WriteKind.Node)]; + return [new LoopSeg(Sub(items, subst), param ?? "item", Consolidate(Plan(body, subst, depth + 1)))]; + } + + bool TryInline(IMethodSymbol sym, InvocationExpressionSyntax inv, ImmutableDictionary subst, int depth, out List segs) + { + segs = []; + if (sym.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() is not MethodDeclarationSyntax decl) return false; + var ret = decl.ExpressionBody?.Expression + ?? decl.Body?.Statements.OfType().SingleOrDefault()?.Expression; + if (ret is null) return false; + + var args = inv.ArgumentList.Arguments; + var bound = subst; + for (int i = 0; i < sym.Parameters.Length && i < args.Count; i++) + bound = bound.SetItem(sym.Parameters[i].Name, Sub(args[i].Expression, subst)); + + var calleeModel = _model.Compilation.GetSemanticModel(ret.SyntaxTree); + segs = new Planner(calleeModel).Plan(ret, bound, depth + 1); + return true; + } + + // ---- helpers ---- + + WriteKind KindOf(ExpressionSyntax e) + { + var ti = _model.GetTypeInfo(e); + var t = ti.Type ?? ti.ConvertedType; // ternaries whose branches share only HtmlItem have no natural Type + if (t is null) return WriteKind.Text; + if (t.SpecialType == SpecialType.System_String) return WriteKind.Text; + if (Inherits(t, "CC.CSX.HtmlNode") || Inherits(t, "CC.CSX.HtmlItem")) return WriteKind.Node; + return WriteKind.Value; + } + + bool IsAttributeArg(ExpressionSyntax e) + { + var t = _model.GetTypeInfo(e).Type; + if (t is null) return false; + if (t.IsTupleType) return true; + return Inherits(t, "CC.CSX.HtmlAttribute"); + } + + static bool ReturnsNode(IMethodSymbol m) => Inherits(m.ReturnType, "CC.CSX.HtmlNode") || m.ReturnType.Name == "HtmlNode"; + static bool ReturnsAttribute(IMethodSymbol m) => Inherits(m.ReturnType, "CC.CSX.HtmlAttribute") || m.ReturnType.Name == "HtmlAttribute"; + static bool Inherits(ITypeSymbol? t, string full) + { + for (var b = t; b is not null; b = b.BaseType) + if (b.ToDisplayString() == full) return true; + return false; + } + static bool IsHtmlPure(IMethodSymbol m) => HasAttr(m, "CC.CSX.HtmlPureAttribute"); + static bool HasAttr(IMethodSymbol m, string full) => m.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == full); + + static ExpressionSyntax Unwrap(ExpressionSyntax e) => e is ParenthesizedExpressionSyntax p ? Unwrap(p.Expression) : e; + static ExpressionSyntax? LastArgLambda(InvocationExpressionSyntax inv) => inv.ArgumentList.Arguments.LastOrDefault()?.Expression; + + static (string? param, ExpressionSyntax? body) ExtractLambda(ExpressionSyntax e) => e switch + { + SimpleLambdaExpressionSyntax s => (s.Parameter.Identifier.Text, s.ExpressionBody), + ParenthesizedLambdaExpressionSyntax p => (p.ParameterList.Parameters.FirstOrDefault()?.Identifier.Text, p.ExpressionBody), + _ => (null, null), + }; + + static string Sub(ExpressionSyntax e, ImmutableDictionary subst) + { + string s = e.ToString(); + foreach (var kv in subst) + s = Regex.Replace(s, $@"\b{Regex.Escape(kv.Key)}\b", kv.Value.Replace("$", "$$")); + return s; + } + + public static List Consolidate(List segs) + { + var outp = new List(); + foreach (var s in segs) + { + if (s is StaticSeg cur && outp.Count > 0 && outp[outp.Count - 1] is StaticSeg prev) prev.Text += cur.Text; + else outp.Add(s); + } + return outp; + } +} diff --git a/src/CC.CSX.RenderPlan.Generator/Report.cs b/src/CC.CSX.RenderPlan.Generator/Report.cs new file mode 100644 index 0000000..2b18745 --- /dev/null +++ b/src/CC.CSX.RenderPlan.Generator/Report.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Text; + +namespace CC.CSX.RenderPlan.Generator; + +/// Renders a readable decomposition report for a planned method. +internal static class Report +{ + public static string Render(MethodPlan plan) + { + var sb = new StringBuilder(); + sb.AppendLine($" ═══ {plan.Signature} ═══"); + if (plan.Plan is null) + { + sb.AppendLine(" (no single return expression — not analyzable)"); + return sb.ToString(); + } + + int statics = 0, holes = 0, loops = 0, opaque = 0, bytes = 0; + sb.AppendLine(" plan:"); + Walk(plan.Plan, sb, " ", ref statics, ref holes, ref loops, ref opaque, ref bytes); + + sb.AppendLine($" template: {Template(plan.Plan)}"); + sb.AppendLine($" stats : {statics} static ({bytes} B), {holes} hole(s), {loops} loop(s), {opaque} opaque"); + sb.AppendLine(opaque == 0 + ? " verdict : fully decomposable" + : $" verdict : {opaque} opaque boundary(ies) (rendered live)"); + return sb.ToString(); + } + + static void Walk(List segs, StringBuilder sb, string indent, + ref int s, ref int h, ref int l, ref int o, ref int bytes) + { + foreach (var seg in segs) + switch (seg) + { + case StaticSeg st: + s++; bytes += Utf8Len(st.Text); + sb.AppendLine($"{indent}STATIC {Utf8Len(st.Text),4}B {Quote(st.Text)}"); + break; + case HoleSeg hole: + h++; + sb.AppendLine($"{indent}HOLE {hole.Kind,-5} {hole.Expr}"); + break; + case OpaqueSeg op: + o++; + sb.AppendLine($"{indent}OPAQUE {op.Kind,-5} {op.Expr}"); + break; + case LoopSeg loop: + l++; + sb.AppendLine($"{indent}LOOP each {loop.ItemVar} in {loop.Items}"); + Walk(loop.Body, sb, indent + " ", ref s, ref h, ref l, ref o, ref bytes); + break; + case CondSeg cond: + sb.AppendLine($"{indent}COND if ({cond.Cond})"); + sb.AppendLine($"{indent} then:"); + Walk(cond.Then, sb, indent + " ", ref s, ref h, ref l, ref o, ref bytes); + sb.AppendLine($"{indent} else:"); + Walk(cond.Else, sb, indent + " ", ref s, ref h, ref l, ref o, ref bytes); + break; + } + } + + static string Template(List segs) + { + var sb = new StringBuilder(); + foreach (var seg in segs) + sb.Append(seg switch + { + StaticSeg st => st.Text, + HoleSeg hole => "{" + hole.Expr + "}", + OpaqueSeg op => "{opaque:" + op.Expr + "}", + LoopSeg loop => "{each " + loop.ItemVar + " in " + loop.Items + ": " + Template(loop.Body) + "}", + CondSeg cond => "{if " + cond.Cond + ": " + Template(cond.Then) + " else: " + Template(cond.Else) + "}", + _ => "", + }); + return sb.ToString(); + } + + static int Utf8Len(string s) => Encoding.UTF8.GetByteCount(s); + static string Quote(string s) + { + s = s.Replace("\n", "\\n").Replace("\r", "\\r"); + if (s.Length > 60) s = s.Substring(0, 57) + "..."; + return "\"" + s + "\""; + } +} diff --git a/src/CC.CSX/CustomElementMethods.cs b/src/CC.CSX/CustomElementMethods.cs index 39efa07..e41e336 100644 --- a/src/CC.CSX/CustomElementMethods.cs +++ b/src/CC.CSX/CustomElementMethods.cs @@ -64,5 +64,23 @@ public static MultiHtmlAttribute MultiAttr(params HtmlAttribute[] attributes) /// public static HtmlNone None => HtmlNone.Instance; + /// + /// A node that renders the given pre-rendered HTML verbatim (no escaping). Use only for trusted + /// HTML; see for caching dynamically-built fragments. + /// + public static RawHtml Raw(string html) => new(html); + + /// + /// Marks a dynamic hole: is evaluated on each render. Surrounding + /// static markup can be baked by ; without a plan it renders inline. + /// + public static DynNode Dyn(Func produce) => new(produce); + + /// + /// Marks a repeated dynamic region: renders for each of + /// . Compiles to a loop hole in a . + /// + public static EachNode Each(IEnumerable items, Func render) => new(items, render); + } \ No newline at end of file diff --git a/src/CC.CSX/Domain/DynamicNodes.cs b/src/CC.CSX/Domain/DynamicNodes.cs new file mode 100644 index 0000000..1b28809 --- /dev/null +++ b/src/CC.CSX/Domain/DynamicNodes.cs @@ -0,0 +1,76 @@ +using System.Text; + +namespace CC.CSX; + +/// +/// Marks a dynamic hole in a view. When a is compiled, the surrounding +/// static markup is baked into byte segments and this node becomes a hole that is evaluated per +/// render. When rendered directly (no plan), it just produces and renders its node — so the same +/// view works with or without plan compilation. +/// +public sealed class DynNode : HtmlNode +{ + internal Func Produce { get; } + + /// Creates a dynamic hole that produces its node on each render. + public DynNode(Func produce) : base("#dyn") + => Produce = produce ?? throw new ArgumentNullException(nameof(produce)); + + /// + public override string ToString() => Produce().ToString(); + /// + public override string ToString(int indent = 0) => Produce().ToString(indent); + /// + public override void AppendTo(ref StringBuilder sb, int indent = 0) => Produce().AppendTo(ref sb, indent); + /// + public override void WriteTo(ref TextWriter tw, int indent = 0) => Produce().WriteTo(ref tw, indent); +} + +/// Non-generic view over an so a can expand it. +internal interface IEachNode +{ + IEnumerable Expand(); +} + +/// +/// Marks a repeated dynamic region: renders for each item. Compiles to +/// a loop hole in a ; renders its items inline when used without a plan. +/// +public sealed class EachNode : HtmlNode, IEachNode +{ + internal IEnumerable Items { get; } + internal Func RenderItem { get; } + + /// Creates a repeated region over . + public EachNode(IEnumerable items, Func renderItem) : base("#each") + { + Items = items ?? throw new ArgumentNullException(nameof(items)); + RenderItem = renderItem ?? throw new ArgumentNullException(nameof(renderItem)); + } + + /// Yields the rendered node for each item (re-evaluated on each call). + public IEnumerable Expand() + { + foreach (T item in Items) yield return RenderItem(item); + } + + /// + public override string ToString() => ToString(0); + /// + public override string ToString(int indent = 0) + { + var sb = new StringBuilder(); + AppendTo(ref sb, indent); + return sb.ToString(); + } + /// + public override void AppendTo(ref StringBuilder sb, int indent = 0) + { + foreach (HtmlNode n in Expand()) n.AppendTo(ref sb, indent); + } + /// + public override void WriteTo(ref TextWriter tw, int indent = 0) + { + foreach (HtmlNode n in Expand()) n.WriteTo(ref tw, indent); + } +} diff --git a/src/CC.CSX/Domain/FragmentCache.cs b/src/CC.CSX/Domain/FragmentCache.cs new file mode 100644 index 0000000..7df50e1 --- /dev/null +++ b/src/CC.CSX/Domain/FragmentCache.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace CC.CSX; + +/// +/// Process-wide control + store for cached HTML fragments. Caching renders a static subtree once +/// and reuses the bytes, skipping the rebuild + re-render of unchanging chrome (head, nav, footer) +/// on every request. +/// +/// +/// defaults to true in Release builds and false in Debug, so +/// development always sees fresh output (edits show immediately) while production gets the win. +/// It is a settable flag, so either default can be overridden at startup. Only cache fragments that +/// are genuinely static for their key — a fragment holding per-request data would serve stale content. +/// +public static class FragmentCache +{ + /// + /// Whether fragment caching takes effect. Default: enabled in Release, disabled in Debug. + /// + public static bool Enabled { get; set; } = +#if DEBUG + false; +#else + true; +#endif + + private static readonly ConcurrentDictionary Store = new(); + + /// + /// Returns a cached fragment for , building it with + /// the first time. When caching is disabled the fragment is built fresh and not stored. + /// + public static HtmlNode GetOrAdd(string key, Func factory) + => Enabled + ? Store.GetOrAdd(key, _ => new CachedFragment(factory())) + : new CachedFragment(factory()); + + /// Empties the keyed fragment store. + public static void Clear() => Store.Clear(); +} + +/// +/// Wraps a source node and, when caching is in effect, renders it once and emits the cached result +/// thereafter. Decides per render (not at construction) so the +/// flag is honored dynamically. Caching only kicks in when is 0; +/// with indentation the output depends on nesting depth, so the source is rendered live instead. +/// +public sealed class CachedFragment : HtmlNode +{ + private const string CachedKey = "#cached"; + private readonly HtmlNode _source; + private RawHtml? _rendered; + + /// Wraps for (flag-gated) caching. + public CachedFragment(HtmlNode source) : base(CachedKey) + => _source = source ?? throw new ArgumentNullException(nameof(source)); + + private bool UseCache => FragmentCache.Enabled && RenderOptions.Indent == 0; + + // Rendered at Indent == 0 (the only state in which the cache is used), so it is reusable at any depth. + private RawHtml Rendered => _rendered ??= new RawHtml(_source.ToString()); + + /// + public override string ToString() => UseCache ? Rendered.ToString() : _source.ToString(); + + /// + public override string ToString(int indent = 0) => UseCache ? Rendered.ToString(indent) : _source.ToString(indent); + + /// + public override void AppendTo(ref StringBuilder sb, int indent = 0) + { + if (UseCache) Rendered.AppendTo(ref sb, indent); + else _source.AppendTo(ref sb, indent); + } + + /// + public override void WriteTo(ref TextWriter tw, int indent = 0) + { + if (UseCache) + { + RawHtml r = Rendered; + r.WriteTo(ref tw, indent); + } + else + { + _source.WriteTo(ref tw, indent); + } + } +} diff --git a/src/CC.CSX/Domain/HtmlNodeExtensions.cs b/src/CC.CSX/Domain/HtmlNodeExtensions.cs index 216b720..74d5ed0 100644 --- a/src/CC.CSX/Domain/HtmlNodeExtensions.cs +++ b/src/CC.CSX/Domain/HtmlNodeExtensions.cs @@ -21,6 +21,13 @@ public static void WriteTo(this HtmlItem node, IBufferWriter output, int i // Utf8HtmlWriter.Dispose() flushes the batched chars into `output`. } + /// + /// Wraps so that, when is set + /// (Release by default), it is rendered once and the bytes reused on every subsequent render. + /// Intended for static chrome held in a static readonly field; see . + /// + public static HtmlNode Cache(this HtmlNode node) => new CachedFragment(node); + /// /// Applies the given action to each node that satisfies the given condition. /// The nodes are searched in depth-first order. diff --git a/src/CC.CSX/Domain/PlanNode.cs b/src/CC.CSX/Domain/PlanNode.cs new file mode 100644 index 0000000..716c544 --- /dev/null +++ b/src/CC.CSX/Domain/PlanNode.cs @@ -0,0 +1,54 @@ +using System.Text; + +namespace CC.CSX; + +/// +/// A lightweight node returned by generated (render-plan) code. It captures the view's arguments in +/// a write callback instead of building an tree; the callback runs the baked +/// static byte segments + dynamic holes when the node is rendered. Cheap to create (one delegate), +/// so an intercepted view call allocates almost nothing until it is written. +/// +public sealed class PlanNode : HtmlNode +{ + private readonly Action _write; + + /// Creates a plan node whose render is . + public PlanNode(Action write) : base("#plan") + => _write = write ?? throw new ArgumentNullException(nameof(write)); + + /// + public override void WriteTo(ref TextWriter tw, int indent = 0) => _write(tw); + + /// + public override void AppendTo(ref StringBuilder sb, int indent = 0) + { + TextWriter tw = new StringWriter(sb); + _write(tw); + } + + /// + public override string ToString(int indent = 0) + { + var sb = new StringBuilder(); + TextWriter tw = new StringWriter(sb); + _write(tw); + return sb.ToString(); + } + + /// + public override string ToString() => ToString(0); +} + +/// Helpers used by generated render-plan code. +public static class PlanStatics +{ + /// + /// Writes a baked static segment: the UTF-8 bytes via + /// (memcpy) when possible, otherwise the equivalent text to any other writer. + /// + public static void WriteStatic(TextWriter tw, byte[] utf8, string text) + { + if (tw is Utf8HtmlWriter u) u.WriteRawUtf8(utf8); + else tw.Write(text); + } +} diff --git a/src/CC.CSX/Domain/RawHtml.cs b/src/CC.CSX/Domain/RawHtml.cs new file mode 100644 index 0000000..131563a --- /dev/null +++ b/src/CC.CSX/Domain/RawHtml.cs @@ -0,0 +1,37 @@ +using System.Text; + +namespace CC.CSX; + +/// +/// A node that emits a pre-rendered HTML string verbatim (no escaping, no child walk). +/// Used as the cached representation of a fragment, and directly for trusted external HTML. +/// On the path it writes its cached UTF-8 bytes (a memcpy) instead +/// of re-encoding the string each time. +/// +public sealed class RawHtml : HtmlNode +{ + private const string RawKey = "#raw"; + private readonly string _html; + private byte[]? _utf8; + + /// Creates a node that renders exactly as given. + public RawHtml(string html) : base(RawKey) => _html = html ?? string.Empty; + + private byte[] Utf8 => _utf8 ??= Encoding.UTF8.GetBytes(_html); + + /// + public override string ToString() => _html; + + /// + public override string ToString(int indent = 0) => _html; + + /// + public override void AppendTo(ref StringBuilder sb, int indent = 0) => sb.Append(_html); + + /// + public override void WriteTo(ref TextWriter tw, int indent = 0) + { + if (tw is Utf8HtmlWriter u) u.WriteRawUtf8(Utf8); // memcpy cached bytes + else tw.Write(_html); + } +} diff --git a/src/CC.CSX/Domain/RenderPlan.cs b/src/CC.CSX/Domain/RenderPlan.cs new file mode 100644 index 0000000..57f7d1d --- /dev/null +++ b/src/CC.CSX/Domain/RenderPlan.cs @@ -0,0 +1,138 @@ +using System.Buffers; +using System.Text; + +namespace CC.CSX; + +/// +/// A compiled view: an ordered sequence of static UTF-8 byte chunks and dynamic holes. Static runs +/// are baked once at time and written by memcpy; only the holes +/// () and loops () are evaluated per render. +/// +/// +/// Plans are valid only at == 0 (indented output depends on +/// nesting depth) — compile and execute with Indent 0. A plan is immutable and safe to hold in a +/// static readonly field and share across threads; the hole callbacks read whatever state +/// they close over at execution time. +/// +public sealed class RenderPlan +{ + private readonly RenderSegment[] _segments; + + private RenderPlan(RenderSegment[] segments) => _segments = segments; + + /// Number of segments (static + dynamic). Useful for tests/diagnostics. + public int SegmentCount => _segments.Length; + + /// Executes the plan, streaming UTF-8 into . + public void WriteTo(IBufferWriter output) + { + using var w = new Utf8HtmlWriter(output); + TextWriter tw = w; + foreach (RenderSegment seg in _segments) seg.WriteTo(w, ref tw); + } + + /// Renders the plan to a string (decodes the UTF-8). Mainly for tests/diagnostics. + public string Render() + { + var buf = new ArrayBufferWriter(); + WriteTo(buf); + return Encoding.UTF8.GetString(buf.WrittenSpan); + } + + /// + /// Walks once, coalescing runs of static markup into baked byte segments + /// and turning / into hole/loop segments. + /// + public static RenderPlan Compile(HtmlNode root) + { + var segments = new List(); + var buf = new ArrayBufferWriter(); + var w = new Utf8HtmlWriter(buf); + + void FlushStatic() + { + w.Flush(); + if (buf.WrittenCount > 0) + { + segments.Add(new StaticSegment(buf.WrittenSpan.ToArray())); + buf.Clear(); + } + } + + void Emit(HtmlNode node) + { + switch (node) + { + case DynNode d: + FlushStatic(); + segments.Add(new HoleSegment(d.Produce)); + break; + case IEachNode each: + FlushStatic(); + segments.Add(new LoopSegment(each.Expand)); + break; + case Fragment frag: + if (frag.RawChildren is { } fc) + foreach (HtmlNode c in fc) Emit(c); + break; + case HtmlTextNode or RawHtml or CachedFragment: + // opaque-but-static: render verbatim into the current static run + EmitStatic(node); + break; + default: + // a regular element: open tag + attributes are static, children may cut the run + TextWriter tw = w; + w.Write('<'); + w.Write(node.Name); + if (node.RawAttributes is { } attrs) + foreach (HtmlAttribute a in attrs) { w.Write(' '); a.WriteTo(ref tw); } + w.Write('>'); + if (node.RawChildren is { } children) + foreach (HtmlNode c in children) Emit(c); + w.Write('<'); + w.Write('/'); + w.Write(node.Name); + w.Write('>'); + break; + } + } + + void EmitStatic(HtmlNode node) + { + TextWriter tw = w; + node.WriteTo(ref tw); + } + + Emit(root); + FlushStatic(); + w.Dispose(); + return new RenderPlan(segments.ToArray()); + } +} + +/// One step of a . +internal abstract class RenderSegment +{ + public abstract void WriteTo(Utf8HtmlWriter w, ref TextWriter tw); +} + +internal sealed class StaticSegment(byte[] utf8) : RenderSegment +{ + private readonly byte[] _utf8 = utf8; + public override void WriteTo(Utf8HtmlWriter w, ref TextWriter tw) => w.WriteRawUtf8(_utf8); +} + +internal sealed class HoleSegment(Func produce) : RenderSegment +{ + private readonly Func _produce = produce; + public override void WriteTo(Utf8HtmlWriter w, ref TextWriter tw) => _produce().WriteTo(ref tw); +} + +internal sealed class LoopSegment(Func> expand) : RenderSegment +{ + private readonly Func> _expand = expand; + public override void WriteTo(Utf8HtmlWriter w, ref TextWriter tw) + { + foreach (HtmlNode n in _expand()) n.WriteTo(ref tw); + } +} diff --git a/src/CC.CSX/Domain/Utf8HtmlWriter.cs b/src/CC.CSX/Domain/Utf8HtmlWriter.cs index 10a14ad..a26f263 100644 --- a/src/CC.CSX/Domain/Utf8HtmlWriter.cs +++ b/src/CC.CSX/Domain/Utf8HtmlWriter.cs @@ -67,6 +67,16 @@ public override void Write(ReadOnlySpan value) _pos += value.Length; } + /// + /// Writes already-encoded UTF-8 bytes straight to the underlying buffer (after draining any + /// pending chars). Used by to emit a cached fragment without re-encoding. + /// + public void WriteRawUtf8(ReadOnlySpan utf8) + { + FlushBuffer(); + _output.Write(utf8); + } + /// public override void Flush() => FlushBuffer(); diff --git a/src/CC.CSX/HtmlPureAttribute.cs b/src/CC.CSX/HtmlPureAttribute.cs new file mode 100644 index 0000000..8a9cbdd --- /dev/null +++ b/src/CC.CSX/HtmlPureAttribute.cs @@ -0,0 +1,11 @@ +namespace CC.CSX; + +/// +/// Declares that a method is a pure builder of HTML: its result depends only on its arguments and +/// it has no side effects. The render-plan analyzer treats calls to such methods as static when all +/// their arguments are static (and may follow into them). The built-in CC.CSX element/attribute +/// factories are recognized automatically; apply this to your own pure component builders to opt +/// them into the same treatment. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class HtmlPureAttribute : Attribute; diff --git a/src/CC.CSX/RenderOptimizedAttribute.cs b/src/CC.CSX/RenderOptimizedAttribute.cs new file mode 100644 index 0000000..70cc9f1 --- /dev/null +++ b/src/CC.CSX/RenderOptimizedAttribute.cs @@ -0,0 +1,10 @@ +namespace CC.CSX; + +/// +/// Marks a method that returns an for render-plan analysis: the +/// CC.CSX.RenderPlan.Generator inspects its body and decomposes it into static (bakeable) chunks +/// and dynamic holes. (Spike stage: the generator only reports the decomposition; codegen + +/// interceptors come later.) +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class RenderOptimizedAttribute : Attribute; diff --git a/tests/CC.CSX.Benchmarks/Benchmarks.cs b/tests/CC.CSX.Benchmarks/Benchmarks.cs index 509e576..0635a2c 100644 --- a/tests/CC.CSX.Benchmarks/Benchmarks.cs +++ b/tests/CC.CSX.Benchmarks/Benchmarks.cs @@ -14,7 +14,7 @@ // run everything: dotnet run -c Release --project tests/CC.CSX.Benchmarks -- --filter * // quick pass: ... -- --filter * --job short BenchmarkSwitcher - .FromTypes([typeof(RenderBenchmarks), typeof(ScalingBenchmarks), typeof(RequestBenchmarks), typeof(CssCompositionBenchmarks), typeof(BlazorComparisonBenchmarks)]) + .FromTypes([typeof(RenderBenchmarks), typeof(ScalingBenchmarks), typeof(RequestBenchmarks), typeof(RenderPlanBenchmarks), typeof(RealisticBenchmarks), typeof(CatalogBenchmarks), typeof(CssCompositionBenchmarks), typeof(BlazorComparisonBenchmarks)]) .Run(args.Length > 0 ? args : ["--filter", "*"]); /// @@ -158,36 +158,163 @@ public class RequestBenchmarks readonly DiscardBufferWriter sink = new(); + // Static chrome, cached once (Release default). Built lazily on first render at Indent=0. + static readonly HtmlNode CachedHead = SiteHead().Cache(); + static readonly HtmlNode CachedNav = SiteNav().Cache(); + static readonly HtmlNode CachedFooter = SiteFooter().Cache(); + [GlobalSetup] - public void Setup() => RenderOptions.Indent = 0; + public void Setup() + { + RenderOptions.Indent = 0; + FragmentCache.Enabled = true; // benchmark the caching path explicitly + } - [Benchmark] + [Benchmark(Baseline = true)] public long BuildAndRender() { - HtmlNode page = Page(Rows); + HtmlNode page = Html(SiteHead(), Body(SiteNav(), Content(Rows), SiteFooter())); + sink.Reset(); + page.WriteTo(sink); + return sink.Count; + } + + [Benchmark] + public long BuildAndRender_CachedChrome() + { + HtmlNode page = Html(CachedHead, Body(CachedNav, Content(Rows), CachedFooter)); sink.Reset(); page.WriteTo(sink); return sink.Count; } - static HtmlNode Page(int rows) => - Html( - Head( - Title("Report"), - Link(rel("stylesheet"), href("/app.css"))), - Body( - Div(@class("nav"), A(href("/"), "Home"), A(href("/about"), "About")), - Div(@class("uk-container"), - H1("Report"), - Table(@class("uk-table"), - Thead(Tr(Th("Id"), Th("Name"), Th("Email"))), - Tbody(Enumerable.Range(0, rows) - .Select(i => Tr(@class(i % 2 == 0 ? "even" : "odd"), - Td(i), - Td($"name-{i}"), - Td($"user{i}@example.com"))) - .ToArray()))), - Div(@class("footer"), P("(c) 2026")))); + // --- representative page parts --- + + static HtmlNode SiteHead() => + Head( + Meta(charset("utf-8")), + Meta(name("viewport"), content("width=device-width, initial-scale=1")), + Title("Report"), + Link(rel("stylesheet"), href("/app.css")), + Link(rel("preconnect"), href("https://fonts.example.com")), + Script(src("/lib/htmx.min.js")), + Script(src("/lib/app.js"))); + + static HtmlNode SiteNav() => + Div(@class("navbar"), + A(@class("brand"), href("/"), "Acme"), + A(href("/products"), "Products"), + A(href("/pricing"), "Pricing"), + A(href("/docs"), "Docs"), + A(href("/blog"), "Blog"), + A(href("/about"), "About"), + A(href("/login"), "Sign in")); + + static HtmlNode SiteFooter() => + Div(@class("footer"), + A(href("/terms"), "Terms"), + A(href("/privacy"), "Privacy"), + A(href("/contact"), "Contact"), + P("(c) 2026 Acme, Inc.")); + + static HtmlNode Content(int rows) => + Div(@class("uk-container"), + H1("Report"), + Table(@class("uk-table"), + Thead(Tr(Th("Id"), Th("Name"), Th("Email"))), + Tbody(Enumerable.Range(0, rows) + .Select(i => Tr(@class(i % 2 == 0 ? "even" : "odd"), + Td(i), + Td($"name-{i}"), + Td($"user{i}@example.com"))) + .ToArray()))); +} + +/// +/// Static/dynamic render plans for the 1000-row table: full live build+render vs a coarse plan +/// (chrome baked, rows rebuilt live) vs a hand-written fine plan (row scaffold baked, only cell +/// values written) — the ceiling a [RenderOptimized] generator would target. All write the same +/// bytes to a discarding sink at Indent 0. +/// +[MemoryDiagnoser] +public class RenderPlanBenchmarks +{ + [Params(10, 100, 1000)] + public int Rows { get; set; } + + (int id, string name, string email)[] rows = []; + readonly DiscardBufferWriter sink = new(); + RenderPlan coarse = null!; + + // baked static fragments for the fine plan + static readonly byte[] FinePrefix = U8(""); + static readonly byte[] RowClassOpen = U8(""); + static readonly byte[] FineSuffix = U8("
IdNameEmail
"); + static readonly byte[] CellSep = U8(""); + static readonly byte[] RowEnd = U8("
"); + static byte[] U8(string s) => Encoding.UTF8.GetBytes(s); + + [GlobalSetup] + public void Setup() + { + RenderOptions.Indent = 0; + rows = Enumerable.Range(0, Rows) + .Select(i => (i, $"name-{i}", $"user{i}@example.com")).ToArray(); + coarse = RenderPlan.Compile(BuildTable()); + } + + // current per-request cost: build the whole tree then stream it + [Benchmark(Baseline = true)] + public long Live_BuildAndRender() + { + sink.Reset(); + BuildTable().WriteTo(sink); + return sink.Count; + } + + // chrome baked once; rows still rebuilt live each execution + [Benchmark] + public long Plan_Coarse() + { + sink.Reset(); + coarse.WriteTo(sink); + return sink.Count; + } + + // the generator's target: no HtmlNode tree built at all, only cell values written + [Benchmark] + public long Plan_Fine_HandWritten() + { + sink.Reset(); + using var w = new Utf8HtmlWriter(sink); + w.WriteRawUtf8(FinePrefix); + Span num = stackalloc char[12]; + foreach (var r in rows) + { + w.WriteRawUtf8(RowClassOpen); + w.Write((r.id & 1) == 0 ? "even" : "odd"); + w.WriteRawUtf8(RowAfterClass); + r.id.TryFormat(num, out int n); + w.Write(num[..n]); + w.WriteRawUtf8(CellSep); + w.Write(r.name); + w.WriteRawUtf8(CellSep); + w.Write(r.email); + w.WriteRawUtf8(RowEnd); + } + w.WriteRawUtf8(FineSuffix); + w.Flush(); + return sink.Count; + } + + HtmlNode BuildTable() => + Table(@class("uk-table"), + Thead(Tr(Th("Id"), Th("Name"), Th("Email"))), + Tbody(Each(rows, r => Tr(@class((r.id & 1) == 0 ? "even" : "odd"), + Td(r.id), + Td(r.name), + Td(r.email))))); } /// diff --git a/tests/CC.CSX.Benchmarks/CC.CSX.Benchmarks.csproj b/tests/CC.CSX.Benchmarks/CC.CSX.Benchmarks.csproj index 8adcdcc..ddc17b5 100644 --- a/tests/CC.CSX.Benchmarks/CC.CSX.Benchmarks.csproj +++ b/tests/CC.CSX.Benchmarks/CC.CSX.Benchmarks.csproj @@ -20,6 +20,8 @@ + + diff --git a/tests/CC.CSX.Benchmarks/CatalogBenchmarks.cs b/tests/CC.CSX.Benchmarks/CatalogBenchmarks.cs new file mode 100644 index 0000000..71f0050 --- /dev/null +++ b/tests/CC.CSX.Benchmarks/CatalogBenchmarks.cs @@ -0,0 +1,114 @@ +using System.Buffers; + +using BenchmarkDotNet.Attributes; + +using CC.CSX; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using RenderPlanSpike; + +/// +/// A dynamic-heavy page (product catalog): a loop of cards, each with a conditional class, a computed +/// price, a STRUCTURAL conditional (SALE badge) and a NESTED loop (tags). A more realistic mix than a +/// plain data table — higher dynamic:static ratio. Compares the production paths: htnet render plan, +/// htnet live tree, and Blazor SSR (ToString + the faster WriteHtmlTo). +/// +[MemoryDiagnoser] +public class CatalogBenchmarks +{ + [Params(10, 100, 1000)] + public int Products { get; set; } + + internal static Product[] Data = []; + readonly DiscardBufferWriter sink = new(); + + ServiceProvider services = null!; + HtmlRenderer renderer = null!; + + [GlobalSetup] + public void Setup() + { + RenderOptions.Indent = 0; + Data = System.Linq.Enumerable.Range(0, Products) + .Select(i => new Product($"Product {i}", 1.5m * i, (i & 1) == 0, i % 3 == 0, + i % 4 == 0 ? new[] { "new", "hot" } : new[] { "sale" })) + .ToArray(); + + var sc = new ServiceCollection(); + sc.AddLogging(); + services = sc.BuildServiceProvider(); + renderer = new HtmlRenderer(services, services.GetRequiredService()); + } + + [GlobalCleanup] + public void Cleanup() + { + renderer.Dispose(); + services.Dispose(); + } + + [Benchmark] + public long Htnet_RenderPlan() + { + sink.Reset(); + CatalogViews__Optimized.Catalog(Data).WriteTo(sink); + return sink.Count; + } + + [Benchmark(Baseline = true)] + public long Htnet_Live() + { + sink.Reset(); + CatalogViews.Catalog(Data).WriteTo(sink); + return sink.Count; + } + + [Benchmark] + public string Blazor_ToString() + => renderer.Dispatcher.InvokeAsync(async () => + (await renderer.RenderComponentAsync()).ToHtmlString()).GetAwaiter().GetResult(); + + [Benchmark] + public void Blazor_WriteTo() + => renderer.Dispatcher.InvokeAsync(async () => + { + var root = await renderer.RenderComponentAsync(); + root.WriteHtmlTo(System.IO.TextWriter.Null); + }).GetAwaiter().GetResult(); +} + +/// The same catalog page as a Blazor component (hand-lowered .razor equivalent). +public class CatalogComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder b) + { + b.OpenElement(0, "div"); b.AddAttribute(1, "class", "catalog"); + b.OpenElement(2, "h1"); b.AddContent(3, "Products"); b.CloseElement(); + b.OpenElement(4, "div"); b.AddAttribute(5, "class", "grid"); + foreach (var p in CatalogBenchmarks.Data) + { + b.OpenElement(6, "div"); + b.AddAttribute(7, "class", p.InStock ? "card in-stock" : "card out-of-stock"); + b.OpenElement(8, "h3"); b.AddContent(9, p.Name); b.CloseElement(); + b.OpenElement(10, "p"); b.AddAttribute(11, "class", "price"); b.AddContent(12, $"${p.Price}"); b.CloseElement(); + if (p.OnSale) + { + b.OpenElement(13, "span"); b.AddAttribute(14, "class", "badge"); b.AddContent(15, "SALE"); b.CloseElement(); + } + b.OpenElement(16, "ul"); b.AddAttribute(17, "class", "tags"); + foreach (var t in p.Tags) + { + b.OpenElement(18, "li"); b.AddContent(19, t); b.CloseElement(); + } + b.CloseElement(); // ul + b.CloseElement(); // card div + } + b.CloseElement(); // grid + b.CloseElement(); // catalog + } +} diff --git a/tests/CC.CSX.Benchmarks/RealisticBenchmarks.cs b/tests/CC.CSX.Benchmarks/RealisticBenchmarks.cs new file mode 100644 index 0000000..530e5c5 --- /dev/null +++ b/tests/CC.CSX.Benchmarks/RealisticBenchmarks.cs @@ -0,0 +1,188 @@ +using System.Buffers; +using System.Text; + +using BenchmarkDotNet.Attributes; + +using CC.CSX; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +/// +/// A realistic data-table page rendered four ways, all emitting the same HTML to a discarding sink +/// at Indent 0: +/// - HandWritten : direct byte writing (the floor) +/// - Htnet_RenderPlan : the generated [RenderOptimized] builder (Views__Optimized.Report) +/// - Htnet_Live : building the CC.CSX HtmlNode tree, then WriteTo (current default path) +/// - Blazor_SSR : the same page as a Blazor component via HtmlRenderer (what .razor compiles to) +/// +[MemoryDiagnoser] +public class RealisticBenchmarks +{ + [Params(10, 100, 1000)] + public int Rows { get; set; } + + internal static (int id, string name, string email)[] Data = []; + readonly DiscardBufferWriter sink = new(); + + ServiceProvider services = null!; + HtmlRenderer renderer = null!; + + static readonly byte[] Prefix = U8("

Report

"); + static readonly byte[] RowOpen = U8(""); + static readonly byte[] Suffix = U8("
IdNameEmail
"); + static readonly byte[] Sep = U8(""); + static readonly byte[] RowEnd = U8("
"); + static byte[] U8(string s) => Encoding.UTF8.GetBytes(s); + + [GlobalSetup] + public void Setup() + { + RenderOptions.Indent = 0; + Data = System.Linq.Enumerable.Range(0, Rows) + .Select(i => (i, $"name-{i}", $"user{i}@example.com")).ToArray(); + + var sc = new ServiceCollection(); + sc.AddLogging(); + services = sc.BuildServiceProvider(); + renderer = new HtmlRenderer(services, services.GetRequiredService()); + } + + [GlobalCleanup] + public void Cleanup() + { + renderer.Dispose(); + services.Dispose(); + } + + [Benchmark] + public long HandWritten() + { + sink.Reset(); + using var w = new Utf8HtmlWriter(sink); + w.WriteRawUtf8(Prefix); + Span num = stackalloc char[12]; + foreach (var r in Data) + { + w.WriteRawUtf8(RowOpen); + w.Write((r.id & 1) == 0 ? "even" : "odd"); + w.WriteRawUtf8(AfterClass); + r.id.TryFormat(num, out int n); + w.Write(num[..n]); + w.WriteRawUtf8(Sep); + w.Write(r.name); + w.WriteRawUtf8(Sep); + w.Write(r.email); + w.WriteRawUtf8(RowEnd); + } + w.WriteRawUtf8(Suffix); + w.Flush(); + return sink.Count; + } + + [Benchmark] + public long Htnet_RenderPlan() + { + sink.Reset(); + RenderPlanSpike.Views__Optimized.Report(Data).WriteTo(sink); + return sink.Count; + } + + [Benchmark(Baseline = true)] + public long Htnet_Live() + { + sink.Reset(); + RenderPlanSpike.Views.Report(Data).WriteTo(sink); + return sink.Count; + } + + // typical Blazor SSR: builds the full HTML string + [Benchmark] + public string Blazor_ToString() + => renderer.Dispatcher.InvokeAsync(async () => + (await renderer.RenderComponentAsync()).ToHtmlString()).GetAwaiter().GetResult(); + + // Blazor optimization 1: render straight to a TextWriter (no output string materialized) + [Benchmark] + public void Blazor_WriteTo() + => renderer.Dispatcher.InvokeAsync(async () => + { + var root = await renderer.RenderComponentAsync(); + root.WriteHtmlTo(System.IO.TextWriter.Null); + }).GetAwaiter().GetResult(); + + // Blazor optimization 2: static HTML as raw markup (AddMarkupContent) — fewer/cheaper frames, + // the hand-tuned analog of baked static chunks — also written to a TextWriter + [Benchmark] + public void Blazor_Markup_WriteTo() + => renderer.Dispatcher.InvokeAsync(async () => + { + var root = await renderer.RenderComponentAsync(); + root.WriteHtmlTo(System.IO.TextWriter.Null); + }).GetAwaiter().GetResult(); +} + +/// The same data-table page as a Blazor component (hand-lowered .razor equivalent). +public class ReportComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder b) + { + b.OpenElement(0, "div"); + b.AddAttribute(1, "class", "uk-container"); + b.OpenElement(2, "h1"); + b.AddContent(3, "Report"); + b.CloseElement(); + b.OpenElement(4, "table"); + b.AddAttribute(5, "class", "uk-table"); + b.OpenElement(6, "thead"); + b.OpenElement(7, "tr"); + b.OpenElement(8, "th"); b.AddContent(9, "Id"); b.CloseElement(); + b.OpenElement(10, "th"); b.AddContent(11, "Name"); b.CloseElement(); + b.OpenElement(12, "th"); b.AddContent(13, "Email"); b.CloseElement(); + b.CloseElement(); // tr + b.CloseElement(); // thead + b.OpenElement(14, "tbody"); + foreach (var r in RealisticBenchmarks.Data) + { + b.OpenElement(15, "tr"); + b.AddAttribute(16, "class", (r.id & 1) == 0 ? "even" : "odd"); + b.OpenElement(17, "td"); b.AddContent(18, r.id); b.CloseElement(); + b.OpenElement(19, "td"); b.AddContent(20, r.name); b.CloseElement(); + b.OpenElement(21, "td"); b.AddContent(22, r.email); b.CloseElement(); + b.CloseElement(); // tr + } + b.CloseElement(); // tbody + b.CloseElement(); // table + b.CloseElement(); // div + } +} + +/// +/// The same page, hand-optimized the way a Blazor dev would for SSR throughput: static HTML emitted +/// as raw markup via (one cheap frame, no escaping), +/// only the dynamic values via AddContent — the Blazor analog of baked static chunks. +/// +public class ReportComponentMarkup : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder b) + { + b.AddMarkupContent(0, "

Report

"); + foreach (var r in RealisticBenchmarks.Data) + { + b.AddMarkupContent(1, ""); + } + b.AddMarkupContent(10, "
IdNameEmail
"); + b.AddContent(4, r.id); + b.AddMarkupContent(5, ""); + b.AddContent(6, r.name); + b.AddMarkupContent(7, ""); + b.AddContent(8, r.email); + b.AddMarkupContent(9, "
"); + } +} diff --git a/tests/CC.CSX.RenderPlan.Tests/CC.CSX.RenderPlan.Tests.csproj b/tests/CC.CSX.RenderPlan.Tests/CC.CSX.RenderPlan.Tests.csproj new file mode 100644 index 0000000..a638b7f --- /dev/null +++ b/tests/CC.CSX.RenderPlan.Tests/CC.CSX.RenderPlan.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/CC.CSX.RenderPlan.Tests/GoldenTests.cs b/tests/CC.CSX.RenderPlan.Tests/GoldenTests.cs new file mode 100644 index 0000000..9ec9048 --- /dev/null +++ b/tests/CC.CSX.RenderPlan.Tests/GoldenTests.cs @@ -0,0 +1,81 @@ +namespace CC.CSX.RenderPlan.Tests; + +using System.Buffers; +using System.Text; + +using CC.CSX; + +using RenderPlanSpike; + +using Xunit; + +// The generated optimized builder (Views__Optimized.*) returns a PlanNode whose render must be +// byte-identical to the original method's WriteTo. (WithUnknown is omitted — DateTime.Now is +// non-deterministic.) +public class GoldenTests +{ + public GoldenTests() => RenderOptions.Indent = 0; // plans assume compact output + + static string Render(HtmlNode node) + { + var b = new ArrayBufferWriter(); + node.WriteTo(b); + return Encoding.UTF8.GetString(b.WrittenSpan); + } + + [Fact] + public void UserRow_Matches() + => Assert.Equal( + Render(Views.UserRow(7, "Ann", "ann@example.com")), + Render(Views__Optimized.UserRow(7, "Ann", "ann@example.com"))); + + [Fact] + public void TableHeader_Matches() + => Assert.Equal(Render(Views.TableHeader()), Render(Views__Optimized.TableHeader())); + + [Fact] + public void Report_Matches() + { + var rows = new[] { (0, "n0", "u0@x.com"), (1, "n1", "u1@x.com"), (2, "n2", "u2@x.com") }; + Assert.Equal(Render(Views.Report(rows)), Render(Views__Optimized.Report(rows))); + } + + [Fact] + public void Profile_Matches() + => Assert.Equal(Render(Views.Profile("costa")), Render(Views__Optimized.Profile("costa"))); + + [Fact] + public void Status_StructuralConditional_Matches() + { + Assert.Equal(Render(Views.Status(true)), Render(Views__Optimized.Status(true))); + Assert.Equal(Render(Views.Status(false)), Render(Views__Optimized.Status(false))); + } + + [Fact] + public void OptimizedBuilder_ReturnsPlanNode() + => Assert.IsType(Views__Optimized.UserRow(1, "a", "b")); +} + +// complex / dynamic-heavy page (loop + structural conditional + nested loop + inlined component) +public class CatalogGoldenTests +{ + public CatalogGoldenTests() => RenderOptions.Indent = 0; + + static string Render(HtmlNode node) + { + var b = new ArrayBufferWriter(); + node.WriteTo(b); + return Encoding.UTF8.GetString(b.WrittenSpan); + } + + static readonly Product[] Products = + [ + new("Widget", 9.99m, true, true, ["new", "hot"]), + new("Gadget", 19.50m, false, false, ["clearance"]), + new("Gizmo", 4.00m, true, false, []), + ]; + + [Fact] + public void Catalog_Matches() + => Assert.Equal(Render(CatalogViews.Catalog(Products)), Render(CatalogViews__Optimized.Catalog(Products))); +} diff --git a/tests/CC.CSX.RenderPlan.Tests/InterceptionTests.cs b/tests/CC.CSX.RenderPlan.Tests/InterceptionTests.cs new file mode 100644 index 0000000..70c35dc --- /dev/null +++ b/tests/CC.CSX.RenderPlan.Tests/InterceptionTests.cs @@ -0,0 +1,52 @@ +namespace CC.CSX.RenderPlan.Tests; + +using System.Buffers; +using System.Text; + +using CC.CSX; + +using RenderPlanSpike; + +using Xunit; + +// Demo.* call [RenderOptimized] views inside RenderPlanSpike, where interceptors are enabled. So the +// `Views.X(...)` calls there are rewritten to the optimized builder and return a PlanNode. (This test +// project has no interceptors, so calling Views.X(...) here yields the real tree — our oracle.) +public class InterceptionTests +{ + public InterceptionTests() => RenderOptions.Indent = 0; + + static string Render(HtmlNode node) + { + var b = new ArrayBufferWriter(); + node.WriteTo(b); + return Encoding.UTF8.GetString(b.WrittenSpan); + } + + [Fact] + public void CallSite_IsIntercepted_ReturnsPlanNode() + { + Assert.IsType(Demo.UserRow()); + Assert.IsType(Demo.Status(true)); + } + + [Fact] + public void Intercepted_UserRow_OutputMatchesOriginal() + => Assert.Equal( + Render(Views.UserRow(1, "Ann", "ann@example.com")), // real tree (not intercepted here) + Render(Demo.UserRow())); // intercepted -> optimized plan + + [Fact] + public void Intercepted_Status_OutputMatchesOriginal() + { + Assert.Equal(Render(Views.Status(true)), Render(Demo.Status(true))); + Assert.Equal(Render(Views.Status(false)), Render(Demo.Status(false))); + } + + [Fact] + public void Intercepted_Report_OutputMatchesOriginal() + { + var rows = new[] { (0, "n0", "u0@x.com"), (1, "n1", "u1@x.com") }; + Assert.Equal(Render(Views.Report(rows)), Render(Demo.Report(rows))); + } +} diff --git a/tests/CC.CSX.Tests/AssemblyInfo.cs b/tests/CC.CSX.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..2616938 --- /dev/null +++ b/tests/CC.CSX.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +// These tests mutate process-global render state (RenderOptions.Indent, FragmentCache.Enabled), +// so they must not run concurrently across test classes. +[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/CC.CSX.Tests/FragmentCacheTests.cs b/tests/CC.CSX.Tests/FragmentCacheTests.cs new file mode 100644 index 0000000..bf6db53 --- /dev/null +++ b/tests/CC.CSX.Tests/FragmentCacheTests.cs @@ -0,0 +1,67 @@ +namespace CC.CSX.Tests; + +using System.Buffers; +using System.Text; + +using static CC.CSX.HtmlElements; +using static CC.CSX.HtmlAttributes; + +public class FragmentCacheTests +{ + static string Utf8(HtmlNode node) + { + var buf = new ArrayBufferWriter(); + node.WriteTo(buf); + return Encoding.UTF8.GetString(buf.WrittenSpan); + } + + [Fact] + public void Raw_RendersVerbatim_AcrossAllPaths() + { + RenderOptions.Indent = 0; + var raw = Raw("hi & bye"); + Assert.Equal("hi & bye", raw.ToString()); + var sb = new StringBuilder(); + raw.AppendTo(ref sb); + Assert.Equal("hi & bye", sb.ToString()); + Assert.Equal("hi & bye", Utf8(raw)); + } + + [Fact] + public void CachedFragment_ProducesSameOutputAsLive_WhenEnabled() + { + RenderOptions.Indent = 0; + FragmentCache.Enabled = true; + var live = Div(@class("nav"), A(href("/"), "Home"), A(href("/x"), "X")); + var cached = Div(@class("nav"), A(href("/"), "Home"), A(href("/x"), "X")).Cache(); + Assert.Equal(live.ToString(), cached.ToString()); + Assert.Equal(Utf8(live), Utf8(cached)); + } + + [Fact] + public void CachedFragment_RendersLive_WhenDisabled() + { + RenderOptions.Indent = 0; + FragmentCache.Enabled = false; + try + { + var source = Div(P("a")); + var cached = source.Cache(); + Assert.Equal(source.ToString(), cached.ToString()); + Assert.Equal(Utf8(source), Utf8(cached)); + } + finally { FragmentCache.Enabled = true; } + } + + [Fact] + public void GetOrAdd_ReturnsSameInstance_WhenEnabled() + { + FragmentCache.Enabled = true; + FragmentCache.Clear(); + var a = FragmentCache.GetOrAdd("k", () => Div(P("x"))); + var b = FragmentCache.GetOrAdd("k", () => Div(P("DIFFERENT"))); + Assert.Same(a, b); // second factory not invoked; cached instance reused + Assert.Equal("

x

", Utf8(a).Replace("\r\n", "\n")); + FragmentCache.Clear(); + } +} diff --git a/tests/CC.CSX.Tests/GeneralRenderingTests.cs b/tests/CC.CSX.Tests/GeneralRenderingTests.cs index 439afb9..c07b8cd 100644 --- a/tests/CC.CSX.Tests/GeneralRenderingTests.cs +++ b/tests/CC.CSX.Tests/GeneralRenderingTests.cs @@ -33,6 +33,7 @@ public void AllCreationMethodsShouldWorkProperly() [Fact] public void WriteTo_Should_ProduceTheSameOutputAsToString() { + RenderOptions.Indent = 2; // this test asserts the indented form var tw = new StringWriter() as TextWriter; _sut.WriteTo(ref tw); Assert.Equal(_expected, tw.ToString()!.Replace("\r\n", "\n")); diff --git a/tests/CC.CSX.Tests/RenderPlanTests.cs b/tests/CC.CSX.Tests/RenderPlanTests.cs new file mode 100644 index 0000000..697f856 --- /dev/null +++ b/tests/CC.CSX.Tests/RenderPlanTests.cs @@ -0,0 +1,72 @@ +namespace CC.CSX.Tests; + +using System.Buffers; +using System.Text; + +using static CC.CSX.HtmlElements; +using static CC.CSX.HtmlAttributes; + +public class RenderPlanTests +{ + static string Live(HtmlNode node) + { + var buf = new ArrayBufferWriter(); + node.WriteTo(buf); + return Encoding.UTF8.GetString(buf.WrittenSpan); + } + + static void AssertPlanMatchesLive(HtmlNode view) + { + RenderOptions.Indent = 0; + var plan = RenderPlan.Compile(view); + Assert.Equal(Live(view), plan.Render()); + } + + [Fact] + public void StaticOnly_MatchesLive() + => AssertPlanMatchesLive(Div(@class("box"), H1("Title"), P("body text"))); + + [Fact] + public void SingleHole_Interleaved_MatchesLive() + { + var count = 42; + AssertPlanMatchesLive( + Div(Span("before"), Dyn(() => P($"count={count}")), Span("after"))); + } + + [Fact] + public void HoleAtTagBoundary_MatchesLive() + { + // hole sits between the opening
and a trailing static node — exercises mid-node cut + AssertPlanMatchesLive(Div(Dyn(() => "x"), Hr())); + } + + [Fact] + public void Each_MatchesLive() + { + var items = new[] { "a", "b", "c" }; + AssertPlanMatchesLive(Ul(Each(items, i => Li(@class("row"), i)))); + } + + [Fact] + public void Nested_StaticDynamicMix_MatchesLive() + { + var rows = Enumerable.Range(0, 5).ToArray(); + AssertPlanMatchesLive( + Div(@class("uk-container"), + H1("Report"), + Table(@class("uk-table"), + Thead(Tr(Th("Id"), Th("Name"))), + Tbody(Each(rows, i => Tr(@class(i % 2 == 0 ? "even" : "odd"), Td(i), Td($"name-{i}"))))), + Dyn(() => P("footer")))); + } + + [Fact] + public void Plan_SegmentsAreCoalesced() + { + RenderOptions.Indent = 0; + // div( static, hole, static ) -> [static, hole, static] = 3 segments + var plan = RenderPlan.Compile(Div(Span("a"), Dyn(() => "b"), Span("c"))); + Assert.Equal(3, plan.SegmentCount); + } +}