Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added css critical section generator as tool #374

Merged
merged 1 commit into from
Nov 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@
<PackageVersion Include="xunit.v3" Version="0.6.0-pre.7" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.0-pre.49" />
</ItemGroup>

<ItemGroup Label="Tools">
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
<PackageVersion Include="Microsoft.Playwright" Version="1.48.0" />
</ItemGroup>
</Project>
9 changes: 9 additions & 0 deletions LinkDotNet.Blog.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
92 changes: 88 additions & 4 deletions docs/Features/AdvancedFeatures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
* 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 `<style>` tags will be replaced
- If no `<style>` tag exists in layout mode, it will be inserted before `</head>`
5 changes: 4 additions & 1 deletion docs/SEO/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,7 @@ This blog also offers an RSS feed ([RSS 2.0 specification](https://validator.w3.

### Sitemap

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

## Critical CSS
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.
20 changes: 20 additions & 0 deletions tools/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Label="Analyzer settings">
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<NuGetAuditLevel>critical</NuGetAuditLevel>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup Label="Code Analyzers">
<PackageReference Include="IDisposableAnalyzers" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
</ItemGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

</Project>
22 changes: 22 additions & 0 deletions tools/LinkDotNet.Blog.CriticalCSS/CommandLineOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using CommandLine;

namespace LinkDotNet.Blog.CriticalCSS;

public class CommandLineOptions
{
[Option('i', "install-playwright", Required = false,
HelpText = "Install Playwright dependencies")]
public bool InstallPlaywright { get; init; }

[Option('o', "output", Required = false,
HelpText = "Output mode: console, file, or layout")]
public string? OutputMode { get; init; }

[Option('p', "path", Required = false,
HelpText = "File path when using file or layout output mode")]
public string? FilePath { get; init; }

[Option('h', "help", Required = false,
HelpText = "Show help information")]
public bool Help { get; init; }
}
71 changes: 71 additions & 0 deletions tools/LinkDotNet.Blog.CriticalCSS/Generator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Microsoft.Playwright;

namespace LinkDotNet.Blog.CriticalCSS;

internal static class CriticalCssGenerator
{
public static async Task<string> GenerateAsync(IReadOnlyCollection<string>urls)
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var criticalCss = new HashSet<string>();

var viewports = new[]
{
new ViewportSize { Width = 1920, Height = 1080 },
new ViewportSize { Width = 375, Height = 667 }
};

foreach (var viewport in viewports)
{
foreach (var url in urls)
{
var page = await browser.NewPageAsync();
await page.GotoAsync(url);
await page.SetViewportSizeAsync(viewport.Width, viewport.Height);

var usedCss = await page.EvaluateAsync<string[]>(
"""
() => {
const styleSheets = Array.from(document.styleSheets);
const usedRules = new Set();

const viewportHeight = window.innerHeight;
const elements = document.querySelectorAll('*');
const aboveFold = Array.from(elements).filter(el => {
const rect = el.getBoundingClientRect();
return rect.top < viewportHeight;
});

styleSheets.forEach(sheet => {
try {
Array.from(sheet.cssRules).forEach(rule => {
if (rule.type === 1) {
aboveFold.forEach(el => {
if (el.matches(rule.selectorText)) {
usedRules.add(rule.cssText);
}
});
}
});
} catch (e) {
}
});

return Array.from(usedRules);
}
""");

foreach (var css in usedCss)
{
criticalCss.Add(css);
}
}
}

var criticalCssContent = string.Join(string.Empty, criticalCss).Replace("@", "@@", StringComparison.OrdinalIgnoreCase);
var styleTag = $"<style>{criticalCssContent}</style>";

return styleTag;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\tests\LinkDotNet.Blog.TestUtilities\LinkDotNet.Blog.TestUtilities.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="CommandLineParser" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.Playwright" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<LinkDotNet.Blog.Web.Program>
{
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<IServer>();
var addresses = server.Features.Get<IServerAddressesFeature>();

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);
}
}
Loading