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
23 changes: 18 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on: [push, pull_request]

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
DOCS_PATH: ${{ secrets.DOCS_PATH }}
DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }}

Expand Down Expand Up @@ -57,10 +56,13 @@ jobs:
10.0.x

- name: Build
run: ./build.sh -AngleSharpVersion 1.5.0
run: ./build.sh -AngleSharpVersion 1.9.0

windows:
runs-on: windows-latest
permissions:
contents: write # create the GitHub release
id-token: write # request the OIDC token for NuGet trusted publishing

steps:
- uses: actions/checkout@v5
Expand All @@ -71,12 +73,23 @@ jobs:
dotnet-version: |
10.0.x

# Exchanges the GitHub OIDC token for a short-lived (1 hour) NuGet API key.
# Requires a trusted publishing policy on nuget.org for this repository and workflow file.
- name: NuGet login
id: nuget_login
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/devel')
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}

- name: Build
env:
NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }}
run: |
if ($env:GITHUB_REF -eq "refs/heads/main") {
.\build.ps1 -Target Publish -AngleSharpVersion 1.5.0
.\build.ps1 -Target Publish -AngleSharpVersion 1.9.0
} elseif ($env:GITHUB_REF -eq "refs/heads/devel") {
.\build.ps1 -Target PrePublish -AngleSharpVersion 1.5.0
.\build.ps1 -Target PrePublish -AngleSharpVersion 1.9.0
} else {
.\build.ps1 -AngleSharpVersion 1.5.0
.\build.ps1 -AngleSharpVersion 1.9.0
}
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ dotnet run --project src/AngleSharp.Performance.Css/AngleSharp.Performance.Css.c

Notes:

- CI invokes `./build.sh -AngleSharpVersion 1.5.0` on Linux and `./build.ps1` on Windows.
- CI invokes `./build.sh -AngleSharpVersion 1.9.0` on Linux and `./build.ps1` on Windows.
- Treat warnings as errors is enabled in `src/Directory.Build.props`.

## 3) Build and target framework facts

