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 @@ + +