From 37eacfe56744c69497a880aba6cf4da257e107f9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:38:43 +0100 Subject: [PATCH 1/5] test(docs): compile public snippets --- .github/workflows/ci.yml | 9 + .gitignore | 1 + README.md | 2 + scripts/Verify-DocSnippets.ps1 | 290 ++++++++++++++++++ tests/Respire.DocTests/Program.cs | 70 +++++ .../Respire.DocTests/Respire.DocTests.csproj | 26 ++ website/docs/commands/strings-and-keys.md | 1 + .../docs/fundamentals/client-side-caching.md | 1 + .../fundamentals/values-and-serialization.md | 1 + website/docs/getting-started.md | 1 + .../docs/guides/batches-and-transactions.md | 1 + website/docs/integrations/caching.md | 1 + .../docs/integrations/dependency-injection.md | 2 + 13 files changed, 406 insertions(+) create mode 100644 scripts/Verify-DocSnippets.ps1 create mode 100644 tests/Respire.DocTests/Program.cs create mode 100644 tests/Respire.DocTests/Respire.DocTests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba878f7c4..8251ac51e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,15 @@ jobs: - name: Build run: dotnet build -c Release --no-restore + + - name: Pack documentation dependencies + if: matrix.framework == 'net10.0' + run: dotnet pack Respire.slnx -c Release --no-build -p:Version=0.0.0-docs -o artifacts/package/release + + - name: Compile documentation snippets + if: matrix.framework == 'net10.0' + shell: pwsh + run: ./scripts/Verify-DocSnippets.ps1 -PackagesPath artifacts/package/release -Version 0.0.0-docs - name: Run Unit Tests run: | diff --git a/.gitignore b/.gitignore index 08c92d003..f9007c87b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build results bin/ obj/ +artifacts/ BenchmarkDotNet.Artifacts/ TestResults/ diff --git a/README.md b/README.md index a3ac213bd..a66473f18 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ pass through a Respire serializer. ### Dependency injection + ```csharp builder.Services.AddRespire(builder.Configuration.GetConnectionString("redis")!); @@ -297,6 +298,7 @@ Standalone clients surface setup exceptions directly, while cluster clients wrap Typed values use reflection-based System.Text.Json metadata by default. For a trimmed or NativeAOT application, generate metadata for every stored type and pass that context to Respire: + ```csharp using System.Text.Json.Serialization; using Respire; diff --git a/scripts/Verify-DocSnippets.ps1 b/scripts/Verify-DocSnippets.ps1 new file mode 100644 index 000000000..83452aab9 --- /dev/null +++ b/scripts/Verify-DocSnippets.ps1 @@ -0,0 +1,290 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackagesPath, + + [Parameter(Mandatory)] + [string]$Version +) + +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$resolvedPackagesPath = (Resolve-Path -LiteralPath $PackagesPath).Path +$generatedDirectory = Join-Path $repositoryRoot 'artifacts/doc-tests/generated' +$generatedPath = Join-Path $generatedDirectory 'GeneratedSnippets.g.cs' +$nugetConfigPath = Join-Path $generatedDirectory 'NuGet.config' +$restorePackagesPath = Join-Path $generatedDirectory 'packages' +$projectPath = Join-Path $repositoryRoot 'tests/Respire.DocTests/Respire.DocTests.csproj' +$documentPaths = @( + (Join-Path $repositoryRoot 'README.md') + Get-ChildItem (Join-Path $repositoryRoot 'website/docs') -Recurse -File -Include '*.md', '*.mdx' | + Sort-Object FullName | + ForEach-Object FullName +) + +$requiredPackages = @( + 'Respire' + 'Respire.Extensions.Caching' + 'Respire.Extensions.Caching.Hybrid' + 'Respire.Extensions.DependencyInjection' +) + +foreach ($packageId in $requiredPackages) +{ + $packagePath = Join-Path $resolvedPackagesPath "$packageId.$Version.nupkg" + if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) + { + throw "Documented package '$packageId' was not packed at '$packagePath'." + } +} + +$snippets = [System.Collections.Generic.List[object]]::new() +$installPackageIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +$projectCommands = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + +foreach ($documentPath in $documentPaths) +{ + $relativePath = [IO.Path]::GetRelativePath($repositoryRoot, $documentPath).Replace('\', '/') + $lines = Get-Content -LiteralPath $documentPath + $csharpOrdinal = 0 + + for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) + { + if ($lines[$lineIndex] -match '^```csharp\s*$') + { + $csharpOrdinal++ + $startLine = $lineIndex + 2 + $body = [System.Collections.Generic.List[string]]::new() + for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch '^```\s*$'; $lineIndex++) + { + $body.Add($lines[$lineIndex]) + } + + if ($lineIndex -ge $lines.Count) + { + throw "Unclosed C# fence at ${relativePath}:$startLine." + } + + $directive = if ($startLine -ge 3) { $lines[$startLine - 3].Trim() } else { '' } + $ignoreReason = $null + if ($directive -match '^$') + { + $ignoreReason = $Matches[1] + } + + $ignored = $null -ne $ignoreReason + if ($directive -match '^$') + { + $mode = 'declaration' + $splitBefore = $Matches[1] + } + elseif ($directive -match '^$') + { + $mode = 'tail-declaration' + $splitBefore = $Matches[1] + } + elseif ($directive -match '^$') + { + $mode = 'declaration' + } + elseif ($directive -match '^ ```csharp ClientSideCache = new RespireClientSideCacheOptions { diff --git a/website/docs/fundamentals/values-and-serialization.md b/website/docs/fundamentals/values-and-serialization.md index ff20605cb..987da37f2 100644 --- a/website/docs/fundamentals/values-and-serialization.md +++ b/website/docs/fundamentals/values-and-serialization.md @@ -51,6 +51,7 @@ else if (hits == 0) { /* key holds 0 */ } `SetAsync` and `GetAsync` use `RespireOptions.Serializer` for object values. `SystemTextJsonSerializer` is the default. Supply an `IRespireSerializer` for another format: + ```csharp var options = new RespireOptions { diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 9d8631766..62d8b1af7 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -50,6 +50,7 @@ Console.WriteLine(state); The default serializer uses `System.Text.Json`: + ```csharp public sealed record User(string Name, int LoginCount); diff --git a/website/docs/guides/batches-and-transactions.md b/website/docs/guides/batches-and-transactions.md index eec152f8a..9dd79d94e 100644 --- a/website/docs/guides/batches-and-transactions.md +++ b/website/docs/guides/batches-and-transactions.md @@ -49,6 +49,7 @@ RespireBatchResult result = await batch.ExecuteAsync(); Both types implement `IRespireCommandQueue`, which unifies every deferred facet and the root shortcuts. Helpers can therefore queue work across facets without choosing an execution model: + ```csharp static void QueueUserUpdate(IRespireCommandQueue queue, string userId) { diff --git a/website/docs/integrations/caching.md b/website/docs/integrations/caching.md index 56fc3a205..b60e71f89 100644 --- a/website/docs/integrations/caching.md +++ b/website/docs/integrations/caching.md @@ -42,6 +42,7 @@ is set, it uses a separately registered `IRespireClient` without taking ownershi Inject the framework abstraction into application code: + ```csharp public sealed class ProductCache(IDistributedCache cache) { diff --git a/website/docs/integrations/dependency-injection.md b/website/docs/integrations/dependency-injection.md index e17d9a0f1..f82f05901 100644 --- a/website/docs/integrations/dependency-injection.md +++ b/website/docs/integrations/dependency-injection.md @@ -25,6 +25,7 @@ builder.Services.AddRespire( Inject `IRespireClient`: + ```csharp public sealed class SessionStore(IRespireClient redis) { @@ -39,6 +40,7 @@ The container owns disposal. Use keyed services when an application talks to separate endpoints: + ```csharp builder.Services.AddKeyedRespire("sessions", "redis://sessions-host"); builder.Services.AddKeyedRespire("jobs", "redis://jobs-host"); From 12a4fc177475889afe2ffbd44022120bbebbb8f2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:53:48 +0100 Subject: [PATCH 2/5] fix(docs): harden snippet validation --- scripts/Verify-DocSnippets.ps1 | 94 +++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/scripts/Verify-DocSnippets.ps1 b/scripts/Verify-DocSnippets.ps1 index 83452aab9..8286b8de6 100644 --- a/scripts/Verify-DocSnippets.ps1 +++ b/scripts/Verify-DocSnippets.ps1 @@ -107,12 +107,6 @@ foreach ($documentPath in $documentPaths) continue } - $sourceWithoutComments = [regex]::Replace($source, '(?s)/\*.*?\*/', '') -replace '(?m)//.*$', '' - if ($sourceWithoutComments -match '\.\.\.') - { - throw "Incomplete C# at $relativePath#$csharpOrdinal must have a doc-test-ignore directive with a reason." - } - $snippets.Add([pscustomobject]@{ Id = "$relativePath#$csharpOrdinal" SourcePath = $documentPath.Replace('\', '/') @@ -139,6 +133,14 @@ foreach ($documentPath in $documentPaths) { [void]$projectCommands.Add($Matches[1].Trim("'`"")) } + elseif ($command -match '^dotnet test\s+--project\s+([^\s]+)') + { + [void]$projectCommands.Add($Matches[1].Trim("'`"")) + } + elseif ($command -match '^dotnet test\s+(?!-)([^\s]+)') + { + [void]$projectCommands.Add($Matches[1].Trim("'`"")) + } } } } @@ -154,7 +156,16 @@ foreach ($packageId in $installPackageIds) foreach ($relativeProjectPath in $projectCommands) { - if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot $relativeProjectPath))) + $commandPath = Join-Path $repositoryRoot $relativeProjectPath + $projectExists = Test-Path -LiteralPath $commandPath -PathType Leaf + if (Test-Path -LiteralPath $commandPath -PathType Container) + { + $projectExists = $null -ne (Get-ChildItem -LiteralPath $commandPath -File | + Where-Object { $_.Extension -in '.csproj', '.fsproj', '.vbproj' } | + Select-Object -First 1) + } + + if (-not $projectExists) { throw "Shell sample references missing project '$relativeProjectPath'." } @@ -180,14 +191,47 @@ $builder = [Text.StringBuilder]::new() [void]$builder.AppendLine('using Respire.Extensions.Caching.Hybrid;') [void]$builder.AppendLine('using Respire.Extensions.DependencyInjection;') [void]$builder.AppendLine('using Respire.Serialization;') -[void]$builder.AppendLine('namespace Respire.DocTests;') [void]$builder.AppendLine('#pragma warning disable') +$usingPattern = '(?m)^\s*(?:global\s+)?using\s+(?:(?:static\s+)?[A-Za-z_][A-Za-z0-9_.]*|[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:global::)?[A-Za-z_][A-Za-z0-9_.]*)\s*;\s*(?://.*)?$' for ($snippetIndex = 0; $snippetIndex -lt $snippets.Count; $snippetIndex++) { $snippet = $snippets[$snippetIndex] - $source = $snippet.Source -replace '(?m)^\s*using\s+[A-Za-z0-9_.]+;\s*(?://.*)?$', '' - $source = $source -replace '(?m)^\s*#pragma\s+warning\s+(?:disable|restore).*$','' + $usingDirectives = [regex]::Matches($snippet.Source, $usingPattern) | + ForEach-Object { $_.Value.Trim() -replace '^global\s+', '' } + $source = [regex]::Replace($snippet.Source, $usingPattern, '') + $source = $source -replace '(?m)^\s*#pragma\s+warning\s+(?:disable|restore).*$', '' + + $declarationLine = $snippet.Line + $statementLine = $snippet.Line + $sourceSplitIndex = -1 + if ($null -ne $snippet.SplitBefore) + { + $originalSplitIndex = $snippet.Source.IndexOf($snippet.SplitBefore, [StringComparison]::Ordinal) + $sourceSplitIndex = $source.IndexOf($snippet.SplitBefore, [StringComparison]::Ordinal) + if ($originalSplitIndex -lt 0 -or $sourceSplitIndex -lt 0) + { + throw "Split marker '$($snippet.SplitBefore)' was not found in $($snippet.Id)." + } + + $splitLine = $snippet.Line + + [regex]::Matches($snippet.Source.Substring(0, $originalSplitIndex), "`n").Count + if ($snippet.Mode -eq 'declaration') + { + $statementLine = $splitLine + } + else + { + $declarationLine = $splitLine + } + } + + [void]$builder.AppendLine('namespace Respire.DocTests') + [void]$builder.AppendLine('{') + foreach ($usingDirective in $usingDirectives) + { + [void]$builder.AppendLine($usingDirective) + } [void]$builder.AppendLine("internal sealed partial class Snippet$snippetIndex : SnippetContext") [void]$builder.AppendLine('{') @@ -200,17 +244,11 @@ for ($snippetIndex = 0; $snippetIndex -lt $snippets.Count; $snippetIndex++) } else { - $splitIndex = $source.IndexOf($snippet.SplitBefore, [StringComparison]::Ordinal) - if ($splitIndex -lt 0) - { - throw "Declaration split marker '$($snippet.SplitBefore)' was not found in $($snippet.Id)." - } - - $declarationSource = $source.Substring(0, $splitIndex).TrimEnd() - $source = $source.Substring($splitIndex) + $declarationSource = $source.Substring(0, $sourceSplitIndex).TrimEnd() + $source = $source.Substring($sourceSplitIndex) } - [void]$builder.AppendLine("#line $($snippet.Line) `"$($snippet.SourcePath)`"") + [void]$builder.AppendLine("#line $declarationLine `"$($snippet.SourcePath)`"") foreach ($line in ($declarationSource -split "`n")) { [void]$builder.AppendLine(" $line") @@ -219,15 +257,9 @@ for ($snippetIndex = 0; $snippetIndex -lt $snippets.Count; $snippetIndex++) } elseif ($snippet.Mode -eq 'tail-declaration') { - $splitIndex = $source.IndexOf($snippet.SplitBefore, [StringComparison]::Ordinal) - if ($splitIndex -lt 0) - { - throw "Tail declaration split marker '$($snippet.SplitBefore)' was not found in $($snippet.Id)." - } - - $declarationSource = $source.Substring($splitIndex).TrimEnd() - $source = $source.Substring(0, $splitIndex).TrimEnd() - [void]$builder.AppendLine("#line $($snippet.Line) `"$($snippet.SourcePath)`"") + $declarationSource = $source.Substring($sourceSplitIndex).TrimEnd() + $source = $source.Substring(0, $sourceSplitIndex).TrimEnd() + [void]$builder.AppendLine("#line $declarationLine `"$($snippet.SourcePath)`"") foreach ($line in ($declarationSource -split "`n")) { [void]$builder.AppendLine(" $line") @@ -239,7 +271,7 @@ for ($snippetIndex = 0; $snippetIndex -lt $snippets.Count; $snippetIndex++) [void]$builder.AppendLine(' {') if (-not [string]::IsNullOrWhiteSpace($source)) { - [void]$builder.AppendLine("#line $($snippet.Line) `"$($snippet.SourcePath)`"") + [void]$builder.AppendLine("#line $statementLine `"$($snippet.SourcePath)`"") foreach ($line in ($source -split "`n")) { [void]$builder.AppendLine(" $line") @@ -248,13 +280,17 @@ for ($snippetIndex = 0; $snippetIndex -lt $snippets.Count; $snippetIndex++) } [void]$builder.AppendLine(' }') [void]$builder.AppendLine('}') + [void]$builder.AppendLine('}') } +[void]$builder.AppendLine('namespace Respire.DocTests') +[void]$builder.AppendLine('{') [void]$builder.AppendLine('internal static class SnippetCatalog') [void]$builder.AppendLine('{') [void]$builder.AppendLine(' public static void Report() =>') [void]$builder.AppendLine(" Console.WriteLine(`"Compiled $($snippets.Count) C# documentation snippets.`");") [void]$builder.AppendLine('}') +[void]$builder.AppendLine('}') [IO.File]::WriteAllText($generatedPath, $builder.ToString()) From f636f7675781c726e939992e096a8e02f707dd13 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:59:21 +0100 Subject: [PATCH 3/5] fix(docs): close snippet parser gaps --- scripts/Verify-DocSnippets.ps1 | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/Verify-DocSnippets.ps1 b/scripts/Verify-DocSnippets.ps1 index 8286b8de6..ef8f17f7b 100644 --- a/scripts/Verify-DocSnippets.ps1 +++ b/scripts/Verify-DocSnippets.ps1 @@ -51,7 +51,7 @@ foreach ($documentPath in $documentPaths) for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { - if ($lines[$lineIndex] -match '^```csharp\s*$') + if ($lines[$lineIndex] -match '^```csharp(?:\s+.*)?$') { $csharpOrdinal++ $startLine = $lineIndex + 2 @@ -119,12 +119,12 @@ foreach ($documentPath in $documentPaths) continue } - if ($lines[$lineIndex] -match '^```(?:bash|sh|shell|powershell|pwsh)\s*$') + if ($lines[$lineIndex] -match '^```(?:bash|sh|shell|powershell|pwsh)(?:\s+.*)?$') { for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch '^```\s*$'; $lineIndex++) { $command = $lines[$lineIndex].Trim() - if ($command -match '^dotnet add package\s+([A-Za-z0-9_.-]+)') + if ($command -match '^dotnet (?:add package|package add)\s+([A-Za-z0-9_.-]+)') { [void]$installPackageIds.Add($Matches[1]) } @@ -157,8 +157,13 @@ foreach ($packageId in $installPackageIds) foreach ($relativeProjectPath in $projectCommands) { $commandPath = Join-Path $repositoryRoot $relativeProjectPath - $projectExists = Test-Path -LiteralPath $commandPath -PathType Leaf - if (Test-Path -LiteralPath $commandPath -PathType Container) + $projectExists = $false + if (Test-Path -LiteralPath $commandPath -PathType Leaf) + { + $projectExists = (Get-Item -LiteralPath $commandPath).Extension -in + '.csproj', '.fsproj', '.vbproj' + } + elseif (Test-Path -LiteralPath $commandPath -PathType Container) { $projectExists = $null -ne (Get-ChildItem -LiteralPath $commandPath -File | Where-Object { $_.Extension -in '.csproj', '.fsproj', '.vbproj' } | From c2c794ed1ab3756831ac3c517c25c6d074022158 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:06:08 +0100 Subject: [PATCH 4/5] fix(docs): validate project references --- scripts/Verify-DocSnippets.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/Verify-DocSnippets.ps1 b/scripts/Verify-DocSnippets.ps1 index ef8f17f7b..a3f786125 100644 --- a/scripts/Verify-DocSnippets.ps1 +++ b/scripts/Verify-DocSnippets.ps1 @@ -129,6 +129,13 @@ foreach ($documentPath in $documentPaths) [void]$installPackageIds.Add($Matches[1]) } + if ($command -match '^dotnet (?:add reference|reference add)\s+([^\s]+)') + { + $referencePath = $Matches[1].Trim("'`"").Replace('\', '/') -replace + '^path/to/Respire/', '' + [void]$projectCommands.Add($referencePath) + } + if ($command -match '^dotnet run\s+.*--project\s+([^\s]+)') { [void]$projectCommands.Add($Matches[1].Trim("'`"")) From 6fcc02be97536b9fb9df0de1c4d1ea79b5caf01b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:30:21 +0100 Subject: [PATCH 5/5] fix(docs): parse Markdown fences --- scripts/Verify-DocSnippets.ps1 | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/Verify-DocSnippets.ps1 b/scripts/Verify-DocSnippets.ps1 index a3f786125..883e1f59b 100644 --- a/scripts/Verify-DocSnippets.ps1 +++ b/scripts/Verify-DocSnippets.ps1 @@ -51,12 +51,25 @@ foreach ($documentPath in $documentPaths) for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { - if ($lines[$lineIndex] -match '^```csharp(?:\s+.*)?$') + $fenceMatch = [regex]::Match( + $lines[$lineIndex], + '^(? {0,3})(?`{3,}|~{3,})(?.*)$') + if (-not $fenceMatch.Success) + { + continue + } + + $delimiter = $fenceMatch.Groups['delimiter'].Value + $closingFencePattern = '^ {0,3}' + [regex]::Escape($delimiter[0]) + + "{$($delimiter.Length),}\s*$" + $info = $fenceMatch.Groups['info'].Value.Trim() + + if ($info -match '^csharp(?:\s+.*)?$') { $csharpOrdinal++ $startLine = $lineIndex + 2 $body = [System.Collections.Generic.List[string]]::new() - for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch '^```\s*$'; $lineIndex++) + for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch $closingFencePattern; $lineIndex++) { $body.Add($lines[$lineIndex]) } @@ -119,9 +132,9 @@ foreach ($documentPath in $documentPaths) continue } - if ($lines[$lineIndex] -match '^```(?:bash|sh|shell|powershell|pwsh)(?:\s+.*)?$') + if ($info -match '^(?:bash|sh|shell|powershell|pwsh)(?:\s+.*)?$') { - for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch '^```\s*$'; $lineIndex++) + for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch $closingFencePattern; $lineIndex++) { $command = $lines[$lineIndex].Trim() if ($command -match '^dotnet (?:add package|package add)\s+([A-Za-z0-9_.-]+)')