From 9ccbf015729dc6f3d54bd0b57fc3227115831b4a Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 27 Jul 2026 16:04:54 +0200 Subject: [PATCH 1/6] Update funding sources --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1f114d6f..b74e64f0 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ # These are supported funding model platforms github: FlorianRappl -custom: https://salt.bountysource.com/teams/anglesharp +custom: ['https://www.paypal.me/FlorianRappl', 'https://buymeacoffee.com/florianrappl'] From 867a5fd984071fb14c46a4a4e89867187dd566b0 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Tue, 28 Jul 2026 22:48:26 +0200 Subject: [PATCH 2/6] Aligned NUKE deps --- nuke/_build.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nuke/_build.csproj b/nuke/_build.csproj index 4c5a47af..5a0000d9 100644 --- a/nuke/_build.csproj +++ b/nuke/_build.csproj @@ -12,10 +12,12 @@ + + - + From 1b81541c9fb85dee8d57b8e0a4e46110f534641b Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 30 Jul 2026 23:26:36 +0200 Subject: [PATCH 3/6] Fixed and improved --- AGENTS.md | 154 +++++++++++++++++ CHANGELOG.md | 6 + CLAUDE.md | 8 + .../Parsing/StyleSheet.cs | 15 ++ .../Values/ConverterIntegrityTests.cs | 162 ++++++++++++++++++ src/AngleSharp.Css/ValueConverters.cs | 10 +- 6 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 src/AngleSharp.Css.Tests/Values/ConverterIntegrityTests.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3451086d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,154 @@ +# AGENTS.md + +This guide helps coding agents become productive quickly in the AngleSharp.Css repository. + +## 1) What this repository is + +- AngleSharp.Css is the CSS extension package for AngleSharp. +- It provides CSS parsing, CSSOM objects, declaration/value conversion, stylesheet integration, and render tree support. +- Main code lives in `src/AngleSharp.Css`. +- Tests live in `src/AngleSharp.Css.Tests`. + +## 2) Fast start commands + +From repository root: + +```bash +# Full build + unit tests through NUKE (Linux/macOS) +./build.sh + +# Full build + unit tests through NUKE (Windows PowerShell) +./build.ps1 + +# Run tests directly +dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj + +# Build solution directly +dotnet build src/AngleSharp.Css.sln + +# Run CSS performance benchmark (Release) +dotnet run --project src/AngleSharp.Performance.Css/AngleSharp.Performance.Css.csproj -c Release --framework net10.0 + +# Short benchmark smoke run +dotnet run --project src/AngleSharp.Performance.Css/AngleSharp.Performance.Css.csproj -c Release --framework net10.0 -- --job short +``` + +Notes: + +- CI invokes `./build.sh -AngleSharpVersion 1.5.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` +- Test project: `src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj` + - Targets: `net8.0` +- NUKE entrypoint: `nuke/Build.cs` + - Default target chain runs unit tests. + +## 4) Repository map (where to change what) + +- `src/AngleSharp.Css/Parser` + - Front-end parser (`CssParser`), tokenizer, builder, and micro parsers. + - If parsing/tokenization behavior changes, start here. + +- `src/AngleSharp.Css/Declarations` + - One static class per declaration (name, converter, initial value, flags). + - Example pattern: `DisplayDeclaration.cs`. + +- `src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs` + - Central registration table that wires declaration metadata. + - New declaration support usually needs an entry here. + +- `src/AngleSharp.Css/ValueConverters.cs` and `src/AngleSharp.Css/Converters` + - Converter composition and reusable converter building blocks. + - New value grammar often starts with a converter addition or composition. + +- `src/AngleSharp.Css/Values` + - CSS value object model (primitives, composites, function values, tuples/lists). + +- `src/AngleSharp.Css/Dom` and `src/AngleSharp.Css/Dom/Internal` + - Public CSSOM interfaces/enums and internal implementations of rules, declarations, and sheets. + +- `src/AngleSharp.Css/Constants` + - Canonical names and keyword constants (`PropertyNames`, `RuleNames`, `CssKeywords`, etc.). + +- `src/AngleSharp.Css/RenderTree` + - Render tree construction and render node/value computation. + +- `src/AngleSharp.Css/FeatureValidators` + - Validators used for media feature / supports-style checks. + +- `src/AngleSharp.Css.Tests` + - Test suites grouped by concern: declarations, rules, parsing, values, styling, extensions. + +## 5) Common change playbooks + +### Add or adjust a CSS declaration/property + +1. Add or update declaration metadata class in `src/AngleSharp.Css/Declarations`. +2. Ensure canonical property name exists in `src/AngleSharp.Css/Constants/PropertyNames.cs`. +3. Wire declaration in `src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs`. +4. Add tests in `src/AngleSharp.Css.Tests/Declarations` and/or related top-level declaration test files. +5. Validate computed/style integration with tests in `src/AngleSharp.Css.Tests/Styling` when behavior impacts cascade/computation. + +### Add or adjust value grammar + +1. Extend converter composition in `src/AngleSharp.Css/ValueConverters.cs` and/or converter implementations in `src/AngleSharp.Css/Converters`. +2. Add/update specific value objects in `src/AngleSharp.Css/Values` if needed. +3. If grammar requires function or token-level parser support, update `src/AngleSharp.Css/Parser/Micro`. +4. Add tests under `src/AngleSharp.Css.Tests/Values` and declaration tests that consume the grammar. + +### Add or adjust at-rules / CSSOM rule behavior + +1. Public contract changes in `src/AngleSharp.Css/Dom` interfaces. +2. Implementation changes in `src/AngleSharp.Css/Dom/Internal/Rules`. +3. Parser/builder adjustments in `src/AngleSharp.Css/Parser`. +4. Rule-focused tests in `src/AngleSharp.Css.Tests/Rules`. + +### Render-tree-related changes + +1. Update computation/building in `src/AngleSharp.Css/RenderTree`. +2. Confirm integration in `src/AngleSharp.Css/Extensions/WindowExtensions.cs` and styling flows. +3. Add or update tests in `src/AngleSharp.Css.Tests/Styling`. + +## 6) Test strategy for agents + +- Prefer targeted test runs while iterating: + +```bash +dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj --filter FullyQualifiedName~CssProperty +``` + +- Before finishing, run full tests for confidence: + +```bash +dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj +``` + +- If changing multi-target-sensitive code (conditional behavior, APIs), also run full build via NUKE script to mimic CI flow. + +## 7) Style and safety expectations + +- Follow `.editorconfig` (spaces, indent size 4 for `.cs`, LF endings). +- Preserve existing namespace/file organization; this repo is convention-heavy. +- 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. +- 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. + +## 8) Documentation and onboarding references + +- Root overview: `README.md` +- Extended docs index: `docs/README.md` +- Suggested docs learning order is documented in `docs/README.md`. + +## 9) Quick pre-PR checklist for agents + +1. Did I update all required wiring points (constants, declaration metadata, factory registration, parser/converter where needed)? +2. Did I add focused tests in the closest matching test area? +3. Does `dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj` pass? +4. If CI-relevant build behavior changed, did I run `./build.sh` (or `./build.ps1` on Windows)? +5. Did I avoid unrelated formatting churn and preserve established structure? diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f81021..37e316b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.0.1 + +Released on Friday, July 31 2026. + +- Fixed NRE in `mask-image` with gradients (#218) + # 1.0.0 Released on Sunday, July 26 2026. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9f02784a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +The guidance is shared with every AI agent working here, so it lives in AGENTS.md and is +imported below. Record new guidance there rather than in this file. + +@AGENTS.md diff --git a/src/AngleSharp.Css.Tests/Parsing/StyleSheet.cs b/src/AngleSharp.Css.Tests/Parsing/StyleSheet.cs index f7ccf797..2a8befe8 100644 --- a/src/AngleSharp.Css.Tests/Parsing/StyleSheet.cs +++ b/src/AngleSharp.Css.Tests/Parsing/StyleSheet.cs @@ -160,5 +160,20 @@ public void OneLetterTagWithCarriageReturnDoesNotWorkAsSelector_Issue190() Assert.AreEqual(1, ss.Rules.Length); Assert.AreEqual("a", ((AngleSharp.Css.Dom.ICssStyleRule)ss.Rules[0]).SelectorText); } + + [Test] + public void ParseMaskImageLinearGradientDoesNotThrow_Issue218() + { + var css = @" + .class{ + mask-image: linear-gradient(red, blue); +} +"; + var parser = new CssParser(); + + Assert.DoesNotThrow(() => parser.ParseStyleSheet(css)); + var sheet = parser.ParseStyleSheet(css); + Assert.AreEqual(1, sheet.Rules.Length); + } } } diff --git a/src/AngleSharp.Css.Tests/Values/ConverterIntegrityTests.cs b/src/AngleSharp.Css.Tests/Values/ConverterIntegrityTests.cs new file mode 100644 index 00000000..91c7cadf --- /dev/null +++ b/src/AngleSharp.Css.Tests/Values/ConverterIntegrityTests.cs @@ -0,0 +1,162 @@ +namespace AngleSharp.Css.Tests.Values +{ + using AngleSharp.Css.Dom; + using NUnit.Framework; + using System; + using System.Collections; + using System.Collections.Generic; + using System.Reflection; + using static CssConstructionFunctions; + + [TestFixture] + public class ConverterIntegrityTests + { + [Test] + public void AllPublicValueConvertersAreNonNullAndContainNoNullChildren() + { + var type = typeof(ValueConverters); + var failures = new List(); + + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.FieldType != typeof(IValueConverter)) + { + continue; + } + + var converter = field.GetValue(null) as IValueConverter; + + if (converter is null) + { + failures.Add($"Field {field.Name} is null"); + continue; + } + + failures.AddRange(FindNullReferences(field.Name, converter)); + } + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + if (property.PropertyType != typeof(IValueConverter) || property.GetIndexParameters().Length > 0) + { + continue; + } + + var converter = property.GetValue(null) as IValueConverter; + + if (converter is null) + { + failures.Add($"Property {property.Name} is null"); + continue; + } + + failures.AddRange(FindNullReferences(property.Name, converter)); + } + + Assert.That(failures, Is.Empty, String.Join(Environment.NewLine, failures)); + } + + [Test] + [TestCase("mask-image: linear-gradient(red, blue)")] + [TestCase("mask-repeat: no-repeat")] + [TestCase("mask-position: 10px 20px")] + [TestCase("mask-size: 10px 20px")] + [TestCase("mask-border-repeat: repeat")] + public void MaskRelatedDeclarationsDoNotThrow(String declaration) + { + Assert.DoesNotThrow(() => ParseDeclaration(declaration)); + var property = ParseDeclaration(declaration); + Assert.IsNotNull(property); + Assert.IsTrue(property.HasValue); + } + + private static IEnumerable FindNullReferences(String rootName, IValueConverter root) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var failures = new List(); + Scan(root, rootName, visited, failures); + return failures; + } + + private static void Scan(Object instance, String path, HashSet visited, List failures) + { + if (instance is null || !visited.Add(instance)) + { + return; + } + + var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + foreach (var field in instance.GetType().GetFields(flags)) + { + if (typeof(IValueConverter).IsAssignableFrom(field.FieldType)) + { + var child = field.GetValue(instance) as IValueConverter; + + if (child is null) + { + failures.Add($"{path}.{field.Name} is null ({instance.GetType().Name})"); + } + else + { + Scan(child, $"{path}.{field.Name}", visited, failures); + } + } + else if (field.FieldType.IsArray && typeof(IValueConverter).IsAssignableFrom(field.FieldType.GetElementType())) + { + var array = field.GetValue(instance) as Array; + + if (array is null) + { + failures.Add($"{path}.{field.Name} array is null ({instance.GetType().Name})"); + continue; + } + + for (var i = 0; i < array.Length; i++) + { + var item = array.GetValue(i) as IValueConverter; + + if (item is null) + { + failures.Add($"{path}.{field.Name}[{i}] is null ({instance.GetType().Name})"); + } + else + { + Scan(item, $"{path}.{field.Name}[{i}]", visited, failures); + } + } + } + else if (typeof(IEnumerable).IsAssignableFrom(field.FieldType) && field.FieldType != typeof(String)) + { + var value = field.GetValue(instance) as IEnumerable; + + if (value is null) + { + continue; + } + + var index = 0; + + foreach (var item in value) + { + if (item is IValueConverter converter) + { + Scan(converter, $"{path}.{field.Name}[{index}]", visited, failures); + } + + index++; + } + } + } + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer(); + + public new Boolean Equals(Object? x, Object? y) => ReferenceEquals(x, y); + + public Int32 GetHashCode(Object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/src/AngleSharp.Css/ValueConverters.cs b/src/AngleSharp.Css/ValueConverters.cs index f86cd968..f7fe0121 100644 --- a/src/AngleSharp.Css/ValueConverters.cs +++ b/src/AngleSharp.Css/ValueConverters.cs @@ -543,7 +543,7 @@ static class ValueConverters /// /// Represents a converter for the mask-image property. /// - public static readonly IValueConverter MaskImageConverter = MultipleImageSourceConverter; + public static IValueConverter MaskImageConverter => MultipleImageSourceConverter; /// /// Represents a converter for the mask-mode property. @@ -555,12 +555,12 @@ static class ValueConverters /// /// Represents a converter for the mask-repeat property. /// - public static readonly IValueConverter MaskRepeatConverter = BackgroundRepeatsConverter; + public static IValueConverter MaskRepeatConverter => BackgroundRepeatsConverter; /// /// Represents a converter for the mask-position property. /// - public static readonly IValueConverter MaskPositionConverter = PointConverter; + public static IValueConverter MaskPositionConverter => PointConverter; /// /// Represents a converter for the mask-clip property. @@ -587,7 +587,7 @@ static class ValueConverters /// /// Represents a converter for the mask-size property. /// - public static readonly IValueConverter MaskSizeConverter = BackgroundSizeConverter; + public static IValueConverter MaskSizeConverter => BackgroundSizeConverter; /// /// Represents a converter for the mask-composite property. @@ -640,7 +640,7 @@ static class ValueConverters /// /// Represents a converter for the mask-border-repeat property. /// - public static readonly IValueConverter MaskBorderRepeatConverter = BackgroundRepeatsConverter; + public static IValueConverter MaskBorderRepeatConverter => BackgroundRepeatsConverter; /// /// Represents a converter for the mask-border-mode property. From fc84fdb9fee79aea4c0383684a4709ebf7ef41c3 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 31 Jul 2026 09:03:19 +0200 Subject: [PATCH 4/6] Modernized and aligned --- .config/dotnet-tools.json | 12 +++ {.nuke => .fallout}/build.schema.json | 16 +++- {.nuke => .fallout}/parameters.json | 0 .gitignore | 10 +- AGENTS.md | 8 +- build.ps1 | 16 +--- build.sh | 16 +--- {nuke => build}/.editorconfig | 0 {nuke => build}/Build.cs | 93 ++++++++----------- {nuke => build}/Configuration.cs | 2 +- {nuke => build}/Directory.Build.props | 0 {nuke => build}/Directory.Build.targets | 0 .../Extensions/StringExtensions.cs | 0 {nuke => build}/ReleaseNotes.cs | 0 {nuke => build}/ReleaseNotesParser.cs | 0 {nuke => build}/SemVersion.cs | 0 build/_build.csproj | 19 ++++ nuke/_build.csproj | 23 ----- .../Properties/AssemblyInfo.cs | 8 -- src/AngleSharp.Css.nuspec | 22 ----- src/AngleSharp.Css.sln | 3 +- src/AngleSharp.Css/AngleSharp.Css.csproj | 25 ++++- src/AngleSharp.Css/Properties/AssemblyInfo.cs | 7 -- .../Properties/AssemblyInfo.cs | 7 -- .../Properties/AssemblyInfo.cs | 8 -- .../Properties/AssemblyInfo.cs | 8 -- 26 files changed, 126 insertions(+), 177 deletions(-) create mode 100644 .config/dotnet-tools.json rename {.nuke => .fallout}/build.schema.json (89%) rename {.nuke => .fallout}/parameters.json (100%) rename {nuke => build}/.editorconfig (100%) rename {nuke => build}/Build.cs (73%) rename {nuke => build}/Configuration.cs (93%) rename {nuke => build}/Directory.Build.props (100%) rename {nuke => build}/Directory.Build.targets (100%) rename {nuke => build}/Extensions/StringExtensions.cs (100%) rename {nuke => build}/ReleaseNotes.cs (100%) rename {nuke => build}/ReleaseNotesParser.cs (100%) rename {nuke => build}/SemVersion.cs (100%) create mode 100644 build/_build.csproj delete mode 100644 nuke/_build.csproj delete mode 100644 src/AngleSharp.Css.Tests/Properties/AssemblyInfo.cs delete mode 100644 src/AngleSharp.Css.nuspec delete mode 100644 src/AngleSharp.Css/Properties/AssemblyInfo.cs delete mode 100644 src/AngleSharp.Performance.Common/Properties/AssemblyInfo.cs delete mode 100644 src/AngleSharp.Performance.Css/Properties/AssemblyInfo.cs delete mode 100644 src/AngleSharp.Performance.Utilities/Properties/AssemblyInfo.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..6a8c8a1d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fallout.cli": { + "version": "11.0.18", + "commands": [ + "fallout" + ] + } + } +} diff --git a/.nuke/build.schema.json b/.fallout/build.schema.json similarity index 89% rename from .nuke/build.schema.json rename to .fallout/build.schema.json index 83a628ff..82690d1a 100644 --- a/.nuke/build.schema.json +++ b/.fallout/build.schema.json @@ -26,7 +26,6 @@ "enum": [ "Clean", "Compile", - "CopyFiles", "CreatePackage", "Default", "Package", @@ -49,7 +48,7 @@ "Quiet" ] }, - "NukeBuild": { + "FalloutBuild": { "properties": { "Continue": { "type": "boolean", @@ -103,6 +102,13 @@ "Verbosity": { "description": "Logging verbosity during build execution. Default is 'Normal'", "$ref": "#/definitions/Verbosity" + }, + "BuildProjectFile": { + "type": [ + "null", + "string" + ], + "description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner." } } } @@ -116,11 +122,11 @@ }, "Configuration": { "type": "string", - "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", "enum": [ "Debug", "Release" - ] + ], + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)" }, "ReleaseNotesFilePath": { "type": "string", @@ -133,7 +139,7 @@ } }, { - "$ref": "#/definitions/NukeBuild" + "$ref": "#/definitions/FalloutBuild" } ] } diff --git a/.nuke/parameters.json b/.fallout/parameters.json similarity index 100% rename from .nuke/parameters.json rename to .fallout/parameters.json diff --git a/.gitignore b/.gitignore index de3ed79f..32b162d7 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,8 @@ local.properties [Dd]ebug/ [Rr]elease/ [Bb]in/ -[Bb]uild/ +build/bin/ +build/obj/ [Oo]bj/ # Visual Studio 2015 cache/options directory @@ -171,7 +172,8 @@ Desktop.ini *.egg *.egg-info dist -build +build/bin +build/obj eggs parts var @@ -195,5 +197,5 @@ pip-log.txt # Mac crap .DS_Store -# Nuke build tool -.nuke/temp +# Fallout build tool +.fallout/temp diff --git a/AGENTS.md b/AGENTS.md index 3451086d..05a4df7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,10 +14,10 @@ This guide helps coding agents become productive quickly in the AngleSharp.Css r From repository root: ```bash -# Full build + unit tests through NUKE (Linux/macOS) +# Full build + unit tests through Fallout (Linux/macOS) ./build.sh -# Full build + unit tests through NUKE (Windows PowerShell) +# Full build + unit tests through Fallout (Windows PowerShell) ./build.ps1 # Run tests directly @@ -45,7 +45,7 @@ Notes: - Additional Windows-only targets: `net462;net472` - Test project: `src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj` - Targets: `net8.0` -- NUKE entrypoint: `nuke/Build.cs` +- Fallout entrypoint: `build/Build.cs` - Default target chain runs unit tests. ## 4) Repository map (where to change what) @@ -128,7 +128,7 @@ dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj --filter FullyQ dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj ``` -- If changing multi-target-sensitive code (conditional behavior, APIs), also run full build via NUKE script to mimic CI flow. +- If changing multi-target-sensitive code (conditional behavior, APIs), also run full build via Fallout script to mimic CI flow. ## 7) Style and safety expectations diff --git a/build.ps1 b/build.ps1 index 3cca4ef3..61c6628f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -13,10 +13,9 @@ $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent # CONFIGURATION ########################################################################### -$BuildProjectFile = "$PSScriptRoot\nuke\_build.csproj" -$TempDirectory = "$PSScriptRoot\\.nuke\temp" +$TempDirectory = "$PSScriptRoot\.fallout\temp" -$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetGlobalFile = "$PSScriptRoot\global.json" $DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" $DotNetChannel = "STS" @@ -32,7 +31,7 @@ function ExecSafe([scriptblock] $cmd) { if ($LASTEXITCODE) { exit $LASTEXITCODE } } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout\temp. if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` $(dotnet --version) -and $LASTEXITCODE -eq 0) { $env:DOTNET_EXE = (Get-Command "dotnet").Path @@ -65,10 +64,5 @@ else { Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" -if (Test-Path env:NUKE_ENTERPRISE_TOKEN) { - & $env:DOTNET_EXE nuget remove source "nuke-enterprise" > $null - & $env:DOTNET_EXE nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password $env:NUKE_ENTERPRISE_TOKEN > $null -} - -ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } -ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } +ExecSafe { & $env:DOTNET_EXE tool restore } +ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments } diff --git a/build.sh b/build.sh index a226b969..fc76bf83 100755 --- a/build.sh +++ b/build.sh @@ -9,10 +9,9 @@ SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) # CONFIGURATION ########################################################################### -BUILD_PROJECT_FILE="$SCRIPT_DIR/nuke/_build.csproj" -TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" +TEMP_DIRECTORY="$SCRIPT_DIR/.fallout/temp" -DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_GLOBAL_FILE="$SCRIPT_DIR/global.json" DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" DOTNET_CHANNEL="STS" @@ -27,7 +26,7 @@ function FirstJsonValue { perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" } -# If dotnet CLI is installed globally and it matches requested version, use for execution +# If dotnet CLI is installed globally, use it; otherwise provision a local copy under .fallout/temp. if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then export DOTNET_EXE="$(command -v dotnet)" else @@ -58,10 +57,5 @@ fi echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" -if [[ ! -z ${NUKE_ENTERPRISE_TOKEN+x} && "$NUKE_ENTERPRISE_TOKEN" != "" ]]; then - "$DOTNET_EXE" nuget remove source "nuke-enterprise" &>/dev/null || true - "$DOTNET_EXE" nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password "$NUKE_ENTERPRISE_TOKEN" --store-password-in-clear-text &>/dev/null || true -fi - -"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet -"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" +"$DOTNET_EXE" tool restore +exec "$DOTNET_EXE" fallout "$@" diff --git a/nuke/.editorconfig b/build/.editorconfig similarity index 100% rename from nuke/.editorconfig rename to build/.editorconfig diff --git a/nuke/Build.cs b/build/Build.cs similarity index 73% rename from nuke/Build.cs rename to build/Build.cs index dbc47f53..63369a93 100644 --- a/nuke/Build.cs +++ b/build/Build.cs @@ -1,12 +1,11 @@ +using Fallout.Common; +using Fallout.Common.CI.GitHubActions; +using Fallout.Common.IO; +using Fallout.Common.Tools.DotNet; +using Fallout.Common.Tools.GitHub; +using Fallout.Common.Utilities.Collections; +using Fallout.Solutions; using Microsoft.Build.Exceptions; -using Nuke.Common; -using Nuke.Common.CI.GitHubActions; -using Nuke.Common.IO; -using Nuke.Common.ProjectModel; -using Nuke.Common.Tools.DotNet; -using Nuke.Common.Tools.GitHub; -using Nuke.Common.Tools.NuGet; -using Nuke.Common.Utilities.Collections; using Octokit; using Octokit.Internal; using Serilog; @@ -14,27 +13,20 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using static Nuke.Common.Tools.DotNet.DotNetTasks; -using static Nuke.Common.Tools.NuGet.NuGetTasks; -using Project = Nuke.Common.ProjectModel.Project; +using static Fallout.Common.Tools.DotNet.DotNetTasks; +using Project = Fallout.Solutions.Project; -class Build : NukeBuild +class Build : FalloutBuild { - /// Support plugins are available for: - /// - JetBrains ReSharper https://nuke.build/resharper - /// - JetBrains Rider https://nuke.build/rider - /// - Microsoft VisualStudio https://nuke.build/visualstudio - /// - Microsoft VSCode https://nuke.build/vscode - public static int Main () => Execute(x => x.RunUnitTests); - [Nuke.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + [Fallout.Common.Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; - [Nuke.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] + [Fallout.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] readonly AbsolutePath ReleaseNotesFilePath = RootDirectory / "CHANGELOG.md"; - [Nuke.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] + [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] readonly string AngleSharpVersion; [Solution] @@ -46,8 +38,6 @@ class Build : NukeBuild AbsolutePath SourceDirectory => RootDirectory / "src"; - AbsolutePath BuildDirectory => SourceDirectory / TargetProjectName / "bin" / Configuration; - AbsolutePath ResultDirectory => RootDirectory / "bin" / Version; AbsolutePath NugetDirectory => ResultDirectory / "nuget"; @@ -56,7 +46,7 @@ class Build : NukeBuild Project TargetProject { get; set; } - // Note: The ChangeLogTasks from Nuke itself look buggy. So using the Cake source code. + // Note: The built-in ChangeLogTasks (inherited from NUKE) look buggy. So using the Cake source code. IReadOnlyList ChangeLog { get; set; } ReleaseNotes LatestReleaseNotes { get; set; } @@ -141,6 +131,8 @@ protected override void OnBuildInitialized() var settings = s .SetProjectFile(Solution) .SetConfiguration(Configuration) + .SetVersion(Version) + .SetContinuousIntegrationBuild(IsServerBuild) .EnableNoRestore(); if (!String.IsNullOrEmpty(AngleSharpVersion)) @@ -161,6 +153,7 @@ protected override void OnBuildInitialized() var settings = s .SetProjectFile(Solution) .SetConfiguration(Configuration) + .SetProperty("Version", Version) .EnableNoRestore() .EnableNoBuild(); @@ -173,39 +166,30 @@ protected override void OnBuildInitialized() }); }); - Target CopyFiles => _ => _ + // The package is produced by `dotnet pack` straight from the project, so the dependency + // groups follow the actual TargetFrameworks instead of a hand-maintained nuspec. + Target CreatePackage => _ => _ .DependsOn(Compile) .Executes(() => { - foreach (var item in TargetFrameworks) + DotNetPack(s => { - var targetDir = NugetDirectory / "lib" / item; - var srcDir = BuildDirectory / item; - - (srcDir / $"{TargetProjectName}.dll").Copy(targetDir / $"{TargetProjectName}.dll", ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.pdb").Copy(targetDir / $"{TargetProjectName}.pdb", ExistsPolicy.FileOverwriteIfNewer); - (srcDir / $"{TargetProjectName}.xml").Copy(targetDir / $"{TargetProjectName}.xml", ExistsPolicy.FileOverwriteIfNewer); - } + var settings = s + .SetProject(TargetProject) + .SetConfiguration(Configuration) + .SetVersion(Version) + .SetOutputDirectory(NugetDirectory) + .SetContinuousIntegrationBuild(IsServerBuild) + .EnableNoRestore() + .EnableNoBuild(); - (SourceDirectory / $"{TargetProjectName}.nuspec").Copy(NugetDirectory / $"{TargetProjectName}.nuspec", ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "logo.png").Copy(NugetDirectory / "logo.png", ExistsPolicy.FileOverwriteIfNewer); - (RootDirectory / "README.md").Copy(NugetDirectory / "README.md", ExistsPolicy.FileOverwriteIfNewer); - }); + if (!String.IsNullOrEmpty(AngleSharpVersion)) + { + settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); + } - Target CreatePackage => _ => _ - .DependsOn(CopyFiles) - .Executes(() => - { - var nuspec = NugetDirectory / $"{TargetProjectName}.nuspec"; - - NuGetPack(_ => _ - .SetTargetPath(nuspec) - .SetVersion(Version) - .SetOutputDirectory(NugetDirectory) - .SetSymbols(true) - .SetSymbolPackageFormat("snupkg") - .AddProperty("Configuration", Configuration) - ); + return settings; + }); }); Target PublishPackage => _ => _ @@ -221,9 +205,10 @@ protected override void OnBuildInitialized() throw new BuildAbortedException("Could not resolve the NuGet API key."); } + // Pushing the .nupkg also uploads the matching .snupkg next to it. foreach (var nupkg in NugetDirectory.GlobFiles("*.nupkg")) { - NuGetPush(s => s + DotNetNuGetPush(s => s .SetTargetPath(nupkg) .SetSource("https://api.nuget.org/v3/index.json") .SetApiKey(apiKey)); @@ -253,7 +238,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release @@ -289,7 +274,7 @@ protected override void OnBuildInitialized() var credentials = new Credentials(gitHubToken); GitHubTasks.GitHubClient = new GitHubClient( - new ProductHeaderValue(nameof(NukeBuild)), + new ProductHeaderValue(nameof(FalloutBuild)), new InMemoryCredentialStore(credentials)); GitHubTasks.GitHubClient.Repository.Release diff --git a/nuke/Configuration.cs b/build/Configuration.cs similarity index 93% rename from nuke/Configuration.cs rename to build/Configuration.cs index 9c08b1ae..75c63eae 100644 --- a/nuke/Configuration.cs +++ b/build/Configuration.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel; using System.Linq; -using Nuke.Common.Tooling; +using Fallout.Common.Tooling; [TypeConverter(typeof(TypeConverter))] public class Configuration : Enumeration diff --git a/nuke/Directory.Build.props b/build/Directory.Build.props similarity index 100% rename from nuke/Directory.Build.props rename to build/Directory.Build.props diff --git a/nuke/Directory.Build.targets b/build/Directory.Build.targets similarity index 100% rename from nuke/Directory.Build.targets rename to build/Directory.Build.targets diff --git a/nuke/Extensions/StringExtensions.cs b/build/Extensions/StringExtensions.cs similarity index 100% rename from nuke/Extensions/StringExtensions.cs rename to build/Extensions/StringExtensions.cs diff --git a/nuke/ReleaseNotes.cs b/build/ReleaseNotes.cs similarity index 100% rename from nuke/ReleaseNotes.cs rename to build/ReleaseNotes.cs diff --git a/nuke/ReleaseNotesParser.cs b/build/ReleaseNotesParser.cs similarity index 100% rename from nuke/ReleaseNotesParser.cs rename to build/ReleaseNotesParser.cs diff --git a/nuke/SemVersion.cs b/build/SemVersion.cs similarity index 100% rename from nuke/SemVersion.cs rename to build/SemVersion.cs diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 00000000..647ac264 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + CS0649;CS0169 + .. + .. + 1 + + + + + + + + + diff --git a/nuke/_build.csproj b/nuke/_build.csproj deleted file mode 100644 index 5a0000d9..00000000 --- a/nuke/_build.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net10.0 - - CS0649;CS0169 - .. - .. - 1 - - - - - - - - - - - - - diff --git a/src/AngleSharp.Css.Tests/Properties/AssemblyInfo.cs b/src/AngleSharp.Css.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index b4202dd2..00000000 --- a/src/AngleSharp.Css.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2026.")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: ComVisible(false)] -[assembly: Guid("e515df87-a0ef-4165-92c8-6a63f6acd342")] \ No newline at end of file diff --git a/src/AngleSharp.Css.nuspec b/src/AngleSharp.Css.nuspec deleted file mode 100644 index fd235206..00000000 --- a/src/AngleSharp.Css.nuspec +++ /dev/null @@ -1,22 +0,0 @@ - - - - AngleSharp.Css - $version$ - AngleSharp - Florian Rappl - MIT - - https://anglesharp.github.io - logo.png - README.md - false - Extends the CSSOM from the core AngleSharp library. It parses CSS3 and is capable of performing a headless rendering evaluating the styles. - https://github.com/AngleSharp/AngleSharp.Css/blob/main/CHANGELOG.md - Copyright 2016-2026, AngleSharp - html html5 css css3 dom styling library anglesharp angle - - - - - diff --git a/src/AngleSharp.Css.sln b/src/AngleSharp.Css.sln index a46c8c7c..e1d81aa0 100644 --- a/src/AngleSharp.Css.sln +++ b/src/AngleSharp.Css.sln @@ -15,11 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngleSharp.Performance.Css" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{988B2C40-782B-4128-9551-7AE65B60F903}" ProjectSection(SolutionItems) = preProject - AngleSharp.Css.nuspec = AngleSharp.Css.nuspec Directory.Build.props = Directory.Build.props EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "_build", "..\nuke\_build.csproj", "{B82798E2-2766-4C1E-860C-136F4B3B1185}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "_build", "..\build\_build.csproj", "{B82798E2-2766-4C1E-860C-136F4B3B1185}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/AngleSharp.Css/AngleSharp.Css.csproj b/src/AngleSharp.Css/AngleSharp.Css.csproj index e9379706..6ef9f33e 100644 --- a/src/AngleSharp.Css/AngleSharp.Css.csproj +++ b/src/AngleSharp.Css/AngleSharp.Css.csproj @@ -6,13 +6,22 @@ $(TargetFrameworks);net462;net472 true enable + + + + https://anglesharp.github.io + MIT + logo.png + README.md + html;html5;css;css3;dom;styling;library;anglesharp;angle + https://github.com/AngleSharp/AngleSharp.Css/blob/main/CHANGELOG.md https://github.com/AngleSharp/AngleSharp.Css git true true true snupkg - 1.* + 1.5.0 @@ -20,10 +29,22 @@ - + + + + + + + + + + <_Parameter1>false + + + false diff --git a/src/AngleSharp.Css/Properties/AssemblyInfo.cs b/src/AngleSharp.Css/Properties/AssemblyInfo.cs deleted file mode 100644 index c260e163..00000000 --- a/src/AngleSharp.Css/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2026")] -[assembly: ComVisible(false)] -[assembly: InternalsVisibleToAttribute("AngleSharp.Css.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001adf274fa2b375134e8e4558d606f1a0f96f5cd0c6b99970f7cce9887477209d7c29f814e2508d8bd2526e99e8cd273bd1158a3984f1ea74830ec5329a77c6ff201a15edeb8b36ab046abd1bce211fe8dbb076d7d806f46b15bfda44def04ead0669971e96c5f666c9eda677f28824fff7aa90d32929ed91d529a7a41699893")] diff --git a/src/AngleSharp.Performance.Common/Properties/AssemblyInfo.cs b/src/AngleSharp.Performance.Common/Properties/AssemblyInfo.cs deleted file mode 100644 index 4ec8bbe1..00000000 --- a/src/AngleSharp.Performance.Common/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Resources; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2026")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] \ No newline at end of file diff --git a/src/AngleSharp.Performance.Css/Properties/AssemblyInfo.cs b/src/AngleSharp.Performance.Css/Properties/AssemblyInfo.cs deleted file mode 100644 index d95e5211..00000000 --- a/src/AngleSharp.Performance.Css/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2026")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: ComVisible(false)] -[assembly: Guid("aa7bce2a-f953-4d04-8bed-69eb73687b22")] \ No newline at end of file diff --git a/src/AngleSharp.Performance.Utilities/Properties/AssemblyInfo.cs b/src/AngleSharp.Performance.Utilities/Properties/AssemblyInfo.cs deleted file mode 100644 index d95e5211..00000000 --- a/src/AngleSharp.Performance.Utilities/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyCopyright("Copyright © AngleSharp, 2013-2026")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: ComVisible(false)] -[assembly: Guid("aa7bce2a-f953-4d04-8bed-69eb73687b22")] \ No newline at end of file From 1468b133f37bd9623f6b3a8f4c8d67274e9e73c2 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Fri, 31 Jul 2026 13:10:32 +0300 Subject: [PATCH 5/6] Speed up cascade resolution by ~4.5x Profiling getComputedStyle over a realistic document (ultra/ETW, 8190 Hz) showed the cascade path dominated by work that was repeated per element: - StyleCollection held a lazy sheet sequence, so every enumeration re-walked the whole DOM looking for style/link elements. The collection is enumerated once per element AND once per ancestor, making StyleExtensions.GetStyleSheets 48% of the profile on its own. The sequence is now walked once and the flattened matching rules cached for the lifetime of the collection, which spans a single cascade or render pass. - CssStyleRule.TryMatch sorted its selector list by descending specificity on every match attempt (29% of its own subtree). The list only changes when the selector is assigned, so it is sorted there instead. OrderByDescending is stable, so equal-specificity ordering is unchanged. - SortBySpecificity built Tuple objects through SelectMany/OrderBy. Under shared generics LINQ's internal ToArray spent 16.6% of the whole profile in array covariance checks (CastHelpers.StelemRef). Replaced with a list of structs plus an index tie-break that reproduces OrderBy's stability exactly. - TryMatch re-read DocumentElement per rule per element because scope was always passed as null; it is now resolved once per element. Also drops a per-call filtered list and LINQ closures from TryCreateShorthand on the parsing path. Measured with BenchmarkDotNet (MediumRun, idle machine), baseline = devel: ComputedStyle 23,169 us -> 5,100 us (4.5x) 25.19 MB -> 1.41 MB RenderTree 29,828 us -> 7,288 us (4.1x) 56.25 MB -> 5.25 MB Stylesheet parsing throughput is unchanged (all deltas within error bars); its allocations drop 2-15% across the eight real-world sample sheets. Adds CssCascadeBenchmarks to cover the styling side, which had no benchmark. Co-Authored-By: Claude Opus 5 (1M context) --- .../Dom/Internal/CssStyleDeclaration.cs | 38 ++++- .../Dom/Internal/Rules/CssStyleRule.cs | 13 +- .../Dom/Internal/StyleCollection.cs | 34 ++-- .../Extensions/StyleCollectionExtensions.cs | 83 +++++++--- .../CssCascadeBenchmarks.cs | 148 ++++++++++++++++++ 5 files changed, 283 insertions(+), 33 deletions(-) create mode 100644 src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index c4adf162..b483b2d3 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -126,7 +126,6 @@ private ICssProperty TryCreateShorthand(String shorthandName, IEnumerable 0) { - var longhands = Declarations.Where(m => !serialized.Contains(m.Name)).ToList(); var values = new ICssValue[requiredProperties.Length]; var important = 0; var count = 0; @@ -135,9 +134,9 @@ private ICssProperty TryCreateShorthand(String shorthandName, IEnumerable 0 ? TryCreateShorthand(name, serialized, usedProperties, force) : - longhands.Where(m => m.Name == name).FirstOrDefault(); + FindUnserializedLonghand(name, serialized); if (property?.Value is not null) { @@ -340,6 +339,39 @@ internal void UpdateDeclarations(IEnumerable decls) => private ICssProperty GetPropertyShorthand(String name) => TryCreateShorthand(name, Enumerable.Empty(), new List(), true); + /// + /// Gets the first declaration with the given name, unless that name was + /// already serialized. Equivalent to filtering all declarations by the + /// serialized set first, since only declarations with exactly this name + /// can ever be returned. + /// + private ICssProperty FindUnserializedLonghand(String name, IEnumerable serialized) + { + if (serialized is ICollection collection) + { + if (collection.Count > 0 && collection.Contains(name)) + { + return null; + } + } + else if (serialized.Contains(name)) + { + return null; + } + + for (var i = 0; i < _declarations.Count; i++) + { + var declaration = _declarations[i]; + + if (declaration.Name == name) + { + return declaration; + } + } + + return null; + } + private ICssProperty CreateProperty(String propertyName) { var newProperty = _context.CreateProperty(propertyName); diff --git a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs index ab6798cd..09d5d83d 100644 --- a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs +++ b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs @@ -21,7 +21,7 @@ sealed class CssStyleRule : CssRule, ICssStyleRule, ISelectorVisitor private readonly CssStyleDeclaration _style; private readonly CssRuleList _rules; private ISelector _selector; - private IEnumerable _selectorList; + private ISelector[] _selectorList; private Boolean _nested; #endregion @@ -120,8 +120,13 @@ public Boolean TryMatch(IElement element, IElement? scope, out Priority specific if (_selectorList is not null) { - foreach (var selector in _selectorList.OrderByDescending(m => m.Specificity)) + // Already ordered by descending specificity when the selector was + // assigned - sorting here would repeat the work for every single + // element the rule is matched against. + for (var i = 0; i < _selectorList.Length; i++) { + var selector = _selectorList[i]; + if (selector.Match(element, scope)) { specificity += selector.Specificity; @@ -186,7 +191,9 @@ void ISelectorVisitor.PseudoElement(string name) void ISelectorVisitor.List(IEnumerable selectors) { - _selectorList = selectors; + // OrderByDescending is stable, so selectors of equal specificity keep + // their declared order - same as when this ran per match attempt. + _selectorList = selectors.OrderByDescending(m => m.Specificity).ToArray(); } void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols) diff --git a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs index cfaa1a18..194a4d50 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs @@ -3,6 +3,7 @@ namespace AngleSharp.Css.Dom using AngleSharp.Dom; using System.Collections; using System.Collections.Generic; + using System.Linq; sealed class StyleCollection : IStyleCollection { @@ -10,6 +11,7 @@ sealed class StyleCollection : IStyleCollection private readonly IEnumerable _sheets; private readonly IRenderDevice _device; + private List? _rules; #endregion @@ -31,25 +33,39 @@ public StyleCollection(IEnumerable sheets, IRenderDevice device) #region Methods - public IEnumerator GetEnumerator() + public IEnumerator GetEnumerator() => GetRules().GetEnumerator(); + + /// + /// Gets the flattened list of style rules that apply to the current + /// device. The supplied sheet sequence is usually a lazy query over the + /// document, so it is walked exactly once and the result is reused for + /// the lifetime of the collection - which spans a single cascade or + /// render pass. Without this the whole DOM would be re-walked for every + /// element, and for every one of its ancestors. + /// + internal List GetRules() => _rules ??= CollectRules(); + + #endregion + + #region Helpers + + private List CollectRules() { + var rules = new List(); + foreach (var sheet in _sheets) { if (!sheet.IsDisabled && sheet.Media.Validate(_device)) { - var rules = sheet.Rules.GetMatchingStyles(_device); - - foreach (var rule in rules) + foreach (var rule in sheet.Rules.GetMatchingStyles(_device)) { - yield return rule; + rules.Add(rule); } } } - } - - #endregion - #region Helpers + return rules; + } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index ced250ae..de94d76f 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -113,11 +113,11 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st { var ctx = element.Owner?.Context ?? throw new InvalidOperationException("The element must be associated with a browsing context."); var computedStyle = new CssStyleDeclaration(ctx); - var rules = styles.SortBySpecificity(element); + var matches = styles.SortBySpecificity(element); - foreach (var rule in rules) + for (var i = 0; i < matches.Count; i++) { - var inlineStyle = rule.Style; + var inlineStyle = matches[i].Rule.Style; computedStyle.SetDeclarations(inlineStyle); } @@ -158,33 +158,80 @@ internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCo #region Helpers - private static IEnumerable SortBySpecificity(this IEnumerable rules, IElement element) + private static List SortBySpecificity(this IStyleCollection styles, IElement element) { - IEnumerable> MapPriority(ICssStyleRule rule) + // Resolving the scope once is equivalent to letting every TryMatch call + // fall back to it, but avoids re-reading DocumentElement per rule. + var scope = element.Owner?.DocumentElement; + var matches = new List(); + + if (styles is StyleCollection collection) { - if (rule.TryMatch(element, null, out var specificity)) + var rules = collection.GetRules(); + + for (var i = 0; i < rules.Count; i++) { - yield return Tuple.Create(rule, specificity); + MapPriority(rules[i], element, scope, matches); } + } + else + { + foreach (var rule in styles) + { + MapPriority(rule, element, scope, matches); + } + } + + // OrderBy is a stable sort; the index tie-break reproduces that for + // rules that share the same specificity. + matches.Sort(RuleMatchComparer.Instance); + return matches; + } + + private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, List matches) + { + if (rule.TryMatch(element, scope, out var specificity)) + { + matches.Add(new RuleMatch(rule, specificity, matches.Count)); + } - foreach (var subRule in rule.Rules) + var subRules = rule.Rules; + + for (var i = 0; i < subRules.Length; i++) + { + if (subRules[i] is ICssStyleRule style) { - if (subRule is ICssStyleRule style) - { - foreach (var item in MapPriority(style)) - { - yield return item; - } - } + MapPriority(style, element, scope, matches); } } + } + + private readonly struct RuleMatch + { + public RuleMatch(ICssStyleRule rule, Priority priority, Int32 index) + { + Rule = rule; + Priority = priority; + Index = index; + } - return rules.SelectMany(MapPriority).OrderBy(GetPriority).Select(GetRule); + public ICssStyleRule Rule { get; } + + public Priority Priority { get; } + + public Int32 Index { get; } } - private static Priority GetPriority(Tuple item) => item.Item2; + private sealed class RuleMatchComparer : IComparer + { + public static readonly RuleMatchComparer Instance = new RuleMatchComparer(); - private static ICssStyleRule GetRule(Tuple item) => item.Item1; + public Int32 Compare(RuleMatch x, RuleMatch y) + { + var result = Comparer.Default.Compare(x.Priority, y.Priority); + return result != 0 ? result : x.Index.CompareTo(y.Index); + } + } #endregion } diff --git a/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs b/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs new file mode 100644 index 00000000..06965c22 --- /dev/null +++ b/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs @@ -0,0 +1,148 @@ +namespace AngleSharp.Performance.Css +{ + using AngleSharp; + using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Dom; + using BenchmarkDotNet.Attributes; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + + /// + /// Covers the styling side of the library: cascade resolution via + /// getComputedStyle, full render tree construction and the parsing of + /// inline style attributes. + /// + [MemoryDiagnoser] + public class CssCascadeBenchmarks + { + private static readonly CssParser DeclarationParser = new CssParser(); + + private IDocument _document = null!; + private IWindow _window = null!; + private IElement[] _elements = null!; + private String[] _inlineStyles = null!; + + [GlobalSetup] + public void Setup() + { + var config = Configuration.Default.WithCss(); + var context = BrowsingContext.New(config); + _document = context.OpenAsync(req => req.Content(BuildDocument())).GetAwaiter().GetResult(); + _window = _document.DefaultView!; + + // A representative sample instead of every element - keeps a single + // benchmark iteration in a sane range while still walking the cascade + // for elements at different depths. + _elements = _document.QuerySelectorAll("#root *").Where((_, i) => i % 12 == 0).ToArray(); + _inlineStyles = BuildInlineStyles(); + } + + [Benchmark] + public Int32 ComputedStyle() + { + var total = 0; + + foreach (var element in _elements) + { + total += _window.GetComputedStyle(element).Length; + } + + return total; + } + + [Benchmark] + public Object RenderTree() + { + return _window.Render(); + } + + [Benchmark] + public Int32 ParseInlineDeclarations() + { + var total = 0; + + foreach (var style in _inlineStyles) + { + total += DeclarationParser.ParseDeclaration(style)?.Length ?? 0; + } + + return total; + } + + private static String[] BuildInlineStyles() + { + var templates = new[] + { + "color:#333;background:#fff", + "display:none", + "margin:0;padding:0", + "width:100%;height:auto", + "font-family:Arial,Helvetica,sans-serif;font-size:12px", + "border:1px solid #ccc;border-radius:4px", + "position:absolute;top:0;left:0;z-index:10", + "background-image:url(https://example.com/i.png);background-repeat:no-repeat", + "text-align:center;line-height:1.5", + "float:left;clear:both;overflow:hidden", + "background:linear-gradient(to right,#fff 0%,#000 100%)", + "transform:translate(10px,20px) rotate(45deg)", + "box-shadow:0 1px 2px rgba(0,0,0,.2)", + "flex:1 1 auto;align-items:center;justify-content:space-between", + "padding:10px 15px 10px 15px;margin:0 auto", + "visibility:hidden;opacity:0.5", + "color:rgb(51,51,51);background-color:rgba(255,255,255,0.9)", + "font:bold 14px/1.2 'Segoe UI',sans-serif", + "grid-template-columns:repeat(3,1fr);gap:10px", + "transition:all .3s ease-in-out", + }; + + var list = new List(); + + for (var i = 0; i < 10; i++) + { + list.AddRange(templates); + } + + return list.ToArray(); + } + + private static String BuildDocument() + { + var sb = new StringBuilder(); + sb.Append("
"); + + for (var s = 0; s < 25; s++) + { + sb.Append("
"); + + for (var c = 0; c < 12; c++) + { + sb.Append("
"); + sb.Append("

Text ").Append(s).Append('-').Append(c).Append("

"); + sb.Append("link"); + sb.Append("
"); + } + + sb.Append("
"); + } + + sb.Append("
"); + return sb.ToString(); + } + } +} From 16d07c9b03649560ca6cc08b3d13082b3c9c4f31 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 31 Jul 2026 13:38:33 +0200 Subject: [PATCH 6/6] Update CHANGELOG with recent fixes and improvements --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e316b2..ed966e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on Friday, July 31 2026. - Fixed NRE in `mask-image` with gradients (#218) +- Improved cascade resolution performance (#220) @lahma # 1.0.0