diff --git a/README.md b/README.md
index e61dbda..671230d 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## Plugins
- Reading Time
+- Social Share Image
### Reading time
@@ -37,6 +38,56 @@ You can then override the `input/_header.cshtml` of your _theme_ and place the c
~@Model.GetString("ReadingTime") minutes
```
+### 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()
+ .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)
diff --git a/docs/LIBRARIES.md b/docs/LIBRARIES.md
index 870f037..ca92a48 100644
--- a/docs/LIBRARIES.md
+++ b/docs/LIBRARIES.md
@@ -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`
diff --git a/sample/Program.cs b/sample/Program.cs
index e0b7e0b..04f9504 100644
--- a/sample/Program.cs
+++ b/sample/Program.cs
@@ -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();
\ No newline at end of file
+ .AddConfigurator(new ReadingTimeConfigurator())
+ .AddConfigurator(new SocialImageConfigurator())
+ .RunAsync();
diff --git a/sample/appsettings.json b/sample/appsettings.json
index 95d5ab1..819b023 100644
--- a/sample/appsettings.json
+++ b/sample/appsettings.json
@@ -5,5 +5,6 @@
"SiteDescription": "Welcome to my blog!",
"DateTimeInputCulture": "en-GB",
"GenerateSearchIndex": true,
- "ReadingSpeed": 100
-}
\ No newline at end of file
+ "ReadingSpeed": 100,
+ "BrandText": "Alex Hedley"
+}
diff --git a/sample/input/posts/2.md b/sample/input/posts/2.md
new file mode 100644
index 0000000..866a25d
--- /dev/null
+++ b/sample/input/posts/2.md
@@ -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.
diff --git a/src/Statiq.Plugins/SocialImage/SocialImageConfigurator.cs b/src/Statiq.Plugins/SocialImage/SocialImageConfigurator.cs
new file mode 100644
index 0000000..36d1663
--- /dev/null
+++ b/src/Statiq.Plugins/SocialImage/SocialImageConfigurator.cs
@@ -0,0 +1,19 @@
+namespace Statiq.Plugins;
+
+// https://wellsb.com/csharp/aspnet/generate-images-statiq-imagesharp
+///
+/// Configures the Statiq Bootstrapper to automatically generate social share images
+/// for blog posts by injecting into the Content pipeline's
+/// Process phase.
+///
+public class SocialImageConfigurator : IConfigurator
+{
+ ///
+ public void Configure(Bootstrapper configurable)
+ {
+ configurable.ModifyPipeline("Content", p =>
+ {
+ p.ProcessModules.Add(new SocialImageModule());
+ });
+ }
+}
diff --git a/src/Statiq.Plugins/SocialImage/SocialImageModule.cs b/src/Statiq.Plugins/SocialImage/SocialImageModule.cs
new file mode 100644
index 0000000..81d3cf1
--- /dev/null
+++ b/src/Statiq.Plugins/SocialImage/SocialImageModule.cs
@@ -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
+///
+/// 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
+/// Image metadata key is updated to point to it.
+/// Processing is skipped for any document that already has an Image
+/// frontmatter property set.
+///
+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";
+
+ /// Sets the brand text drawn at the bottom of the generated image.
+ public SocialImageModule WithBrandText(string brandText)
+ {
+ _brandText = brandText;
+ return this;
+ }
+
+ /// Sets the font family used for all text in the generated image.
+ public SocialImageModule WithFontFamily(string fontFamily)
+ {
+ _fontFamily = fontFamily;
+ return this;
+ }
+
+ /// Sets the background colour of the generated image.
+ public SocialImageModule WithBackgroundColor(Color color)
+ {
+ _backgroundColor = color;
+ return this;
+ }
+
+ /// Sets the colour used to draw the post title text.
+ public SocialImageModule WithTitleColor(Color color)
+ {
+ _titleColor = color;
+ return this;
+ }
+
+ /// Sets the colour used to draw the brand text.
+ public SocialImageModule WithBrandColor(Color color)
+ {
+ _brandColor = color;
+ return this;
+ }
+
+ /// Sets the dimensions of the generated image (defaults to 1200x630).
+ 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;
+ }
+
+ ///
+ /// Sets the output directory path (relative to the site root) where images are saved.
+ /// Defaults to images/social.
+ ///
+ public SocialImageModule WithOutputPath(string outputPath)
+ {
+ _outputPath = outputPath.Trim('/');
+ return this;
+ }
+
+ ///
+ protected override async Task> 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(_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.");
+ }
+}
diff --git a/src/Statiq.Plugins/Statiq.Plugins.csproj b/src/Statiq.Plugins/Statiq.Plugins.csproj
index a631cc1..674b282 100644
--- a/src/Statiq.Plugins/Statiq.Plugins.csproj
+++ b/src/Statiq.Plugins/Statiq.Plugins.csproj
@@ -40,6 +40,8 @@
+
+