├── input ├── _ViewStart.cshtml ├── assets │ ├── styles.css │ └── avatar.png ├── posts │ ├── 2016 │ │ └── 10 │ │ │ ├── commits.png │ │ │ ├── huge-commit.png │ │ │ └── lambda-assert.png │ ├── 2020 │ │ ├── 09 │ │ │ ├── unused-classes.png │ │ │ ├── used-implicitly.png │ │ │ ├── quick-documentation.png │ │ │ ├── tagging-query-with-ef-core.md │ │ │ └── external-annotations.md │ │ └── 08 │ │ │ ├── generated-files-settings.png │ │ │ ├── ef-core-5-and-stackoverflow.md │ │ │ └── cqrs-ef-core-repository-pattern.md │ ├── 2021 │ │ ├── 12 │ │ │ ├── full-loading.gif │ │ │ ├── 2021-12-12-21-13-58.png │ │ │ ├── 2021-12-12-21-28-41.png │ │ │ ├── generate-delegating-members.png │ │ │ ├── tagging-query-with-ef-core.md │ │ │ └── optimizing-your-powershell-load-times.md │ │ └── 01 │ │ │ ├── 2021-01-14-23-00-48.png │ │ │ ├── 2021-01-14-23-03-31.png │ │ │ ├── 2021-01-18-21-42-38.png │ │ │ ├── statiq-and-vercel.md │ │ │ └── dotnet-soddi.md │ ├── 2022 │ │ ├── 11 │ │ │ ├── 2022-11-20-14-33-15.png │ │ │ ├── 2022-11-20-14-34-02.png │ │ │ └── adventures-in-trimming.md │ │ └── 05 │ │ │ ├── 2022-05-30-02-19-19.png │ │ │ ├── 2022-05-30-02-28-36.png │ │ │ ├── 2022-05-30-02-34-53.png │ │ │ ├── adding-config-to-incremental-generator.md │ │ │ └── tour-of-the-thirty25.com-repository.md │ ├── _ViewStart.cshtml │ └── _layout.cshtml ├── _book.yaml ├── _ViewImports.cshtml ├── index.cshtml ├── _index.yaml ├── _tags.yaml ├── _taglist.cshtml ├── tags.cshtml ├── _nextprevious.cshtml ├── _posts.cshtml ├── _layout.cshtml └── book.cshtml ├── readme.md ├── .vscode ├── settings.json ├── launch.json └── tasks.json ├── .config └── dotnet-tools.json ├── NuGet.config ├── Constants.cs ├── Statiq ├── SocialCard.cshtml.cs ├── Pipelines │ ├── Feeds.cs │ ├── Monorail.cs │ ├── SocialImages.cs │ └── Book.cs ├── FullUrlShortCode.cs ├── PngCompress.cs ├── NewPostCommand.cs ├── RoslynHighlightModule.cs └── SocialCard.cshtml ├── .github └── workflows │ ├── book.yml │ └── pages.yml ├── LICENSE ├── thirty25-statiq.csproj ├── thirty25-statiq.sln ├── Program.cs ├── thirty25-statiq.sln.DotSettings ├── .gitignore └── .editorconfig /input/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = @"_layout.cshtml"; 3 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Thirty25.com Blog 2 | 3 | This is the source for my blog -------------------------------------------------------------------------------- /input/assets/styles.css: -------------------------------------------------------------------------------- 1 | /* will be overwritten by the monorail pipeline */ -------------------------------------------------------------------------------- /input/posts/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = @$"_layout.cshtml"; 3 | } -------------------------------------------------------------------------------- /input/assets/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/assets/avatar.png -------------------------------------------------------------------------------- /input/_book.yaml: -------------------------------------------------------------------------------- 1 | Title: Thirty25 - The Book 2 | BookSources: posts/**/* 3 | BookOrderKey: date 4 | BookOrderDescending: true -------------------------------------------------------------------------------- /input/posts/2016/10/commits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2016/10/commits.png -------------------------------------------------------------------------------- /input/posts/2016/10/huge-commit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2016/10/huge-commit.png -------------------------------------------------------------------------------- /input/posts/2021/12/full-loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2021/12/full-loading.gif -------------------------------------------------------------------------------- /input/posts/2016/10/lambda-assert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2016/10/lambda-assert.png -------------------------------------------------------------------------------- /input/posts/2020/09/unused-classes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2020/09/unused-classes.png -------------------------------------------------------------------------------- /input/posts/2020/09/used-implicitly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2020/09/used-implicitly.png -------------------------------------------------------------------------------- /input/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using Statiq.Common 2 | @using Statiq.Razor 3 | @using Statiq.Web 4 | @using Statiq.Web.Pipelines 5 | @using Thirty25 6 | -------------------------------------------------------------------------------- /input/posts/2020/09/quick-documentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2020/09/quick-documentation.png -------------------------------------------------------------------------------- /input/posts/2021/01/2021-01-14-23-00-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2021/01/2021-01-14-23-00-48.png -------------------------------------------------------------------------------- /input/posts/2021/01/2021-01-14-23-03-31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2021/01/2021-01-14-23-03-31.png -------------------------------------------------------------------------------- /input/posts/2021/01/2021-01-18-21-42-38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2021/01/2021-01-18-21-42-38.png -------------------------------------------------------------------------------- /input/posts/2021/12/2021-12-12-21-13-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2021/12/2021-12-12-21-13-58.png -------------------------------------------------------------------------------- /input/posts/2021/12/2021-12-12-21-28-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2021/12/2021-12-12-21-28-41.png -------------------------------------------------------------------------------- /input/posts/2022/05/2022-05-30-02-19-19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2022/05/2022-05-30-02-19-19.png -------------------------------------------------------------------------------- /input/posts/2022/05/2022-05-30-02-28-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2022/05/2022-05-30-02-28-36.png -------------------------------------------------------------------------------- /input/posts/2022/05/2022-05-30-02-34-53.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2022/05/2022-05-30-02-34-53.png -------------------------------------------------------------------------------- /input/posts/2022/11/2022-11-20-14-33-15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2022/11/2022-11-20-14-33-15.png -------------------------------------------------------------------------------- /input/posts/2022/11/2022-11-20-14-34-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2022/11/2022-11-20-14-34-02.png -------------------------------------------------------------------------------- /input/posts/2020/08/generated-files-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2020/08/generated-files-settings.png -------------------------------------------------------------------------------- /input/posts/2021/12/generate-delegating-members.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phil-scott-78/thirty25-statiq/HEAD/input/posts/2021/12/generate-delegating-members.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Statiq" 4 | ], 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true 7 | } -------------------------------------------------------------------------------- /input/index.cshtml: -------------------------------------------------------------------------------- 1 | @inherits StatiqRazorPage 2 | @await Html.PartialAsync("_posts.cshtml", Document.GetChildren()) 3 | @await Html.PartialAsync("_nextprevious.cshtml", Document) -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "microsoft.playwright.cli": { 6 | "version": "1.2.2", 7 | "commands": [ 8 | "playwright" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /input/_index.yaml: -------------------------------------------------------------------------------- 1 | Title: Recent Posts 2 | ArchiveSources: posts/**/* 3 | ArchiveDestination: > 4 | => GetInt("Index") <= 1 ? $"index.html" : $"{GetInt("Index")}.html" 5 | ArchivePageSize: => @Constants.PostsPerPage 6 | ArchiveOrderKey: date 7 | ArchiveOrderDescending: true -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Thirty25; 2 | 3 | public static class Constants 4 | { 5 | public const string BlogTitle = "Thirty25"; 6 | public const string Description = "A collection of my bad ideas and questionable architectural decisions."; 7 | public const string ProdSiteUrl = "https://thirty25.com/"; 8 | public const string CssFile = nameof(CssFile); 9 | public const int PostsPerPage = 10; 10 | } 11 | -------------------------------------------------------------------------------- /input/_tags.yaml: -------------------------------------------------------------------------------- 1 | ArchiveSources: posts/**/* 2 | ArchiveKey: Tags 3 | ArchiveKeyComparer: => StringComparer.OrdinalIgnoreCase.ToConvertingEqualityComparer() 4 | ArchiveDestination: > 5 | => GetInt("Index") <= 1 ? $"tags/{NormalizedPath.OptimizeFileName(GetString("GroupKey"))}/index.html" : $"tags/{NormalizedPath.OptimizeFileName(GetString("GroupKey"))}/{GetInt("Index")}.html" 6 | ArchivePageSize: => @Constants.PostsPerPage 7 | ArchiveOrderKey: date 8 | ArchiveOrderDescending: true 9 | Title: Tags 10 | ArchiveTitle: => GetString("GroupKey") -------------------------------------------------------------------------------- /Statiq/SocialCard.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | 4 | namespace Thirty25.Statiq; 5 | 6 | public class SocialCardModel : PageModel 7 | { 8 | [BindProperty(Name = "title", SupportsGet = true)] 9 | public string Title { get; set; } 10 | 11 | [BindProperty(Name = "desc", SupportsGet = true)] 12 | public string Description { get; set; } 13 | 14 | [BindProperty(Name = "tags", SupportsGet = true)] 15 | public string Tags { get; set; } 16 | 17 | public void OnGet() 18 | { 19 | } 20 | } -------------------------------------------------------------------------------- /input/_taglist.cshtml: -------------------------------------------------------------------------------- 1 | @inherits StatiqRazorPage> 2 | 3 | 12 | -------------------------------------------------------------------------------- /input/tags.cshtml: -------------------------------------------------------------------------------- 1 | @inherits StatiqRazorPage 2 | 3 | @if (Document.ContainsKey(Keys.GroupKey)) 4 | { 5 | 6 | @await Html.PartialAsync("_posts.cshtml", Document.GetChildren()) 7 | @await Html.PartialAsync("_nextprevious.cshtml", Document) 8 | } 9 | else 10 | { 11 | 12 | @foreach (var tag in Document.GetChildren().OrderByDescending(x => x.GetChildren().Count())) 13 | { 14 | var postCount = tag.GetChildren().Count().ToString(); 15 | @tag.GetTitle() (@postCount) 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/book.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Setup .NET 15 | uses: actions/setup-dotnet@v2 16 | with: 17 | dotnet-version: 6.0.x 18 | - name: Restore dependencies 19 | run: dotnet restore 20 | - name: Create Book 21 | run: dotnet run -- book 22 | - uses: "marvinpinto/action-automatic-releases@latest" 23 | with: 24 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 25 | automatic_release_tag: "latest" 26 | title: "Latest Book" 27 | files: | 28 | public/book.pdf 29 | -------------------------------------------------------------------------------- /input/_nextprevious.cshtml: -------------------------------------------------------------------------------- 1 | @inherits StatiqRazorPage 2 | @model IDocument 3 | 4 | @{ 5 | var older = Model.GetDocument(Keys.Next); 6 | var newer = Model.GetDocument(Keys.Previous); 7 | } 8 | 9 | 10 | @if (older != null || newer != null) 11 | { 12 |
13 |
14 | @if (older != null) 15 | { 16 | Older 17 | } 18 |
19 |
20 | @if (newer != null) 21 | { 22 | Newer 23 | } 24 |
25 |
26 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Thirty25 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Statiq/Pipelines/Feeds.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using JetBrains.Annotations; 3 | using Statiq.Common; 4 | using Statiq.Core; 5 | using Statiq.Feeds; 6 | using Statiq.Web.Pipelines; 7 | 8 | namespace Thirty25.Statiq.Pipelines; 9 | 10 | [UsedImplicitly] 11 | public class Feeds : Pipeline 12 | { 13 | public Feeds() 14 | { 15 | Dependencies.Add(nameof(Content)); 16 | 17 | ProcessModules = new ModuleList 18 | { 19 | new ConcatDocuments(nameof(Content)), 20 | new FilterDocuments(Config.FromDocument(doc => !doc.GetBool("Draft"))), 21 | new OrderDocuments(Config.FromDocument((x => x.GetDateTime("Date")))).Descending(), 22 | new GenerateFeeds() 23 | .WithItemDescription(Config.FromDocument(doc => doc.GetString("Excerpt"))) 24 | .WithItemPublished(Config.FromDocument(doc => (DateTime?)doc.GetDateTime("Date"))) 25 | .WithRssPath(new NormalizedPath("rss.xml")) 26 | .WithAtomPath(new NormalizedPath("atom.xml")) 27 | }; 28 | 29 | OutputModules = new ModuleList 30 | { 31 | new WriteFiles() 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /input/_posts.cshtml: -------------------------------------------------------------------------------- 1 | @inherits StatiqRazorPage> 2 |
3 | @foreach (var post in Model) 4 | { 5 |
6 |
7 |

@post.GetString("Title")

8 | 11 |
12 |

@post.GetString("Description")

13 |
14 | @await Html.PartialAsync("_taglist.cshtml", @post.GetList("tags")) 15 |
16 |
17 |
18 |
19 | } 20 |
-------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/bin/Debug/netcoreapp3.1/thirty25-statiq.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}", 16 | // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console 17 | "console": "internalConsole", 18 | "stopAtEntry": false 19 | }, 20 | { 21 | "name": ".NET Core Attach", 22 | "type": "coreclr", 23 | "request": "attach", 24 | "processId": "${command:pickProcess}" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /Statiq/FullUrlShortCode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using JetBrains.Annotations; 4 | using Statiq.Common; 5 | 6 | namespace Thirty25.Statiq; 7 | 8 | [UsedImplicitly] 9 | internal class FullUrlShortCode : SyncShortcode 10 | { 11 | public override ShortcodeResult Execute(KeyValuePair[] args, string content, IDocument document, 12 | IExecutionContext context) 13 | { 14 | var env = context.GetString("VERCEL_ENV", "development"); 15 | string host; 16 | if (env != "production") 17 | { 18 | host = context.GetString("VERCEL_URL", Constants.ProdSiteUrl); 19 | } 20 | else 21 | { 22 | host = Constants.ProdSiteUrl; 23 | } 24 | 25 | if (host.EndsWith("/")) 26 | { 27 | host = host.Substring(0, host.Length - 1); 28 | } 29 | 30 | if (host.StartsWith("https://")) 31 | { 32 | host = host.Substring(8); 33 | } 34 | 35 | var path = args.First(x => x.Key == "path").Value ?? ""; 36 | if (path.StartsWith("/")) 37 | { 38 | path = path.Substring(1); 39 | } 40 | 41 | return $"https://{host}/{path}"; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/thirty25-statiq.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/thirty25-statiq.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/thirty25-statiq.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Setup .NET 34 | uses: actions/setup-dotnet@v2 35 | with: 36 | dotnet-version: 6.0.x 37 | - name: Restore dependencies 38 | run: dotnet restore 39 | - name: Build Statiq Site 40 | run: dotnet run 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v1 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v3 45 | with: 46 | # Upload entire repository 47 | path: './public' 48 | - name: Deploy to GitHub Pages 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /thirty25-statiq.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | netcoreapp6 6 | Thirty25 7 | latest 8 | NETSDK1187;MVC1000 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /thirty25-statiq.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "thirty25-statiq", "thirty25-statiq.csproj", "{44962C0A-72B8-4720-8FA2-D22A17D31226}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Debug|x64 = Debug|x64 12 | Debug|x86 = Debug|x86 13 | Release|Any CPU = Release|Any CPU 14 | Release|x64 = Release|x64 15 | Release|x86 = Release|x86 16 | EndGlobalSection 17 | GlobalSection(SolutionProperties) = preSolution 18 | HideSolutionNode = FALSE 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Debug|x64.ActiveCfg = Debug|Any CPU 24 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Debug|x64.Build.0 = Debug|Any CPU 25 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Debug|x86.ActiveCfg = Debug|Any CPU 26 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Debug|x86.Build.0 = Debug|Any CPU 27 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Release|x64.ActiveCfg = Release|Any CPU 30 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Release|x64.Build.0 = Release|Any CPU 31 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Release|x86.ActiveCfg = Release|Any CPU 32 | {44962C0A-72B8-4720-8FA2-D22A17D31226}.Release|x86.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | EndGlobal 35 | -------------------------------------------------------------------------------- /input/posts/_layout.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = @"../_layout.cshtml"; 3 | } 4 | @inherits StatiqRazorPage 5 | 6 | @section subheading 7 | { 8 |
9 |

Posted

10 | 11 | @await Html.PartialAsync($"_taglist", Document.GetList("tags")) 12 |
13 | 14 | } 15 | 16 |
17 | @RenderBody() 18 |
19 | 20 | @if (@Document.ContainsKey("Repository")) 21 | { 22 |
23 | 24 | 25 | 26 |

View the supporting repository at @Document.GetString("Repository")

27 |
28 | } -------------------------------------------------------------------------------- /Statiq/Pipelines/Monorail.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using ConcurrentCollections; 6 | using JetBrains.Annotations; 7 | using MonorailCss; 8 | using Statiq.Common; 9 | using Statiq.Core; 10 | using Statiq.Web.Pipelines; 11 | using IDocument = Statiq.Common.IDocument; 12 | 13 | namespace Thirty25.Statiq.Pipelines; 14 | 15 | [UsedImplicitly] 16 | public class Monorail : Pipeline 17 | { 18 | public Monorail() 19 | { 20 | Deployment = true; 21 | ExecutionPolicy = ExecutionPolicy.Normal; 22 | Dependencies.AddRange( 23 | nameof(Content), 24 | nameof(Data), 25 | nameof(Archives)); 26 | 27 | DependencyOf.AddRange(nameof(AnalyzeContent)); 28 | 29 | ProcessModules = new ModuleList 30 | { 31 | new ConcatDocuments(nameof(Content)), 32 | new ConcatDocuments(nameof(Data)), 33 | new ConcatDocuments(nameof(Archives)), 34 | new JitCss() 35 | }; 36 | 37 | OutputModules = new ModuleList { new WriteFiles() }; 38 | } 39 | } 40 | 41 | public class JitCss : Module 42 | { 43 | protected override async Task> ExecuteContextAsync(IExecutionContext context) 44 | { 45 | var results = new ConcurrentHashSet(); 46 | 47 | foreach (var result in await context.Inputs.ParallelSelectAsync( 48 | async input => await ParseCssClassesAsync(input))) 49 | { 50 | results.AddRange(result); 51 | } 52 | 53 | var framework = context.GetService(typeof(CssFramework)) as CssFramework ?? new CssFramework(); 54 | var style = framework.Process(results); 55 | 56 | return context.Inputs.Add(context.CreateDocument(context.GetString(Constants.CssFile), 57 | context.GetContentProvider(style, MediaTypes.Css))); 58 | } 59 | 60 | 61 | private static async Task> ParseCssClassesAsync( 62 | IDocument input) 63 | { 64 | var htmlDocument = await input.ParseHtmlAsync(false); 65 | if (htmlDocument is null) 66 | { 67 | return default; 68 | } 69 | 70 | var elements = new HashSet(); 71 | foreach (var element in htmlDocument.All.Where(x => x.ClassName != null)) 72 | { 73 | var classes = element.ClassName!.Split(' ', 74 | StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); 75 | elements.AddRange(classes); 76 | } 77 | 78 | return elements; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Statiq/PngCompress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | using System.ComponentModel; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using ByteSizeLib; 8 | using JetBrains.Annotations; 9 | using LibGit2Sharp; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Logging; 12 | using Spectre.Console.Cli; 13 | using Statiq.App; 14 | using Statiq.Common; 15 | using TinyPng; 16 | using LogLevel = Microsoft.Extensions.Logging.LogLevel; 17 | 18 | namespace Thirty25.Statiq; 19 | 20 | [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] 21 | public class PngCompressSettings : EngineCommandSettings 22 | { 23 | [CommandOption("-c")] 24 | [Description("Compress all files, not just uncommitted files")] 25 | public bool AllFiles { get; set; } 26 | } 27 | 28 | [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] 29 | public class PngCompressCommand : EngineCommand 30 | { 31 | public PngCompressCommand(IConfiguratorCollection configurators, Settings settings, 32 | IServiceCollection serviceCollection, IFileSystem fileSystem, Bootstrapper bootstrapper) : base( 33 | configurators, settings, 34 | serviceCollection, fileSystem, bootstrapper) 35 | { 36 | } 37 | 38 | protected override async Task ExecuteEngineAsync(CommandContext commandContext, 39 | PngCompressSettings commandSettings, 40 | IEngineManager engineManager) 41 | { 42 | var tinyPngKey = Environment.GetEnvironmentVariable("TinyPngKey") ?? 43 | throw new Exception( 44 | "TinyPng key not found. Expected value for environment variable \"TinyPngKey\""); 45 | 46 | var rootPath = engineManager.Engine.FileSystem.RootPath.FullPath; 47 | using var repo = new Repository(rootPath); 48 | var status = repo.RetrieveStatus(); 49 | 50 | var modifiedPngs = status 51 | .Where(_ => Path.HasExtension(".png")) 52 | .Select(i => new NormalizedPath(Path.Combine(rootPath, i.FilePath))) 53 | .ToImmutableList(); 54 | 55 | var pngCompressor = new TinyPngClient(tinyPngKey); 56 | 57 | var pngs = engineManager.Engine.FileSystem 58 | .GetInputFiles("**/*.png") 59 | .Select(i => i.Path) 60 | .Where(i => commandSettings.AllFiles || modifiedPngs.Contains(i)) 61 | .ToImmutableList(); 62 | 63 | var totalPre = 0L; 64 | var totalPost = 0L; 65 | 66 | var message = commandSettings.AllFiles ? "all files" : "checked out files"; 67 | engineManager.Engine.Logger.Log(LogLevel.Information, "Beginning compression on {FileTypes}", message); 68 | 69 | foreach (var png in pngs) 70 | { 71 | var preSize = engineManager.Engine.FileSystem.GetFile(png.FullPath).Length; 72 | totalPre += preSize; 73 | 74 | await pngCompressor 75 | .Compress(png.FullPath) 76 | .Download() 77 | .SaveImageToDisk(png.FullPath); 78 | 79 | var postSize = engineManager.Engine.FileSystem.GetFile(png.FullPath).Length; 80 | totalPost += postSize; 81 | 82 | var percentCompressed = 1 - postSize / (decimal)preSize; 83 | engineManager.Engine.Logger.Log(LogLevel.Information, 84 | "Compressed {Path}. Reduced from {PreSize} to {PostSize} ({PercentCompressed:P})", 85 | png.Name, 86 | ByteSize.FromBytes(preSize).ToString(), 87 | ByteSize.FromBytes(postSize).ToString(), 88 | percentCompressed); 89 | } 90 | 91 | engineManager.Engine.Logger.Log(LogLevel.Information, 92 | "Compression complete. Reduced from {PreSize} to {PostSize}", 93 | ByteSize.FromBytes(totalPre).ToString(), 94 | ByteSize.FromBytes(totalPost).ToString()); 95 | 96 | return 0; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using JetBrains.Annotations; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using MonorailCss; 7 | using MonorailCss.Css; 8 | using MonorailCss.Plugins; 9 | using MonorailCss.Plugins.Prose; 10 | using Statiq.App; 11 | using Statiq.Common; 12 | using Statiq.Core; 13 | using Statiq.Feeds; 14 | using Statiq.Web; 15 | using Statiq.Web.Pipelines; 16 | using Thirty25; 17 | using Thirty25.Statiq; 18 | using Thirty25.Statiq.Pipelines; 19 | using GatherHeadings = Statiq.Core.GatherHeadings; 20 | using ISettings = MonorailCss.Plugins.ISettings; 21 | 22 | [assembly: AspMvcPartialViewLocationFormat("~/input/{0}.cshtml")] 23 | 24 | await Bootstrapper.Factory 25 | .CreateWeb(args) 26 | .AddSetting(Keys.Host, "thirty25.com") 27 | .AddSetting(Keys.Title, "Thirty25") 28 | .AddSetting(FeedKeys.Author, "Phil Scott") 29 | .AddSetting(FeedKeys.Copyright, DateTime.UtcNow.Year.ToString()) 30 | .AddSetting(Constants.CssFile, "assets/styles.css") 31 | .SetOutputPath("public") 32 | .AddShortcode("FullUrl") 33 | .ConfigureServices(i => 34 | { 35 | i.AddSingleton(GetCssFramework()); 36 | }) 37 | .ModifyPipeline(nameof(Content), pipeline => 38 | { 39 | // make sure to include child content like code blocks. 40 | pipeline.ProcessModules 41 | .GetLast().Children 42 | .GetLast()[0] 43 | .ReplaceFirst(_ => true, new GatherHeadings(2).WithNestedElements()); 44 | // in addition to the archive pipeline we also have the book so make sure the regular content skips those too 45 | pipeline.ProcessModules.ReplaceFirst(_ => true, new FilterDocuments(Config.FromDocument(doc => !Archives.IsArchive(doc) && !Book.IsBook(doc)))); 46 | pipeline.PostProcessModules.Add(new RoslynHighlightModule()); 47 | }) 48 | .RunAsync(); 49 | 50 | 51 | CssFramework GetCssFramework() 52 | { 53 | var proseSettings = new Prose.Settings() 54 | { 55 | CustomSettings = designSystem => new Dictionary() 56 | { 57 | { 58 | "DEFAULT", new CssSettings() 59 | { 60 | ChildRules = new CssRuleSetList() 61 | { 62 | new("a", 63 | new CssDeclarationList() 64 | { 65 | new(CssProperties.FontWeight, "inherit"), 66 | new(CssProperties.TextDecoration, "none"), 67 | new(CssProperties.BorderBottomWidth, "1px"), 68 | new(CssProperties.BorderBottomColor, 69 | designSystem.Colors[ColorNames.Blue][ColorLevels._500].AsRgbWithOpacity("75%")) 70 | }) 71 | } 72 | } 73 | } 74 | }.ToImmutableDictionary() 75 | }; 76 | 77 | return new CssFramework( 78 | new CssFrameworkSettings() 79 | { 80 | DesignSystem = DesignSystem.Default with 81 | { 82 | Colors = DesignSystem.Default.Colors.AddRange( 83 | new Dictionary>() 84 | { 85 | { "primary", DesignSystem.Default.Colors[ColorNames.Sky] }, 86 | { "base", DesignSystem.Default.Colors[ColorNames.Gray] }, 87 | }) 88 | }, 89 | PluginSettings = new List { proseSettings }, 90 | Applies = new Dictionary 91 | { 92 | { "body", "font-sans" }, 93 | { ".token.comment,.token.prolog,.token.doctype,.token.cdata,.token.punctuation,.token.selector,.token.tag", "text-gray-300" }, 94 | { ".token.boolean,.token.number,.token.constant,.token.attr-name,.token.deleted", "text-blue-300" }, 95 | { ".token.string,.token.char,.token.attr-value,.token.builtin,.token.inserted", "text-green-300" }, 96 | { ".token.operator,.token.entity,.token.url,.token.symbol,.token.class-name,.language-css .token.string,.style .token.string", "text-cyan-300" }, 97 | { ".token.atrule,.token.keyword", "text-indigo-300" }, 98 | { ".token.property,.token.function", "text-orange-300" }, 99 | { ".token.regex,.token.important", "text-red-300" }, 100 | } 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /Statiq/Pipelines/SocialImages.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using System.Web; 8 | using AngleSharp.Dom; 9 | using JetBrains.Annotations; 10 | using Microsoft.AspNetCore.Builder; 11 | using Microsoft.AspNetCore.Http; 12 | using Microsoft.Extensions.DependencyInjection; 13 | using Microsoft.Extensions.FileProviders; 14 | using Microsoft.Extensions.Logging; 15 | using Microsoft.Playwright; 16 | using Statiq.Common; 17 | using Statiq.Core; 18 | using Statiq.Web; 19 | using Statiq.Web.Modules; 20 | using Statiq.Web.Pipelines; 21 | using IDocument = Statiq.Common.IDocument; 22 | 23 | namespace Thirty25.Statiq.Pipelines; 24 | 25 | [UsedImplicitly] 26 | public class SocialImages : Pipeline 27 | { 28 | public SocialImages() 29 | { 30 | Dependencies.AddRange(nameof(Inputs)); 31 | 32 | ProcessModules = new ModuleList 33 | { 34 | new GetPipelineDocuments(ContentType.Content), 35 | 36 | // Filter to non-archive content 37 | new FilterDocuments(Config.FromDocument(doc => !Archives.IsArchive(doc))), 38 | 39 | // Process the content 40 | new CacheDocuments 41 | { 42 | new AddTitle(), 43 | new SetDestination(true), 44 | new ExecuteIf(Config.FromSetting(WebKeys.OptimizeContentFileNames, true)) 45 | { 46 | new OptimizeFileName() 47 | }, 48 | new GenerateSocialImage(), 49 | } 50 | }; 51 | 52 | OutputModules = new ModuleList { new WriteFiles() }; 53 | } 54 | } 55 | 56 | internal class GenerateSocialImage : ParallelModule 57 | { 58 | private WebApplication _app; 59 | private IPlaywright _playwright; 60 | private IBrowser _browser; 61 | private IBrowserContext _context; 62 | 63 | protected override async Task BeforeExecutionAsync(IExecutionContext context) 64 | { 65 | var builder = WebApplication.CreateBuilder(); 66 | //builder.Logging.ClearProviders(); 67 | builder.Services 68 | .AddRazorPages() 69 | .WithRazorPagesRoot("/Statiq"); 70 | 71 | _app = builder.Build(); 72 | _app.MapRazorPages(); 73 | _app.UseStaticFiles(new StaticFileOptions() 74 | { 75 | FileProvider = new PhysicalFileProvider( 76 | Path.Combine(Directory.GetCurrentDirectory(), @"input/assets")), 77 | RequestPath = new PathString("/assets") 78 | }); 79 | await _app.StartAsync(); 80 | 81 | _playwright = await Playwright.CreateAsync(); 82 | Debug.Assert(Chromium.Path != null, "Chromium.Path != null"); 83 | _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions 84 | { 85 | ExecutablePath = Chromium.Path.Replace("ubuntu.18.04-x64", "linux-x64"), 86 | Headless = true, 87 | }); 88 | _context = await _browser.NewContextAsync(new BrowserNewContextOptions 89 | { 90 | ViewportSize = new ViewportSize { Width = 1200, Height = 628 }, 91 | }); 92 | await base.BeforeExecutionAsync(context); 93 | } 94 | 95 | protected override async Task FinallyAsync(IExecutionContext context) 96 | { 97 | await _context.DisposeAsync(); 98 | await _browser.DisposeAsync(); 99 | _playwright.Dispose(); 100 | await _app.DisposeAsync(); 101 | await base.FinallyAsync(context); 102 | } 103 | 104 | protected override async Task> ExecuteInputAsync(IDocument input, 105 | IExecutionContext context) 106 | { 107 | var url = _app.Urls.First(u => u.StartsWith("http://")); 108 | var page = await _context.NewPageAsync(); 109 | 110 | var title = HttpUtility.UrlEncode(input.GetString("Title")); 111 | var description = HttpUtility.UrlEncode(input.GetString("Description")); 112 | var tags = input.GetList("tags") ?? Array.Empty(); 113 | 114 | await page.GotoAsync($"{url}/SocialCard?title={title}&desc={description}&tags={HttpUtility.UrlEncode(string.Join(';', tags))}"); 115 | 116 | // This will not just wait for the page to load over the network, but it'll also give 117 | // chrome a chance to complete rendering of the fonts while the wait timeout completes. 118 | await page.WaitForLoadStateAsync(LoadState.NetworkIdle).ConfigureAwait(false); 119 | var bytes = await page.ScreenshotAsync(); 120 | 121 | var destination = input.Destination.InsertSuffix("-social").ChangeExtension("png"); 122 | // can we set this property then pull it when rendering the page? 123 | var doc = context.CreateDocument( 124 | input.Source, 125 | destination, 126 | new MetadataItems { { "DocId", input.Id } }, 127 | context.GetContentProvider(bytes)); 128 | 129 | return new[] { doc }; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Statiq/NewPostCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using JetBrains.Annotations; 6 | using LibGit2Sharp; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Logging; 9 | using Spectre.Console.Cli; 10 | using Statiq.App; 11 | using Statiq.Common; 12 | using YamlDotNet.Serialization; 13 | using YamlDotNet.Serialization.NamingConventions; 14 | using Command = SimpleExec.Command; 15 | using LogLevel = Microsoft.Extensions.Logging.LogLevel; 16 | 17 | namespace Thirty25.Statiq; 18 | 19 | [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] 20 | public class NewPostSettings : EngineCommandSettings 21 | { 22 | [CommandArgument(0, "[POST_TITLE]")] public string Title { get; set; } 23 | 24 | [CommandOption("-t|--tags ")] public string Tags { get; set; } = ""; 25 | 26 | [CommandOption("-e")] 27 | [Description("Launch editor after creation")] 28 | public bool LaunchEditor { get; set; } 29 | 30 | [CommandOption("-b")] 31 | [Description("Create new Git branch for post")] 32 | public bool CreateBranch { get; set; } 33 | 34 | [CommandOption("--desc ")] 35 | public string Description { get; set; } = ""; 36 | } 37 | 38 | [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] 39 | public class FrontMatter 40 | { 41 | public string Title { get; set; } 42 | public string Description { get; set; } 43 | public string Date { get; set; } 44 | public string[] Tags { get; set; } 45 | 46 | public static FrontMatter FromSettings(NewPostSettings settings, DateTime dateTime) => new() 47 | { 48 | Title = settings.Title, 49 | Description = settings.Description, 50 | Tags = settings.Tags 51 | .Split(new[] { ',', ';', '|' }, StringSplitOptions.RemoveEmptyEntries) 52 | .Select(i => i.Trim()) 53 | .ToArray(), 54 | Date = dateTime.ToString("yyyy-M-d") 55 | }; 56 | 57 | private static readonly ISerializer s_serializer = new SerializerBuilder() 58 | .WithNamingConvention(new CamelCaseNamingConvention()) 59 | .Build(); 60 | 61 | public string ToYaml() 62 | { 63 | return $@"--- 64 | {s_serializer.Serialize(this).Trim()} 65 | ---"; 66 | } 67 | } 68 | 69 | [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] 70 | public class NewPost : EngineCommand 71 | { 72 | protected override async Task ExecuteEngineAsync(CommandContext commandContext, NewPostSettings settings, 73 | IEngineManager engineManager) 74 | { 75 | var fileSystem = engineManager.Engine.FileSystem; 76 | var dateTime = DateTime.Now; 77 | var optimizedTitle = NormalizedPath.OptimizeFileName(settings.Title); 78 | var rootPath = fileSystem.GetRootPath(); 79 | 80 | if (settings.CreateBranch) 81 | { 82 | CreateBranch(rootPath, optimizedTitle, engineManager); 83 | } 84 | 85 | var filePath = rootPath 86 | .Combine($"input/posts/{dateTime:yyyy/MM/}") 87 | .GetFilePath($"{optimizedTitle}.md"); 88 | var file = fileSystem.GetFile(filePath); 89 | 90 | var frontMatter = FrontMatter 91 | .FromSettings(settings, dateTime) 92 | .ToYaml(); 93 | 94 | await file.WriteAllTextAsync(frontMatter); 95 | 96 | 97 | if (settings.LaunchEditor) 98 | { 99 | var args = $"\"{rootPath.FullPath}\" -g \"{filePath}:0\""; 100 | await Command.RunAsync( 101 | "code", 102 | args, 103 | windowsName: "cmd", 104 | windowsArgs: "/c code " + args, 105 | noEcho: true); 106 | } 107 | 108 | engineManager.Engine.Logger.Log(LogLevel.Information, "Wrote new markdown file at {File}", filePath); 109 | return 0; 110 | } 111 | 112 | private static void CreateBranch(NormalizedPath rootPath, string optimizedTitle, IEngineManager engineManager) 113 | { 114 | using var repo = new Repository(rootPath.FullPath); 115 | 116 | if (repo.Head.FriendlyName != "main" && repo.Head.FriendlyName != "master") 117 | throw new Exception($"Only branching from main or master is support"); 118 | 119 | if (repo.Branches.Any(i => i.FriendlyName == optimizedTitle)) 120 | throw new Exception($"Branch with name \"{optimizedTitle}\" already exists"); 121 | 122 | var branchName = $"posts/{optimizedTitle}"; 123 | 124 | repo.CreateBranch(branchName); 125 | engineManager.Engine.Logger.Log(LogLevel.Information, "Created new git branch {Branch}", branchName); 126 | Commands.Checkout(repo, repo.Branches[branchName]); 127 | engineManager.Engine.Logger.Log(LogLevel.Information, "Set current git branch to {Branch}", branchName); 128 | } 129 | 130 | public NewPost(IConfiguratorCollection configurators, Settings settings, IServiceCollection serviceCollection, IFileSystem fileSystem, 131 | Bootstrapper bootstrapper) : base(configurators, settings, serviceCollection, fileSystem, bootstrapper) 132 | { 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /input/posts/2020/09/tagging-query-with-ef-core.md: -------------------------------------------------------------------------------- 1 | --- 2 | Title: Better Tagging of EF Core Queries 3 | description: How to add tags to your EF Core queries, and how to automatically give those tags better info. 4 | date: 2020-09-02 5 | tags: 6 | - Entity Framework 7 | repository: https://github.com/thirty25/ef-core-tagging/tree/efcore-5 8 | --- 9 | 10 | One of the quickest ways to earn the ire of a DBA is to shrug your shoulders, and tell them you don't know what code 11 | created the query that is slowing down their server. Sure, with a little hunting and knowledge of the code base you can 12 | generally spot the offending query, but wouldn't it be nice if the query the DBA is looking at had more information to 13 | track it down? 14 | 15 | This is where query tagging comes in. Query tags let you add a comment to the command EF sends to your database 16 | provider. Because this tag is a comment included as part of the command, it will be stored with the statement in your 17 | common DBA tools like SQL Query Store or when viewing them via Extended Events. 18 | 19 | With [EF Core 2.2](https://devblogs.microsoft.com/dotnet/announcing-entity-framework-core-2-2/#query-tags) Microsoft 20 | added the `TagWith` extension method. This allows us to write a query such as 21 | 22 | ```csharp 23 | var result = await bloggingContext.Blogs 24 | .Where(i => i.Url.StartsWith("http://example.com")) 25 | .TagWith("Looking for example.com") 26 | .FirstOrDefaultAsync(); 27 | ``` 28 | 29 | Now when you execute your code the following statement, you'll see a comment included with the command 30 | 31 | ```sql 32 | -- Looking for example.com 33 | 34 | SELECT "b"."BlogId", "b"."Url" 35 | FROM "Blogs" AS "b" 36 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'http://%') 37 | LIMIT 1 38 | ``` 39 | 40 | Now when an irate DBA sees a misbehaving query they can pass it along hopefully keeping the comment. Now a little find 41 | in files and we should be able to go right there. 42 | 43 | But what if we wanted to make it even easier on us? 44 | 45 | Using 46 | [caller information](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/caller-information) 47 | attributes we can automatically pull the method, filename, and line number. Given this we can create our own `TagWith` 48 | that accepts these parameters and automatically generates a comment with the source location of whoever is calling the 49 | LINQ query. Our extension method will look similar to this. 50 | 51 | ```csharp 52 | public static IQueryable TagWithSource( 53 | this IQueryable queryable, 54 | [CallerLineNumber] int lineNumber = 0, 55 | [CallerFilePath] string filePath = "", 56 | [CallerMemberName] string memberName = "") 57 | { 58 | return queryable.TagWith($"{memberName} - {filePath}:{lineNumber}"); 59 | } 60 | ``` 61 | 62 | Additionally, `TagWith` can be chained. EF will allow you to call it multiple times and it'll generate a new comment for 63 | each call 64 | 65 | ```csharp 66 | var result = await bloggingContext.Blogs 67 | .Where(i => i.Url.StartsWith("http://example.com")) 68 | .TagWith("Looking for example.com") 69 | .TagWithSource() 70 | .FirstOrDefaultAsync(); 71 | ``` 72 | 73 | This generated the following command 74 | 75 | ```sql 76 | -- Looking for example.com 77 | 78 | -- Test1 - R:\Projects\thirty25\ef-core-tagging\tests\EfCoreTagging.Tests\UnitTest1.cs:45 79 | 80 | SELECT "b"."BlogId", "b"."Url" 81 | FROM "Blogs" AS "b" 82 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'http://%') 83 | LIMIT 1 84 | ``` 85 | 86 | Because this pattern is pretty common, I'll also typically create an overload for our `TagWithSource` that takes in a 87 | string 88 | 89 | ```csharp 90 | public static IQueryable TagWithSource(this IQueryable queryable, 91 | string tag, 92 | [CallerLineNumber] int lineNumber = 0, 93 | [CallerFilePath] string filePath = "", 94 | [CallerMemberName] string memberName = "") 95 | { 96 | return queryable.TagWith($"{tag}{Environment.NewLine}{memberName} - {filePath}:{lineNumber}"); 97 | } 98 | ``` 99 | 100 | This will produce similar output as above, but slightly cleaner without the line-break between the two tag statements 101 | 102 | ```sql 103 | -- Looking for example.com 104 | -- Test1 - R:\Projects\thirty25\ef-core-tagging\tests\EfCoreTagging.Tests\UnitTest1.cs:45 105 | 106 | SELECT "b"."BlogId", "b"."Url" 107 | FROM "Blogs" AS "b" 108 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'http://%') 109 | LIMIT 1 110 | ``` 111 | 112 | ## Limitations 113 | 114 | The biggest limitation is that you'll only get the immediate caller of your queries. If you have this buried in a super 115 | abstracted repository there's a good chance every comment will be the same comment pointing to something like the 116 | `Get - Repository.cs:49`. In this case you might want to look at parsing the `StackTrace` to move up the stack and 117 | include all the user code in the path with your tag. I know that we've all been taught to avoid accessing the stack 118 | trace directly because "it's slow", but honestly grabbing the stacktrace and looping through all the frames with file 119 | names and line numbers is around 50 microseconds on my dev machine. We can make a database query that will be measured 120 | in, at best, milliseconds. Personally, in the systems I've worked with this is an acceptable perf loss for the added 121 | benefit, but obviously don't implement it blindly if this code is part of an extremely high traffic site. 122 | -------------------------------------------------------------------------------- /input/posts/2021/01/statiq-and-vercel.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hosting Statiq Sites on Vercel 3 | description: How to configure and deploy a statiq site to Vercel 4 | date: 2021-01-14 5 | tags: 6 | - Vercel 7 | - Statiq 8 | repository: https://github.com/thirty25/statiqdev.github.io 9 | --- 10 | 11 | [Vercel](https://vercel.com) (formerly Zeit) is a platform for hosting static sites and serverless functions. They provide a lot of services for free that make it perfect for hosting your developer blog: 12 | 13 | - A seamless integration with Git for automated deployments 14 | - Free SSL on custom domains 15 | - Automatic creation of additional development deployments based on branching and pull requests 16 | - Fast edge servers - no need for configuring any CDN here 17 | 18 | While most projects that they host are Next.js, Svelt, Nuxt.js or Gatsby sites, there's no reason we can't use it to host our [Statiq](https://statiq.dev) sites as long as we can play within their sandbox properly. 19 | 20 | A few things we need to keep in mind before moving over our site: 21 | 22 | - Vercel will run our deploy on Linux servers. This means casing matters. A big gotcha I ran into was casing of `_ViewStart` and `_ViewImports`. Make sure they are properly capitalized. 23 | - Vercel will by default run `npm run build` to build our site 24 | - Vercel will look in the `public` folder for the output rather than the default `output` folder of Statiq. Either we'll have to change that in a setting of your Vercel site, or tell Statiq the right folder for output. 25 | - Vercel does not support clean URLs by default (e.g. `/tags` rather than `/tags.html`). We can adjust that easily though. 26 | 27 | Ok, given this knowledge I'm going to try and get the Statiq website hosted. The repository is . One gotcha is that because the Statiq docs are hosted on github pages, the main branch is currently set up as the content of the site. With Vercel we don't want that. Your `main` branch will be the code that generates your production site. `git reset develop --hard` while on the main branch will get things pointed in the right direction. If you don't do that we can still work with just the develop branch and ignore main, we just won't have a production site in Vercel. 28 | 29 | Let's tackle our bullet list of things to keep in mind. First, we are lucky because the casing is already correct for `_ViewImports` and `_ViewStart` for this project. So we can jump right to the second step. 30 | 31 | I'm sure that if I was smart enough I could write a bash script that installs dotnet and run it directly from that. But I'll typically be using npm anyways, so I'll stick to npm. Luckily we have a couple of npm packages ready for us with [dotnet-sdk-3.1](https://www.npmjs.com/package/dotnet-sdk-3.1) and [dotnet-sdk-cli-3.1](https://www.npmjs.com/package/dotnet-sdk-3.1). To quickly get started we can run 32 | 33 | ```bash 34 | npm init -y 35 | npm install dotnet-sdk-3.1 dotnet-sdk-cli-3.1 36 | ``` 37 | 38 | This will initialize npm with defaults and then install the packages for wrapping .NET 3.1 for our needs. 39 | 40 | With this in place, we need to add our `build` command to npm. Open up the newly created `package.json` and find the scripts. While I'm in here I'll one script for launching the preview along with the build command. Keep in mind that we need to output to the `public` folder too. 41 | 42 | With that in mind, our scripts section will now look like this 43 | 44 | ```json 45 | "scripts": { 46 | "build": "dotnet run -- -o public", 47 | "watch": "dotnet run -- preview" 48 | }, 49 | ``` 50 | 51 | The last step is making configuring Vercel to use the clean urls that Statiq will generate. Thankfully, this is just a [config toggle](https://vercel.com/docs/configuration#project/clean-urls). To do this we can create a `vercel.json` file in the root and add the following configuration to it 52 | 53 | ```json 54 | { 55 | "cleanUrls": true 56 | } 57 | ``` 58 | 59 | With that in place, we can commit our changes and push. 60 | 61 | ## Setting up Vercel 62 | 63 | There is a ton to do in Vercel, but that's for a later blog post. For now let's just get our site imported into Vercel. First, head to and log in with GitHub credentials. With that done we can now import our recently created project. 64 | 65 | ![import git repository into vercel](2021-01-14-23-03-31.png) 66 | 67 | Once you've selected your project that rest is just accepting the defaults. Once this completes, it should kick off a build of your site and show you the completed screen. 68 | 69 | ![results of deployment in vercel](2021-01-14-23-00-48.png) 70 | 71 | My site is living at and you are welcome to check it out. 72 | 73 | From here you'll notice a few things. The build has multiple urls. It has a url for the commit, one for the branch name and also one that acts as the product site. You can also easily add custom domains to act as your production site and you'll see this pop up here. 74 | 75 | If you create a branch and push to it, it will create a new deployment separate from this one that acts as a development site. They all in this case point to the same build. Try and create a branch from main with a CSS change and push it up and you'll get a couple new URLs to use based off of the branch name and commit hash. 76 | 77 | The branching model is the killer feature for blogging for me. I have terrible spelling and grammar as you have surely noticed. By being able to create a branch per post I'm (theoretically) able to send it to a proof reader who can look it over on the site as it would be displayed in production as part of a pull request. GitHub comments can be used to review it, and Vercel keeps it all in sync. Finally, when I merge the PR (or schedule it) the post goes to main and Vercel builds and deploys the site. 78 | 79 | Magic. 80 | -------------------------------------------------------------------------------- /input/posts/2022/11/adventures-in-trimming.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Adventures in Trimming 3 | description: "Using .NET trimming to do some dumb stuff with Source Generators and Blazor" 4 | date: Nov 20 2022 5 | tags: 6 | - Blazor 7 | - Source Generator 8 | - Trimming 9 | repository: https://github.com/phil-scott-78/Icons 10 | --- 11 | 12 | When it comes to using icons with Blazor there are lots of existing options out there. But they all seem to do one thing - wrap up the entirety of Font Awesome or some equivalent library. You get every icon with every style plus many times you also need to bring in a supporting javascript, CSS and maybe even a few fonts. Most of these JS libraries are written in such a way that something like webpack is going to minimize the JS that's included, but we don't have that luxury without some serious hacking about when it comes to Blazor. 13 | 14 | ## I wonder how I can keep my requests small? 15 | 16 | For my use case I needed a couple of icons so the idea of multiple requests of over 400kb didn't sit well with me so I went down a different route. I really only need the content of the icon - ultimately it is just an svg element. I don't need javascript, and I don't really need CSS either - I have my own CSS framework that can add color or sizing to an HTML element. 17 | 18 | So, on the front end of things a Blazor component that simply wrote out the SVG is all I really need. But I also want the ability to use any of the thousand icons of Font Awesome from my library. 19 | 20 | ## What if I relied on .NET trimming to keep it small? 21 | 22 | [Trimming in .NET](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained) uses build time analysis to figure out what parts of an assembly are being called and only keep those parts. With .NET 6, trimming moved out of experimental support to being fully supported. 23 | 24 | If I structured my icon library in such a way that it played nicely with the .NET trimming rules, the compiler should be able to see that I'm only needing, for example, the GitHub icon and a Home icon. Even if I have code in there for generating the thousand other icons at development time, when it comes time to publish the compiler would only keep the parts that the deployed app is using. 25 | 26 | ## Let's try! 27 | 28 | To do the experiment, we need a Razor component that is exposed that will write out the SVG of the icon. At first I toyed with making each Icon its own Razor component. This proved a bit taxing on IDEs, plus the rules regarding [organizing via Namespaces](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/?view=aspnetcore-7.0#namespaces) can be pretty restricting for keeping things tidy so this was a non-starter. 29 | 30 | So I settled on having a single Icon component that has a property that takes in a struct containing the icon data. My component looks approximately like this with some extra code related to `AdditionalAttributes` stripped. 31 | 32 | ```csharp 33 | using Microsoft.AspNetCore.Components; 34 | using Microsoft.AspNetCore.Components.Rendering; 35 | 36 | namespace App 37 | { 38 | public class IconData 39 | { 40 | public string Content { get; init; } 41 | public string ViewBox { get; init; } 42 | } 43 | 44 | public class Icon : ComponentBase 45 | { 46 | [Parameter, EditorRequired] 47 | public IconData Item { get; set; } = null! 48 | 49 | protected override void BuildRenderTree(RenderTreeBuilder builder) 50 | { 51 | var viewBox = Item.ViewBox; 52 | var content = Item.Content; 53 | 54 | builder.OpenElement(0, "svg"); 55 | builder.AddAttribute(1, "xmlns", "http://www.w3.org/2000/svg"); 56 | builder.AddAttribute(2, "viewBox", viewBox); 57 | builder.AddMarkupContent(3, content); 58 | builder.CloseElement(); 59 | } 60 | } 61 | } 62 | ``` 63 | 64 | Now, we just need to expose every icon in a library as a method that returns `IconData`. You could use a T4 template, a build task or in my case I threw together a source generator. I'll leave that as an exercise to the reader but you can check out my solution in the repository linked at the bottom. 65 | 66 | The end result is thousands of method calls that look like this 67 | 68 | ```csharp 69 | public static class Solid 70 | { 71 | public static IconData Circle() => new IconData() 72 | { 73 | Content = "(svg of the icon)", 74 | ViewBox = "0 0 512 512 " 75 | }; 76 | 77 | // and thousands of other icons 78 | } 79 | ``` 80 | 81 | Now to consume this library I just do something like so 82 | 83 | ```html 84 | 85 | ``` 86 | 87 | The item will provide intellisense for all the available icons and we'll have a nice development experience. 88 | 89 | The problem is when I go to compile, I now have a 4mb dll that contains every one of those methods. 90 | 91 | ![all the icons](2022-11-20-14-33-15.png) 92 | 93 | Ouch. 94 | 95 | ## Trimming to the rescue. 96 | 97 | Telling the compiler that our library is trimmable is easy enough 98 | 99 | ```xml 100 | 101 | true 102 | 103 | ``` 104 | 105 | Now, there are a [load of rules and things to test for a much more complicated library](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/prepare-libraries-for-trimming#enable-project-specific-trimming). But we kept it simple, so we don't need to worry those. We just have a couple of classes with public methods so the compiler can make quick work of what is being called and what isn't. 106 | 107 | That means when we publish via 108 | 109 | ``` 110 | dotnet publish -c Release 111 | ``` 112 | 113 | the output of our library is much different. It goes from 4mb down to 9kb! No third party javascript, no external fonts - just pure C# writing out the icons as a SVG. 114 | 115 | ![only what we used](2022-11-20-14-34-02.png) 116 | -------------------------------------------------------------------------------- /input/posts/2022/05/adding-config-to-incremental-generator.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Adding Configuration to an Incremental Generator 3 | description: Using AnalyzerConfigOptionsProvider and GlobalAnalyzerConfigFiles to configure an .NET Incremental Generator 4 | date: 2022-05-12 5 | tags: 6 | 7 | - Source Generators 8 | 9 | --- 10 | 11 | [Incremental Generators](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md) solve a lot 12 | of problems with performance of source generators. They add a layer of caching around the generator which drastically 13 | improves performance. But to achieve this performance you need to play within a certain set of rules. 14 | 15 | We must use `IncrementalGeneratorInitializationContext` to pull in our data via pipelines. By routing everything through 16 | this object, the compiler can keep track of values used between builds and cache them. It has the follow providers: 17 | 18 | * CompilationProvider 19 | * AdditionalTextsProvider 20 | * AnalyzerConfigOptionsProvider 21 | * MetadataReferencesProvider 22 | * ParseOptionsProvider 23 | 24 | Take this initializer from a generator. We are using the `AdditionalTextsProvider` to find all the razor files in a 25 | project and extract CSS classes from them via a regular expression. We'll use `AdditionalTextsProvider` to access the 26 | razor files. Because 27 | we are accessing these files via this property, the compiler can keep track of which ones we are accessing and cache the 28 | transforms. 29 | 30 | ```csharp 31 | public void Initialize(IncrementalGeneratorInitializationContext context) 32 | { 33 | var cssClasses = context.AdditionalTextsProvider 34 | .Where(static value => value.Path.EndsWith(".cshtml") || value.Path.EndsWith(".razor")) 35 | .Select(static (value, token) => value.GetText(token)!.ToString()) 36 | .Select(static (value, _) => Helpers.GetCssClassFromHtml(value, @"(class\s*=\s*[\'\""](?[^<]*?)[\'\""])")); 37 | 38 | context.RegisterSourceOutput(cssClasses, static (spc, source) => Execute(source, spc)); 39 | } 40 | ``` 41 | 42 | In this example we are accessing files with the extension `.cshtml` and `.razor`., extracting their full text and then 43 | using a regular expression to parse out all the css classes. In a regular source generator this would be madness. We'd 44 | have to parse every file constantly, dramatically slowing down the IDE. The caching of the incremental generator, 45 | however, will make sure we only parse when the file changes. The are two obvious things that we want to configure - the 46 | file extensions our generator cares about as well as the regular expression. An end user might also have some html files 47 | they want to include, or maybe they have some custom components that use `cssclass` as the attribute. 48 | 49 | To configure our value provider we can use `AnalyzerConfigOptionsProvider`. This works like the value provider for 50 | AdditionalContext, but instead of looking at the files it looks at the config. 51 | 52 | ```csharp 53 | var config = context.AnalyzerConfigOptionsProvider.Select((provider, _) => 54 | { 55 | var regex = @"(class\s*=\s*[\'\""](?[^<]*?)[\'\""])"; 56 | var additionalFileFilter = new[] { ".cshtml", ".razor" }; 57 | 58 | if (provider.GlobalOptions.TryGetValue("parser_regex", out var configValue)) 59 | regex = configValue; 60 | 61 | if (provider.GlobalOptions.TryGetValue("parser_filter", out var fileFilter)) 62 | additionalFileFilter = fileFilter.Split('|'); 63 | 64 | return (Regex: regex, Filter: additionalFileFilter); 65 | }); 66 | ``` 67 | 68 | Here we are using the `TryGetValue` method to pull the two configuration values out and if we don't find them we'll fall 69 | back to our defaults. 70 | 71 | We now need to adjust our original value provider to use these values. We'll 72 | use [`Combine`](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md#combine) to merge the 73 | two providers together. This will create a tuple with a left and right side. The left side, the one calling combine, 74 | will be the original value. 75 | The right side will have the configuration. 76 | 77 | Add just the combine call makes our `cssClasses` value provider now looks like this: 78 | 79 | ```csharp 80 | var cssClasses = context.AdditionalTextsProvider 81 | .Combine(config) 82 | .Where(static value => value.Left.Path.EndsWith(".cshtml") || value.Left.Path.EndsWith(".razor")) 83 | .Select(static (value, token) => value.Left.GetText(token)!.ToString()) 84 | .Select(static (value, _) => Helpers.GetCssClassFromHtml(value, @"(class\s*=\s*[\'\""](?[^<]*?)[\'\""])")); 85 | ``` 86 | 87 | Note that now we are access `value.Left` to get at the `AdditionalTextProvider` values. `value.Right` contains our 88 | configuration tuple. 89 | 90 | We can now adjust our call to use that tuple instead. 91 | 92 | ```csharp 93 | var cssClasses = context.AdditionalTextsProvider 94 | .Combine(config) 95 | .Where(static value => value.Right.Filter.Any(i => value.Left.Path.EndsWith(i))) 96 | .Select(static (value, token) => (Config: value.Right, Content: value.Left.GetText(token)!.ToString())) 97 | .Select(static (value, _) => Helpers.GetCssClassFromHtml(value.Content, value.Config.Regex)); 98 | ``` 99 | 100 | Note that in the first `Select` we are creating a new tuple that passes the content and also the config along with it to 101 | be used in the final call. 102 | 103 | So we are now using a configuration, time to actually do the configuration. 104 | 105 | For this global option we'll use 106 | a [global AnalyzerConfig file](https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files#global-analyzerconfig) 107 | . file. These files look like an `.editorconfig` file, but everything is 108 | at the top level. The preferred naming convention is `generatorname.globalconfig`. E.g. if our generator was 109 | named `CssClassGenerator` our file name would be 110 | `cssclassgenerator.globalconfig` 111 | 112 | For example, if we wanted to configure our regex to look for `class` and `cssclass` tags our config 113 | 114 | ```text 115 | is_global = true 116 | 117 | parser_regex = (class\s*=\s*[\'\"](?[^<]*?)[\'\"])|(cssclass\s*=\s*[\'\"](?[^<]*?)[\'\"]) 118 | ``` 119 | 120 | We'll put this file in the root of our project. But we still aren't done! Our last step is to tell msbuild about it. 121 | This requires adding a new element named `GlobalAnalyzerConfigFiles`to provide the value. 122 | 123 | ```xml 124 | 125 | 126 | 127 | ``` 128 | 129 | Once this is all in place you can finally build and see our configured values in place! -------------------------------------------------------------------------------- /input/_layout.cshtml: -------------------------------------------------------------------------------- 1 | @inherits StatiqRazorPage 2 | 3 | 4 | 5 | 6 | @{ 7 | var bigTitle = $"{Constants.BlogTitle} - {Model.GetString("Title")}"; 8 | var blogDescription = Model.GetString("Description") ?? Constants.Description; 9 | 10 | var card = Outputs 11 | .FirstOrDefault(i => i.GetString("DocId") == Document.Id.ToString()); 12 | } 13 | 14 | @bigTitle 15 | 16 | 17 | 18 | @* ReSharper disable Html.PathError *@ 19 | 20 | 21 | 22 | @* ReSharper restore Html.PathError *@ 23 | 24 | 25 | 26 | @if (card != null) 27 | { 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | } 36 | 43 | 44 | @await RenderSectionAsync("head", required: false) 45 | 46 | 47 |
48 | 70 |
71 | 72 |
73 |
74 |

@Model.GetString("Title")

75 | @await RenderSectionAsync("subheading", required: false) 76 |
77 | 78 | @RenderBody() 79 |
80 | 81 |
82 | 83 | © @DateTime.Now.Year Phil Scott (Mastodon) | Built with Statiq 84 | 85 |
86 | 87 | 88 | 89 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /input/posts/2021/01/dotnet-soddi.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: DotNet Soddi 3 | description: Introducing DotNet Soddi, a modern CLI tool for working with Stack Overflow archives 4 | date: 2021-01-17 5 | tags: 6 | - Stack Overflow 7 | - Soddi 8 | --- 9 | 10 | Whenever I get into learning a new technology around data access, there is always the issue of finding a good database to work against. Long gone are the days of Northwind. You need a database not with just a lot of rows, you need a database with a interesting and varied distribution of data. The databases I turn to are the [Stack Exchange archives](https://archive.org/details/stackexchange). These frequently updated archives contain anonymized exports from all the Stack Exchange sites, from the tiny [Sports](https://sports.stackexchange.com/) all the way to the huge main site [Stack Overflow](https://stackoverflow.com/). With hundreds of sites to pick from, I can grab a fresh database of whatever size that matches my need. With sites ranging from 2mb to over 100gb of data you can find a database with real data for playing with that fits your size. The schema is simple, well defined and has a variety of real-world data making it perfect for playing with SQL Server indexing strategies, or testing out the latest Entity Framework version. 11 | 12 | The archives themselves are just huge xml files, ready to be used for whatever. For me, that means SQL Server. Getting these into your database server I've always relied on [Soddi](https://github.com/BrentOzarULTD/soddi). Originally written by Sky Sanders, and now maintained by Brent Ozar's team, it smooths out importing the 7z files into your database server. And it works well. 13 | 14 | But I tend to run into the same set of problems every time I want to grab one of these archives. 15 | 16 | - For the larger sites, the download times are brutal over HTTP. Torrenting works best, but I typically don't have a torrent client running on my dev machines. And I so infrequently do need a client it seems whatever one I used last has been sold to some dodgy company. 17 | - They are packed as 7z archives. Same as the previous issue, I typically don't have 7-zip installed on my dev machines as I don't really mess with archives that often. Installing 7-zip isn't a big deal, but one I'd like to avoid if possible. 18 | - With the larger databases you need to full expand the archive out before importing it. You need well over 150gb of storage to import that Stack Overflow database 19 | - The existing SODDI application is not terribly intuitive. You need to get your folder structure set up in a particular way. It never feels like I flip the right levers on the first try. 20 | - When I'm messing with the SO databases, I'm usually doing some crazy things with the database so dropping and recreating it is a frequent task. Like to make the full import process quick and easy as possible. 21 | 22 | With that in mind (and after I read a couple of articles around creating dotnet tools) I decided I'd try and create a modern CLI version of SODDI. 23 | 24 | ## dotnet soddi 25 | 26 | ![soddi screenshot](2021-01-18-21-42-38.png) 27 | 28 | Because dotnet-soddi is a dotnet tool, you install it using the `dotnet tool install` command. As of right now, it's still in (very) pre-release so a version number will be needed e.g. 29 | 30 | ```bash 31 | dotnet tool install --global dotnet-soddi --version 0.1.1 32 | ``` 33 | 34 | You can also clone and run it from it's repository at . 35 | 36 | Once installed there are four main commands 37 | 38 | - `list` 39 | - `download` 40 | - `torrent` 41 | - `import` 42 | 43 | `list` will display all the archives available along with their file size. You can specific a filter to only include a partial list. E.g. `soddi list spa`, which will only include archives with spa in their name. 44 | 45 | `download` will download the archive over HTTPS. This is preferred for smaller sized files (less than 100mb) so you don't need to worry about the overhead of bittorrent. 46 | 47 | `torrent` use bittorrent to download the archives. For larger archives, especially things like math and the main site, this is almost needed. On my machine, I'll max out at 35mbps over http, but have seen the torrent download reach up to 600mbps depending on the other peers connected) 48 | 49 | `import` is where the real magic happens. 50 | 51 | Import will take a given .7z file, and simultaneously extract and perform a bulk insert into SQL Server. No need for scratch space for the extracted files, it streams directly to your SQL Server. Not only keeping disk space requirements low, but it also keeps memory usage low. Even a 50gb import rarely reaches over 65mb of memory used on my machine. 52 | 53 | By default, import will expect the destination database to be created and empty with a name matching the site (e.g. `stackoverflow.com` or `math.stackexchange.com`). With many of these databases having large sizes, this allows you to fine tune the log and data locations before the import. If you are ok with blindly creating the DB in the default locations, the `-–dropAndCreate` option will take care of that for you. It won't check for disk space or anything ahead of time so use with caution. 54 | 55 | During this process, keys and indexes will be created as well as a helper table named PostTags. These can be turned off with a switch if so desired. 56 | 57 | ## Sample Workflow 58 | 59 | 1. `soddi list spa` - this lists all archives containing the phrase "spa" 60 | 2. `soddi download space` - you can use either the full site name (space.stackexchange.com) or the short name to download. we'll use the short name, and it'll download a file whose name matches the site name. 61 | 3. `soddi import space.stackexchange.com.7z --dropAndCreate` - import the space.stackexchange.com.7z file we just downloaded. the `--dropAndCreate` option will drop any database named space.stackexchange.com and recreate it using your server's configured defaults. `soddi import -h` will give you more options for specifying a connectionstring and specifying a database name. 62 | 63 | Once the import is done, you'll have a local copy of the Stack Exchange database of your choice! 64 | 65 | ## Database Info 66 | 67 | If you've never worked with the Stack Exchange schema, here's a few helpful pieces of documentation 68 | 69 | - [Schema description](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede/2678#2678) 70 | - [Schema diagram](https://i.stack.imgur.com/AyIkW.png) 71 | - [Sample queries](https://data.stackexchange.com/stackoverflow/queries) 72 | 73 | ## Technical Notes 74 | 75 | A few technical notes on the app itself 76 | 77 | - Uses [Spectre.Console](https://github.com/spectresystems/spectre.console) for console rendering and CLI input. 78 | - Uses [SharpCompress](https://github.com/adamhathcock/sharpcompress) for extracting 7z files. 79 | - Uses [MonoTorrent](https://github.com/alanmcgovern/monotorrent) for torrent downloading. 80 | - For bulk inserts, it creates the index and leaves them updating prior to the insertion. Typically you wouldn't do this for performance reasons, but with the decompression being a bottleneck, SQL Server has plenty of time to keep the indexes up to date. 81 | - Decompression is single threaded, and extremely CPU bound. If someone can figure out a way to decompress on multiple threads, PRs are certainly welcomed. 82 | -------------------------------------------------------------------------------- /input/posts/2022/05/tour-of-the-thirty25.com-repository.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Tour of the thirty25.com Repository" 3 | description: "A tour of the Statiq site that generates thirty25.com" 4 | date: 2022-5-30 5 | tags: ["statiq"] 6 | --- 7 | 8 | The repository for generating this site has quite a few features built on top of [statiq](https://www.statiq.dev/). You are welcome to fork it and use it for your own blog, but I wanted to document some of the features that might be overlooked or confusing. 9 | 10 | Repository is located here https://github.com/phil-scott-78/thirty25-statiq. To run a live preview just run `dotnet run -- preview` 11 | 12 | ## `New-Post` command 13 | 14 | It has a few helper commands, using Spectre.Console, to help maintain the site. new-post will create a new post with a title, description, its tags and date, then create a new branch and open vscode on the file. 15 | 16 | To run execute 17 | 18 | ```bash 19 | dotnet run -- new-post -e -b "Tour of the thirty25.com repository" 20 | ``` 21 | 22 | The `-b` creates a new branch and `-e` launches VS Code. I like to put new posts in branches because it sometimes takes me way too long to get around to finishing them up, and often time I'm distracted and add new features to the blog instead. 23 | 24 | https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/NewPostCommand.cs 25 | 26 | ![New-Post](2022-05-30-02-19-19.png) 27 | 28 | png-compress is another command. It looks at all files edited or untracked for the current commit and uses TinyPng to compress them. You can find that command here. 29 | 30 | https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/PngCompress.cs 31 | 32 | ## `Png-Compress` command 33 | 34 | `png-compress` will send all .png files that are part of the current commit or untracked to tinypng.com's API. This is a relatively slow operation using a service with limits, so doing this on demand only for files that have been recently edited can keep our build time low while also being able to use the free tier from [tinypng's developer portal](https://tinypng.com/developers) 35 | 36 | To use you'll need an API key from tinypng and set its value with a `TinyPngKey` environmental variable 37 | 38 | https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/PngCompress.cs 39 | 40 | ![running png-compress](2022-05-30-02-28-36.png) 41 | 42 | ## Roslyn highlighting 43 | 44 | While the site is configured to use prism.js to highlight code blocks, prism relies on an out of date parser for C#. As new features are added to C# it lags behind significantly or doesn't support some parts of the language at all. So, instead we are using Roslyn to provide the color highlighting server side. This gets us better highlighting and better client perf as a bonus. Prism is left configured to highlight other code blocks such as SQL statements and Powershell. 45 | 46 | https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/RoslynHighlightModule.cs 47 | 48 | ## Social cards 49 | 50 | Social cards are generated for each post using an [ASP.NET razor file](https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/SocialCard.cshtml) to convert the razor output to a png using Playwright. 51 | 52 | ![Social media card in action](2022-05-30-02-34-53.png) 53 | 54 | https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/Pipelines/SocialImages.cs 55 | 56 | ## MonorailCSS for the styling 57 | 58 | There is no CSS or SASS files part of the repository. The CSS for the site is a utility-first CSS framework inspired by Tailwind named [MonorailCSS](https://github.com/monorailcss/MonorailCss.Framework). The pipeline gathers up all the CSS classes in the output files using AngleSharp and sends them to MonorailCss.Framework. MonorailCss takes those CSS classes and generates an appropriate stylesheet which is then persisted with the rest of the static site. 59 | 60 | One gotcha for those looking to add this to your Statiq site - since it needs the full output of the site to work it is marked as part of the deployment process. If you are also running a deployment command like publishing to netlify you are likely to run into conditions where the publish command runs before the CSS generation. 61 | 62 | https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/Pipelines/Monorail.cs 63 | 64 | ## Publish the site as a book 65 | 66 | And finally the book output. The book is generated by gathering all the posts up and combining them into one document. That document is fed to a razor page that uses [paged.js](https://pagedjs.org/) to handle dividing the site up into a book format. It not only applies printer friendly styling, but also adds things like custom headers and footers, a table of contents, and footnotes containing the URLs for hyperlinks all with our specified page dimensions for printing. 67 | 68 | This razor page is then hosted in a minimal ASPNET instance and Playwright is used to generate a PDF of the output. 69 | 70 | https://github.com/phil-scott-78/thirty25-statiq/blob/main/Statiq/Pipelines/Book.cs 71 | 72 | ## Configuring Statiq 73 | 74 | With this many custom hooks into Statiq, we need to tell the Statiq bootstrapper about them all. We also need to modify some of the default pipelines to either replace 75 | or add in some of our own modules too. 76 | 77 | ```csharp 78 | await Bootstrapper.Factory 79 | .CreateWeb(args) 80 | // used to link social cards from the layout page. They require an absolute path 81 | // including the domain name. This short code helps with that. 82 | .AddShortcode("FullUrl") 83 | // The monorail pipeline takes in an instance of the CSS framework so we need to configure it. 84 | .ConfigureServices(i => 85 | { 86 | i.AddSingleton(GetCssFramework()); 87 | }) 88 | // we need to modify the Content pipeline 89 | .ModifyPipeline(nameof(Content), pipeline => 90 | { 91 | // the default GatherHeadings module doesn't include child content like code blocks, so we need to swap it with one 92 | // that is configured to parse all the nested elements. 93 | pipeline.ProcessModules 94 | .GetLast().Children 95 | .GetLast()[0] 96 | .ReplaceFirst(_ => true, new GatherHeadings(2).WithNestedElements()); 97 | // in addition to the archive pipeline we also have the book so make sure the regular content skips those too 98 | pipeline.ProcessModules 99 | .ReplaceFirst( 100 | _ => true, 101 | new FilterDocuments(Config.FromDocument(doc => !Archives.IsArchive(doc) && !Book.IsBook(doc)))); 102 | // include our custom highlighting module after the HTML has been generated in the process modules. 103 | pipeline.PostProcessModules.Add(new RoslynHighlightModule()); 104 | }) 105 | // the playwright nuget package relies on external resources like chrome to run. we can use the dotnet playwright 106 | // tool to install them. 107 | .AddProcess(ProcessTiming.Initialization, 108 | _ => new ProcessLauncher(dotnetPath, "tool restore") { LogErrors = false }) 109 | .AddProcess(ProcessTiming.Initialization, 110 | _ => new ProcessLauncher(dotnetPath, "tool run playwright install chromium") { LogErrors = false }) 111 | .RunAsync(); 112 | ``` 113 | -------------------------------------------------------------------------------- /thirty25-statiq.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | WARNING 3 | WARNING 4 | WARNING 5 | WARNING 6 | WARNING 7 | WARNING 8 | WARNING 9 | WARNING 10 | WARNING 11 | WARNING 12 | WARNING 13 | WARNING 14 | WARNING 15 | WARNING 16 | WARNING 17 | WARNING 18 | WARNING 19 | WARNING 20 | WARNING 21 | WARNING 22 | WARNING 23 | WARNING 24 | WARNING 25 | WARNING 26 | WARNING 27 | WARNING 28 | WARNING 29 | WARNING 30 | WARNING 31 | WARNING 32 | WARNING 33 | WARNING 34 | WARNING 35 | WARNING 36 | WARNING 37 | WARNING 38 | WARNING 39 | WARNING 40 | WARNING 41 | True 42 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 43 | True 44 | True 45 | True 46 | True 47 | True 48 | True 49 | True 50 | True 51 | True 52 | True 53 | True 54 | True 55 | True -------------------------------------------------------------------------------- /Statiq/Pipelines/Book.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using AngleSharp.Html.Parser; 6 | using JetBrains.Annotations; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.Extensions.FileProviders; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Playwright; 11 | using Statiq.Common; 12 | using Statiq.Core; 13 | using Statiq.Web; 14 | using Statiq.Web.Modules; 15 | using Statiq.Web.Pipelines; 16 | using IDocument = Statiq.Common.IDocument; 17 | 18 | namespace Thirty25.Statiq.Pipelines; 19 | 20 | [UsedImplicitly] 21 | public class Book : Pipeline 22 | { 23 | public Book(Templates templates) 24 | { 25 | ExecutionPolicy = ExecutionPolicy.Manual; 26 | Dependencies.AddRange(nameof(Inputs), nameof(Content), nameof(Data), nameof(Assets)); 27 | 28 | ProcessModules = new ModuleList 29 | { 30 | new GetPipelineDocuments(Config.FromDocument(doc => 31 | doc.Get(WebKeys.ContentType) != ContentType.Asset || 32 | doc.MediaTypeEquals(MediaTypes.CSharp))), 33 | new FilterDocuments(Config.FromDocument(IsBook)), 34 | new ForEachDocument 35 | { 36 | new ExecuteConfig(Config.FromDocument((bookDoc, _) => 37 | { 38 | var modules = new ModuleList 39 | { 40 | new ReplaceDocuments(bookDoc.GetList(BookKeys.BookPipelines, new[] { nameof(Content) }) 41 | .ToArray()), 42 | new MergeMetadata(Config.FromValue(bookDoc.Yield())).KeepExisting(), 43 | // we are gonna roll up all the pages into one so any relative link 44 | // will be invalid so we need them to be absolute. 45 | new MakeLinksAbsolute(), 46 | new ProcessHtml("a[\"href\"]", link => 47 | { 48 | // printed content so we don't want regular links. 49 | // if the link is a simple url converted to a link with the same href 50 | // as the text we can just drop the href and keep the text. 51 | // otherwise we want to convert it to a footnote. 52 | var href = link.GetAttribute("href"); 53 | 54 | if (link.TextContent == href) 55 | { 56 | link.RemoveAttribute("href"); 57 | } 58 | else 59 | { 60 | // replace the link with a footnote. 61 | var parser = new HtmlParser(); 62 | var document = parser.ParseFragment( 63 | $"{link.TextContent} {href}", 64 | link.ParentElement!); 65 | link.Replace(document.ToArray()); 66 | } 67 | }) 68 | }; 69 | 70 | // Filter by document source 71 | if (bookDoc.ContainsKey(BookKeys.BookSources)) 72 | { 73 | modules.Add(new FilterSources(bookDoc.GetList(BookKeys.BookSources))); 74 | } 75 | 76 | // Order the documents 77 | if (bookDoc.ContainsKey(BookKeys.BookOrderKey)) 78 | { 79 | modules.Add( 80 | new OrderDocuments(bookDoc.GetString(BookKeys.BookOrderKey)) 81 | .Descending(bookDoc.GetBool(BookKeys.BookOrderDescending))); 82 | } 83 | 84 | modules.Add(new ExecuteIf(Config.FromContext(ctx => ctx.Inputs.Length > 0), 85 | GetTopLevelIndexModules(bookDoc))); 86 | // If it's a script, evaluate it now (deferred from inputs pipeline) 87 | modules.Add(new ProcessScripts(false)); 88 | 89 | // Now execute templates 90 | modules.Add(new CacheDocuments { new RenderContentProcessTemplates(templates) }); 91 | 92 | return modules; 93 | })) 94 | }, 95 | }; 96 | 97 | PostProcessModules = new ModuleList { new RenderContentPostProcessTemplates(templates) }; 98 | 99 | OutputModules = new ModuleList 100 | { 101 | new FilterDocuments(Config.FromDocument(WebKeys.ShouldOutput, true)), new HtmlToPdf(), new WriteFiles() 102 | }; 103 | } 104 | 105 | private static IModule[] GetTopLevelIndexModules(IDocument bookDoc) => new IModule[] 106 | { 107 | new ReplaceDocuments(Config.FromContext(ctx => 108 | bookDoc.Clone(new MetadataItems { { Keys.Children, ctx.Inputs } }).Yield())), 109 | new AddTitle(), 110 | new SetDestination(Config.FromSettings(s => 111 | bookDoc.Destination.ChangeExtension(s.GetPageFileExtensions()[0]))) 112 | }; 113 | 114 | public static bool IsBook(IDocument document) => 115 | document.ContainsKey(BookKeys.BookPipelines) || document.ContainsKey(BookKeys.BookSources); 116 | } 117 | 118 | public class HtmlToPdf : Module 119 | { 120 | protected override async Task> ExecuteInputAsync(IDocument input, IExecutionContext context) 121 | { 122 | var builder = WebApplication.CreateBuilder(); 123 | builder.Logging.ClearProviders(); 124 | 125 | await using var app = builder.Build(); 126 | var root = Path.Combine(Directory.GetCurrentDirectory(), @"public"); 127 | app.UseStaticFiles(new StaticFileOptions() 128 | { 129 | FileProvider = new PhysicalFileProvider(root), RequestPath = "/static" 130 | }); 131 | await app.StartAsync(); 132 | 133 | using var playwright = await Playwright.CreateAsync(); 134 | var browser = await playwright.Chromium.LaunchAsync(); 135 | var browserContext = await browser.NewContextAsync(new BrowserNewContextOptions 136 | { 137 | ViewportSize = new ViewportSize { Width = 1200, Height = 628 }, 138 | }); 139 | 140 | var page = await browserContext.NewPageAsync(); 141 | var content = await input.GetContentStringAsync(); 142 | context.Logger.Log(LogLevel.Information, input, "Setting content"); 143 | await page.SetContentAsync(content); 144 | context.Logger.Log(LogLevel.Information, input, "Waiting for request"); 145 | 146 | await page.WaitForConsoleMessageAsync(new PageWaitForConsoleMessageOptions() 147 | { 148 | Predicate = message => message.Text == "after render" 149 | }); 150 | 151 | context.Logger.Log(LogLevel.Information, input, "Writing PDF"); 152 | var pdf = await page.PdfAsync(new PagePdfOptions() 153 | { 154 | Margin = new Margin() { Bottom = "0", Left = "0", Right = "0", Top = "0" }, 155 | DisplayHeaderFooter = false, 156 | PrintBackground = true, 157 | PreferCSSPageSize = true, 158 | }); 159 | 160 | await using var contentStream = context.GetContentStream(); 161 | await contentStream.WriteAsync(pdf); 162 | return new[] 163 | { 164 | context.CreateDocument(input.Destination.ChangeExtension("pdf"), 165 | context.GetContentProvider(contentStream, "application/pdf")) 166 | }; 167 | } 168 | } 169 | 170 | public static class BookKeys 171 | { 172 | public static string BookOrderKey = nameof(BookOrderKey); 173 | public static string BookOrderDescending = nameof(BookOrderDescending); 174 | public static string BookSources = nameof(BookSources); 175 | public static string BookPipelines = nameof(BookPipelines); 176 | } 177 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | cache/ 21 | [Dd]ebug/ 22 | [Dd]ebugPublic/ 23 | [Rr]elease/ 24 | [Rr]eleases/ 25 | x64/ 26 | x86/ 27 | [Ww][Ii][Nn]32/ 28 | [Aa][Rr][Mm]/ 29 | [Aa][Rr][Mm]64/ 30 | bld/ 31 | [Bb]in/ 32 | [Oo]bj/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*[.json, .xml, .info] 147 | 148 | # Visual Studio code coverage results 149 | *.coverage 150 | *.coveragexml 151 | 152 | # NCrunch 153 | _NCrunch_* 154 | .*crunch*.local.xml 155 | nCrunchTemp_* 156 | 157 | # MightyMoose 158 | *.mm.* 159 | AutoTest.Net/ 160 | 161 | # Web workbench (sass) 162 | .sass-cache/ 163 | 164 | # Installshield output folder 165 | [Ee]xpress/ 166 | 167 | # DocProject is a documentation generator add-in 168 | DocProject/buildhelp/ 169 | DocProject/Help/*.HxT 170 | DocProject/Help/*.HxC 171 | DocProject/Help/*.hhc 172 | DocProject/Help/*.hhk 173 | DocProject/Help/*.hhp 174 | DocProject/Help/Html2 175 | DocProject/Help/html 176 | 177 | # Click-Once directory 178 | publish/ 179 | 180 | # Publish Web Output 181 | *.[Pp]ublish.xml 182 | *.azurePubxml 183 | # Note: Comment the next line if you want to checkin your web deploy settings, 184 | # but database connection strings (with potential passwords) will be unencrypted 185 | *.pubxml 186 | *.publishproj 187 | 188 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 189 | # checkin your Azure Web App publish settings, but sensitive information contained 190 | # in these scripts will be unencrypted 191 | PublishScripts/ 192 | 193 | # NuGet Packages 194 | *.nupkg 195 | # NuGet Symbol Packages 196 | *.snupkg 197 | # The packages folder can be ignored because of Package Restore 198 | **/[Pp]ackages/* 199 | # except build/, which is used as an MSBuild target. 200 | !**/[Pp]ackages/build/ 201 | # Uncomment if necessary however generally it will be regenerated when needed 202 | #!**/[Pp]ackages/repositories.config 203 | # NuGet v3's project.json files produces more ignorable files 204 | *.nuget.props 205 | *.nuget.targets 206 | 207 | # Microsoft Azure Build Output 208 | csx/ 209 | *.build.csdef 210 | 211 | # Microsoft Azure Emulator 212 | ecf/ 213 | rcf/ 214 | 215 | # Windows Store app package directories and files 216 | AppPackages/ 217 | BundleArtifacts/ 218 | Package.StoreAssociation.xml 219 | _pkginfo.txt 220 | *.appx 221 | *.appxbundle 222 | *.appxupload 223 | 224 | # Visual Studio cache files 225 | # files ending in .cache can be ignored 226 | *.[Cc]ache 227 | # but keep track of directories ending in .cache 228 | !?*.[Cc]ache/ 229 | 230 | # Others 231 | ClientBin/ 232 | ~$* 233 | *~ 234 | *.dbmdl 235 | *.dbproj.schemaview 236 | *.jfm 237 | *.pfx 238 | *.publishsettings 239 | orleans.codegen.cs 240 | 241 | # Including strong name files can present a security risk 242 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 243 | #*.snk 244 | 245 | # Since there are multiple workflows, uncomment next line to ignore bower_components 246 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 247 | #bower_components/ 248 | 249 | # RIA/Silverlight projects 250 | Generated_Code/ 251 | 252 | # Backup & report files from converting an old project file 253 | # to a newer Visual Studio version. Backup files are not needed, 254 | # because we have git ;-) 255 | _UpgradeReport_Files/ 256 | Backup*/ 257 | UpgradeLog*.XML 258 | UpgradeLog*.htm 259 | ServiceFabricBackup/ 260 | *.rptproj.bak 261 | 262 | # SQL Server files 263 | *.mdf 264 | *.ldf 265 | *.ndf 266 | 267 | # Business Intelligence projects 268 | *.rdl.data 269 | *.bim.layout 270 | *.bim_*.settings 271 | *.rptproj.rsuser 272 | *- [Bb]ackup.rdl 273 | *- [Bb]ackup ([0-9]).rdl 274 | *- [Bb]ackup ([0-9][0-9]).rdl 275 | 276 | # Microsoft Fakes 277 | FakesAssemblies/ 278 | 279 | # GhostDoc plugin setting file 280 | *.GhostDoc.xml 281 | 282 | # Node.js Tools for Visual Studio 283 | .ntvs_analysis.dat 284 | node_modules/ 285 | 286 | # Visual Studio 6 build log 287 | *.plg 288 | 289 | # Visual Studio 6 workspace options file 290 | *.opt 291 | 292 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 293 | *.vbw 294 | 295 | # Visual Studio LightSwitch build output 296 | **/*.HTMLClient/GeneratedArtifacts 297 | **/*.DesktopClient/GeneratedArtifacts 298 | **/*.DesktopClient/ModelManifest.xml 299 | **/*.Server/GeneratedArtifacts 300 | **/*.Server/ModelManifest.xml 301 | _Pvt_Extensions 302 | 303 | # Paket dependency manager 304 | .paket/paket.exe 305 | paket-files/ 306 | 307 | # FAKE - F# Make 308 | .fake/ 309 | 310 | # CodeRush personal settings 311 | .cr/personal 312 | 313 | # Python Tools for Visual Studio (PTVS) 314 | __pycache__/ 315 | *.pyc 316 | 317 | # Cake - Uncomment if you are using it 318 | # tools/** 319 | # !tools/packages.config 320 | 321 | # Tabs Studio 322 | *.tss 323 | 324 | # Telerik's JustMock configuration file 325 | *.jmconfig 326 | 327 | # BizTalk build output 328 | *.btp.cs 329 | *.btm.cs 330 | *.odx.cs 331 | *.xsd.cs 332 | 333 | # OpenCover UI analysis results 334 | OpenCover/ 335 | 336 | # Azure Stream Analytics local run output 337 | ASALocalRun/ 338 | 339 | # MSBuild Binary and Structured Log 340 | *.binlog 341 | 342 | # NVidia Nsight GPU debugger configuration file 343 | *.nvuser 344 | 345 | # MFractors (Xamarin productivity tool) working folder 346 | .mfractor/ 347 | 348 | # Local History for Visual Studio 349 | .localhistory/ 350 | 351 | # BeatPulse healthcheck temp database 352 | healthchecksdb 353 | 354 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 355 | MigrationBackup/ 356 | 357 | # Ionide (cross platform F# VS Code tools) working folder 358 | .ionide/ 359 | 360 | # Fody - auto-generated XML schema 361 | FodyWeavers.xsd 362 | 363 | # Idea 364 | .idea/ 365 | 366 | # Torrent files for testing 367 | .____padding_file/ 368 | torrents/ 369 | 370 | # blog output 371 | output/ 372 | public/ -------------------------------------------------------------------------------- /input/posts/2020/09/external-annotations.md: -------------------------------------------------------------------------------- 1 | --- 2 | Title: UsedImplicitly on External Libraries 3 | description: How to use ExternalAnnotations to apply UsedImplicitly on third party libraries in your CQRS application. 4 | date: 2020-09-10 5 | tags: 6 | - MediatR 7 | - AutoMapper 8 | - FluentValidation 9 | - Rider 10 | - ReSharper 11 | repository: https://github.com/thirty25/ContosoUniversityDotNetCore-Pages 12 | --- 13 | 14 | Three tools that repeatedly show up in my ASP.NET Core applications are AutoMapper, FluentValidation and MediatR. They 15 | end up representing the bulk of the code in the web application portion of nearly all my web projects. 16 | 17 | But one little thing starts to bother me when working with these libraries is ReSharper and Rider insistence that my 18 | implementations of these tools are unused. 19 | 20 | ![Unused images as seen in rider](unused-classes.png) 21 | 22 | Take this sample code from 23 | [Jimmy Bogard's Contoso University ASP.NET Pages demo application](https://github.com/jbogard/ContosoUniversityDotNetCore-Pages). 24 | I can assure you that all three of the classes here are used, but their usage is discovered at runtime therefor the 25 | inspections are blissfully unaware 26 | 27 | - `Validator`. This class is dynamically created by FluentValidation. In `StartUp.cs` we tell FluentValidation to 28 | discover all validation classes in the assembly at runtime via the code 29 | 30 | ```csharp 31 | .AddFluentValidation(cfg => { cfg.RegisterValidatorsFromAssemblyContaining(); }); 32 | ``` 33 | 34 | - `MappingProfile`. Used by AutoMapper. Also in `StartUp.cs`. The code 35 | 36 | ```csharp 37 | services.AddAutoMapper(typeof(Startup)); 38 | ``` 39 | 40 | will scan the assembly looking for classes that inherit from `MappingProfile` 41 | 42 | - `QueryHandler`. Used by MediatR. The code 43 | 44 | ```csharp 45 | services.AddMediatR(typeof(Startup)); 46 | ``` 47 | 48 | will scan our assembly registering handlers automatically. 49 | 50 | Obviously it isn't the end of the world that we are getting these tips. But it would be nice for devs already not 51 | familiar with this pattern to not get the warnings. One way we can overcome this is to install the 52 | `JetBrains.Annotations` NuGet package. It contains the `[UsedImplicitly]` annotation. This annotation hints to the 53 | inspector that the class is not instantiated directly. 54 | 55 | ![Used implicitly applied](used-implicitly.png) 56 | 57 | While this solves the warning, it's ugly and gets repetitive real quick. 58 | 59 | But there is hope. With the 2020 release of Rider and ReSharper the [UsedImplicitly] attribute got a new flag as one of 60 | it's parameters - 61 | [`ImplicitUseTargetFlags.WithInheritors`](https://www.jetbrains.com/help/resharper/Reference__Code_Annotation_Attributes.html#UsedImplicitlyAttribute). 62 | What this flag does is tells the code inspector that all classes that inherit (or implement) this class are used 63 | implicitly. 64 | 65 | Unfortunately, we can't apply that attribute to the classes we need. They are all third party packages. One workaround 66 | is we could create a class that inherits from the three we need to annotate, apply this annotation, and then change all 67 | our code to use those instead. That's not much better than just applying the `[UsedImplicitly]` attribute directly 68 | though. 69 | 70 | ## External Annotations to the Rescue 71 | 72 | The analysis engine allows us to cheat. 73 | [External annotations](https://www.jetbrains.com/help/resharper/Code_Analysis__External_Annotations.html) allow us to 74 | create an xml file containing the types we want to annotate along with the specific annotation we want. 75 | 76 | To do so we create a folder in the root of our solution named `ExternalAnnotations`. In this folder we'll create an XML 77 | file matching each of our third party libraries (i.e. `AutoMapper.xml`, `MediatR.xml` and `FluentValidation.xml`). 78 | 79 | The format of these files will look like this 80 | 81 | ```xml 82 | 83 | 84 | 85 | 86 | 87 | ``` 88 | 89 | E.g. for AutoMapper we want to apply the `UsedImplicitly` annotation to the `AutoMapper.Profile` type. To get the symbol 90 | id we can use a ReSharper trick. You can click on the class you need to discover the symbol and click 91 | `ReSharper Edit | Copy XML-Doc ID to Clipboard`. Clicking on `Profile` and running this command copies 92 | `T:AutoMapper.Profile` to our clipboard. I don't think this feature exists in Rider so you will need to use ReSharper. 93 | The second step involves finding the constructor of the attribute we want to apply. The secret to doing this is to 94 | search the internet and find an external annotation that already has applied it and copy and paste. 95 | 96 | Given this we can now generate our full annotation 97 | 98 | ```xml 99 | 100 | 101 | 102 | 5 103 | 104 | 105 | 106 | ``` 107 | 108 | The 5 in this case comes from the flag `ImplicitUseTargetFlags` definition. We want `WithInheritors` and we might as 109 | well add `Itself` too. 110 | 111 | ```csharp 112 | [Flags] 113 | public enum ImplicitUseTargetFlags 114 | { 115 | Default = 1, 116 | Itself = Default, // 0x00000001 117 | /// Members of entity marked with attribute are considered used. 118 | Members = 2, 119 | /// Inherited entities are considered used. 120 | WithInheritors = 4, 121 | /// Entity marked with attribute and all its members considered used. 122 | WithMembers = Members | Itself, // 0x00000003 123 | } 124 | ``` 125 | 126 | Armed with this knowledge we can create an xml file for MediatR and FluentValidation. For MediatR we'll want to annotate 127 | `IRequestHandler<>`. The code for that is 128 | 129 | ```xml 130 | 131 | 132 | 133 | 5 134 | 135 | 136 | 137 | ``` 138 | 139 | For FluentValidation we want `AbstractValidator<>`. The code for that is 140 | 141 | ```xml 142 | 143 | 144 | 145 | 5 146 | 147 | 148 | 149 | ``` 150 | 151 | After we create the file we will need to close our solution. You might even need to shut Visual Studio and/or Rider all 152 | the way down and restart them. 153 | 154 | But once you reload your solution, your handlers, validators and profiles should no longer be marked as unused. If you 155 | want to verify the attribute has been applied you can select one of the classes we wrote the external annotations for 156 | and choose "Quick Documentation" (`CTRL-SHIFT-F1`). 157 | 158 | ![Quick documentation](quick-documentation.png) 159 | 160 | Here we see the `UsedImplicitly` flag being applied. 161 | 162 | ## Conclusion 163 | 164 | I've never been a huge fan of littering `[UsedImplicitly]` throughout my code. But with the new addition of 165 | `WithInheritors` not only is it easier to apply I feel applying this at the interface or base class level it is more 166 | descriptive than on the implementation itself when working with DI. For example if you had a generic repository you 167 | could mark `IRepository<>` with the interface. Now classes that properly implement that interface will be marked while a 168 | class that does not will remain marked as unused. Maybe one day the analysis engine will be able to dynamically load 169 | these classes that are used implicitly, but until then this is a decent workaround. 170 | 171 | To see these changes in action (well, lack of action I guess) check out the sample repository. And feel free to copy and 172 | paste the `ExternalAnnotations` folder into your application, restart your IDE and see your unused warnings go away. 173 | -------------------------------------------------------------------------------- /input/posts/2020/08/ef-core-5-and-stackoverflow.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Playing with EF Core 5 and Stack Overflow Data 3 | description: How to populate a test database with real data from a Stack Overflow site, and generate a DbContext to query it. 4 | date: 2020-08-21 5 | tags: 6 | - Entity Framework 7 | - Stack Overflow 8 | - SQL Server 9 | repository: https://github.com/thirty25/ef-core-5-and-stackoverflow 10 | --- 11 | 12 | Entity Framework Core 5 is starting to take shape with Preview 7 being released in late July. When I need to play with 13 | new data code, I like to use the [Stack Exchange Data Dump](https://archive.org/details/stackexchange). These are The 14 | schema is simple enough, but with the variety of sized databases you can grab one that has enough data to make querying 15 | against a SQL Server a little more like live fire especially when looking at execution plans. 16 | 17 | ## Building the Database 18 | 19 | Step one is picking the right StackOverflow site database. These files are XML and are compressed pretty heavily so plan 20 | accordingly. For example, the Coffee database sits at 3.5mb but expands out to 20mb. Once imported into SQL Server I was 21 | looking at a 35mb database file. 22 | 23 | Personally, I like using the 24 | [math.stackexchange.com database](https://archive.org/download/stackexchange/math.stackexchange.com.7z). The 2gb 25 | download expands into a nicely sized 18gb data. Not too large but enough data distributed around to make any bad queries 26 | actually look bad. Next size up will be the StackOverflow.com databases themselves, but that gets into the 300gb 27 | database size. If you got the bandwidth and disk space to spare go for it, but be prepared to wait a bit while 28 | importing. 29 | 30 | Once we have the 7z file downloaded we need to import them using the 31 | [soddi tool](https://github.com/BrentOzarULTD/soddi) maintained by [Brent Ozar](https://www.brentozar.com/). These are 32 | the steps I take when running the soddi tool 33 | 34 | - Extract the xml files to a temp location with a filename matching the site e.g. `c:\stackdata\math` 35 | - Create a new SQL Server database to house the data. I name them StackOverflowSite e.g. `StackOverflowMath` 36 | - Install and run `soddi` 37 | - Set the source field to your temp location without the site name e.g. `c:\stackdata` 38 | - After you do this, you'll see the Sites on the right populate. Click the name of the database you want to import 39 | - Set the Target to the database you just created e.g. 40 | `Data Source=.;Initial Catalog=StackOverflowCoffee;Integrated Security=True` and pick the appropriate provider. 41 | - I leave the rest as is. I want the tags, indices and key created. The batch size you might want to tweak but I've 42 | never seen much of a difference. Full text indexing is something I have not used in 15 years so that gets left 43 | unchecked too. 44 | 45 | Once you have done that click `Import`. With a database the size of the Math one be prepared to wait a minute. 46 | 47 | ## Scaffold the EF Core Model 48 | 49 | Now that we have a database, we need to scaffold up the model. Since we are working with a preview release, we'll need 50 | to do a few extra steps from the usual process. My target set up will be wo projects, a data project that is a .NET 51 | Standard class library and a .NET Core xUnit project for running tests. The data project will house the models and 52 | `DbContext`. Test project is for, well, the tests. 53 | 54 | We'll build up our project from the command line 55 | 56 | ```shell 57 | dotnet new sln 58 | dotnet new gitignore 59 | dotnet new classlib --name StackEfCore.Data --output src/StackEfCore.Data 60 | dotnet new xunit --name StackEfCore.Tests --output tests/StackEfCore.Tests 61 | dotnet sln add src/StackEfCore.Data 62 | dotnet sln add Tests/StackEfCore.Tests 63 | ``` 64 | 65 | Now that we have a project structure built, we need to add our nuget packages. The data project we'll add 66 | `Microsoft.EntityFrameworkCore.SqlServer` and the test project will also need `Microsoft.EntityFrameworkCore.Design` for 67 | the scaffolding tooling. 68 | 69 | EF Core 5 will require .NET Standard 2.1. You'll need to edit the `StackEfCore.Data.csproj` to be 70 | 71 | ```xml 72 | 73 | 74 | netstandard2.1 75 | 76 | 77 | ``` 78 | 79 | Once the we've update the project to .NET Standard 2.1 we can add our references making sure we include the preview 80 | version we want to target. We'll also go ahead and have our test project reference the data project while we are at it. 81 | 82 | ```shell 83 | dotnet add .\tests\StackEfCore.Tests\ package Microsoft.EntityFrameworkCore.Design --version 5.0.0-preview.7.20365.15 84 | 85 | dotnet add .\src\StackEfCore.Data\ package Microsoft.EntityFrameworkCore.SqlServer --version 5.0.0-preview.7.20365.15 86 | 87 | dotnet add .\tests\StackEfCore.Tests\ reference .\src\StackEfCore.Data\ 88 | ``` 89 | 90 | With that we have our project structure in place and ready for to scaffold the context. We'll use the preview version of 91 | the EF tools to do that. Because this is in preview we don't want to install it globally just for playing around, so 92 | We'll need to use the [.NET Core local tool](https://docs.microsoft.com/en-us/dotnet/core/tools/local-tools-how-to-use) 93 | to install the preview bits. 94 | 95 | ```shell 96 | dotnet new tool-manifest 97 | ``` 98 | 99 | This will create a `.config/dotnet-tools.json` file. With this in place we can install the preview bits of the EF Core 100 | tools 101 | 102 | ```shell 103 | dotnet tool install dotnet-ef --version 5.0.0-preview.7.20365.15 104 | ``` 105 | 106 | After this command has been ran the content of our .config/dotnet-tools.json file should look like this 107 | 108 | ```json 109 | { 110 | "version": 1, 111 | "isRoot": true, 112 | "tools": { 113 | "dotnet-ef": { 114 | "version": "5.0.0-preview.7.20365.15", 115 | "commands": ["dotnet-ef"] 116 | } 117 | } 118 | } 119 | ``` 120 | 121 | With our tool in place we are ready to scaffold. We'll use 122 | [`dotnet ef dbcontext scaffold`](https://docs.microsoft.com/en-us/ef/core/miscellaneous/cli/dotnet#dotnet-ef-dbcontext-scaffold) 123 | command but because we are running a local tool we'll need to use the `dotnet tool` command to do so. So, our syntax 124 | will now be 125 | 126 | ```shell 127 | dotnet tool run dotnet-ef dbcontext scaffold "server=.;database=StackOverflowMath;integrated security=true" Microsoft.EntityFrameworkCore.SqlServer --project .\src\StackEfCore.Data\ --startup-project .\tests\StackEfCore.Tests\ 128 | ``` 129 | 130 | Note the `dotnet tool run`. That ensures we are using the local version. Without it you might run the old EF Core tool 131 | if it's installed globally. 132 | 133 | If you've been following along so far without the urge to open up your IDE of choice, go ahead and fire that up now. 134 | 135 | ## Let's Actually Run Some Code 136 | 137 | After all that we can finally run a query or two. Open the generated `UnitTest1.cs` file and add some code in there to 138 | verify it all works 139 | 140 | ```c# 141 | [Fact] 142 | public async Task Can_query_some_users() 143 | { 144 | var context = new StackOverflowMathContext(); 145 | var usersCount = await context.Users.LongCountAsync(); 146 | Assert.True(usersCount > 0, "expected to find a user or two"); 147 | } 148 | ``` 149 | 150 | Now all that's left is to run `dotnet test` or use the test runner in your IDE of choice to verify that we are querying. 151 | 152 | ## Finishing Touches 153 | 154 | If you are running Resharper or Rider you'll have quite a few warnings from the generated code, especially related to 155 | unused getters and setters for your models. You have a couple of options. I personally like to mark the generated files 156 | with the following comment. It'll disable inspections for the entire file. 157 | 158 | ```c# 159 | // 160 | // This code was generated by ef scaffold. 161 | // 162 | ``` 163 | 164 | Another option would be to move the generated models and context into their own folder that is marked as generated in 165 | your Resharper or Rider Options. 166 | 167 | ![Rider Settings Page](generated-files-settings.png) 168 | 169 | When you save these make sure to save them to your Team Shared Settings. This will create a 170 | `solution-name.sln.dotsettings` file that you should commit to source control. Without this file in source control other 171 | team members or build tools will continue to receive warnings. 172 | 173 | Speaking of warnings, if for some reason you are going to production with this backup copy of the Stack Overlow database 174 | or you decided to use your production database server for testing this out then make sure you adjust your `DbContext` 175 | appropriately. Personally since I'm just messing around with integrated security against `(local)` I just delete the 176 | warning. 177 | -------------------------------------------------------------------------------- /Statiq/RoslynHighlightModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using AngleSharp.Dom; 8 | using Microsoft.CodeAnalysis; 9 | using Microsoft.CodeAnalysis.Classification; 10 | using Microsoft.CodeAnalysis.Text; 11 | using Statiq.Common; 12 | using IDocument = Statiq.Common.IDocument; 13 | using Task = System.Threading.Tasks.Task; 14 | 15 | namespace Thirty25.Statiq; 16 | 17 | public class RoslynHighlightModule : Module 18 | { 19 | private string _codeQuerySelector = "pre code.language-csharp"; 20 | 21 | /// 22 | /// Sets the query selector to use to find code blocks. 23 | /// 24 | /// The query selector to use to select code blocks. The default value is pre code 25 | /// The current instance. 26 | public RoslynHighlightModule WithCodeQuerySelector(string querySelector) 27 | { 28 | _codeQuerySelector = querySelector; 29 | return this; 30 | } 31 | 32 | protected override async Task> ExecuteContextAsync(IExecutionContext context) 33 | { 34 | // we can reuse the workspace, solution and project across all the documents and their children. 35 | using var workspace = new AdhocWorkspace(); 36 | var solution = workspace.CurrentSolution; 37 | var project = solution.AddProject("projectName", "assemblyName", LanguageNames.CSharp); 38 | 39 | var results = await context.Inputs.ParallelSelectAsync(async input => 40 | { 41 | var htmlDocument = await input.ParseHtmlAsync(); 42 | var highlighted = false; 43 | foreach (var element in htmlDocument.QuerySelectorAll(_codeQuerySelector)) 44 | { 45 | highlighted = true; 46 | await HighlightElement(element, project); 47 | } 48 | 49 | return highlighted ? input.Clone(context.GetContentProvider(htmlDocument)) : input; 50 | }); 51 | 52 | return results.ToList(); 53 | } 54 | 55 | 56 | private static async Task HighlightElement(IElement element, Project project) 57 | { 58 | // markdig will html escape our code for html, let's decode it. 59 | var original = element.TextContent; 60 | original = WebUtility.HtmlDecode(original); 61 | 62 | // we'll be running concurrently here so create a randomish name so we don't have a collision. 63 | var filename = $"name.{original.GetHashCode()}.{Environment.CurrentManagedThreadId}.cs"; 64 | var document = project.AddDocument(filename, original); 65 | var text = await document.GetTextAsync(); 66 | var classifiedSpans = await Classifier.GetClassifiedSpansAsync(document, TextSpan.FromBounds(0, text.Length)); 67 | var ranges = classifiedSpans.Select(classifiedSpan => new Range(classifiedSpan, text.GetSubText(classifiedSpan.TextSpan).ToString())); 68 | 69 | // the classified text won't include the whitespace so we need to add to fill in those gaps. 70 | ranges = FillGaps(text, ranges); 71 | 72 | var sb = new StringBuilder(element.TextContent.Length); 73 | 74 | foreach (var range in ranges) 75 | { 76 | var cssClass = ClassificationTypeToPrismClass(range.ClassificationType); 77 | if (string.IsNullOrWhiteSpace(cssClass)) 78 | { 79 | sb.Append(range.Text); 80 | } 81 | else 82 | { 83 | // include the prism css class but also include the roslyn classification. 84 | sb.Append($"{range.Text}"); 85 | } 86 | } 87 | 88 | // if prism.js runs client side we want it to skip this one, so mark it as language-none and remove the csharp identifier. 89 | element.ClassList.Remove("language-csharp"); 90 | element.InnerHtml = sb.ToString(); 91 | } 92 | 93 | private static string ClassificationTypeToPrismClass(string rangeClassificationType) 94 | { 95 | if (rangeClassificationType == null) 96 | return string.Empty; 97 | 98 | switch (rangeClassificationType) 99 | { 100 | case ClassificationTypeNames.Identifier: 101 | return "symbol"; 102 | case ClassificationTypeNames.LocalName: 103 | return "variable"; 104 | case ClassificationTypeNames.ParameterName: 105 | case ClassificationTypeNames.PropertyName: 106 | case ClassificationTypeNames.EnumMemberName: 107 | case ClassificationTypeNames.FieldName: 108 | return "property"; 109 | case ClassificationTypeNames.ClassName: 110 | case ClassificationTypeNames.StructName: 111 | case ClassificationTypeNames.RecordClassName: 112 | case ClassificationTypeNames.RecordStructName: 113 | case ClassificationTypeNames.InterfaceName: 114 | case ClassificationTypeNames.DelegateName: 115 | case ClassificationTypeNames.EnumName: 116 | case ClassificationTypeNames.ModuleName: 117 | case ClassificationTypeNames.TypeParameterName: 118 | return "title.class"; 119 | case ClassificationTypeNames.MethodName: 120 | case ClassificationTypeNames.ExtensionMethodName: 121 | return "title.function"; 122 | case ClassificationTypeNames.Comment: 123 | return "comment"; 124 | case ClassificationTypeNames.Keyword: 125 | case ClassificationTypeNames.ControlKeyword: 126 | case ClassificationTypeNames.PreprocessorKeyword: 127 | return "keyword"; 128 | case ClassificationTypeNames.StringLiteral: 129 | case ClassificationTypeNames.VerbatimStringLiteral: 130 | return "string"; 131 | case ClassificationTypeNames.NumericLiteral: 132 | return "number"; 133 | case ClassificationTypeNames.Operator: 134 | case ClassificationTypeNames.StringEscapeCharacter: 135 | return "operator"; 136 | case ClassificationTypeNames.Punctuation: 137 | return "punctuation"; 138 | case ClassificationTypeNames.StaticSymbol: 139 | return string.Empty; 140 | case ClassificationTypeNames.XmlDocCommentComment: 141 | case ClassificationTypeNames.XmlDocCommentDelimiter: 142 | case ClassificationTypeNames.XmlDocCommentName: 143 | case ClassificationTypeNames.XmlDocCommentText: 144 | case ClassificationTypeNames.XmlDocCommentAttributeName: 145 | case ClassificationTypeNames.XmlDocCommentAttributeQuotes: 146 | case ClassificationTypeNames.XmlDocCommentAttributeValue: 147 | case ClassificationTypeNames.XmlDocCommentEntityReference: 148 | case ClassificationTypeNames.XmlDocCommentProcessingInstruction: 149 | case ClassificationTypeNames.XmlDocCommentCDataSection: 150 | return "comment"; 151 | default: 152 | return rangeClassificationType.Replace(" ", "-"); 153 | } 154 | } 155 | 156 | private static IEnumerable FillGaps(SourceText text, IEnumerable ranges) 157 | { 158 | const string WhitespaceClassification = null; 159 | var current = 0; 160 | Range previous = null; 161 | 162 | foreach (var range in ranges) 163 | { 164 | var start = range.TextSpan.Start; 165 | if (start > current) 166 | { 167 | yield return new Range(WhitespaceClassification, TextSpan.FromBounds(current, start), text); 168 | } 169 | 170 | if (previous == null || range.TextSpan != previous.TextSpan) 171 | { 172 | yield return range; 173 | } 174 | 175 | previous = range; 176 | current = range.TextSpan.End; 177 | } 178 | 179 | if (current < text.Length) 180 | { 181 | yield return new Range(WhitespaceClassification, TextSpan.FromBounds(current, text.Length), text); 182 | } 183 | } 184 | 185 | private class Range 186 | { 187 | private ClassifiedSpan ClassifiedSpan { get; } 188 | public string Text { get; } 189 | 190 | public Range(string classification, TextSpan span, SourceText text) : 191 | this(classification, span, text.GetSubText(span).ToString()) 192 | { 193 | } 194 | 195 | private Range(string classification, TextSpan span, string text) : 196 | this(new ClassifiedSpan(classification, span), text) 197 | { 198 | } 199 | 200 | public Range(ClassifiedSpan classifiedSpan, string text) 201 | { 202 | ClassifiedSpan = classifiedSpan; 203 | Text = text; 204 | } 205 | 206 | public string ClassificationType => ClassifiedSpan.ClassificationType; 207 | 208 | public TextSpan TextSpan => ClassifiedSpan.TextSpan; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /input/posts/2021/12/tagging-query-with-ef-core.md: -------------------------------------------------------------------------------- 1 | --- 2 | Title: Better Tagging of EF Core Queries with .NET 6 3 | description: How to add tags to your EF Core queries, and how to automatically give those tags better info. 4 | date: 2021-12-10 5 | tags: 6 | - Entity Framework 7 | - net6 8 | repository: https://github.com/thirty25/ef-core-tagging 9 | --- 10 | 11 | **Note:** This is an updated version of a [previous post](../../2020/09/tagging-query-with-ef-core/) that extends the functionality using .NET 6. 12 | 13 | With [EF Core 2.2](https://devblogs.microsoft.com/dotnet/announcing-entity-framework-core-2-2/#query-tags) Microsoft 14 | added the `TagWith` extension method. This allows us to write a query such as 15 | 16 | ```csharp 17 | var result = await bloggingContext.Blogs 18 | .Where(i => i.Url.StartsWith("http://example.com")) 19 | .TagWith("Looking for example.com") 20 | .FirstOrDefaultAsync(); 21 | ``` 22 | 23 | Now when you execute your code the following statement, you'll see a comment included with the command 24 | 25 | ```sql 26 | -- Looking for example.com 27 | 28 | SELECT "b"."BlogId", "b"."Url" 29 | FROM "Blogs" AS "b" 30 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'http://%') 31 | LIMIT 1 32 | ``` 33 | 34 | The [previous post on tagging](../../2020/09/tagging-query-with-ef-core/) introduced my `TagWithSource` that extended this functionality by automatically including the caller information. 35 | 36 | Well, a year later EF Core 6 now includes [`TagWithCallSite`](https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-6.0/whatsnew#tag-queries-with-file-name-and-line-number) so this functionality is built in. 37 | 38 | ```csharp 39 | var result = await bloggingContext.Blogs 40 | .Where(i => i.Url.StartsWith("https://")) 41 | .Take(5) 42 | .OrderBy(i => i.BlogId) 43 | .TagWithCallSite() 44 | .ToListAsync(); 45 | ``` 46 | 47 | This will now include the file (but no method name) in the query, similar to my previous post. 48 | 49 | ```sql 50 | -- File: R:\thirty25\ef-core-tagging\tests\EfCoreTagging.Tests\UnitTest1.cs:46 51 | 52 | SELECT "t"."BlogId", "t"."Url" 53 | FROM ( 54 | SELECT "b"."BlogId", "b"."Url" 55 | FROM "Blogs" AS "b" 56 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'https://%') 57 | LIMIT @__p_0 58 | ) AS "t" 59 | ORDER BY "t"."BlogId" 60 | ``` 61 | 62 | But, we can stay one step of ahead of Microsoft. Let's include a bit more info. .NET 6 also introduced 63 | [CallerArgumentExpression](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/caller-argument-expression.md) 64 | which allows us to even include the expression that called our method. 65 | 66 | With the addition of this attribute, we can create our own `TagWith` 67 | that in addition to the the source location, we can also pull the expression calling the 68 | LINQ query. Our extension method will look similar to this: 69 | 70 | ```csharp 71 | public static IQueryable TagWithSource(this IQueryable queryable, 72 | string tag = default, 73 | [CallerLineNumber] int lineNumber = 0, 74 | [CallerFilePath] string filePath = "", 75 | [CallerMemberName] string memberName = "", 76 | [CallerArgumentExpression("queryable")] 77 | string argument = "") 78 | { 79 | // argument could be multiple lines with whitespace so let's normalize it down to one line 80 | var trimmedLines = string.Join( 81 | string.Empty, 82 | argument.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Select(i => i.Trim()) 83 | ); 84 | 85 | var tagContent = string.IsNullOrWhiteSpace(tag) 86 | ? default 87 | : tag + Environment.NewLine; 88 | 89 | tagContent += trimmedLines + Environment.NewLine + $" at {memberName} - {filePath}:{lineNumber}"; 90 | 91 | return queryable.TagWith(tagContent); 92 | } 93 | ``` 94 | 95 | This allows us to include an optionally custom tag text, plus automatically include the method name, file, file number and now thanks to the addition of `CallerArgumentExpression` 96 | we get the full LINQ statement too. 97 | 98 | ```sql 99 | -- bloggingContext.Blogs.Where(i => i.Url.StartsWith("https://")).Take(5).OrderBy(i => i.BlogId) 100 | -- at Test1 - R:\thirty25\ef-core-tagging\tests\EfCoreTagging.Tests\UnitTest1.cs:46 101 | 102 | SELECT "t"."BlogId", "t"."Url" 103 | FROM ( 104 | SELECT "b"."BlogId", "b"."Url" 105 | FROM "Blogs" AS "b" 106 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'https://%') 107 | LIMIT @__p_0 108 | ) AS "t" 109 | ORDER BY "t"."BlogId" 110 | 111 | ``` 112 | 113 | So the first line is our call with the whitespace normalized out. Second line is what we could get with our previous helper or the built in statement. Pretty cool! 114 | 115 | There are some gotchas. Because of the `CallerArgumentExpression` addition, order will matter. Typically `TagWith` or `TagWithSiteCaller` can be placed anywhere in the LINQ chain. They are only bringing in a file name and a line number. But, because we are going to want to include everything in the 116 | LINQ chain, the only way the compiler will know this is if we place the `TagWithSource` call at the end. Because of this, it might make sense to add some helpers that also wrap `ToListAsync`, `FirstOrDefaultAsync` and other final LINQ operators to ensure it is called in the correct spot. 117 | 118 | This is easier said than done. We unfortunately can't just wrap our call to `TagWithSource` like so. 119 | 120 | ```csharp 121 | public static async Task> ToListWithSourceAsync(this IQueryable queryable, CancellationToken cancellationToken = default) 122 | { 123 | return await queryable 124 | .TagWithSource() 125 | .ToListAsync(cancellationToken); 126 | } 127 | ``` 128 | 129 | The result of this call will use this helper as the source when we call `TagWithSource`. Note that file is actually our extension and 130 | the only member we know about is `queryable`. Without reflection we can't go up the call stack. 131 | 132 | ```sql 133 | -- ToListWithSourceAsync - R:\thirty25\ef-core-tagging\src\EfCoreTagging.Data\IQueryableTaggingExtensions.cs27 134 | -- queryable 135 | 136 | SELECT "t"."BlogId", "t"."Url" 137 | FROM ( 138 | SELECT "b"."BlogId", "b"."Url" 139 | FROM "Blogs" AS "b" 140 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'https://%') 141 | LIMIT @__p_0 142 | ) AS "t" 143 | ORDER BY "t"."BlogId" 144 | ``` 145 | 146 | To work around this, you have to include all the `Caller` attributes on your `ToListAsync` wrapper. A nice bonus here is that because 147 | we are controlling the last call in the chain, we can also include this if we want. 148 | 149 | After moving the formatting code into it's helper, the method looks like 150 | 151 | ```csharp 152 | public static async Task> ToListWithSourceAsync(this IQueryable queryable, 153 | string tag = default, 154 | [CallerLineNumber] int lineNumber = 0, 155 | [CallerFilePath] string filePath = "", 156 | [CallerMemberName] string memberName = "", 157 | [CallerArgumentExpression("queryable")] string argument = "", 158 | CancellationToken cancellationToken = default) 159 | { 160 | return await queryable 161 | .TagWith(GetTagContent(tag, lineNumber, filePath, memberName, 162 | $"{argument}.{nameof(ToListWithSourceAsync)}()")) 163 | .ToListAsync(cancellationToken); 164 | } 165 | 166 | ``` 167 | 168 | and this is called by 169 | 170 | ```csharp 171 | var result = await bloggingContext.Blogs 172 | .Where(i => i.Url.StartsWith("https://")) 173 | .Take(5) 174 | .OrderBy(i => i.BlogId) 175 | .ToListWithSourceTagAsync(); 176 | ``` 177 | 178 | Now we not only get the correct tag with the proper expression, but we also get our addition call to `ToListWithSourceTagAsync` method included 179 | 180 | ```sql 181 | -- bloggingContext.Blogs.Where(i => i.Url.StartsWith("https://")).Take(5).OrderBy(i => i.BlogId).ToListWithSourceAsync() 182 | -- at Test1_WithToList - R:\thirty25\ef-core-tagging\tests\EfCoreTagging.Tests\UnitTest1.cs:67 183 | 184 | SELECT "t"."BlogId", "t"."Url" 185 | FROM ( 186 | SELECT "b"."BlogId", "b"."Url" 187 | FROM "Blogs" AS "b" 188 | WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'https://%') 189 | LIMIT @__p_0 190 | ) AS "t" 191 | ORDER BY "t"."BlogId" 192 | ``` 193 | 194 | It could get rather tedious to include them all, but this might prove worth a bit of copy and paste (or a T4 template) to get you there. 195 | 196 | ## Notes 197 | 198 | While poking around the source of Microsoft's `TagWithCallSite` source, I noticed they used the attribute [`NotParameterized`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.query.notparameterizedattribute?view=efcore-5.0) on the tag and caller info. Only information I can find states that this "signals that custom LINQ operator parameter should not be parameterized during query compilation." This sounds like a good optimization to also include, so the full call now looks more like this when you view the repository. 199 | 200 | ```csharp 201 | public static IQueryable TagWithSource(this IQueryable queryable, 202 | [NotParameterized] string tag = default, 203 | [NotParameterized] [CallerLineNumber] int lineNumber = 0, 204 | [NotParameterized] [CallerFilePath] string filePath = "", 205 | [NotParameterized] [CallerMemberName] string memberName = "", 206 | [NotParameterized] [CallerArgumentExpression("queryable")] 207 | string argument = "") 208 | { 209 | return queryable.TagWith(GetTagContent(tag, lineNumber, filePath, memberName, argument)); 210 | } 211 | ``` 212 | 213 | Additionally, because we are including comments that can change between compiles, this will cause a miss on the plan cache after a deployment for possibly many queries. 214 | You might want to run this by the DBA. Personally I think this is an acceptable risk, but if this is an extremely critical path it is worth noting. -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Don't use tabs for indentation. 7 | [*] 8 | indent_style = space 9 | # (Please don't specify an indent_size here; that has too many unintended consequences.) 10 | 11 | # Code files 12 | [*.{cs,csx,vb,vbx}] 13 | indent_size = 4 14 | insert_final_newline = true 15 | charset = utf-8-bom 16 | 17 | # XML project files 18 | [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] 19 | indent_size = 2 20 | 21 | # XML config files 22 | [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] 23 | indent_size = 2 24 | 25 | # JSON files 26 | [*.json] 27 | indent_size = 2 28 | 29 | # Powershell files 30 | [*.ps1] 31 | indent_size = 2 32 | 33 | # Shell script files 34 | [*.sh] 35 | end_of_line = lf 36 | indent_size = 2 37 | 38 | # Dotnet code style settings: 39 | [*.{cs,vb}] 40 | 41 | # IDE0055: Fix formatting 42 | dotnet_diagnostic.IDE0055.severity = warning 43 | 44 | # Sort using and Import directives with System.* appearing first 45 | dotnet_sort_system_directives_first = true 46 | dotnet_separate_import_directive_groups = false 47 | # Avoid "this." and "Me." if not necessary 48 | dotnet_style_qualification_for_field = false:refactoring 49 | dotnet_style_qualification_for_property = false:refactoring 50 | dotnet_style_qualification_for_method = false:refactoring 51 | dotnet_style_qualification_for_event = false:refactoring 52 | 53 | # Use language keywords instead of framework type names for type references 54 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 55 | dotnet_style_predefined_type_for_member_access = true:suggestion 56 | 57 | # Suggest more modern language features when available 58 | dotnet_style_object_initializer = true:suggestion 59 | dotnet_style_collection_initializer = true:suggestion 60 | dotnet_style_coalesce_expression = true:suggestion 61 | dotnet_style_null_propagation = true:suggestion 62 | dotnet_style_explicit_tuple_names = true:suggestion 63 | 64 | # Non-private static fields are PascalCase 65 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion 66 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields 67 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style 68 | 69 | dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field 70 | dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected 71 | dotnet_naming_symbols.non_private_static_fields.required_modifiers = static 72 | 73 | dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case 74 | 75 | # Non-private readonly fields are PascalCase 76 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion 77 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields 78 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style 79 | 80 | dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field 81 | dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected 82 | dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly 83 | 84 | dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case 85 | 86 | # Constants are PascalCase 87 | dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion 88 | dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants 89 | dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style 90 | 91 | dotnet_naming_symbols.constants.applicable_kinds = field, local 92 | dotnet_naming_symbols.constants.required_modifiers = const 93 | 94 | dotnet_naming_style.constant_style.capitalization = pascal_case 95 | 96 | # Static fields are camelCase and start with s_ 97 | dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion 98 | dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields 99 | dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style 100 | 101 | dotnet_naming_symbols.static_fields.applicable_kinds = field 102 | dotnet_naming_symbols.static_fields.required_modifiers = static 103 | 104 | dotnet_naming_style.static_field_style.capitalization = camel_case 105 | dotnet_naming_style.static_field_style.required_prefix = s_ 106 | 107 | # Instance fields are camelCase and start with _ 108 | dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion 109 | dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields 110 | dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style 111 | 112 | dotnet_naming_symbols.instance_fields.applicable_kinds = field 113 | 114 | dotnet_naming_style.instance_field_style.capitalization = camel_case 115 | dotnet_naming_style.instance_field_style.required_prefix = _ 116 | 117 | # Locals and parameters are camelCase 118 | dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion 119 | dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters 120 | dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style 121 | 122 | dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local 123 | 124 | dotnet_naming_style.camel_case_style.capitalization = camel_case 125 | 126 | # Local functions are PascalCase 127 | dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion 128 | dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions 129 | dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style 130 | 131 | dotnet_naming_symbols.local_functions.applicable_kinds = local_function 132 | 133 | dotnet_naming_style.local_function_style.capitalization = pascal_case 134 | 135 | # By default, name items with PascalCase 136 | dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion 137 | dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members 138 | dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style 139 | 140 | dotnet_naming_symbols.all_members.applicable_kinds = * 141 | 142 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 143 | 144 | # error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}' 145 | dotnet_diagnostic.RS2008.severity = none 146 | 147 | # IDE0073: File header 148 | dotnet_diagnostic.IDE0073.severity = none 149 | 150 | # CSharp code style settings: 151 | [*.cs] 152 | # Newline settings 153 | csharp_new_line_before_open_brace = all 154 | csharp_new_line_before_else = true 155 | csharp_new_line_before_catch = true 156 | csharp_new_line_before_finally = true 157 | csharp_new_line_before_members_in_object_initializers = true 158 | csharp_new_line_before_members_in_anonymous_types = true 159 | csharp_new_line_between_query_expression_clauses = true 160 | 161 | # Indentation preferences 162 | csharp_indent_block_contents = true 163 | csharp_indent_braces = false 164 | csharp_indent_case_contents = true 165 | csharp_indent_case_contents_when_block = true 166 | csharp_indent_switch_labels = true 167 | csharp_indent_labels = flush_left 168 | 169 | # Prefer "var" everywhere 170 | csharp_style_var_for_built_in_types = true:suggestion 171 | csharp_style_var_when_type_is_apparent = true:suggestion 172 | csharp_style_var_elsewhere = true:suggestion 173 | 174 | # Prefer method-like constructs to have a block body 175 | csharp_style_expression_bodied_methods = false:none 176 | csharp_style_expression_bodied_constructors = false:none 177 | csharp_style_expression_bodied_operators = false:none 178 | 179 | # Prefer property-like constructs to have an expression-body 180 | csharp_style_expression_bodied_properties = true:none 181 | csharp_style_expression_bodied_indexers = true:none 182 | csharp_style_expression_bodied_accessors = true:none 183 | 184 | # Suggest more modern language features when available 185 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 186 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 187 | csharp_style_inlined_variable_declaration = true:suggestion 188 | csharp_style_throw_expression = true:suggestion 189 | csharp_style_conditional_delegate_call = true:suggestion 190 | 191 | # Space preferences 192 | csharp_space_after_cast = false 193 | csharp_space_after_colon_in_inheritance_clause = true 194 | csharp_space_after_comma = true 195 | csharp_space_after_dot = false 196 | csharp_space_after_keywords_in_control_flow_statements = true 197 | csharp_space_after_semicolon_in_for_statement = true 198 | csharp_space_around_binary_operators = before_and_after 199 | csharp_space_around_declaration_statements = do_not_ignore 200 | csharp_space_before_colon_in_inheritance_clause = true 201 | csharp_space_before_comma = false 202 | csharp_space_before_dot = false 203 | csharp_space_before_open_square_brackets = false 204 | csharp_space_before_semicolon_in_for_statement = false 205 | csharp_space_between_empty_square_brackets = false 206 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 207 | csharp_space_between_method_call_name_and_opening_parenthesis = false 208 | csharp_space_between_method_call_parameter_list_parentheses = false 209 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 210 | csharp_space_between_method_declaration_name_and_open_parenthesis = false 211 | csharp_space_between_method_declaration_parameter_list_parentheses = false 212 | csharp_space_between_parentheses = false 213 | csharp_space_between_square_brackets = false 214 | 215 | # Blocks are allowed 216 | csharp_prefer_braces = true:silent 217 | csharp_preserve_single_line_blocks = true 218 | csharp_preserve_single_line_statements = true 219 | 220 | # warning RS0037: PublicAPI.txt is missing '#nullable enable' 221 | dotnet_diagnostic.RS0037.severity = none 222 | 223 | [src/CodeStyle/**.{cs,vb}] 224 | # warning RS0005: Do not use generic CodeAction.Create to create CodeAction 225 | dotnet_diagnostic.RS0005.severity = none 226 | 227 | [src/{Analyzers,CodeStyle,Features,Workspaces}/**/*.{cs,vb}] 228 | # IDE0005: Remove unnecessary usings/imports 229 | dotnet_diagnostic.IDE0005.severity = warning 230 | 231 | # IDE0011: Add braces 232 | csharp_prefer_braces = when_multiline:warning 233 | # NOTE: We need the below severity entry for Add Braces due to https://github.com/dotnet/roslyn/issues/44201 234 | dotnet_diagnostic.IDE0011.severity = warning 235 | 236 | # IDE0035: Remove unreachable code 237 | dotnet_diagnostic.IDE0035.severity = warning 238 | 239 | # IDE0036: Order modifiers 240 | dotnet_diagnostic.IDE0036.severity = warning 241 | 242 | # IDE0040: Add accessibility modifiers 243 | dotnet_diagnostic.IDE0040.severity = warning 244 | 245 | # IDE0043: Format string contains invalid placeholder 246 | dotnet_diagnostic.IDE0043.severity = warning 247 | 248 | # IDE0044: Make field readonly 249 | dotnet_diagnostic.IDE0044.severity = warning 250 | 251 | # CONSIDER: Are IDE0051 and IDE0052 too noisy to be warnings for IDE editing scenarios? Should they be made build-only warnings? 252 | # IDE0051: Remove unused private member 253 | dotnet_diagnostic.IDE0051.severity = warning 254 | 255 | # IDE0052: Remove unread private member 256 | dotnet_diagnostic.IDE0052.severity = warning 257 | 258 | # IDE0059: Unnecessary assignment to a value 259 | dotnet_diagnostic.IDE0059.severity = warning 260 | 261 | # IDE0060: Remove unused parameter 262 | dotnet_diagnostic.IDE0060.severity = warning 263 | 264 | # CA1822: Make member static 265 | dotnet_diagnostic.CA1822.severity = warning 266 | 267 | # Prefer "var" everywhere 268 | dotnet_diagnostic.IDE0007.severity = warning 269 | csharp_style_var_for_built_in_types = true:warning 270 | csharp_style_var_when_type_is_apparent = true:warning 271 | csharp_style_var_elsewhere = true:warning 272 | -------------------------------------------------------------------------------- /input/posts/2020/08/cqrs-ef-core-repository-pattern.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: CQRS Repository with EF Core 3 | description: Creating a repository of questionable value around an EF Core DbContext. 4 | date: 2020-08-26 5 | tags: 6 | - Entity Framework 7 | repository: https://github.com/thirty25/cqrs-ef-core-repository-pattern 8 | --- 9 | 10 | Chances are the only reason you clicked on this article was to scroll right to the comments so you can call me an idiot. 11 | "`DbSet` IS a repository!" you are here to tell me. Well yes. But maybe, just maybe, we can have a bit of a better 12 | repository. We'll leave all the functionality on the existing `DbSet` and expose it directly. Our main work will be 13 | wrapping up our `DbContext` and adding a little extra to make things a bit easier when working in a CQRS architecture. 14 | 15 | ## Setting Up A Useless Repository 16 | 17 | Our basic querying interface will look like this 18 | 19 | ```c# 20 | public interface IRepository where TContext : DbContext 21 | { 22 | IQueryable Query() where T : class; 23 | } 24 | ``` 25 | 26 | Because `DbSet` is the repository pattern we just need to expose it. 27 | 28 | ```c# 29 | public class Repository : IRepository where TContext : DbContext 30 | { 31 | private readonly TContext _context; 32 | 33 | public Repository(TContext context) => _context = context; 34 | public IQueryable Query() where T : class => _context.Set(); 35 | } 36 | ``` 37 | 38 | Looking good! Now we'll use our DI framework of choice that supports open generics to wire our interface up. Once that's 39 | done querying will look something along the lines of 40 | 41 | ```c# 42 | await _repository.Query().FirstAsync(i => i.Id = 123); 43 | ``` 44 | 45 | Two steps back, zero steps forward so far. But this is just the base. Let's solve some pain points. 46 | 47 | ## Problem One - Tagging 48 | 49 | A feature gone unnoticed for many with EF Core 2.2 was the addition of 50 | [Query tags](https://docs.microsoft.com/en-us/ef/core/querying/tags). This allows us to include a comment in the 51 | generated SQL. This may not seem like a huge deal, but once our code makes it to production and our SQL DBA is sending 52 | you questions on some costly query that's showing up in the Query Store we can use the information included in this tag 53 | to track down the problematic code. 54 | 55 | Given this linq query 56 | 57 | ```c# 58 | var context = new BlogContext(); 59 | var blogs = await context.Blogs 60 | .TagWith("Querying all blogs") 61 | .ToListAsync(); 62 | ``` 63 | 64 | With this is the SQL that is executed with our tag 65 | 66 | ```sql 67 | -- Querying all blogs 68 | 69 | SELECT "b"."BlogId", "b"."Url" 70 | FROM "Blogs" AS "b" 71 | ``` 72 | 73 | It's important to remember that this is what's sent to the server. That means it is what will show up a trace session or 74 | the query store. These tags are helpful when you start seeing expensive queries that could come from anywhere in the 75 | app. Well, that is if we remembered to include it. 76 | 77 | So, let's include it automatically. 78 | 79 | While it's nice to have some text in there what if we included the method name and filename? We can rely on a little 80 | compiler magic and add that to our repository. 81 | 82 | ```c# 83 | public IQueryable Query( 84 | [System.Runtime.CompilerServices.CallerMemberName] 85 | string memberName = "", 86 | [System.Runtime.CompilerServices.CallerFilePath] 87 | string sourceFilePath = "", 88 | [System.Runtime.CompilerServices.CallerLineNumber] 89 | int sourceLineNumber = 0) where T : class 90 | { 91 | return _context.Set() 92 | .TagWith($"{memberName} {sourceFilePath}:{sourceLineNumber}"); 93 | } 94 | ``` 95 | 96 | Our new `Query` method now takes 3 optional parameter marked with 97 | [caller information](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/caller-information). 98 | If you aren't familiar with these attributes the compiler will automatically populate those fields with the appropriate 99 | values. Keep in mind that they are populated by the compiler and not at runtime so there won't be a performance penalty. 100 | One gotcha here is we need to make sure our interface also has the same attributes. Without both the interface and 101 | implementation having the interfaces you'll get the default values. 102 | 103 | Now when we rerun our our original LINQ query, we generated SQL somewhat like the following 104 | 105 | ```sql 106 | -- Can_query() C:\projects\repo\RepositoryTests.cs:42 107 | 108 | SELECT "b"."BlogId", "b"."Url" 109 | FROM "Blogs" AS "b" 110 | WHERE "b"."BlogId" = 123 111 | LIMIT 1 112 | ``` 113 | 114 | With this change we now automatically get all our queries tagged with their source. `TagWith()` can be chained too, 115 | allowing us to still provide extra details when the need arises. 116 | 117 | ```c# 118 | var first = await repository.Query(i => i.Blogs) 119 | .TagWith("Querying blog 123") 120 | .FirstOrDefaultAsync(i => i.BlogId == 123); 121 | ``` 122 | 123 | produces 124 | 125 | ```sql 126 | -- Can_query() C:\projects\repo\RepositoryTests.cs:42 127 | 128 | -- Querying blog 123 129 | 130 | SELECT "b"."BlogId", "b"."Url" 131 | FROM "Blogs" AS "b" 132 | WHERE "b"."BlogId" = 123 133 | LIMIT 1 134 | ``` 135 | 136 | ## Problem 2 - Commands and Queries 137 | 138 | [Command Query Responsibility Segregation](https://martinfowler.com/bliki/CQRS.html) (CQRS) isn't new, but it has seen a 139 | rise in popularity of late. If you aren't familiar with it, I'll leave getting into the weeds of that pattern for 140 | someone else. For our purposes we'll stick to a super simple idea that an architecture with different code path for when 141 | doing queries and commands is sometimes useful. One way I like to enforce this is with two repositories - a command and 142 | a query repository. 143 | 144 | For our purposes we'll split `IRepository` into `ICommandRepository` and `IQueryRepository`. Query repository 145 | stays the same with just a `Query` method. That's all it needs. We won't be updating the data when issuing our queries 146 | so no need for anything else. Our command repository we'll add a simple `SaveChangesAsync()` and `Set` for working 147 | directly with the `DbSet` and persisting the data. 148 | 149 | ```c# 150 | public Task SaveChangesAsync() => _context.SaveChangesAsync(); 151 | public DbSet Set() where T : class => _context.Set(); 152 | ``` 153 | 154 | Save changes doesn't allow tagging, unfortunately, so our implementation is dead simple. Now our code to create and 155 | persist an item would look something like this 156 | 157 | ```csharp 158 | var blog = new Blog() 159 | { 160 | Url = "http://example.com/rss.xml", 161 | BlogId = 123 162 | }; 163 | 164 | await commandRepository.Set.AddAsync(blog); 165 | await commandRepository.SaveChangesAsync(); 166 | ``` 167 | 168 | Not a ton of value being added, even though it is nice having two implementations that are explicit on their use. But 169 | there is one EF detail that we can include in our query repository. Our `QueryRepository` will never return data that 170 | will be used for updates in a `DbContext`. We don't even have a method to persist it if we wanted. With EF when you are 171 | querying data it makes sense to also call `AsNoTracking()`. By default, 172 | [EF will include tracking details](https://docs.microsoft.com/en-us/ef/core/querying/tracking) when we query. This data 173 | can become quite cumbersome over time and introduces a performance penalty when all we want is read-only data. When we 174 | are using our `QueryRepository` that is precisely what we are doing so let's include it. Now our implementation while 175 | simple is providing a much better foundation for queries. 176 | 177 | ```c# 178 | public IQueryable Query( 179 | [System.Runtime.CompilerServices.CallerMemberName] 180 | string memberName = "", 181 | [System.Runtime.CompilerServices.CallerFilePath] 182 | string sourceFilePath = "", 183 | [System.Runtime.CompilerServices.CallerLineNumber] 184 | int sourceLineNumber = 0) where T : class 185 | { 186 | return _context.Set() 187 | .AsNoTracking() 188 | .TagWith($"{memberName}() {sourceFilePath}:{sourceLineNumber}"); 189 | } 190 | ``` 191 | 192 | ## Problem 3 - DbContextFactory Management 193 | 194 | With EF Core 5 preview 7 Microsoft 195 | [shipped a context factory](https://devblogs.microsoft.com/dotnet/announcing-entity-framework-core-ef-core-5-0-preview-7/#dbcontextfactory) 196 | out of the box with EF Core. Many people have rolled their own implementation, but if you haven't ran across this 197 | pattern the added benefit of using a factory comes when you are relying on dependency injection. Take for example a 198 | controller taking in a `DbContext` as a dependency. 199 | 200 | In our scenario maybe we are doing some basic validation on the command that is requested. If it doesn't meet our rules 201 | we send back a `BadRequest` without touching the database. But if our constructor is asking for `DbContext` that means 202 | ASP.NET is gonna build up an instance even though we don't need one. Even for a simple context this can be at least 20ms 203 | of time and allocations we don't need. By using a factory ASP.NET will give us an instance of the factory, which by 204 | default is a singleton. So in our `BadRequest` example there would be now zero allocations and no time spent building 205 | the context. 206 | 207 | The downside is we must now manage the lifetime of the context. This can be done pretty simply with an `using` 208 | statement, but that is still one more thing to worry about while writing code and doing code reviews. If we've gone 209 | through the trouble of creating our repository we might as well do this once here. 210 | 211 | Now our constructor of our repository will look something like this 212 | 213 | ```c# 214 | private readonly Lazy _context; 215 | 216 | public CommandRepository(IDbContextFactory contextFactory) 217 | { 218 | _context = new Lazy(contextFactory.CreateDbContext); 219 | } 220 | ``` 221 | 222 | Because `_context` is now lazy we'll need to adjust our instance methods to use the `.Value` property 223 | 224 | ```c# 225 | public Task SaveChangesAsync() => _context.Value.SaveChangesAsync(); 226 | public DbSet Set() where T : class => _context.Value.Set(); 227 | public DbSet Set(Func> action) where T : class 228 | => action.Invoke(_context.Value); 229 | ``` 230 | 231 | and we'll also need to dispose because that's the whole reason we are going through this trouble 232 | 233 | ```c# 234 | public void Dispose() 235 | { 236 | if (_context.IsValueCreated) _context.Value.Dispose(); 237 | } 238 | ``` 239 | 240 | Assuming we adjust our container properly we shouldn't have to adjust any of our code. A more complete example might 241 | toss the `Lazy` altogether and reduce our allocations even further. 242 | 243 | ## Pros and Cons 244 | 245 | Obviously this usage isn't for everyone. But I find by restricting the surface of `DbContext` on commands plus enhancing 246 | `IQueryable` with our tags and automatic `AsNoTracking` it produces more consistent code. 247 | 248 | ### Pros 249 | 250 | - Automatic tagging of all queries with member name, source file and line number for debugging in SQL tools. 251 | - Automatic disabling of tracking information in code paths that are query only. 252 | - No temptation to include commands in query only code path. You must be explicit with the command repository. 253 | - Command repositories have enforced standard code paths. E.g. without adding `Add` method to the repository interface 254 | developers will need to be consistent and use the `DbSet` implementation. 255 | - We aren't hiding `DbSet` or `IQueryable` allowing the devs to work with EF in an optimal fashion and not have to 256 | write a ton of boiler plate `Get`, `GetAll`, `GetById`, etc implementations of your typical `IRepository` 257 | implementation around EF. 258 | 259 | ### Cons 260 | 261 | - As you need more functionality exposed from the `DbContext` you may find yourself expanding out the interfaces. 262 | Ideally you can leave these two interfaces slim and add new services when needing to work with underlying 263 | `DbContext` details. 264 | - If you are used to using `IRepository` because it makes mocking your data access easier than mocking out EF's 265 | objects this won't help you as you'll still need to mock `DbSet`. 266 | - You look like a crazy person for wrapping a perfectly good repository pattern. 267 | 268 | ## Example Code Notes 269 | 270 | The code in the repository is configured using Lamar as a container with a SQLite backend to demonstrate what would be 271 | closer to real world usage. It also expands upon the repositories to include a `Set` and `Query` method that accept a 272 | lambda to allow code such as `Query(i => i.Blogs)` for better discoverability of the context's `DbSet` members. 273 | 274 | To demonstrate the `IDbContextFactory` we are targeting EF Core 5 preview bits which require .NET Standard 2.1. The CQRS 275 | portion works fine in EF Core 3 as long as you rework the constructor to take a `DbContext` directly. 276 | -------------------------------------------------------------------------------- /input/posts/2021/12/optimizing-your-powershell-load-times.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Optimizing Your PowerShell Load Times 3 | description: 'Speed up your time from launching PowerShell to seeing C:\' 4 | date: 2021-12-11 5 | tags: [] 6 | --- 7 | 8 | Sometimes I just want to drop into my shell and get things done without a GUI or any other things slowing me down. I love PowerShell and Windows Terminal, but the idea I'm hoping into speedy power-mode is dashed when I sit and watch an empty screen load things up and a little message telling me how slow it is. 9 | 10 | Not that PowerShell is at fault here. These performance issues are self-inflected, of course. I have multiple tools running to make my experience better, and with tools loading comes load times. Right now my PowerShell profile requires the following tools, and at this point I'm not sure I can live without them. 11 | 12 | - [oh-my-posh](https://ohmyposh.dev/). Prompt theming. A must have. 13 | - [posh-git](https://github.com/dahlbyk/posh-git). Enhancements for git in PowerShell 14 | - [PSReadLine](https://github.com/PowerShell/PSReadLine). Improvements for the command line editing experience. 15 | - [Visual Studio Developer Command Prompt and Developer PowerShell](https://docs.microsoft.com/en-us/visualstudio/ide/reference/command-prompt-powershell?view=vs-2022). Adds common Visual Studio paths and environmental variables to the path. 16 | - [ZLocation](https://github.com/vors/ZLocation). Tracks most common directories used and let's you quickly jump to them with the `z` command. 17 | - [Chocolatey](https://chocolatey.org/). Package manager. 18 | 19 | Each tool I can't live without, but each tool needs to be imported each time I launch a new shell. It's not the end of the world, but clicking that button and seeing a blank screen followed by this doesn't sit right with me 20 | 21 | ``` 22 | PowerShell 7.2.0 23 | Copyright (c) Microsoft Corporation. 24 | 25 | https://aka.ms/powershell 26 | Type 'help' to get help. 27 | 28 | Loading personal and system profiles took 1234ms. 29 | ``` 30 | 31 | We can make this faster. 32 | 33 | ## Our Profile 34 | 35 | I'm going to assume you are already familiar with [PowerShell profiles](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles?view=powershell-7.2). For my purposes I do everything in the "Current user, Current host" profile which you can edit by running `notepad $PROFILE`. 36 | 37 | Here's my profile as a starting point for our performance tuning. Most of it is pretty straight forward and copied right from the different modules' getting started guide. The first line might not make sense at first, but it's forcing the console to be UTF8. This allows our console apps to use advanced unicode output e.g. rocket ship emojis. 38 | 39 | ```powershell 40 | [console]::InputEncoding = [console]::OutputEncoding = New-Object System.Text.UTF8Encoding 41 | 42 | # Chocolatey profile 43 | $ChocolateyProfile = "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1" 44 | Import-Module "$ChocolateyProfile" 45 | 46 | # posh-git 47 | Import-Module posh-git 48 | $env:POSH_GIT_ENABLED = $true 49 | 50 | # oh-my-posh 51 | Import-Module oh-my-posh 52 | set-poshprompt C:\Users\phils\hotstick.omp.json 53 | $DefaultUser = 'phils' 54 | Enable-Poshtooltips 55 | 56 | # Chocolatey profile 57 | Import-Module ZLocation 58 | 59 | # PSReadLine 60 | Import-Module PSReadLine 61 | 62 | # VS2022 environmental variables 63 | Import-Module "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" 64 | Enter-VsDevShell d0d15d66 -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64" | Out-Null 65 | 66 | ``` 67 | 68 | The last section where I load the Visual Studio Command Line. I got this script from reverse engineering the profile that is automatically added to Windows Terminal. Depending on your version of Visual Studio it may (and almost certainly will) be different. Alternately, you can use [this gist that will search for the latest VS version and use it](https://gist.github.com/ArthurHNL/b29f008cbc7a159a6ae82a7152b1cb2a). I've found it adds even more time so I'm ok with hard coding the path and other script values. The very last line redirects the output to `Out-Null` simply because I don't want the big default loading banner. 69 | 70 | While it is a simple profile, launching it results in a blank screen followed by PowerShell telling me that my profile loaded in over a second. To me, that's pretty darn slow, especially on modern hardware. 71 | 72 | ## Finding Out What's So Darn Slow 73 | 74 | So let's find out what the hold up is. First things first, I want to create a new Windows Terminal Profile for launching PowerShell without a profile. We are going to do some measuring of our script speed and it is critical nothing is loaded when we do so. To do so create a new profile and set the command line to `pwsh.exe -noProfile`. 75 | 76 | ![Windows Terminal PowerShell no Profile](2021-12-12-21-13-58.png) 77 | 78 | We need to do this because we are going to use a profiling tool to measure the script line-by-line. The tool I like to use is [PSProfiler](https://github.com/IISResetMe/PSProfiler). I personally use the `1.0.5-preview1` release which includes a a nice feature for highlighting the slowest lines. 79 | 80 | First we need to install the module by running 81 | 82 | ```powershell 83 | Install-Module -Name PSProfiler -AllowPrerelease 84 | ``` 85 | 86 | Because we won't be using this all the time I won't include this in my PowerShell profile, but I'll instead run `Import-Module PSProfiler` when needed. 87 | 88 | Once this is installed, start a new terminal tab using our "PowerShell No Profile" option. This should launch in a flash giving us a glimmer of hope of what we can accomplish. We have nothing loaded yet which means PSProfiler will get a true measurement of the time it takes to import all our modules. To do so we run the following command 89 | 90 | ```powershell 91 | Import-Module PSProfiler 92 | Measure-Script -Path $PROFILE -Top 5 93 | ``` 94 | 95 | This script will launch our profile and measure each line's performance. With the `-Top` command it'll also go ahead and highlight the top 5 slowest lines. 96 | 97 | ![PSProfiler results](2021-12-12-21-28-41.png) 98 | 99 | Taking a look at my script's performance we can see that while posh-git and zlocation are both taking over 100ms each, it's loading up the Visual Studio Shell that's really eating up some time. It's over half a second by itself. Unfortunately that half a second is going to be the fastest we can run our profile. It's a single line and we have to live with it. 100 | 101 | But, there is good news. It's actions don't impact the other modules. We can load it in parallel while the other items load too. The way we can do this is via [`Start-ThreadJob`](https://docs.microsoft.com/en-us/powershell/module/threadjob/start-threadjob?view=powershell-7.2). This will create a background-job we can use for loading this module. The code for this will be 102 | 103 | ```powershell 104 | $vsshell = Start-ThreadJob { 105 | Import-Module "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" 106 | Enter-VsDevShell d0d15d66 -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64" | Out-Null 107 | } 108 | ``` 109 | 110 | This code should be at very top of our script before any other modules run. It needs to launch immediately. The rest of our script stays the same and they run side-by-side while `Enter-VsDevShell` does it's thing. But, because the VS shell script is so slow, the other items will complete before our background job. That means before we exit our profile we need to tell our script to wait for our background job to complete. To do so we run 111 | 112 | ```powershell 113 | Receive-Job $vsshell -Wait -AutoRemoveJob 114 | ``` 115 | 116 | This uses the variable we stored our job to tell powershell to wait for it to complete and go ahead and remove it too. This should be the last thing our script does. 117 | 118 | We can save our profile and relaunch a new PowerShell instance. It still won't be as fast as running with no profile, but with any luck it should be around a half second or faster now as we are able to load everything else while `Enter-VsDevShell` is running. On my machine, this shaves around 300ms as everything completes and waits 200ms additional seconds for the VS shell to be built. This gets me below 500ms pretty consistently which I can live with. 119 | 120 | ## Adding Visual Feedback 121 | 122 | Since we are stuck with half-second load time there is another trick we can do to at least make it seem like it's loading fast - display stuff. Seeing the screen do something is at least better than blank screen, so let's add some feedback. The pattern I've landed on is displaying "Loading _module name_" followed by a check mark once it is loaded. To do this I created a function to keep it consistent. 123 | 124 | ```powershell 125 | function Run-Step([string] $Description, [ScriptBlock]$script) 126 | { 127 | Write-Host -NoNewline "Loading " $Description.PadRight(20) 128 | & $script 129 | Write-Host "`u{2705}" # checkmark emoji 130 | } 131 | ``` 132 | 133 | This function takes a description that I want to display and a script block. It displays my text, runs the script and finally writes an emoji checkmark. An example call would be 134 | 135 | ```powershell 136 | Run-Step "Posh-Git" { 137 | Import-Module posh-git 138 | $env:POSH_GIT_ENABLED = $true 139 | } 140 | ``` 141 | 142 | Keep in mind, however, that we are running two threads here. I keep all the UI off the background thread that's loading the dev shell stuff. I wrap the `Receive-Job` in the `Run-Step` command. This has the added benefit of about every 100ms a new line is printed to the screen with the final one being the completion of the VS Shell import. 143 | 144 | I do a few other things to keep things moving fast. I include the `-noLogo` switch for my PowerShell profile in Windows Terminal to hide the default logo display and use my own. This has the added benefit of not checking for a new version of PowerShell which does speed things up a tad. Since we won't have the typical PowerShell banner, I roll my own. 145 | 146 | This results in our final script. 147 | 148 | ```powershell 149 | function Run-Step([string] $Description, [ScriptBlock]$script) 150 | { 151 | Write-Host -NoNewline "Loading " $Description.PadRight(20) 152 | & $script 153 | Write-Host "`u{2705}" # checkmark emoji 154 | } 155 | 156 | [console]::InputEncoding = [console]::OutputEncoding = New-Object System.Text.UTF8Encoding 157 | 158 | Write-Host "Loading PowerShell $($PSVersionTable.PSVersion)..." -ForegroundColor 3 159 | Write-Host 160 | 161 | # this takes a sec, but we can also do it concurrently with the rest of the profile loading 162 | $vsshell = Start-ThreadJob { 163 | Import-Module "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" 164 | Enter-VsDevShell d0d15d66 -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64" | Out-Null 165 | } 166 | 167 | # Chocolatey profile 168 | Run-Step "Chocolatey" { 169 | $ChocolateyProfile = "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1" 170 | Import-Module "$ChocolateyProfile" 171 | } 172 | 173 | # posh-git 174 | Run-Step "Posh-Git" { 175 | Import-Module posh-git 176 | $env:POSH_GIT_ENABLED = $true 177 | } 178 | 179 | # oh-my-posh 180 | Run-Step "oh-my-posh" { 181 | Import-Module oh-my-posh 182 | set-poshprompt C:\Users\phils\hotstick.omp.json 183 | $DefaultUser = 'phils' 184 | Enable-Poshtooltips 185 | } 186 | 187 | # Chocolatey profile 188 | Run-Step "ZLocation" { 189 | Import-Module ZLocation 190 | } 191 | 192 | Run-Step "PSReadline" { 193 | Import-Module PSReadLine 194 | } 195 | 196 | # we already started this task earlier, just gotta receive it now 197 | Run-Step "VS2022 Shell" { 198 | Receive-Job $vsshell -Wait -AutoRemoveJob 199 | } 200 | ``` 201 | 202 | ![Final Powershell prompt](full-loading.gif) 203 | 204 | ## Really Stretching for Performance 205 | 206 | If you are still seeing things go slow, there is one last thing to try - working around Windows Defender. We are loading quite a bit of code on start up, many times from locations that defender hasn't scanned recently. This can add some serious delays. Run your script with it enabled a few times to get a baseline, then disable it and run it a few more times. This will give you an idea if it is the culprit. 207 | 208 | I'm not going to provide a recipe for how to speed it up, but I've had good luck using [Process Monitor](https://docs.microsoft.com/en-us/sysinternals/downloads/procmon) and adding filtering on the `MsMpEng.exe` process. From there I go through the paths that show up frequently and just make a judgement call on whether or not they should be hit that often. 209 | 210 | Personally, I do not like to add processes to my exclusion list. The idea of adding `pwsh.exe` as something that Defender is ignoring is a step too far for me. I want that process monitored. But my personal comfort level of safety I do feel ok adding paths like `C:\Program Files\PowerShell` to the exclusion list. By filtering out folders that I feel other pieces of Windows should be keeping safe, I can find a good middle ground between performance and security that works for me. 211 | -------------------------------------------------------------------------------- /input/book.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = null; 3 | } 4 | @inherits StatiqRazorPage 5 | 6 | 7 | 8 | 9 | @Document.GetString("Title") 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 327 | 328 | @{ 329 | var allChildren = Document.GetChildren().ToArray(); 330 | 331 | } 332 | 333 | 334 |
335 |
336 |

Phil Scott

337 |

The Computer Files (Unexpurgated)

338 |
339 |
340 | 341 |

Table of Contents

342 |
343 |
    344 | @foreach (var doc in allChildren) 345 | { 346 | IReadOnlyList headings = doc 347 | .GetDocumentList(Keys.Headings) 348 | .ToList(); 349 |
  1. 350 |
    351 | @doc.GetString("Title") 352 | Page 353 |
    354 | @if (headings?.Count > 0) 355 | { 356 |
      357 | @foreach (var heading in headings) 358 | { 359 |
    1. 360 |
      361 | @(await heading.GetContentStringAsync()) 362 | Page 363 |
      364 |
    2. 365 | } 366 |
    367 | } 368 |
  2. 369 | } 370 |
371 |
372 | 373 | @foreach (var doc in allChildren) 374 | { 375 | { 376 | var content = await doc.GetContentStringAsync(); 377 |
378 |
379 |

@doc.GetString("Title")

380 |

Originally published @doc.GetDateTime("date").ToLongDateString()

381 |
382 | @Html.Raw(content) 383 |
384 | } 385 | } 386 | 387 | 402 | 403 | 404 | -------------------------------------------------------------------------------- /Statiq/SocialCard.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Thirty25.Statiq.SocialCardModel 3 | 4 | @{ 5 | Layout = null; 6 | } 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 101 | 102 | 103 |
104 | 105 |

@Model.Title

106 |

@Model.Description

107 |
108 |
109 |
    110 | @{ var tags = Model.Tags?.Split(";") ?? Array.Empty(); } 111 | @foreach (var tag in tags) 112 | { 113 |
  • @tag
  • 114 | } 115 |
116 |
117 |
118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 |
223 |
224 | 225 | 226 | --------------------------------------------------------------------------------