Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f1f8acd
Build the parts of the render tree only when needed
michaelthyregod Nov 4, 2026
8148e86
Support for selection pseudo element
FlorianRappl Nov 4, 2026
a1cb93c
Merge pull request #266 from michaelthyregod/feature/partial-render-tree
FlorianRappl Nov 4, 2026
3defd6d
Updated changelog
FlorianRappl Nov 4, 2026
deb3328
Get the inner text from a partial render tree
michaelthyregod Nov 4, 2026
c8ee9e2
Add the CHANGELOG entries
michaelthyregod Nov 4, 2026
1e9430b
Keep what only a partial render tree needs out of the render nodes
michaelthyregod Nov 4, 2026
8264cd4
Merge branch 'devel' into feature/innertext-render-tree
michaelthyregod Nov 4, 2026
bae919a
Merge pull request #267 from michaelthyregod/feature/innertext-render…
FlorianRappl Nov 4, 2026
5b1fb5d
Read the quirks mode without reflection
michaelthyregod Nov 4, 2026
53b4ec0
Add the CHANGELOG entry
michaelthyregod Nov 5, 2026
35541d5
Keep the pending children of a render node in its children field
michaelthyregod Nov 5, 2026
577908c
Add the CHANGELOG entry
michaelthyregod Nov 5, 2026
bb6515d
Merge pull request #268 from michaelthyregod/feature/compat-mode-check
FlorianRappl Nov 5, 2026
603b2c5
Improved DOM compatibility
FlorianRappl Nov 5, 2026
374f25d
Merge branch 'devel' of github.com:AngleSharp/AngleSharp.Css into devel
FlorianRappl Nov 5, 2026
0ec72a6
Merge branch 'devel' into feature/render-node-children
FlorianRappl Nov 5, 2026
df89ed3
Merge pull request #269 from michaelthyregod/feature/render-node-chil…
FlorianRappl Nov 5, 2026
a092021
Added stylesheet adoption
FlorianRappl Nov 6, 2026
44a76a1
Added popover
FlorianRappl Nov 6, 2026
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
10.0.x

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

windows:
runs-on: windows-latest
Expand Down Expand Up @@ -87,9 +87,9 @@ jobs:
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.9.0
.\build.ps1 -Target Publish -AngleSharpVersion 1.10.0
} elseif ($env:GITHUB_REF -eq "refs/heads/devel") {
.\build.ps1 -Target PrePublish -AngleSharpVersion 1.9.0
.\build.ps1 -Target PrePublish -AngleSharpVersion 1.10.0
} else {
.\build.ps1 -AngleSharpVersion 1.9.0
.\build.ps1 -AngleSharpVersion 1.10.0
}
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ dotnet run --project src/AngleSharp.Performance.Css/AngleSharp.Performance.Css.c

Notes:

