Skip to content
Draft
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
## Plugins

- Reading Time
- Social Share Image

### Reading time

Expand All @@ -37,6 +38,56 @@ You can then override the `input/_header.cshtml` of your _theme_ and place the c
<span>~@Model.GetString("ReadingTime") minutes</span>
```

### Social Share Image

**Automatically generate a 1200x630 social share image for each blog post using ImageSharp**

- https://wellsb.com/csharp/aspnet/generate-images-statiq-imagesharp

The plugin generates a PNG image per blog post, saves it to `output/images/social/`, and sets
the `Image` metadata key so that Open Graph / Twitter Card meta tags pick it up automatically.
Processing is skipped for any post that already has an `image` frontmatter property set.

Register the configurator in `Program.cs`:

```csharp
return await Bootstrapper
.Factory
.CreateWeb(args)
.AddConfigurator<SocialImageConfigurator>()
.RunAsync();
```

Optionally, add settings to `appsettings.json`:

```json
{
"BrandText": "My Blog",
"SocialImageFont": "Arial"
}
```

`BrandText` is the text drawn in the lower-left corner of every generated image. If omitted, the
`SiteTitle` setting is used as a fallback. `SocialImageFont` names the system font to use; a set
of common cross-platform fonts is tried automatically when the setting is absent.

You can also configure the module directly when registering it:

```csharp
configurable.ModifyPipeline("Content", p =>
{
p.ProcessModules.Add(
new SocialImageModule()
.WithBrandText("My Blog")
.WithFontFamily("Arial")
.WithBackgroundColor(SixLabors.ImageSharp.Color.FromRgb(30, 30, 30))
.WithTitleColor(SixLabors.ImageSharp.Color.White)
.WithBrandColor(SixLabors.ImageSharp.Color.FromRgb(180, 180, 180))
.WithOutputPath("images/social")
);
});
```

## Docs

- [Docs](docs/README.md)
Expand Down
10 changes: 10 additions & 0 deletions docs/LIBRARIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@
- https://www.nuget.org/packages/HtmlAgilityPack/

`dotnet add package HtmlAgilityPack --version 1.11.72`

- https://github.com/SixLabors/ImageSharp
- https://www.nuget.org/packages/SixLabors.ImageSharp/

`dotnet add package SixLabors.ImageSharp --version 3.1.12`

- https://github.com/SixLabors/ImageSharp.Drawing
- https://www.nuget.org/packages/SixLabors.ImageSharp.Drawing/

`dotnet add package SixLabors.ImageSharp.Drawing --version 2.1.7`
8 changes: 5 additions & 3 deletions sample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.Threading.Tasks;
using Statiq.App;
using Statiq.App;
using Statiq.Plugins;
using Statiq.Web;

return await Bootstrapper
.Factory
.CreateWeb(args)
.RunAsync();
.AddConfigurator(new ReadingTimeConfigurator())
.AddConfigurator(new SocialImageConfigurator())
.RunAsync();
5 changes: 3 additions & 2 deletions sample/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"SiteDescription": "Welcome to my blog!",
"DateTimeInputCulture": "en-GB",
"GenerateSearchIndex": true,
"ReadingSpeed": 100
}
"ReadingSpeed": 100,
"BrandText": "Alex Hedley"
}
23 changes: 23 additions & 0 deletions sample/input/posts/2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Title: 2
Lead: A post without a custom image
Published: 02/01/2023
Tags:
- thoughts
---
# 2

This post has no `Image` frontmatter, so the Social Share Image plugin will
automatically generate a social image for it.

What is Lorem Ipsum?
Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the 1500s,
when an unknown printer took a galley of type and scrambled it to make a type
specimen book. It has survived not only five centuries, but also the leap into
electronic typesetting, remaining essentially unchanged.

Why do we use it?
It is a long established fact that a reader will be distracted by the readable
content of a page when looking at its layout. The point of using Lorem Ipsum is
that it has a more-or-less normal distribution of letters, as opposed to using
'Content here, content here', making it look like readable English.
19 changes: 19 additions & 0 deletions src/Statiq.Plugins/SocialImage/SocialImageConfigurator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Statiq.Plugins;

// https://wellsb.com/csharp/aspnet/generate-images-statiq-imagesharp
/// <summary>
/// Configures the Statiq <c>Bootstrapper</c> to automatically generate social share images
/// for blog posts by injecting <see cref="SocialImageModule"/> into the Content pipeline's
/// Process phase.
/// </summary>
public class SocialImageConfigurator : IConfigurator<Bootstrapper>
{
/// <inheritdoc />
public void Configure(Bootstrapper configurable)
{
configurable.ModifyPipeline("Content", p =>
{
p.ProcessModules.Add(new SocialImageModule());
});
}
}
199 changes: 199 additions & 0 deletions src/Statiq.Plugins/SocialImage/SocialImageModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;

namespace Statiq.Plugins;

// https://wellsb.com/csharp/aspnet/generate-images-statiq-imagesharp
/// <summary>
/// A module that generates a social share image for each blog post.
/// The generated image is saved to the output directory and the document's
/// <c>Image</c> metadata key is updated to point to it.
/// Processing is skipped for any document that already has an <c>Image</c>
/// frontmatter property set.
/// </summary>
public class SocialImageModule : ParallelModule
{
private string _brandText = string.Empty;
private string _fontFamily = string.Empty;
private Color _backgroundColor = Color.FromRgb(30, 30, 30);
private Color _titleColor = Color.White;
private Color _brandColor = Color.FromRgb(180, 180, 180);
private int _width = 1200;
private int _height = 630;
private string _outputPath = "images/social";

/// <summary>Sets the brand text drawn at the bottom of the generated image.</summary>
public SocialImageModule WithBrandText(string brandText)
{
_brandText = brandText;
return this;
}

/// <summary>Sets the font family used for all text in the generated image.</summary>
public SocialImageModule WithFontFamily(string fontFamily)
{
_fontFamily = fontFamily;
return this;
}

/// <summary>Sets the background colour of the generated image.</summary>
public SocialImageModule WithBackgroundColor(Color color)
{
_backgroundColor = color;
return this;
}

/// <summary>Sets the colour used to draw the post title text.</summary>
public SocialImageModule WithTitleColor(Color color)
{
_titleColor = color;
return this;
}

/// <summary>Sets the colour used to draw the brand text.</summary>
public SocialImageModule WithBrandColor(Color color)
{
_brandColor = color;
return this;
}

/// <summary>Sets the dimensions of the generated image (defaults to 1200x630).</summary>
public SocialImageModule WithDimensions(int width, int height)
{
if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width), "Width must be greater than zero.");
if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height), "Height must be greater than zero.");
_width = width;
_height = height;
return this;
}

/// <summary>
/// Sets the output directory path (relative to the site root) where images are saved.
/// Defaults to <c>images/social</c>.
/// </summary>
public SocialImageModule WithOutputPath(string outputPath)
{
_outputPath = outputPath.Trim('/');
return this;
}

/// <inheritdoc />
protected override async Task<IEnumerable<IDocument>> ExecuteInputAsync(IDocument input, IExecutionContext context)
{
// Skip if the document already has an image set via frontmatter
var existingImage = input.GetString(WebKeys.Image, string.Empty);
if (!string.IsNullOrEmpty(existingImage))
{
return input.Yield();
}

var title = input.GetString("Title", string.Empty);
if (string.IsNullOrEmpty(title))
{
return input.Yield();
}

// Resolve brand text: module config → appsettings BrandText → appsettings SiteTitle
var brandText = !string.IsNullOrEmpty(_brandText)
? _brandText
: context.GetString("BrandText", context.GetString("SiteTitle", string.Empty));

// Resolve font family: module config → appsettings SocialImageFont → first available system font
var fontFamily = !string.IsNullOrEmpty(_fontFamily)
? _fontFamily
: context.GetString("SocialImageFont", string.Empty);

// Derive the image file name from the source document
var sourceName = input.Source.IsNullOrEmpty
? Guid.NewGuid().ToString("N")
: input.Source.FileNameWithoutExtension.ToString();

var imageFileName = $"{sourceName}.png";
var imageRelativePath = $"{_outputPath}/{imageFileName}";

var imageBytes = GenerateSocialImage(title, brandText, fontFamily);

// Write the image directly to the output file system
var outputFile = context.FileSystem.GetOutputFile(imageRelativePath);
await using (var stream = outputFile.OpenWrite())
{
await stream.WriteAsync(imageBytes);
}

// Return the document with the Image metadata updated
return input.Clone(new MetadataItems
{
{ WebKeys.Image, "/" + imageRelativePath }
}).Yield();
}

private byte[] GenerateSocialImage(string title, string brandText, string fontFamily)
{
var resolvedFont = ResolveFont(fontFamily);

using var image = new Image<Rgba32>(_width, _height);

image.Mutate(ctx =>
{
ctx.Fill(_backgroundColor);

var titleFont = resolvedFont.CreateFont(60, FontStyle.Bold);
var brandFont = resolvedFont.CreateFont(30, FontStyle.Regular);

var titleOptions = new RichTextOptions(titleFont)
{
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Origin = new PointF(80, 160),
WrappingLength = _width - 160
};
ctx.DrawText(titleOptions, title, _titleColor);

if (!string.IsNullOrEmpty(brandText))
{
var brandOptions = new RichTextOptions(brandFont)
{
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Bottom,
Origin = new PointF(80, _height - 80)
};
ctx.DrawText(brandOptions, brandText, _brandColor);
}
});

using var ms = new MemoryStream();
image.SaveAsPng(ms);
return ms.ToArray();
}

private static FontFamily ResolveFont(string fontFamily)
{
// Try the requested font first
if (!string.IsNullOrEmpty(fontFamily) && SystemFonts.TryGet(fontFamily, out var requested))
{
return requested;
}

// Try common cross-platform fallbacks
foreach (var candidate in new[] { "Arial", "Liberation Sans", "DejaVu Sans", "Verdana", "Helvetica", "Ubuntu" })
{
if (SystemFonts.TryGet(candidate, out var fallback))
{
return fallback;
}
}

// Use the first available system font
var families = SystemFonts.Families.ToList();
if (families.Count > 0)
{
return families[0];
}

throw new InvalidOperationException(
"No system fonts are available. Install fonts on the host or configure a font family path.");
}
}
2 changes: 2 additions & 0 deletions src/Statiq.Plugins/Statiq.Plugins.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageReference Include="Statiq.Web" Version="[1.0.0-beta.60, )" NoWarn="NU5104" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
</ItemGroup>
Expand Down