Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ 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<byte>)` (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.
- HTML escaping is **on by default**: `HtmlTextNode` and `HtmlAttribute` values are escaped (`& < > " '`) by `HtmlEscape` (vectorized `SearchValues` scan with a verbatim fast path — no cost when there's nothing to escape). `Raw(string)`/`RawHtml` is the opt-out (writes verbatim); `<script>`/`<style>` are raw-text elements whose text children are written un-escaped. The render-plan generator escapes baked constant content and dynamic **text/attribute holes** identically (numeric/`Value` holes skip escaping since they can't contain specials); golden tests pin plan output equal to live, including escaping.
- 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`.
Expand Down
9 changes: 9 additions & 0 deletions samples/RenderPlanSpike/Views.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ public static HtmlNode Status(bool ok) =>
ok ? Span(@class("ok"), "Online")
: Span(@class("err"), "Offline"));

// escaping: a constant with special chars (baked escaped) + dynamic text and attribute holes
// (escaped at runtime) — must match the live renderer exactly.
[RenderOptimized]
public static HtmlNode Escaped(string user, string note) =>
Div(@class("box"),
P("a < b & \"c\""), // constant special chars
P(user), // dynamic text hole
Span(title(note), "ok")); // dynamic attribute-value hole

// a boundary: calls an unknown/impure helper -> should become an opaque hole
[RenderOptimized]
public static HtmlNode WithUnknown(string s) =>
Expand Down
3 changes: 2 additions & 1 deletion src/CC.CSX.RenderPlan.Generator/CodeEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ static void EmitWrite(string expr, WriteKind kind, StringBuilder body, string in
switch (kind)
{
case WriteKind.Text:
body.AppendLine($"{indent}__tw.Write({expr});");
body.AppendLine($"{indent}global::CC.CSX.HtmlEscape.WriteEscaped(__tw, {expr});");
break;
case WriteKind.Value:
// numeric/bool ToString cannot contain HTML-special chars, so no escaping needed
body.AppendLine($"{indent}__tw.Write(({expr}).ToString());");
break;
case WriteKind.Node:
Expand Down
23 changes: 22 additions & 1 deletion src/CC.CSX.RenderPlan.Generator/RenderPlanGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ public List<Segment> Plan(ExpressionSyntax expr, ImmutableDictionary<string, str
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() ?? "")];
// constant content (text or attribute value) is baked, so escape it here exactly as the
// live renderer would; structural markup segments are produced elsewhere and never escaped.
if (cv.HasValue) return [new StaticSeg(HtmlEscape(cv.Value?.ToString() ?? ""))];

switch (expr)
{
Expand Down Expand Up @@ -314,6 +316,25 @@ static string Sub(ExpressionSyntax e, ImmutableDictionary<string, string> subst)
return s;
}

// mirrors CC.CSX.HtmlEscape (entities for & < > " ') so baked constants match the live renderer
static string HtmlEscape(string s)
{
if (s.IndexOfAny(EscapeChars) < 0) return s;
var sb = new StringBuilder(s.Length + 16);
foreach (char c in s)
sb.Append(c switch
{
'&' => "&amp;",
'<' => "&lt;",
'>' => "&gt;",
'"' => "&quot;",
'\'' => "&#39;",
_ => c.ToString(),
});
return sb.ToString();
}
static readonly char[] EscapeChars = ['&', '<', '>', '"', '\''];

public static List<Segment> Consolidate(List<Segment> segs)
{
var outp = new List<Segment>();
Expand Down
8 changes: 5 additions & 3 deletions src/CC.CSX/Domain/HtmlAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public HtmlAttribute(in string name) : base(name) { }
/// <summary>
/// Renders the attribute to HTML by taking into account the indentation.
/// </summary>
public override string ToString(int indent = 0) => string.IsNullOrEmpty(Name) ? string.Empty : Value is null ? Name : $"{Name}=\"{Value}\"";
public override string ToString(int indent = 0) => string.IsNullOrEmpty(Name) ? string.Empty : Value is null ? Name : $"{Name}=\"{HtmlEscape.Escape(Value)}\"";
/// <summary>
/// Renders the attribute to HTML by taking into account the indentation.
/// </summary>
Expand All @@ -50,7 +50,9 @@ public override void AppendTo(ref StringBuilder sb, int indent = 0)
}
else
{
sb.Append(Name).Append(CharEqual).Append(CharQuote).Append(Value).Append(CharQuote);
sb.Append(Name).Append(CharEqual).Append(CharQuote);
HtmlEscape.AppendEscaped(sb, Value);
sb.Append(CharQuote);
}
}

Expand All @@ -70,7 +72,7 @@ public override void WriteTo(ref TextWriter sb, int indent = 0)
sb.Write(Name);
sb.Write(CharEqual);
sb.Write(CharQuote);
sb.Write(Value);
HtmlEscape.WriteEscaped(sb, Value);
sb.Write(CharQuote);
}
}
Expand Down
80 changes: 80 additions & 0 deletions src/CC.CSX/Domain/HtmlEscape.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Buffers;
using System.Text;

namespace CC.CSX;

/// <summary>
/// HTML-escapes text and attribute values. Uses a vectorized scan (<see cref="SearchValues{T}"/>)
/// with a verbatim fast path: when the value contains no special character (the common case) it is
/// written in one shot with no per-char work and no allocation. Escapes <c>&amp; &lt; &gt; " '</c>,
/// which is safe for both element text and double-quoted attribute values.
/// </summary>
public static class HtmlEscape
{
static readonly SearchValues<char> Special = SearchValues.Create("&<>\"'");

static string Entity(char c) => c switch
{
'&' => "&amp;",
'<' => "&lt;",
'>' => "&gt;",
'"' => "&quot;",
'\'' => "&#39;",
_ => "",
};

/// <summary>Writes <paramref name="value"/> to <paramref name="tw"/>, HTML-escaped.</summary>
public static void WriteEscaped(TextWriter tw, string? value)
{
if (!string.IsNullOrEmpty(value)) WriteEscaped(tw, value.AsSpan());
}

/// <summary>Writes <paramref name="s"/> to <paramref name="tw"/>, HTML-escaped.</summary>
public static void WriteEscaped(TextWriter tw, ReadOnlySpan<char> s)
{
int idx = s.IndexOfAny(Special);
if (idx < 0) { tw.Write(s); return; } // fast path: nothing to escape
int start = 0;
while (idx >= 0)
{
if (idx > start) tw.Write(s.Slice(start, idx - start));
tw.Write(Entity(s[idx]));
start = idx + 1;
int next = s.Slice(start).IndexOfAny(Special);
idx = next < 0 ? -1 : start + next;
}
if (start < s.Length) tw.Write(s.Slice(start));
}

/// <summary>Appends <paramref name="value"/> to <paramref name="sb"/>, HTML-escaped.</summary>
public static void AppendEscaped(StringBuilder sb, string? value)
{
if (!string.IsNullOrEmpty(value)) AppendEscaped(sb, value.AsSpan());
}

/// <summary>Appends <paramref name="s"/> to <paramref name="sb"/>, HTML-escaped.</summary>
public static void AppendEscaped(StringBuilder sb, ReadOnlySpan<char> s)
{
int idx = s.IndexOfAny(Special);
if (idx < 0) { sb.Append(s); return; }
int start = 0;
while (idx >= 0)
{
if (idx > start) sb.Append(s.Slice(start, idx - start));
sb.Append(Entity(s[idx]));
start = idx + 1;
int next = s.Slice(start).IndexOfAny(Special);
idx = next < 0 ? -1 : start + next;
}
if (start < s.Length) sb.Append(s.Slice(start));
}

/// <summary>Returns <paramref name="value"/> HTML-escaped (or the same instance if nothing to escape).</summary>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value) || value!.AsSpan().IndexOfAny(Special) < 0) return value ?? string.Empty;
var sb = new StringBuilder(value.Length + 16);
AppendEscaped(sb, value.AsSpan());
return sb.ToString();
}
}
9 changes: 8 additions & 1 deletion src/CC.CSX/Domain/HtmlNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,18 @@ public override void WriteTo(ref TextWriter tw, int indent = 0)
tw.WriteLine();
}

// <script>/<style> are raw-text elements: their text content must NOT be HTML-escaped
// (e.g. CSS `a > b {}` or JS `a < b`), so write text children verbatim.
bool rawText = Name is "script" or "style";

if (children is not null)
{
foreach (HtmlNode? child in children)
{
child?.WriteTo(ref tw, indent + RenderOptions.Indent);
if (rawText && child is HtmlTextNode { Value: { } raw })
tw.Write(raw);
else
child?.WriteTo(ref tw, indent + RenderOptions.Indent);
if (newLines)
{
if (child is HtmlTextNode)
Expand Down
14 changes: 7 additions & 7 deletions src/CC.CSX/Domain/HtmlTextNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,24 @@ public class HtmlTextNode(in string value) : HtmlNode(TextNodeKey, value)
/// <summary>
/// Creates a new instance of <see cref="HtmlTextNode"/> with the given value.
/// </summary>
public override string ToString()
{
return Value ?? "";
}
public override string ToString() => HtmlEscape.Escape(Value);

///<summary>
///<inheritdoc/>
///</summary>
public override string ToString(int indent = 0)
{
return (new string(' ', indent) + Value) ?? "";
var sb = new StringBuilder();
if (indent > 0) Indentation.AppendTo(sb, indent);
HtmlEscape.AppendEscaped(sb, Value);
return sb.ToString();
}

///<inheritdoc/>
public override void AppendTo(ref StringBuilder sb, int indent = 0)
{
if (indent > 0) Indentation.AppendTo(sb, indent);
sb.Append(Value);
HtmlEscape.AppendEscaped(sb, Value);
}

///<inheritdoc/>
Expand All @@ -41,7 +41,7 @@ public override void WriteTo(ref TextWriter tw, int indent = 0)
if (Value is not null)
{
if (indent > 0) Indentation.WriteTo(tw, indent);
tw.Write(Value);
HtmlEscape.WriteEscaped(tw, Value);
}
}
}
12 changes: 12 additions & 0 deletions tests/CC.CSX.RenderPlan.Tests/GoldenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ public void Status_StructuralConditional_Matches()
[Fact]
public void OptimizedBuilder_ReturnsPlanNode()
=> Assert.IsType<PlanNode>(Views__Optimized.UserRow(1, "a", "b"));

[Fact]
public void Escaped_Matches_AndActuallyEscapes()
{
const string user = "<script>alert('x')&</script>";
const string note = "a\"b&c<";
var live = Render(Views.Escaped(user, note));
var opt = Render(Views__Optimized.Escaped(user, note));
Assert.Equal(live, opt); // plan escapes identically to live
Assert.Contains("&lt;script&gt;", live); // and it really is escaped
Assert.DoesNotContain("<script>", live);
}
}

// complex / dynamic-heavy page (loop + structural conditional + nested loop + inlined component)
Expand Down
51 changes: 51 additions & 0 deletions tests/CC.CSX.Tests/EscapingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace CC.CSX.Tests;

using Xunit;

using static CC.CSX.HtmlElements;
using static CC.CSX.HtmlAttributes;

public class EscapingTests
{
public EscapingTests() => RenderOptions.Indent = 0;

[Fact]
public void TextContent_IsEscaped()
{
var html = Div(P("a < b & c > d")).ToString();
Assert.Contains("a &lt; b &amp; c &gt; d", html);
Assert.DoesNotContain("< b", html);
}

[Fact]
public void AttributeValue_IsEscaped()
{
var html = A(href("/s?a=1&b=2"), title("say \"hi\"")).ToString();
Assert.Contains("href=\"/s?a=1&amp;b=2\"", html);
Assert.Contains("title=\"say &quot;hi&quot;\"", html);
}

[Fact]
public void Raw_IsNotEscaped()
{
var html = Div(Raw("<b>bold &amp; raw</b>")).ToString();
Assert.Contains("<b>bold &amp; raw</b>", html);
}

[Fact]
public void StyleContent_IsNotEscaped()
{
// raw-text element: CSS like `a > b {}` must survive verbatim
var html = Style("a > b { content: \"x\"; }").ToString();
Assert.Contains("a > b { content: \"x\"; }", html);
Assert.DoesNotContain("&gt;", html);
}

[Fact]
public void ScriptContent_IsNotEscaped()
{
var html = Script("if (a < b && c) {}").ToString();
Assert.Contains("if (a < b && c) {}", html);
Assert.DoesNotContain("&lt;", html);
}
}
Loading