Skip to content

Commit 71b1b59

Browse files
committed
feat: added css critical section generator as tool
1 parent 1e73727 commit 71b1b59

11 files changed

+453
-5
lines changed

Diff for: Directory.Packages.props

+5
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,9 @@
4141
<PackageVersion Include="xunit.v3" Version="0.6.0-pre.7" />
4242
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.0-pre.49" />
4343
</ItemGroup>
44+
45+
<ItemGroup Label="Tools">
46+
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
47+
<PackageVersion Include="Microsoft.Playwright" Version="1.48.0" />
48+
</ItemGroup>
4449
</Project>

Diff for: LinkDotNet.Blog.sln

+9
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CAD2F4A3
3535
tests\.editorconfig = tests\.editorconfig
3636
EndProjectSection
3737
EndProject
38+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{A931171C-22A6-4DB5-802B-67286B536BD2}"
39+
EndProject
40+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LinkDotNet.Blog.CriticalCSS", "tools\LinkDotNet.Blog.CriticalCSS\LinkDotNet.Blog.CriticalCSS.csproj", "{8CB83177-C078-4953-BC27-8968D2A6E0FE}"
41+
EndProject
3842
Global
3943
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4044
Debug|Any CPU = Debug|Any CPU
@@ -65,6 +69,10 @@ Global
6569
{310ABEE1-C131-43E6-A759-F2DB75A483DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
6670
{310ABEE1-C131-43E6-A759-F2DB75A483DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
6771
{310ABEE1-C131-43E6-A759-F2DB75A483DD}.Release|Any CPU.Build.0 = Release|Any CPU
72+
{8CB83177-C078-4953-BC27-8968D2A6E0FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
73+
{8CB83177-C078-4953-BC27-8968D2A6E0FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
74+
{8CB83177-C078-4953-BC27-8968D2A6E0FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
75+
{8CB83177-C078-4953-BC27-8968D2A6E0FE}.Release|Any CPU.Build.0 = Release|Any CPU
6876
EndGlobalSection
6977
GlobalSection(SolutionProperties) = preSolution
7078
HideSolutionNode = FALSE
@@ -79,5 +87,6 @@ Global
7987
{DEFDA17A-9586-4E50-83FB-8F75AC29D39A} = {CAD2F4A3-1282-49B5-B0AB-655CDBED0A35}
8088
{310ABEE1-C131-43E6-A759-F2DB75A483DD} = {CAD2F4A3-1282-49B5-B0AB-655CDBED0A35}
8189
{5B868911-7C93-4190-AEE4-3A6694F2FFCE} = {CAD2F4A3-1282-49B5-B0AB-655CDBED0A35}
90+
{8CB83177-C078-4953-BC27-8968D2A6E0FE} = {A931171C-22A6-4DB5-802B-67286B536BD2}
8291
EndGlobalSection
8392
EndGlobal

Diff for: docs/Features/AdvancedFeatures.md

+88-4
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@
22

33
This page lists some of the more advanced or less-used features of the blog software.
44

5-
### Shortcodes
5+
## Shortcodes
66
Shortcodes are markdown content that can be shown inside blog posts (like templates that can be referenced).
77
The idea is to reuse certain shortcodes across various blog posts.
88
If you update the shortcode, it will be updated across all those blog posts as well.
99

1010
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.
1111

12-
#### Creating a shortcode
12+
### Creating a shortcode
1313

1414
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.
1515

1616
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.
1717

18-
#### Using a shortcode
18+
### Using a shortcode
1919
There are two ways:
2020
1. If you know the shortcode name, just type in `[[SHORTCODENAME]]` where `SHORTCODENAME` is the name you gave the shortcode.
2121
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
2626
* are not part of the table of contents even though they might have headers.
2727
* are not part of the reading time calculation.
2828
* are only available in the content section of a blog post and not the description.
29-
* are currently only copied to the clipboard and not inserted directly into the editor at the cursor position.
29+
* are currently only copied to the clipboard and not inserted directly into the editor at the cursor position.
30+
31+
## Critical CSS Generator
32+
33+
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.
34+
35+
### How it works
36+
37+
The generator:
38+
39+
1. Starts a test instance of the blog
40+
2. Visits the homepage and a sample blog post
41+
3. Extracts the critical CSS using Playwright
42+
4. Outputs the CSS based on the chosen output
43+
44+
The generator is under `tools/LinkDotNet.Blog.CriticalCSS`. You can run it from the command line or directly via `dotnet run`. Here an example
45+
46+
```bash
47+
dotnet run -- --install-playwright -o file -p "critical.css"
48+
```
49+
50+
The output of the "critical.css" should be copied into the head of the [`_Layout.cshtml`](../../src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml) file.
51+
52+
### Options
53+
54+
| Option | Long Form | Description | Required | Example |
55+
|--------|-----------|-------------|----------|---------|
56+
| `-i` | `--install-playwright` | Installs required Playwright dependencies | No | `--install-playwright` |
57+
| `-o` | `--output` | Output mode: `console`, `file`, or `layout` | Yes | `--output console` |
58+
| `-p` | `--path` | File path for `file` or `layout` output modes | Yes* | `--path styles.css` |
59+
| `-h` | `--help` | Shows help information | No | `--help` |
60+
61+
*Required when using `file` or `layout` output modes
62+
63+
## Output Modes
64+
65+
### #Console Mode
66+
Outputs the critical CSS directly to the console:
67+
68+
```sh
69+
dotnet run -- --output console
70+
```
71+
72+
#### File Mode
73+
Saves the critical CSS to a new file:
74+
75+
```sh
76+
dotnet run -- --output file --path critical.css
77+
```
78+
79+
#### Layout Mode
80+
Injects or updates the critical CSS in your layout file:
81+
82+
```sh
83+
dotnet run -- --output layout --path ./Pages/Shared/_Layout.cshtml
84+
```
85+
86+
### Examples
87+
88+
1. Install Playwright and output to console:
89+
```sh
90+
dotnet run -- --install-playwright --output console
91+
```
92+
93+
2. Save critical CSS to a file:
94+
```sh
95+
dotnet run -- --output file --path styles.css
96+
```
97+
98+
3. Update layout file with critical CSS:
99+
```sh
100+
dotnet run -- --output layout --path _Layout.cshtml
101+
```
102+
103+
4. Show help information:
104+
```sh
105+
dotnet run -- --help
106+
```
107+
108+
### Notes
109+
110+
- The tool requires an internet connection for Playwright installation
111+
- The generated CSS is minified for optimal performance
112+
- When using layout mode, existing `<style>` tags will be replaced
113+
- If no `<style>` tag exists in layout mode, it will be inserted before `</head>`

Diff for: docs/SEO/Readme.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,7 @@ This blog also offers an RSS feed ([RSS 2.0 specification](https://validator.w3.
3434

3535
### Sitemap
3636

37-
This blog offers to generate a [sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) that lists all blog posts, the archive and pages of the blog. A sitemap can be generated in the Admin tab of the navigation bar under "Sitemap". This allows, especially new sites that don't have many inbound links, to be indexed easier by search engines.
37+
This blog offers to generate a [sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) that lists all blog posts, the archive and pages of the blog. A sitemap can be generated in the Admin tab of the navigation bar under "Sitemap". This allows, especially new sites that don't have many inbound links, to be indexed easier by search engines.
38+
39+
## Critical CSS
40+
The blog offers an integrated tool, that generates critical CSS for the blog. Read more about it in the ["*Advanced Features*"](../Features/AdvancedFeatures.md) section.

Diff for: tools/Directory.Build.props

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
3+
<PropertyGroup Label="Analyzer settings">
4+
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
5+
<EnableNETAnalyzers>true</EnableNETAnalyzers>
6+
<AnalysisLevel>latest</AnalysisLevel>
7+
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
8+
<NuGetAuditLevel>critical</NuGetAuditLevel>
9+
<Nullable>enable</Nullable>
10+
</PropertyGroup>
11+
12+
<ItemGroup Label="Code Analyzers">
13+
<PackageReference Include="IDisposableAnalyzers" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
14+
</ItemGroup>
15+
16+
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
17+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
18+
</PropertyGroup>
19+
20+
</Project>
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using CommandLine;
2+
3+
namespace LinkDotNet.Blog.CriticalCSS;
4+
5+
public class CommandLineOptions
6+
{
7+
[Option('i', "install-playwright", Required = false,
8+
HelpText = "Install Playwright dependencies")]
9+
public bool InstallPlaywright { get; init; }
10+
11+
[Option('o', "output", Required = false,
12+
HelpText = "Output mode: console, file, or layout")]
13+
public string? OutputMode { get; init; }
14+
15+
[Option('p', "path", Required = false,
16+
HelpText = "File path when using file or layout output mode")]
17+
public string? FilePath { get; init; }
18+
19+
[Option('h', "help", Required = false,
20+
HelpText = "Show help information")]
21+
public bool Help { get; init; }
22+
}

Diff for: tools/LinkDotNet.Blog.CriticalCSS/Generator.cs

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using Microsoft.Playwright;
2+
3+
namespace LinkDotNet.Blog.CriticalCSS;
4+
5+
internal static class CriticalCssGenerator
6+
{
7+
public static async Task<string> GenerateAsync(IReadOnlyCollection<string>urls)
8+
{
9+
using var playwright = await Playwright.CreateAsync();
10+
await using var browser = await playwright.Chromium.LaunchAsync();
11+
var criticalCss = new HashSet<string>();
12+
13+
var viewports = new[]
14+
{
15+
new ViewportSize { Width = 1920, Height = 1080 },
16+
new ViewportSize { Width = 375, Height = 667 }
17+
};
18+
19+
foreach (var viewport in viewports)
20+
{
21+
foreach (var url in urls)
22+
{
23+
var page = await browser.NewPageAsync();
24+
await page.GotoAsync(url);
25+
await page.SetViewportSizeAsync(viewport.Width, viewport.Height);
26+
27+
var usedCss = await page.EvaluateAsync<string[]>(
28+
"""
29+
() => {
30+
const styleSheets = Array.from(document.styleSheets);
31+
const usedRules = new Set();
32+
33+
const viewportHeight = window.innerHeight;
34+
const elements = document.querySelectorAll('*');
35+
const aboveFold = Array.from(elements).filter(el => {
36+
const rect = el.getBoundingClientRect();
37+
return rect.top < viewportHeight;
38+
});
39+
40+
styleSheets.forEach(sheet => {
41+
try {
42+
Array.from(sheet.cssRules).forEach(rule => {
43+
if (rule.type === 1) {
44+
aboveFold.forEach(el => {
45+
if (el.matches(rule.selectorText)) {
46+
usedRules.add(rule.cssText);
47+
}
48+
});
49+
}
50+
});
51+
} catch (e) {
52+
}
53+
});
54+
55+
return Array.from(usedRules);
56+
}
57+
""");
58+
59+
foreach (var css in usedCss)
60+
{
61+
criticalCss.Add(css);
62+
}
63+
}
64+
}
65+
66+
var criticalCssContent = string.Join(string.Empty, criticalCss).Replace("@", "@@", StringComparison.OrdinalIgnoreCase);
67+
var styleTag = $"<style>{criticalCssContent}</style>";
68+
69+
return styleTag;
70+
}
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net9.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\tests\LinkDotNet.Blog.TestUtilities\LinkDotNet.Blog.TestUtilities.csproj" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="CommandLineParser" />
16+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
17+
<PackageReference Include="Microsoft.Playwright" />
18+
</ItemGroup>
19+
20+
</Project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using LinkDotNet.Blog.Infrastructure.Persistence;
2+
using Microsoft.AspNetCore.Hosting;
3+
using Microsoft.AspNetCore.Hosting.Server;
4+
using Microsoft.AspNetCore.Hosting.Server.Features;
5+
using Microsoft.AspNetCore.Mvc.Testing;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Hosting;
8+
9+
namespace LinkDotNet.Blog.CriticalCSS;
10+
11+
internal sealed class PlaywrightWebApplicationFactory : WebApplicationFactory<LinkDotNet.Blog.Web.Program>
12+
{
13+
private IHost? host;
14+
15+
public string ServerAddress => ClientOptions.BaseAddress.ToString();
16+
17+
public override IServiceProvider Services => host?.Services
18+
?? throw new InvalidOperationException("Create the Client first before retrieving instances from the container");
19+
20+
protected override IHost CreateHost(IHostBuilder builder)
21+
{
22+
var testHost = builder.Build();
23+
24+
builder = builder.ConfigureWebHost(b =>
25+
{
26+
b.UseSetting("PersistenceProvider", PersistenceProvider.Sqlite.Key);
27+
b.UseSetting("ConnectionString", "DataSource=file::memory:?cache=shared");
28+
b.UseSetting("Logging:LogLevel:Default", "Error");
29+
b.UseKestrel();
30+
});
31+
32+
host?.Dispose();
33+
host = builder.Build();
34+
host.Start();
35+
36+
var server = host!.Services.GetRequiredService<IServer>();
37+
var addresses = server.Features.Get<IServerAddressesFeature>();
38+
39+
ClientOptions.BaseAddress = addresses!.Addresses
40+
.Select(x => new Uri(x))
41+
.Last();
42+
43+
testHost.Start();
44+
return testHost;
45+
}
46+
47+
protected override void Dispose(bool disposing)
48+
{
49+
if (disposing)
50+
{
51+
host?.Dispose();
52+
}
53+
54+
base.Dispose(disposing);
55+
}
56+
}

0 commit comments

Comments
 (0)