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..883e1f59b --- /dev/null +++ b/scripts/Verify-DocSnippets.ps1 @@ -0,0 +1,351 @@ +[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++) + { + $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 $closingFencePattern; $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");