- CI invokes `./build.sh -AngleSharpVersion 1.9.0` on Linux and `./build.ps1` on Windows.
- CI invokes `./build.sh -AngleSharpVersion 1.10.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 AngleSharp dependency defaults to version `1.10.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`
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
# 1.4.0

Released on Friday, November 6 2026.

- Updated to use a minimum of AngleSharp 1.10
- Improved rendering by building pseudo-elements and specified styles only when needed
- Improved presentational hints by reading the quirks mode without reflection (#268) @michaelthyregod
- Improved `GetInnerText` by using the render tree, skipping hidden content and reading shadow hosts through their slots (#267) @michaelthyregod
- Improved the memory use of render trees (#269) @michaelthyregod
- Fixed `maxWidth` of a style being a method for script
- Fixed the initial values of `position` and `background-origin` being named `inline` and `border-box`
- Fixed `getComputedStyle` giving script empty values for what was not declared instead of the initial values, e.g. `position` not being `static`
- Fixed `CSS.supports` throwing for a condition it can't parse, and not taking a declaration without parentheses
- Added support for `::selection` pseudo elements
- Added `addListener` and `removeListener` to `MediaQueryList`
- Added `selector()` to supports conditions
- Added the accessors of all style properties for script that can be applied (e.g. `gap`, `aspectRatio`, `gridTemplateColumns`, `inset`), with the names having hyphens (`style['max-width']`) and `float`
- Added `ICssUsedValueProvider`, for `getComputedStyle` to give script what layout found
- Added `CSSStyleSheet` and `adoptedStyleSheets` to the document
- Added support for the popover API using the `:popover-open` pseudo-class, the `::backdrop` pseudo-element

# 1.3.2

Released on Tuesday, November 3 2026.
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.2",
"version": "1.4.0",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
Expand Down
49 changes: 49 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/InnerText.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable disable
namespace AngleSharp.Css.Tests.Extensions
{
using AngleSharp.Css.RenderTree;
using AngleSharp.Dom;
using NUnit.Framework;
using System;
Expand Down Expand Up @@ -96,5 +97,53 @@ public void SpanShouldNotHaveRemovedSpaces_Issue32()

Assert.AreEqual("Div with a span in it.", element.GetInnerText());
}

// the rendered text walks the element's own children: a shadow tree is left out, and
// a light child only counts if it is rendered, i.e. assigned to a slot
[TestCase("<p>shadow</p><slot></slot>", "<span>light</span>", "light")]
[TestCase("<p>shadow</p>", "<span>light</span>", "")]
[TestCase("<div><slot name=b></slot></div><slot></slot>", "<span slot=b>b</span><p>a</p>", "b\n\na")]
[TestCase("<slot name=b></slot>", "<span slot=b>b</span><span>unassigned</span>", "b")]
public void ShadowHostGivesTheTextOfItsRenderedChildren(String shadow, String light, String expected)
{
var document = "<div id=host></div>".ToHtmlDocument(Configuration.Default.WithCss());
var host = document.GetElementById("host");
host.AttachShadow().InnerHtml = shadow;
host.InnerHtml = light;

Assert.AreEqual(expected, host.GetInnerText());
}

[Test]
public void GeneratedContentIsNotPartOfTheText()
{
var document = "<style>p::before { content: 'B' } p::after { content: 'A' }</style><p id=target>x<br>y</p>".ToHtmlDocument(Configuration.Default.WithCss());

Assert.AreEqual("x\ny", document.GetElementById("target").GetInnerText());
}

[TestCase("<p>a</p><div style=\"display: none\">b</div><table><tr><td>1</td><td>2</td></tr></table><span style=\"text-transform: uppercase\">c</span>")]
[TestCase("<ul><li>a<li style=\"visibility: hidden\">b<li>c</ul><pre> x\n y </pre>")]
public void TextOfARenderedNodeIsTheTextOfItsElement(String html)
{
var document = $"<div id=target>{html}</div>".ToHtmlDocument(Configuration.Default.WithCss());
var target = document.GetElementById("target");
var node = (ElementRenderNode)document.DefaultView.Render().Find(target);

Assert.AreEqual(target.GetInnerText(), node.GetInnerText());
}

// the walk of an element releases what it has walked, the walk of a rendered node does not
[Test]
public void TextOfAShadowHostIsTheTextOfItsRenderedNode()
{
var document = "<div id=target><p>a</p><div id=host><span slot=s>b</span><table><tr><td>1</td><td>2</td></tr><tr><td>3</td></tr></table></div><p>c</p></div>".ToHtmlDocument(Configuration.Default.WithCss());
var target = document.GetElementById("target");
document.GetElementById("host").AttachShadow().InnerHtml = "<div><slot name=s></slot></div><ul><li><slot></slot></ul>";
var node = (ElementRenderNode)document.DefaultView.Render().Find(target);

Assert.AreEqual("a\n\nb\n1\t2\n3\n\nc", target.GetInnerText());
Assert.AreEqual(target.GetInnerText(), node.GetInnerText());
}
}
}
161 changes: 161 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,167 @@ namespace AngleSharp.Css.Tests.Extensions
[TestFixture]
public class MatchMediaTests
{
[TestCase("selector(div)", true)]
[TestCase("selector(:hover)", true)]
[TestCase("selector(:not-a-real-pseudo-class)", false)]
[TestCase("not selector(:not-a-real-pseudo-class)", true)]
[TestCase("selector(div) and (color: red)", true)]
[TestCase("(color: red)", true)]
[TestCase("color: red", true)]
[TestCase("(color: notacolor)", false)]
[TestCase("this is ( not parseable", false)]
[TestCase("", false)]
public void SupportsAnswersWithoutThrowing(System.String condition, System.Boolean expected)
{
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
Assert.AreEqual(expected, window.Supports(condition));
}

private static (IWindow Window, IElement Element) CreateStyled(System.String style)
{
var document = new HtmlParser(new HtmlParserOptions(), BrowsingContext.New(Configuration.Default.WithCss())).ParseDocument($"<!doctype html><div id=d style=\"{style}\"></div>");
return (document.DefaultView, document.GetElementById("d"));
}

[TestCase("position", "static")]
[TestCase("visibility", "visible")]
[TestCase("overflow-x", "visible")]
[TestCase("overflow-y", "visible")]
[TestCase("overflow", "visible")]
[TestCase("opacity", "1")]
[TestCase("flex-direction", "row")]
public void ScriptSeesInitialValuesOfWhatWasNotDeclared(System.String property, System.String expected)
{
var (window, element) = CreateStyled("");
Assert.AreEqual(expected, window.GetComputedStyleOfScript(element).GetPropertyValue(property));
}

[Test]
public void ScriptSeesWhatWasDeclared()
{
var (window, element) = CreateStyled("position: absolute; overflow: hidden");
var style = window.GetComputedStyleOfScript(element);

Assert.AreEqual("absolute", style.GetPropertyValue("position"));
Assert.AreEqual("hidden", style.GetPropertyValue("overflow"));
Assert.AreEqual("visible", style.GetPropertyValue("visibility"));
}

[Test]
public void TheComputedStyleForDotNetStillTellsWhatWasNotDeclared()
{
var (window, element) = CreateStyled("");
window.GetComputedStyleOfScript(element);

Assert.AreEqual(System.String.Empty, window.GetComputedStyle(element).GetPropertyValue("position"));
}

private static readonly System.Collections.Generic.HashSet<System.String> NotForScript = new System.Collections.Generic.HashSet<System.String>
{
// pages test for these to rely on them, but nothing applies them
"anchor-name", "anchor-scope", "position-anchor", "position-area", "position-try-fallbacks", "position-try-order", "position-visibility",
"animation-timeline", "animation-range", "animation-range-start", "animation-range-end", "view-transition-name", "view-transition-class",
// these are not properties of a style
"src", "unicode-range", "font-display", "size-adjust", "ascent-override", "descent-override", "line-gap-override",
"min-zoom", "max-zoom", "user-zoom", "orientation", "string-set", "running", "footnote-display", "footnote-policy",
"bookmark-level", "bookmark-label", "bookmark-state", "background-repeat-x", "background-repeat-y", "scrollbar-base-color",
};

private static System.Collections.Generic.IEnumerable<System.String> AllProperties() =>
typeof(ICssStyleDeclaration).Assembly.GetType("AngleSharp.Css.PropertyNames")
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Select(m => (System.String)m.GetValue(null));

private static ILookup<System.String, System.Reflection.MethodInfo> ScriptMembers() =>
typeof(StyleDeclarationExtensions).GetMethods()
.SelectMany(m => m.GetCustomAttributes(typeof(AngleSharp.Attributes.DomNameAttribute), false)
.Cast<AngleSharp.Attributes.DomNameAttribute>()
.Select(n => new { n.OfficialName, Method = m }))
.ToLookup(m => m.OfficialName, m => m.Method);

private static System.String ToCamelCase(System.String name)
{
var parts = name.TrimStart('-').Split('-');
return parts[0] + System.String.Concat(parts.Skip(1).Select(m => char.ToUpperInvariant(m[0]) + m.Substring(1)));
}

[Test]
public void EveryPropertyThatCanBeAppliedHasAnAccessorAndAHyphenatedName()
{
var members = ScriptMembers();
var missing = AllProperties()
.Where(m => !NotForScript.Contains(m) && m != "float")
.Where(m => members[ToCamelCase(m)].Count() != 2 || members[m].Count() != 2)
.ToArray();

Assert.IsEmpty(missing);
}

[Test]
public void WhatCanNotBeAppliedHasNoAccessorSoThatPagesDoNotRelyOnIt()
{
var members = ScriptMembers();
var exposed = NotForScript.Where(m => members[ToCamelCase(m)].Any() || members[m].Any()).ToArray();

Assert.IsEmpty(exposed);
}

[Test]
public void EveryMemberIsAnAccessor()
{
var plain = typeof(StyleDeclarationExtensions).GetMethods()
.Where(m => m.IsDefined(typeof(AngleSharp.Attributes.DomNameAttribute), false) && !m.IsDefined(typeof(AngleSharp.Attributes.DomAccessorAttribute), false))
.Select(m => m.Name)
.ToArray();

Assert.IsEmpty(plain);
}

[Test]
public void FloatHasBothOfItsNames()
{
var members = ScriptMembers();

Assert.AreEqual(2, members["cssFloat"].Count());
Assert.AreEqual(2, members["float"].Count());
}

private sealed class FixedUsedValues : ICssUsedValueProvider
{
public System.String GetUsedValue(IElement element, System.String propertyName) => propertyName == "width" ? "123px" : null;
}

[Test]
public void ScriptSeesWhatLayoutFound()
{
var config = Configuration.Default.WithCss();
var context = BrowsingContext.New(config);
context.SetUsedValueProvider(new FixedUsedValues());
var document = new HtmlParser(new HtmlParserOptions(), context).ParseDocument("<!doctype html><div id=d style='height: 5px'></div>");
var element = document.GetElementById("d");
var style = document.DefaultView.GetComputedStyleOfScript(element);

Assert.AreEqual("123px", style.GetPropertyValue("width"));
Assert.AreEqual("5px", style.GetPropertyValue("height"));
Assert.AreEqual(System.String.Empty, document.DefaultView.GetComputedStyle(element).GetPropertyValue("width"));
}

[Test]
public void AddListenerListensToChangesUntilRemoved()
{
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
var list = window.MatchMedia("all");
var calls = 0;
DomEventHandler listener = (_, _) => calls++;

list.AddListener(listener);
list.Dispatch(new AngleSharp.Dom.Events.Event("change"));
list.RemoveListener(listener);
list.Dispatch(new AngleSharp.Dom.Events.Event("change"));

Assert.AreEqual(1, calls);
}

[Test]
public void MatchMediaWithoutAnyQueryIsMatched()
{
Expand Down
39 changes: 39 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ public void ElementRenderedAgainBelowItsParentTakesItsNewStyleAndInherits()
Assert.IsInstanceOf<IPseudoElement>(node.Children.OfType<ElementRenderNode>().First().Ref);
}

[Test]
public void SelectionPseudoElementCanBeCreated()
{
var document = BrowsingContext.New(Configuration.Default.WithCss()).OpenNewAsync().Result;
var element = document.CreateElement("p");

Assert.AreEqual("selection", element.Pseudo("selection")?.PseudoName);
}

[Test]
public void BeforePseudoElementIsTheFirstChildWithItsGeneratedTextContent()
{
Expand Down Expand Up @@ -216,5 +225,35 @@ public void PseudoClassesBeforeAPseudoElementReferToTheOriginatingElement(string

Assert.AreEqual(expected, target.Children.Any(m => m.Ref is IPseudoElement));
}

// only a rule declaring the content gives a pseudo-element a box, wherever it comes from
[TestCase("*::before { color: red }", false, false)]
[TestCase("*::before { color: red } #target::before { content: 'X' }", true, false)]
[TestCase("*::before { content: 'X' }", true, true)]
[TestCase("#target::before { color: red } .other::before { content: 'X' }", false, true)]
[TestCase("#target { &::before { content: 'X' } }", true, false)]
[TestCase("@media screen { #target::before { content: 'X' } }", true, false)]
public void OnlyPseudoElementsWithDeclaredContentGenerateNodes(string css, bool target, bool other)
{
var root = Render($"<html><head><style>{css}</style></head>" +
"<body><div id=target>A</div><div id=other class=other>B</div></body></html>");

Assert.AreEqual(target, FindElement(root, "target").Children.Any(m => m.Ref is IPseudoElement), "target");
Assert.AreEqual(other, FindElement(root, "other").Children.Any(m => m.Ref is IPseudoElement), "other");
}

[Test]
public void ContentAddedThroughTheObjectModelGeneratesANode()
{
var document = ParseDocument("<html><head><style>#target::before { color: red }</style></head>" +
"<body><div id=target>A</div></body></html>");
var window = document.DefaultView;
var rule = (ICssStyleRule)((ICssStyleSheet)document.StyleSheets[0]).Rules[0];
Assert.IsFalse(FindElement((ElementRenderNode)window.Render(new PlainRenderDevice()), "target").Children.Any(m => m.Ref is IPseudoElement));

rule.Style.SetProperty("content", "'X'");

Assert.IsTrue(FindElement((ElementRenderNode)window.Render(new PlainRenderDevice()), "target").Children.Any(m => m.Ref is IPseudoElement));
}
}
}
22 changes: 22 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/ShadowTreeRenderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,27 @@ public void ShadowTreesAreStyledByTheirOwnStyleSheets()
Assert.AreEqual("rgba(0, 128, 0, 1)", shadow.ComputedStyle.GetColor());
Assert.AreEqual("rgba(255, 0, 0, 1)", light.ComputedStyle.GetColor());
}

[Test]
public void AdoptedSheetsAreScopedToTheirShadowRootAndStyleItsHost()
{
AssumeHostIsSupported();

var document = CreateHost("<span id=light>Light</span>", "<span id=shadow>Shadow</span><slot></slot>");
var host = document.GetElementById("host");
var shadowRoot = host.ShadowRoot;
var sheet = new CssStyleSheet(document.DefaultView);
sheet.ReplaceSync(":host { display: inline-block } span { color: green }");
shadowRoot.SetAdoptedStyleSheets(new[] { sheet });

var nodes = Flatten(Render(document)).ToList();
var renderedHost = nodes.First(m => m.Ref.Id == "host");
var shadow = nodes.First(m => m.Ref.Id == "shadow");
var light = nodes.First(m => m.Ref.Id == "light");

Assert.AreEqual("inline-block", renderedHost.ComputedStyle.GetDisplay());
Assert.AreEqual("rgba(0, 128, 0, 1)", shadow.ComputedStyle.GetColor());
Assert.AreEqual("rgba(255, 0, 0, 1)", light.ComputedStyle.GetColor());
}
}
}
4 changes: 3 additions & 1 deletion src/AngleSharp.Css.Tests/Styling/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ public void ObtainDefaultSheet()
// 80, not 73: the UA stylesheet now also has rules of its own for text areas, selects,
// list boxes, file, search and date/time inputs, progress bars and meters.
// 81, not 80: the UA stylesheet now also gives `iframe` its default border.
Assert.AreEqual(81, sheet.Rules.Length);
// 85, not 81: the UA stylesheet now also carries the popover rules (hidden unless
// shown, centered in the viewport, and the `::backdrop` of a shown popover).
Assert.AreEqual(85, sheet.Rules.Length);
}
}
}
Loading
Loading