- Library project: `src/AngleSharp.Css/AngleSharp.Css.csproj`
- Targets: `netstandard2.0;net8.0;net10.0`
- Additional Windows-only targets: `net462;net472`
- The AngleSharp dependency defaults to version `1.9.0` and allows versions below `2.0.0`.
- The `net8.0` and `net10.0` targets set `IsAotCompatible`, so trim and AOT analyzer warnings fail the build.
- Test project: `src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj`
- Targets: `net8.0`
- Fallout entrypoint: `build/Build.cs`
Expand Down Expand Up @@ -137,7 +139,9 @@ dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj
- Avoid broad refactors when making focused feature fixes.
- Keep public API changes intentional; many files under `Dom` are effectively surface area.
- Existing code mixes nullable contexts (`#nullable enable` and `#nullable disable`); do not normalize unrelated files.
- Stay trim- and AOT-clean. The library sets `IsAotCompatible` on its .NETCoreApp targets, so the trim and AOT analyzers run on every build and a warning breaks it. Reflection over a runtime type (`GetType().GetProperties()`, `Assembly.GetTypes()`) is what they reject: read members off an annotated generic parameter instead (`Css<T, TProperties>` via AngleSharp's `ToDictionary<T>`), or use a fixed table. The analyzers only see the library itself; publishing a small `PublishAot` console app against it is the end-to-end check.
- In `src/AngleSharp.Css/ValueConverters.cs`, avoid static field initializers that reference converters declared later in the same file. Forward references can capture `null` during type initialization and only fail at runtime.
- Untrusted input can nest arbitrarily deep, and a stack overflow ends the process. `CssBuilder` parses nested blocks recursively and carries a depth that stops at `CssParserOptions.MaxNestingDepth`; a new recursive parsing path needs the same. The limit is a mitigation, not a fix: the recursion is still there, so a raised limit can still overflow the stack. `src/AngleSharp.Css.Tests/Parsing/DeepNesting.cs` holds the tests, which parse on a 256 KB stack.

## 8) Documentation and onboarding references

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# 1.3.1

Released on Sunday, November 1 2026.

- Improved the parser with a nesting limit for rules (`MaxNestingDepth`) (#258) @michaelthyregod
- Updated to use a minimum of AngleSharp 1.9 (#257) @michaelthyregod
- Added `Render(element, parent)` to the window, rendering the subtree of an element again below its parent's render node from an earlier render tree (e.g. after its inline style changed)
- Added trimming and Native AOT compatibility (#257) @michaelthyregod
- Added a trim-safe generic `Css` overload; the `Object` overload is obsolete (#257) @michaelthyregod

# 1.3.0

Released on Wednesday, October 28 2026.
Expand Down
2 changes: 1 addition & 1 deletion build/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,9 @@ protected override void OnBuildInitialized()
.DependsOn(RunUnitTests)
.Executes(() =>
{
// In CI this is the short-lived key issued by NuGet trusted publishing (NuGet/login).
var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY");


if (apiKey.IsNullOrEmpty())
{
throw new BuildAbortedException("Could not resolve the NuGet API key.");
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Css.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
"version": "1.3.0",
"version": "1.3.1",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
Expand Down
40 changes: 40 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/ApiExtension.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
namespace AngleSharp.Css.Tests.Extensions
{
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;
using static CssConstructionFunctions;

Expand All @@ -23,5 +26,42 @@ public void ExtensionCssWithEmptyListOnly()
var elements = document.QuerySelectorAll("li").Css("color", "red");
Assert.AreEqual(0, elements.Count());
}

[Test]
public void ExtensionCssWithAnonymousObjectSetsDeclarations()
{
var document = ParseDocument("<ul><li>a</li><li>b</li></ul>");
var elements = document.QuerySelectorAll("li").Css(new { color = "red", display = "none" });
Assert.AreEqual(2, elements.Count());

foreach (var element in elements)
{
Assert.AreEqual("rgba(255, 0, 0, 1)", element.GetStyle().GetPropertyValue("color"));
Assert.AreEqual("none", element.GetStyle().GetPropertyValue("display"));
}
}

[Test]
public void ExtensionCssWithDictionaryCopiesItsEntries()
{
// A dictionary binds to the generic overload ahead of the key value pair one, so it
// must be copied there rather than have the properties of Dictionary read.
var document = ParseDocument("<ul><li>a</li></ul>");
var properties = new Dictionary<String, String> { ["color"] = "red", ["margin-left"] = "2px" };
var element = document.QuerySelectorAll("li").Css(properties).Single();
Assert.AreEqual("rgba(255, 0, 0, 1)", element.GetStyle().GetPropertyValue("color"));
Assert.AreEqual("2px", element.GetStyle().GetPropertyValue("margin-left"));
}

[Test]
public void ExtensionCssWithValueDeclaredAsObjectStillWorks()
{
var document = ParseDocument("<ul><li>a</li></ul>");
Object properties = new { color = "red" };
#pragma warning disable CS0618 // The obsolete Object overload is the one under test.
var element = document.QuerySelectorAll("li").Css(properties).Single();
#pragma warning restore CS0618
Assert.AreEqual("rgba(255, 0, 0, 1)", element.GetStyle().GetPropertyValue("color"));
}
}
}
19 changes: 19 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ private static ElementRenderNode FindElement(ElementRenderNode node, string id)
return null;
}

[Test]
public void ElementRenderedAgainBelowItsParentTakesItsNewStyleAndInherits()
{
var document = ParseDocument("<html><head><style>#parent { color: red } #target { display: none } #target::before { content: 'x' }</style></head>" +
"<body><div id=parent><div id=target><span id=child>text</span></div></div></body></html>");
var window = document.DefaultView;
var root = (ElementRenderNode)window.Render(new PlainRenderDevice());
var parent = FindElement(root, "parent");
var target = document.GetElementById("target");
target.SetAttribute("style", "display: block");

var node = window.Render(target, parent, new PlainRenderDevice());

Assert.AreSame(parent, node.Parent);
Assert.AreEqual("block", node.ComputedStyle.GetDisplay());
Assert.AreEqual("rgba(255, 0, 0, 1)", FindElement(node, "child").ComputedStyle.GetColor());
Assert.IsInstanceOf<IPseudoElement>(node.Children.OfType<ElementRenderNode>().First().Ref);
}

[Test]
public void BeforePseudoElementIsTheFirstChildWithItsGeneratedTextContent()
{
Expand Down
172 changes: 172 additions & 0 deletions src/AngleSharp.Css.Tests/Parsing/DeepNesting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#nullable disable
namespace AngleSharp.Css.Tests.Parsing
{
using AngleSharp.Css.Dom;
using AngleSharp.Css.Dom.Events;
using AngleSharp.Css.Parser;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Text;
using System.Threading;
using static CssConstructionFunctions;

/// <summary>
/// Deeply nested input used to overflow the stack, which ends the process. The deep cases
/// use depths that crashed before. The limits only mitigate this: parsing still recurses
/// once per level, so these tests parse on a thread with a small, fixed stack to show the
/// defaults hold there too.
/// </summary>
[TestFixture]
public class DeepNesting
{
private const Int32 Deep = 10000;

// as AngleSharp's own selector nesting tests do: less stack than the 1 MB main thread
private const Int32 SmallStack = 256 * 1024;

private static T OnSmallStack<T>(Func<T> parse)
{
var result = default(T);
var error = default(Exception);
var thread = new Thread(() =>
{
try
{
result = parse();
}
catch (Exception ex)
{
error = ex;
}
}, SmallStack);
thread.Start();
thread.Join();

if (error is not null)
{
ExceptionDispatchInfo.Capture(error).Throw();
}

return result;
}

private static CssStyleSheet ParseOnSmallStack(String source) =>
OnSmallStack(() => ParseStyleSheet(source));

private static String Repeat(String value, Int32 count) =>
new StringBuilder(value.Length * count).Insert(0, value, count).ToString();

private static String NestedGroups(String atRule, Int32 depth, String inner) =>
Repeat(atRule + "{", depth) + inner + Repeat("}", depth);

/// <summary>
/// The number of grouping rules nested in each other, following the first rule of each.
/// </summary>
private static Int32 GroupDepth(ICssRuleList rules, out ICssRuleList innermost)
{
var depth = 0;
innermost = rules;

while (innermost.Length > 0 && innermost[0] is ICssGroupingRule group)
{
depth++;
innermost = group.Rules;
}

return depth;
}

private static List<Int32> CollectErrors(String source, CssParserOptions options)
{
var codes = new List<Int32>();
var parser = new CssParser(options);
parser.Error += (_, ev) => codes.Add(((CssErrorEvent)ev).Code);
parser.ParseStyleSheet(source);
return codes;
}

[TestCase("@media all")]
[TestCase("@supports (display: block)")]
[TestCase("@layer x")]
[TestCase("@container (width > 1px)")]
[TestCase("@scope (a)")]
[TestCase("@starting-style")]
public void DeeplyNestedGroupingRulesStopAtTheDefaultLimit(String atRule)
{
var sheet = ParseOnSmallStack(NestedGroups(atRule, Deep, "a{color:red}") + "b{color:blue}");
var depth = GroupDepth(sheet.Rules, out _);
Assert.AreEqual(CssParserOptions.DefaultMaxNestingDepth + 1, depth);
Assert.AreEqual(2, sheet.Rules.Length);
Assert.AreEqual("b", ((ICssStyleRule)sheet.Rules[1]).SelectorText);
}

[Test]
public void StyleRuleNestedInAsManyGroupsAsAllowedIsKept()
{
var limit = CssParserOptions.DefaultMaxNestingDepth;
var sheet = ParseOnSmallStack(NestedGroups("@media all", limit, "a{color:red}"));
Assert.AreEqual(limit, GroupDepth(sheet.Rules, out var innermost));
Assert.AreEqual(1, innermost.Length);
Assert.AreEqual("a", ((ICssStyleRule)innermost[0]).SelectorText);
}

[Test]
public void StyleRuleNestedDeeperThanAllowedIsDroppedWithAnError()
{
var limit = CssParserOptions.DefaultMaxNestingDepth;
var source = NestedGroups("@media all", limit + 1, "a{color:red}");
var sheet = ParseOnSmallStack(source);
Assert.AreEqual(limit + 1, GroupDepth(sheet.Rules, out var innermost));
Assert.AreEqual(0, innermost.Length);
CollectionAssert.Contains(CollectErrors(source, default), (Int32)CssParseError.NestingTooDeep);
}

[Test]
public void NestingLimitIsConfigurable()
{
var options = new CssParserOptions { MaxNestingDepth = 3 };
var source = NestedGroups("@media all", 5, "a{color:red}") + "b{color:blue}";
var sheet = ParseStyleSheet(source, options);
Assert.AreEqual(4, GroupDepth(sheet.Rules, out var innermost));
Assert.AreEqual(0, innermost.Length);
Assert.AreEqual(2, sheet.Rules.Length);
Assert.AreEqual(1, CollectErrors(source, options).Count(code => code == (Int32)CssParseError.NestingTooDeep));
}

[Test]
public void NestingLimitAboveTheDefaultIsHonored()
{
// on the 1 MB test thread: a raised limit is the caller's risk, and 200 nested @media
// already overflow a 256 KB stack
var options = new CssParserOptions { MaxNestingDepth = 200 };
var sheet = ParseStyleSheet(NestedGroups("@media all", 200, "a{color:red}"), options);
Assert.AreEqual(200, GroupDepth(sheet.Rules, out var innermost));
Assert.AreEqual(1, innermost.Length);
}

[TestCase("& b")]
[TestCase(".b")]
public void DeeplyNestedStyleRulesDoNotOverflow(String selector)
{
var sheet = ParseOnSmallStack("a{" + Repeat(selector + "{", Deep) + "color:red" + Repeat("}", Deep + 1) + "c{color:blue}");
Assert.AreEqual(2, sheet.Rules.Length);
Assert.AreEqual("c", ((ICssStyleRule)sheet.Rules[1]).SelectorText);
}

[Test]
public void NestedStyleRuleBeyondTheLimitIsDroppedAndTheDeclarationsContinue()
{
var options = new CssParserOptions { MaxNestingDepth = 1 };
var sheet = ParseStyleSheet("a { & b { & c { color: red } color: green } color: blue }", options);
var a = (ICssStyleRule)sheet.Rules[0];
Assert.AreEqual("blue", a.Style.GetPropertyValue("color").Replace("rgba(0, 0, 255, 1)", "blue"));
Assert.AreEqual(1, a.Rules.Length);
var b = (ICssStyleRule)a.Rules[0];
Assert.AreEqual("rgba(0, 128, 0, 1)", b.Style.GetPropertyValue("color"));
Assert.AreEqual(0, b.Rules.Length);
}
}
}
21 changes: 20 additions & 1 deletion src/AngleSharp.Css/AngleSharp.Css.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@
<Nullable>enable</Nullable>
</PropertyGroup>

<!-- Marks the package trimmable and AOT-compatible, and runs the trim, AOT and single-file
analyzers on every build. Only the .NETCoreApp targets support it; the SDK warns on the
others. Keyed on TargetFramework because TargetFrameworkIdentifier is not set yet when the
project's own properties are evaluated. -->
<PropertyGroup Condition=" '$(TargetFramework)' != 'netstandard2.0' And !$(TargetFramework.StartsWith('net4')) ">
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<!-- IsAotCompatible makes the SDK reference Microsoft.NET.ILLink.Tasks implicitly, at the version
its KnownILLinkPack table lists per target (8.0.x for net8.0, 10.0.x for net10.0). Two versions
of one package in one project make Visual Studio's NuGet Consolidate tab report it, so every
target uses the trimmer of the SDK doing the build instead. Following the SDK's bundled version
keeps it current without pinning, and covers the next pair of targets too (net10.0 next to net11.0).
KnownILLinkPack is SDK-internal and undocumented: if an SDK renames or reshapes it, this goes
inert or fails the restore - drop it then, or once only one .NETCoreApp target is left. -->
<ItemGroup>
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks" ILLinkPackVersion="$(BundledNETCoreAppPackageVersion)" />
</ItemGroup>

<PropertyGroup Label="Packaging">
<PackageProjectUrl>https://anglesharp.github.io</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand All @@ -21,7 +40,7 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<AngleSharpVersion Condition=" '$(AngleSharpVersion)' == '' ">1.5.0</AngleSharpVersion>
<AngleSharpVersion Condition=" '$(AngleSharpVersion)' == '' ">1.9.0</AngleSharpVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading
Loading