diff --git a/Directory.Packages.props b/Directory.Packages.props index aa764771..46a46771 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,4 +41,9 @@ + + + + + diff --git a/LinkDotNet.Blog.sln b/LinkDotNet.Blog.sln index b730cfc6..9146ab35 100644 --- a/LinkDotNet.Blog.sln +++ b/LinkDotNet.Blog.sln @@ -35,6 +35,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CAD2F4A3 tests\.editorconfig = tests\.editorconfig EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{A931171C-22A6-4DB5-802B-67286B536BD2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LinkDotNet.Blog.CriticalCSS", "tools\LinkDotNet.Blog.CriticalCSS\LinkDotNet.Blog.CriticalCSS.csproj", "{8CB83177-C078-4953-BC27-8968D2A6E0FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -65,6 +69,10 @@ Global {310ABEE1-C131-43E6-A759-F2DB75A483DD}.Debug|Any CPU.Build.0 = Debug|Any CPU {310ABEE1-C131-43E6-A759-F2DB75A483DD}.Release|Any CPU.ActiveCfg = Release|Any CPU {310ABEE1-C131-43E6-A759-F2DB75A483DD}.Release|Any CPU.Build.0 = Release|Any CPU + {8CB83177-C078-4953-BC27-8968D2A6E0FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CB83177-C078-4953-BC27-8968D2A6E0FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CB83177-C078-4953-BC27-8968D2A6E0FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CB83177-C078-4953-BC27-8968D2A6E0FE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -79,5 +87,6 @@ Global {DEFDA17A-9586-4E50-83FB-8F75AC29D39A} = {CAD2F4A3-1282-49B5-B0AB-655CDBED0A35} {310ABEE1-C131-43E6-A759-F2DB75A483DD} = {CAD2F4A3-1282-49B5-B0AB-655CDBED0A35} {5B868911-7C93-4190-AEE4-3A6694F2FFCE} = {CAD2F4A3-1282-49B5-B0AB-655CDBED0A35} + {8CB83177-C078-4953-BC27-8968D2A6E0FE} = {A931171C-22A6-4DB5-802B-67286B536BD2} EndGlobalSection EndGlobal diff --git a/docs/Features/AdvancedFeatures.md b/docs/Features/AdvancedFeatures.md index 3f8557fb..2be0be2f 100644 --- a/docs/Features/AdvancedFeatures.md +++ b/docs/Features/AdvancedFeatures.md @@ -2,20 +2,20 @@ This page lists some of the more advanced or less-used features of the blog software. -### Shortcodes +## Shortcodes Shortcodes are markdown content that can be shown inside blog posts (like templates that can be referenced). The idea is to reuse certain shortcodes across various blog posts. If you update the shortcode, it will be updated across all those blog posts as well. For example if you have a running promotion you can add a shortcode and link it in various blog posts. Updating the shortcode (for example that it is almost sold out) will update all blog posts that reference this shortcode. -#### Creating a shortcode +### Creating a shortcode To create a shortcode, click on "Shortcodes" in the Admin tab of the navigation bar. You can create a shortcode by adding a name in the top row and the markdown content in the editor. Clicking on an already existing shortcode will allow you to either edit the shortcode or delete it. Currently, deleting a shortcode will leave the shortcode name inside the blogpost. Therefore only delete shortcodes if you are sure that they are not used anymore. -#### Using a shortcode +### Using a shortcode There are two ways: 1. If you know the shortcode name, just type in `[[SHORTCODENAME]]` where `SHORTCODENAME` is the name you gave the shortcode. 2. Click on the more button in the editor and select "Shortcodes". This will open a dialog where you can select the shortcode you want to insert and puts it into the clipboard. @@ -26,4 +26,88 @@ Shortcodes * are not part of the table of contents even though they might have headers. * are not part of the reading time calculation. * are only available in the content section of a blog post and not the description. - * are currently only copied to the clipboard and not inserted directly into the editor at the cursor position. \ No newline at end of file + * are currently only copied to the clipboard and not inserted directly into the editor at the cursor position. + +## Critical CSS Generator + +The Critical CSS Generator is a tool that extracts the minimal CSS required for rendering the above-the-fold content of the blog. This optimization improves the initial page load performance by reducing render-blocking CSS. + +### How it works + +The generator: + +1. Starts a test instance of the blog +2. Visits the homepage and a sample blog post +3. Extracts the critical CSS using Playwright +4. Outputs the CSS based on the chosen output + +The generator is under `tools/LinkDotNet.Blog.CriticalCSS`. You can run it from the command line or directly via `dotnet run`. Here an example + +```bash +dotnet run -- --install-playwright -o file -p "critical.css" +``` + +The output of the "critical.css" should be copied into the head of the [`_Layout.cshtml`](../../src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml) file. + +### Options + +| Option | Long Form | Description | Required | Example | +|--------|-----------|-------------|----------|---------| +| `-i` | `--install-playwright` | Installs required Playwright dependencies | No | `--install-playwright` | +| `-o` | `--output` | Output mode: `console`, `file`, or `layout` | Yes | `--output console` | +| `-p` | `--path` | File path for `file` or `layout` output modes | Yes* | `--path styles.css` | +| `-h` | `--help` | Shows help information | No | `--help` | + +*Required when using `file` or `layout` output modes + +## Output Modes + +### #Console Mode +Outputs the critical CSS directly to the console: + +```sh +dotnet run -- --output console +``` + +#### File Mode +Saves the critical CSS to a new file: + +```sh +dotnet run -- --output file --path critical.css +``` + +#### Layout Mode +Injects or updates the critical CSS in your layout file: + +```sh +dotnet run -- --output layout --path ./Pages/Shared/_Layout.cshtml +``` + +### Examples + +1. Install Playwright and output to console: +```sh +dotnet run -- --install-playwright --output console +``` + +2. Save critical CSS to a file: +```sh +dotnet run -- --output file --path styles.css +``` + +3. Update layout file with critical CSS: +```sh +dotnet run -- --output layout --path _Layout.cshtml +``` + +4. Show help information: +```sh +dotnet run -- --help +``` + +### Notes + +- The tool requires an internet connection for Playwright installation +- The generated CSS is minified for optimal performance +- When using layout mode, existing `"; + + return styleTag; + } +} diff --git a/tools/LinkDotNet.Blog.CriticalCSS/LinkDotNet.Blog.CriticalCSS.csproj b/tools/LinkDotNet.Blog.CriticalCSS/LinkDotNet.Blog.CriticalCSS.csproj new file mode 100644 index 00000000..8850032a --- /dev/null +++ b/tools/LinkDotNet.Blog.CriticalCSS/LinkDotNet.Blog.CriticalCSS.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/tools/LinkDotNet.Blog.CriticalCSS/PlaywrightWebApplicationFactory.cs b/tools/LinkDotNet.Blog.CriticalCSS/PlaywrightWebApplicationFactory.cs new file mode 100644 index 00000000..39292291 --- /dev/null +++ b/tools/LinkDotNet.Blog.CriticalCSS/PlaywrightWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using LinkDotNet.Blog.Infrastructure.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LinkDotNet.Blog.CriticalCSS; + +internal sealed class PlaywrightWebApplicationFactory : WebApplicationFactory +{ + private IHost? host; + + public string ServerAddress => ClientOptions.BaseAddress.ToString(); + + public override IServiceProvider Services => host?.Services + ?? throw new InvalidOperationException("Create the Client first before retrieving instances from the container"); + + protected override IHost CreateHost(IHostBuilder builder) + { + var testHost = builder.Build(); + + builder = builder.ConfigureWebHost(b => + { + b.UseSetting("PersistenceProvider", PersistenceProvider.Sqlite.Key); + b.UseSetting("ConnectionString", "DataSource=file::memory:?cache=shared"); + b.UseSetting("Logging:LogLevel:Default", "Error"); + b.UseKestrel(); + }); + + host?.Dispose(); + host = builder.Build(); + host.Start(); + + var server = host!.Services.GetRequiredService(); + var addresses = server.Features.Get(); + + ClientOptions.BaseAddress = addresses!.Addresses + .Select(x => new Uri(x)) + .Last(); + + testHost.Start(); + return testHost; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + host?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/tools/LinkDotNet.Blog.CriticalCSS/Program.cs b/tools/LinkDotNet.Blog.CriticalCSS/Program.cs new file mode 100644 index 00000000..bf62155c --- /dev/null +++ b/tools/LinkDotNet.Blog.CriticalCSS/Program.cs @@ -0,0 +1,136 @@ +using System.Text.RegularExpressions; +using CommandLine; +using LinkDotNet.Blog.CriticalCSS; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.TestUtilities; +using Microsoft.Extensions.DependencyInjection; + +return await Parser.Default.ParseArguments(args) + .MapResult( + async opts => await RunWithOptions(opts), + _ => Task.FromResult(1)); + +static async Task RunWithOptions(CommandLineOptions options) +{ + if (options.Help) + { + ShowHelp(); + return 0; + } + + if (options.InstallPlaywright) + { + InstallPlaywrightDependencies(); + } + + var css = await ExtractCriticalCss(); + + if (options.OutputMode is null) + { + throw new InvalidOperationException("The OutputMode is required."); + } + + if (options.OutputMode.Equals("console", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(css); + } + else if (options.OutputMode.Equals("file", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(options.FilePath)) + { + OutputToFile(css, options.FilePath); + } + else if (options.OutputMode.Equals("layout", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(options.FilePath)) + { + OutputToLayout(css, options.FilePath); + } + else + { + await Console.Error.WriteLineAsync( + "Invalid output mode or missing file path. Use --help for usage information."); + return 1; + } + + return 0; +} + +static void InstallPlaywrightDependencies() => Microsoft.Playwright.Program.Main(["install"]); + +static async Task ExtractCriticalCss() +{ + var factory = new PlaywrightWebApplicationFactory(); + using var httpClient = factory.CreateClient(); + var blogPost = await SaveBlogPostAsync(factory.Services); + + List urls = + [ + $"{factory.ServerAddress}", + $"{factory.ServerAddress}blogPost/{blogPost.Id}", + ]; + + var criticalCss = await CriticalCssGenerator.GenerateAsync(urls); + await factory.DisposeAsync(); + return criticalCss; + + static async Task SaveBlogPostAsync(IServiceProvider services) + { + using var scope = services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService>(); + var blogPost = new BlogPostBuilder() + .WithTitle("Test") + .WithShortDescription("**Test**") + .WithContent(""" + > Quote + ```csharp + public async internal void Test() + { + await Task.Delay(100); + } + ``` + """) + .Build(); + await repository.StoreAsync(blogPost); + return blogPost; + } +} + +static void OutputToFile(string css, string? outputPath) +{ + ArgumentException.ThrowIfNullOrEmpty(css); + ArgumentException.ThrowIfNullOrEmpty(outputPath); + + File.WriteAllText(outputPath, css); +} + +static void OutputToLayout(string css, string? layoutPath) +{ + ArgumentException.ThrowIfNullOrEmpty(css); + ArgumentException.ThrowIfNullOrEmpty(layoutPath); + + var layoutContent = File.ReadAllText(layoutPath); + const string styleTagPattern = "]*>.*?"; + const string headEndTag = ""; + + var newStyleTag = $""; + + layoutContent = Regex.IsMatch(layoutContent, styleTagPattern, RegexOptions.Singleline) + ? Regex.Replace(layoutContent, styleTagPattern, newStyleTag, RegexOptions.Singleline) + : layoutContent.Replace(headEndTag, $"{newStyleTag}\n {headEndTag}", StringComparison.OrdinalIgnoreCase); + + File.WriteAllText(layoutPath, layoutContent); +} + +static void ShowHelp() +{ + Console.WriteLine("Critical CSS Extractor"); + Console.WriteLine("Usage: criticalcss [options]"); + Console.WriteLine("\nOptions:"); + Console.WriteLine(" -i, --install-playwright Install Playwright dependencies"); + Console.WriteLine(" -o, --output Output mode: console, file, or layout (required)"); + Console.WriteLine(" -p, --path File path when using file or layout output mode"); + Console.WriteLine(" -h, --help Show this help message"); + Console.WriteLine("\nExamples:"); + Console.WriteLine(" criticalcss --output console"); + Console.WriteLine(" criticalcss --output file --path styles.css"); + Console.WriteLine(" criticalcss --output layout --path _Layout.cshtml"); + Console.WriteLine(" criticalcss --install-playwright --output console"); +} diff --git a/tools/LinkDotNet.Blog.CriticalCSS/README.md b/tools/LinkDotNet.Blog.CriticalCSS/README.md new file mode 100644 index 00000000..aa9548ad --- /dev/null +++ b/tools/LinkDotNet.Blog.CriticalCSS/README.md @@ -0,0 +1,22 @@ +# Critical CSS Generator + +Critical CSS is the minimal set of CSS required to render the initial view of a webpage. Extracting critical CSS improves page load performance by: + +* Reducing render-blocking CSS +* Improving First Contentful Paint (FCP) +* Reducing the initial page load size + +## Usage +You can run the tool from the command line or directly via `dotnet run` + +```bash +dotnet run -- --install-playwright --output console +``` + +This will install a `Chromium` driver for `Playwright` and output the critical CSS to the console. + +For help, run: + +```bash +dotnet run -- --help +``` \ No newline at end of file