From f2b2b492762233342d1e5ae5a04bf89e03d972e9 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Sat, 20 Dec 2025 23:05:57 +1100 Subject: [PATCH 1/4] Add plugin for OpenGraph --- ScissorHandsPlugins.sln | 15 ++++ .../GoogleAnalyticsComponent.razor | 10 +++ .../GoogleAnalyticsComponent.razor.cs | 27 +++++++ .../GoogleAnalyticsPlugin.cs | 8 +- ...ScissorHands.Plugin.GoogleAnalytics.csproj | 2 +- .../_Imports.razor | 4 + .../OpenGraphComponent.razor | 22 +++++ .../OpenGraphComponent.razor.cs | 80 +++++++++++++++++++ .../OpenGraphPlugin.cs | 66 +++++++++++++++ .../OpenGraphPluginHelper.cs | 49 ++++++++++++ .../ScissorHands.Plugin.OpenGraph.csproj | 64 +++++++++++++++ .../_Imports.razor | 4 + 12 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor create mode 100644 src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor.cs create mode 100644 src/ScissorHands.Plugin.GoogleAnalytics/_Imports.razor create mode 100644 src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor create mode 100644 src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor.cs create mode 100644 src/ScissorHands.Plugin.OpenGraph/OpenGraphPlugin.cs create mode 100644 src/ScissorHands.Plugin.OpenGraph/OpenGraphPluginHelper.cs create mode 100644 src/ScissorHands.Plugin.OpenGraph/ScissorHands.Plugin.OpenGraph.csproj create mode 100644 src/ScissorHands.Plugin.OpenGraph/_Imports.razor diff --git a/ScissorHandsPlugins.sln b/ScissorHandsPlugins.sln index 01c04d8..415303e 100644 --- a/ScissorHandsPlugins.sln +++ b/ScissorHandsPlugins.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScissorHands.Plugin.GoogleAnalytics", "src\ScissorHands.Plugin.GoogleAnalytics\ScissorHands.Plugin.GoogleAnalytics.csproj", "{CB30DD74-0080-424B-A248-E9B2E7746CE2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScissorHands.Plugin.OpenGraph", "src\ScissorHands.Plugin.OpenGraph\ScissorHands.Plugin.OpenGraph.csproj", "{287434CA-3059-457C-BCB5-F3C4E1166942}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScissorHands.Plugin.GoogleAnalytics.Tests", "test\ScissorHands.Plugin.GoogleAnalytics.Tests\ScissorHands.Plugin.GoogleAnalytics.Tests.csproj", "{8EDFD210-29DC-433A-970A-FB8BD53CCAFB}" @@ -33,6 +35,18 @@ Global {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|x64.Build.0 = Release|Any CPU {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|x86.ActiveCfg = Release|Any CPU {CB30DD74-0080-424B-A248-E9B2E7746CE2}.Release|x86.Build.0 = Release|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Debug|Any CPU.Build.0 = Debug|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Debug|x64.ActiveCfg = Debug|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Debug|x64.Build.0 = Debug|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Debug|x86.ActiveCfg = Debug|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Debug|x86.Build.0 = Debug|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Release|Any CPU.ActiveCfg = Release|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Release|Any CPU.Build.0 = Release|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Release|x64.ActiveCfg = Release|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Release|x64.Build.0 = Release|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Release|x86.ActiveCfg = Release|Any CPU + {287434CA-3059-457C-BCB5-F3C4E1166942}.Release|x86.Build.0 = Release|Any CPU {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|Any CPU.Build.0 = Debug|Any CPU {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -51,6 +65,7 @@ Global EndGlobalSection GlobalSection(NestedProjects) = preSolution {CB30DD74-0080-424B-A248-E9B2E7746CE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {287434CA-3059-457C-BCB5-F3C4E1166942} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8EDFD210-29DC-433A-970A-FB8BD53CCAFB} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection EndGlobal diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor new file mode 100644 index 0000000..1c4205e --- /dev/null +++ b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor @@ -0,0 +1,10 @@ +@inherits ScissorHands.Plugin.PluginComponentBase + + + + diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor.cs b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor.cs new file mode 100644 index 0000000..e0ac601 --- /dev/null +++ b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsComponent.razor.cs @@ -0,0 +1,27 @@ +namespace ScissorHands.Plugin.GoogleAnalytics; + +/// +/// This represents the UI component entity for Google Analytics. +/// +public partial class GoogleAnalyticsComponent : PluginComponentBase +{ + /// + /// Gets or sets the measurement ID. + /// + protected string? MeasurementId { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + if (Plugin is null) + { + return; + } + + MeasurementId = Plugin.Options!.TryGetValue("MeasurementId", out var measurementIdValue) + ? measurementIdValue as string + : default; + } +} \ No newline at end of file diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs index a3a9363..4fd0520 100644 --- a/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs +++ b/src/ScissorHands.Plugin.GoogleAnalytics/GoogleAnalyticsPlugin.cs @@ -1,4 +1,4 @@ -using ScissorHands.Core.Manifests; +using ScissorHands.Core.Manifests; using ScissorHands.Core.Models; namespace ScissorHands.Plugin.GoogleAnalytics; @@ -25,11 +25,11 @@ public class GoogleAnalyticsPlugin : ContentPlugin public override string Name => "Google Analytics"; /// - public override async Task PostHtmlAsync(string html, ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + public override async Task PostHtmlAsync(string html, ContentDocument document, PluginManifest plugin, SiteManifest site, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var measurementId = manifest.Options!.TryGetValue("MeasurementId", out var id) + var measurementId = plugin.Options!.TryGetValue("MeasurementId", out var id) ? id as string : default; if (measurementId is null) @@ -37,7 +37,7 @@ public override async Task PostHtmlAsync(string html, ContentDocument do return html; } - var script = GOOGLE_ANALYTICS_SCRIPT.Replace("{{MEASUREMENT_ID}}", measurementId); + var script = GOOGLE_ANALYTICS_SCRIPT.Replace("{{MEASUREMENT_ID}}", measurementId, StringComparison.OrdinalIgnoreCase); html = html.Replace(PLACEHOLDER, $"\n{script}\n", StringComparison.OrdinalIgnoreCase); diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj b/src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj index 7412bfe..3ec9535 100644 --- a/src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj +++ b/src/ScissorHands.Plugin.GoogleAnalytics/ScissorHands.Plugin.GoogleAnalytics.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/_Imports.razor b/src/ScissorHands.Plugin.GoogleAnalytics/_Imports.razor new file mode 100644 index 0000000..0f561e0 --- /dev/null +++ b/src/ScissorHands.Plugin.GoogleAnalytics/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web +@using ScissorHands.Plugin + +@namespace ScissorHands.Plugin.GoogleAnalytics \ No newline at end of file diff --git a/src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor b/src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor new file mode 100644 index 0000000..3ac9014 --- /dev/null +++ b/src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor @@ -0,0 +1,22 @@ +@inherits ScissorHands.Plugin.PluginComponentBase + + + + + + + + + + +@if (string.IsNullOrWhiteSpace(TwitterSiteId) == false) +{ + +} +@if (string.IsNullOrWhiteSpace(TwitterCreatorId) == false) +{ + +} + + + diff --git a/src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor.cs b/src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor.cs new file mode 100644 index 0000000..f390de7 --- /dev/null +++ b/src/ScissorHands.Plugin.OpenGraph/OpenGraphComponent.razor.cs @@ -0,0 +1,80 @@ +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.OpenGraph; + +/// +/// This represents the UI component entity for Open Graph. +/// +public partial class OpenGraphComponent : PluginComponentBase +{ + /// + /// Gets or sets the content title. + /// + protected string? ContentTitle { get; set; } + + /// + /// Gets or sets the content description. + /// + protected string? ContentDescription { get; set; } + + /// + /// Gets or sets the content locale. + /// + protected string? ContentLocale { get; set; } + + /// + /// Gets or sets the content URL. + /// + protected string? ContentUrl { get; set; } + + /// + /// Gets or sets the hero image URL. + /// + protected string? HeroImageUrl { get; set; } + + /// + /// Gets or sets the site name. + /// + protected string? SiteName { get; set; } + + /// + /// Gets or sets the Twitter site ID. + /// + protected string? TwitterSiteId { get; set; } + + /// + /// Gets or sets the Twitter creator ID. + /// + protected string? TwitterCreatorId { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + if (Plugin is null) + { + return; + } + + TwitterSiteId = Plugin.Options!.TryGetValue("TwitterSiteId", out var siteIdValue) + ? siteIdValue as string + : default; + TwitterCreatorId = Plugin.Options!.TryGetValue("TwitterCreatorId", out var creatorIdValue) + ? creatorIdValue as string + : default; + TwitterCreatorId = Document?.Metadata.TwitterHandle is null ? TwitterCreatorId : Document.Metadata.TwitterHandle; + if (Documents?.Any() == true || Document?.Kind == ContentKind.Page) + { + TwitterCreatorId = default; + } + + ContentTitle = Documents?.Any() == true ? Site!.Title : Document?.Metadata.Title; + ContentDescription = Documents?.Any() == true ? Site!.Description : Document?.Metadata.Description; + ContentLocale = Site!.Locale; + + ContentUrl = $"{OpenGraphPluginHelper.GetContentUrl(Document, Site)}"; + HeroImageUrl = $"{OpenGraphPluginHelper.GetHeroImageUrl(Document, Site)}"; + SiteName = Site!.Title; + } +} \ No newline at end of file diff --git a/src/ScissorHands.Plugin.OpenGraph/OpenGraphPlugin.cs b/src/ScissorHands.Plugin.OpenGraph/OpenGraphPlugin.cs new file mode 100644 index 0000000..ca121bc --- /dev/null +++ b/src/ScissorHands.Plugin.OpenGraph/OpenGraphPlugin.cs @@ -0,0 +1,66 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.OpenGraph; + +/// +/// This represents the plugin entity for Open Graph. +/// +public class OpenGraphPlugin : ContentPlugin +{ + private const string OPEN_GRAPH_TEMPLATE = """ + + + + + + + + + + {{TWITTER_CARD_SITE}} + {{TWITTER_CARD_CREATOR}} + + + + """; + + private const string PLACEHOLDER = ""; + + /// + public override string Name => "Open Graph"; + + /// + public override async Task PostHtmlAsync(string html, ContentDocument document, PluginManifest plugin, SiteManifest site, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var template = OPEN_GRAPH_TEMPLATE; + var siteId = plugin.Options!.TryGetValue("TwitterSiteId", out var siteIdValue) + ? siteIdValue as string + : default; + template = siteId is null + ? template.Replace("{{TWITTER_CARD_SITE}}", string.Empty, StringComparison.OrdinalIgnoreCase) + : template.Replace("{{TWITTER_CARD_SITE}}", $"", StringComparison.OrdinalIgnoreCase); + + var creatorId = plugin.Options!.TryGetValue("TwitterCreatorId", out var creatorIdValue) + ? creatorIdValue as string + : default; + creatorId = document.Metadata.TwitterHandle is null ? creatorId : document.Metadata.TwitterHandle; + creatorId = document.Kind == ContentKind.Post ? creatorId : default; + template = creatorId is null + ? template.Replace("{{TWITTER_CARD_CREATOR}}", string.Empty, StringComparison.OrdinalIgnoreCase) + : template.Replace("{{TWITTER_CARD_CREATOR}}", $"", StringComparison.OrdinalIgnoreCase); + + template = template.Replace("{{CONTENT_TITLE}}", document.Metadata.Title, StringComparison.OrdinalIgnoreCase); + template = template.Replace("{{CONTENT_DESCRIPTION}}", document.Metadata.Description ?? site.Description, StringComparison.OrdinalIgnoreCase); + template = template.Replace("{{CONTENT_LOCALE}}", site.Locale, StringComparison.OrdinalIgnoreCase); + template = template.Replace("{{CONTENT_URL}}", $"{OpenGraphPluginHelper.GetContentUrl(document, site)}", StringComparison.OrdinalIgnoreCase); + template = template.Replace("{{CONTENT_HERO_IMAGE_URL}}", $"{OpenGraphPluginHelper.GetHeroImageUrl(document, site)}", StringComparison.OrdinalIgnoreCase); + template = template.Replace("{{SITE_NAME}}", site.Title, StringComparison.OrdinalIgnoreCase); + + html = html.Replace(PLACEHOLDER, $"{template}\n\n", StringComparison.OrdinalIgnoreCase); + + return await Task.FromResult(html); + } +} diff --git a/src/ScissorHands.Plugin.OpenGraph/OpenGraphPluginHelper.cs b/src/ScissorHands.Plugin.OpenGraph/OpenGraphPluginHelper.cs new file mode 100644 index 0000000..ce6335a --- /dev/null +++ b/src/ScissorHands.Plugin.OpenGraph/OpenGraphPluginHelper.cs @@ -0,0 +1,49 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.OpenGraph; + +/// +/// This represents the helper entity for Open Graph plugin. +/// +public static class OpenGraphPluginHelper +{ + /// + /// Gets the content URL. + /// + /// instance. + /// instance. + /// Returns the content URL. + public static string GetContentUrl(ContentDocument? document, SiteManifest? site) + { + var siteUrl = GetSiteUrl(site); + var contentUrl = document?.Metadata.Slug.TrimStart('/'); + + return $"{siteUrl}/{contentUrl}".TrimEnd('/'); + } + + /// + /// Gets the hero image URL. + /// + /// instance. + /// instance. + /// Returns the hero image URL. + public static string GetHeroImageUrl(ContentDocument? document, SiteManifest? site) + { + var siteUrl = GetSiteUrl(site); + var siteHeroImage = site?.HeroImage?.TrimStart('/'); + var contentImage = document?.Metadata.HeroImage?.TrimStart('/'); + + return contentImage is null + ? $"{siteUrl}/{siteHeroImage}" + : $"{siteUrl}/{contentImage}"; + } + + private static string GetSiteUrl(SiteManifest? site) + { + var siteUrl = site?.SiteUrl.TrimEnd('/'); + var baseUrl = site?.BaseUrl.Trim('/'); + + return $"{siteUrl}/{baseUrl}".TrimEnd('/'); + } +} diff --git a/src/ScissorHands.Plugin.OpenGraph/ScissorHands.Plugin.OpenGraph.csproj b/src/ScissorHands.Plugin.OpenGraph/ScissorHands.Plugin.OpenGraph.csproj new file mode 100644 index 0000000..b7c8f35 --- /dev/null +++ b/src/ScissorHands.Plugin.OpenGraph/ScissorHands.Plugin.OpenGraph.csproj @@ -0,0 +1,64 @@ + + + + net10.0 + enable + enable + + latest + + ScissorHands.Plugin.OpenGraph + ScissorHands.Plugin.OpenGraph + + + + true + True + + ScissorHands.Plugin.OpenGraph + 1.0.0 + + ScissorHands.Plugin.OpenGraph + Justin Yoo + GetScissorHands + ScissorHands.Plugin.OpenGraph + ScissorHands.Plugin.OpenGraph: Open Graph plugin for ScissorHands.NET + This is a library that uses Open Graph for ScissorHands.NET + © GetScissorHands. All rights reserved. + justinyoo + + https://github.com/getscissorhands/plugins + ScissorHands.png + https://raw.githubusercontent.com/getscissorhands/plugins/refs/heads/main/assets/ScissorHands.png + README.md + scissorhands;plugin;static-site;open-graph + MIT + True + + https://github.com/getscissorhands/plugins + git + + True + snupkg + + + + + True + \ + + + True + \ + + + True + \ + + + + + + + + diff --git a/src/ScissorHands.Plugin.OpenGraph/_Imports.razor b/src/ScissorHands.Plugin.OpenGraph/_Imports.razor new file mode 100644 index 0000000..adab34a --- /dev/null +++ b/src/ScissorHands.Plugin.OpenGraph/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web +@using ScissorHands.Plugin + +@namespace ScissorHands.Plugin.OpenGraph \ No newline at end of file From 2927e198acbf38df15f791728a91a9ace1bed5c4 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Sun, 21 Dec 2025 00:04:51 +1100 Subject: [PATCH 2/4] Add tests for OpenGraph plugin --- .../expert-dotnet-software-engineer.agent.md | 2 +- .github/workflows/main.yaml | 1 + ScissorHandsPlugins.sln | 15 ++ .../GoogleAnalyticsComponentTests.cs | 108 +++++++++ .../GoogleAnalyticsPluginTests.cs | 173 ++++++++------ .../OpenGraphComponentTests.cs | 201 ++++++++++++++++ .../OpenGraphPluginHelperTests.cs | 128 ++++++++++ .../OpenGraphPluginTests.cs | 220 ++++++++++++++++++ ...ScissorHands.Plugin.OpenGraph.Tests.csproj | 36 +++ 9 files changed, 806 insertions(+), 78 deletions(-) create mode 100644 test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsComponentTests.cs create mode 100644 test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphComponentTests.cs create mode 100644 test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginHelperTests.cs create mode 100644 test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginTests.cs create mode 100644 test/ScissorHands.Plugin.OpenGraph.Tests/ScissorHands.Plugin.OpenGraph.Tests.csproj diff --git a/.github/agents/expert-dotnet-software-engineer.agent.md b/.github/agents/expert-dotnet-software-engineer.agent.md index 363122b..5108591 100644 --- a/.github/agents/expert-dotnet-software-engineer.agent.md +++ b/.github/agents/expert-dotnet-software-engineer.agent.md @@ -1,7 +1,7 @@ --- description: "Provide expert .NET software engineering guidance using modern software design patterns." name: "Expert .NET software engineer mode instructions" -tools: ["agent", "edit", "execute", "read", "search", "todo", "vscode", "web", "microsoft-docs/*"] +tools: ['vscode', 'execute', 'read', 'edit', 'search', 'web', 'microsoft-docs/*', 'agent', 'todo'] --- # Agent Overview diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 14aeb80..d20b0eb 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -93,6 +93,7 @@ jobs: shell: pwsh run: | dotnet pack ./src/ScissorHands.Plugin.GoogleAnalytics -c Release -o published --include-symbols -p:Version=${{ steps.version.outputs.value }} + dotnet pack ./src/ScissorHands.Plugin.OpenGraph -c Release -o published --include-symbols -p:Version=${{ steps.version.outputs.value }} - name: Upload Artifact if: ${{ startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-latest' }} diff --git a/ScissorHandsPlugins.sln b/ScissorHandsPlugins.sln index 415303e..42fcba5 100644 --- a/ScissorHandsPlugins.sln +++ b/ScissorHandsPlugins.sln @@ -13,6 +13,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScissorHands.Plugin.GoogleAnalytics.Tests", "test\ScissorHands.Plugin.GoogleAnalytics.Tests\ScissorHands.Plugin.GoogleAnalytics.Tests.csproj", "{8EDFD210-29DC-433A-970A-FB8BD53CCAFB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScissorHands.Plugin.OpenGraph.Tests", "test\ScissorHands.Plugin.OpenGraph.Tests\ScissorHands.Plugin.OpenGraph.Tests.csproj", "{D6941307-2596-4D5E-B80D-05AB3E7DD55B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +61,18 @@ Global {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|x64.Build.0 = Release|Any CPU {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|x86.ActiveCfg = Release|Any CPU {8EDFD210-29DC-433A-970A-FB8BD53CCAFB}.Release|x86.Build.0 = Release|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Debug|x64.ActiveCfg = Debug|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Debug|x64.Build.0 = Debug|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Debug|x86.ActiveCfg = Debug|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Debug|x86.Build.0 = Debug|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Release|Any CPU.Build.0 = Release|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Release|x64.ActiveCfg = Release|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Release|x64.Build.0 = Release|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Release|x86.ActiveCfg = Release|Any CPU + {D6941307-2596-4D5E-B80D-05AB3E7DD55B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -67,5 +81,6 @@ Global {CB30DD74-0080-424B-A248-E9B2E7746CE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {287434CA-3059-457C-BCB5-F3C4E1166942} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8EDFD210-29DC-433A-970A-FB8BD53CCAFB} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {D6941307-2596-4D5E-B80D-05AB3E7DD55B} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection EndGlobal diff --git a/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsComponentTests.cs b/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsComponentTests.cs new file mode 100644 index 0000000..e115f8f --- /dev/null +++ b/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsComponentTests.cs @@ -0,0 +1,108 @@ +using Bunit; + +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.GoogleAnalytics.Tests; + +public class GoogleAnalyticsComponentTests +{ + [Fact] + public void Given_NullPlugin_When_Rendered_Then_It_Should_Not_Throw() + { + // Arrange + using var ctx = new BunitContext(); + var document = new ContentDocument(); + var site = new SiteManifest(); + + // Act + var render = () => ctx.Render(ps => ps + .Add(p => p.Plugin, (PluginManifest?)null) + .Add(p => p.Document, document) + .Add(p => p.Site, site)); + + // Assert + render.ShouldNotThrow(); + } + + [Theory] + [InlineData("G-XXXXXXXXXX")] + public void Given_MeasurementId_When_Rendered_Then_It_Should_Render_GtagScript_With_Id(string measurementId) + { + // Arrange + using var ctx = new BunitContext(); + var plugin = CreatePluginManifest(measurementId); + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin)); + + // Assert + cut.WaitForAssertion(() => + { + cut.Markup.ShouldContain($"https://www.googletagmanager.com/gtag/js?id={measurementId}"); + cut.Markup.ShouldContain($"gtag('config', '{measurementId}');"); + }); + } + + [Fact] + public void Given_NoMeasurementId_When_Rendered_Then_It_Should_Not_Throw_And_Should_Not_Contain_Any_Id() + { + // Arrange + using var ctx = new BunitContext(); + var plugin = new PluginManifest + { + Options = new Dictionary + { + { "MeasurementId", null } + } + }; + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin)); + + // Assert + cut.WaitForAssertion(() => + { + cut.Markup.ShouldNotContain("gtag/js?id=G-"); + cut.Markup.ShouldNotContain("gtag('config', 'G-"); + }); + } + + [Fact] + public void Given_NonStringMeasurementId_When_Rendered_Then_It_Should_Not_Throw_And_Should_Not_Contain_Any_Id() + { + // Arrange + using var ctx = new BunitContext(); + var plugin = new PluginManifest + { + Options = new Dictionary + { + { "MeasurementId", 12345 } + } + }; + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin)); + + // Assert + cut.WaitForAssertion(() => + { + cut.Markup.ShouldNotContain("gtag/js?id=G-"); + cut.Markup.ShouldNotContain("gtag('config', 'G-"); + }); + } + + private static PluginManifest CreatePluginManifest(string measurementId) + { + return new PluginManifest + { + Options = new Dictionary + { + { "MeasurementId", measurementId } + } + }; + } +} diff --git a/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs b/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs index f48c65c..3928338 100644 --- a/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs +++ b/test/ScissorHands.Plugin.GoogleAnalytics.Tests/GoogleAnalyticsPluginTests.cs @@ -14,10 +14,10 @@ public class GoogleAnalyticsPluginTests public void When_Instantiated_Then_Name_Should_Be(string name) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); + var pg = new GoogleAnalyticsPlugin(); // Act - var result = plugin.Name; + var result = pg.Name; // Assert result.ShouldBe(name); @@ -28,15 +28,16 @@ public void When_Instantiated_Then_Name_Should_Be(string name) public void Given_CancellationToken_When_PostHtmlAsync_Invoked_Then_It_Should_Throw_TaskCanceledException(Type exception) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); + var pg = new GoogleAnalyticsPlugin(); var html = string.Empty; var document = new ContentDocument(); - var manifest = new PluginManifest(); + var plugin = new PluginManifest(); + var site = new SiteManifest(); var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); // Act - Func func = async () => await plugin.PostHtmlAsync(html, document, manifest, cancellationTokenSource.Token); + Func func = async () => await pg.PostHtmlAsync(html, document, plugin, site, cancellationTokenSource.Token); // Assert func.ShouldThrow(exception); @@ -47,18 +48,19 @@ public void Given_CancellationToken_When_PostHtmlAsync_Invoked_Then_It_Should_Th public async Task Given_InvalidOption_When_PostHtmlAsync_Invoked_Then_It_Should_Return_OriginalHtml(string html) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); + var pg = new GoogleAnalyticsPlugin(); var document = new ContentDocument(); - var manifest = new PluginManifest + var plugin = new PluginManifest { Options = new Dictionary { { "SomeOtherKey", "SomeValue" } } }; + var site = CreateSiteManifest(); // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + var result = await pg.PostHtmlAsync(html, document, plugin, site); // Assert result.ShouldBe(html); @@ -69,18 +71,13 @@ public async Task Given_InvalidOption_When_PostHtmlAsync_Invoked_Then_It_Should_ public async Task Given_NoMeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_Return_OriginalHtml(string html) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); - var document = new ContentDocument(); - var manifest = new PluginManifest - { - Options = new Dictionary - { - { "MeasurementId", null! } - } - }; + var pg = new GoogleAnalyticsPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(measurementId: null); + var site = CreateSiteManifest(); // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + var result = await pg.PostHtmlAsync(html, document, plugin, site); // Assert result.ShouldBe(html); @@ -91,18 +88,13 @@ public async Task Given_NoMeasurementId_When_PostHtmlAsync_Invoked_Then_It_Shoul public async Task Given_MeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_Replace_Placeholder(string html, string measurementId) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); - var document = new ContentDocument(); - var manifest = new PluginManifest - { - Options = new Dictionary - { - { "MeasurementId", measurementId } - } - }; + var pg = new GoogleAnalyticsPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(measurementId: measurementId); + var site = CreateSiteManifest(); // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + var result = await pg.PostHtmlAsync(html, document, plugin, site); // Assert result.ShouldContain($"https://www.googletagmanager.com/gtag/js?id={measurementId}"); @@ -116,18 +108,13 @@ public async Task Given_MeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_ public async Task Given_MeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_Insert_GoogleTagScript(string html, string measurementId) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); - var document = new ContentDocument(); - var manifest = new PluginManifest - { - Options = new Dictionary - { - { "MeasurementId", measurementId } - } - }; + var pg = new GoogleAnalyticsPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(measurementId: measurementId); + var site = CreateSiteManifest(); // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + var result = await pg.PostHtmlAsync(html, document, plugin, site); // Assert result.ShouldContain(""); @@ -142,18 +129,13 @@ public async Task Given_MeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_ public async Task Given_HTML_When_PostHtmlAsync_Invoked_Then_It_Should_Return_OriginalHtml(string html, string measurementId) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); - var document = new ContentDocument(); - var manifest = new PluginManifest - { - Options = new Dictionary - { - { "MeasurementId", measurementId } - } - }; + var pg = new GoogleAnalyticsPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(measurementId: measurementId); + var site = CreateSiteManifest(); // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + var result = await pg.PostHtmlAsync(html, document, plugin, site); // Assert result.ShouldBe(html); @@ -164,18 +146,13 @@ public async Task Given_HTML_When_PostHtmlAsync_Invoked_Then_It_Should_Return_Or public async Task Given_Multiple_Placeholders_When_PostHtmlAsync_Invoked_Then_It_Should_Replace_AllOccurrences(string html, string measurementId, int expected) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); - var document = new ContentDocument(); - var manifest = new PluginManifest - { - Options = new Dictionary - { - { "MeasurementId", measurementId } - } - }; + var pg = new GoogleAnalyticsPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(measurementId: measurementId); + var site = CreateSiteManifest(); // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + var result = await pg.PostHtmlAsync(html, document, plugin, site); var count = GoogleTagRegex.Count(result); // Assert @@ -188,19 +165,14 @@ public async Task Given_Multiple_Placeholders_When_PostHtmlAsync_Invoked_Then_It public async Task Given_EmptyHTML_When_PostHtmlAsync_Invoked_Then_It_Should_Return_EmptyString(string measurementId) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); + var pg = new GoogleAnalyticsPlugin(); var html = string.Empty; - var document = new ContentDocument(); - var manifest = new PluginManifest - { - Options = new Dictionary - { - { "MeasurementId", measurementId } - } - }; + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(measurementId: measurementId); + var site = CreateSiteManifest(); // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + var result = await pg.PostHtmlAsync(html, document, plugin, site); // Assert result.ShouldBe(string.Empty); @@ -211,20 +183,67 @@ public async Task Given_EmptyHTML_When_PostHtmlAsync_Invoked_Then_It_Should_Retu public async Task Given_NonStringMeasurementId_When_PostHtmlAsync_Invoked_Then_It_Should_Return_OriginalHtml(string html, object measurementId) { // Arrange - var plugin = new GoogleAnalyticsPlugin(); - var document = new ContentDocument(); - var manifest = new PluginManifest + var pg = new GoogleAnalyticsPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(measurementId: measurementId as string); + var site = CreateSiteManifest(); + + // Act + var result = await pg.PostHtmlAsync(html, document, plugin, site); + + // Assert + result.ShouldBe(html); + } + + private static ContentDocument CreateDocument( + ContentKind kind, + string title, + string slug, + string? description = "Document description", + string? heroImage = "/images/doc-hero.png", + string? twitterHandle = null) + { + return new ContentDocument { - Options = new Dictionary + Kind = kind, + Metadata = new ContentMetadata { - { "MeasurementId", measurementId } + Title = title, + Slug = slug, + Description = description, + HeroImage = heroImage, + TwitterHandle = twitterHandle, } }; + } - // Act - var result = await plugin.PostHtmlAsync(html, document, manifest); + private static PluginManifest CreatePluginManifest(string? measurementId = null) + { + return new PluginManifest + { + Options = new Dictionary + { + { "MeasurementId", measurementId }, + } + }; + } - // Assert - result.ShouldBe(html); + private static SiteManifest CreateSiteManifest( + string siteUrl = "https://example.com", + string baseUrl = "", + string title = "Site title", + string description = "Site description", + string locale = "en-US", + string heroImage = "/images/site-hero.png") + { + return new SiteManifest + { + SiteUrl = siteUrl, + BaseUrl = baseUrl, + Title = title, + Description = description, + Locale = locale, + HeroImage = heroImage, + }; } } diff --git a/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphComponentTests.cs b/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphComponentTests.cs new file mode 100644 index 0000000..b2b8c64 --- /dev/null +++ b/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphComponentTests.cs @@ -0,0 +1,201 @@ +using Bunit; + +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.OpenGraph.Tests; + +public class OpenGraphComponentTests +{ + [Fact] + public void Given_NullPlugin_When_Rendered_Then_It_Should_Not_Throw() + { + // Arrange + using var ctx = new BunitContext(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var site = CreateSiteManifest(); + + // Act + var render = () => ctx.Render(ps => ps + .Add(p => p.Plugin, (PluginManifest?)null) + .Add(p => p.Document, document) + .Add(p => p.Site, site)); + + // Assert + render.ShouldNotThrow(); + } + + [Fact] + public void Given_TwitterOptionsAndPost_When_Rendered_Then_It_Should_Render_TwitterSite_And_Creator_MetaTags() + { + // Arrange + using var ctx = new BunitContext(); + var plugin = CreatePluginManifest(twitterSiteId: "@site", twitterCreatorId: "@creator"); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var site = CreateSiteManifest(); + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin) + .Add(p => p.Document, document) + .Add(p => p.Site, site)); + + // Assert + cut.WaitForAssertion(() => + { + cut.FindAll("meta[name='twitter:site']").Count.ShouldBe(1); + cut.Find("meta[name='twitter:site']").GetAttribute("content").ShouldBe("@site"); + + cut.FindAll("meta[name='twitter:creator']").Count.ShouldBe(1); + cut.Find("meta[name='twitter:creator']").GetAttribute("content").ShouldBe("@creator"); + }); + } + + [Fact] + public void Given_NoTwitterSiteId_When_Rendered_Then_It_Should_Not_Render_TwitterSite_MetaTag() + { + // Arrange + using var ctx = new BunitContext(); + var plugin = CreatePluginManifest(twitterSiteId: null, twitterCreatorId: "@creator"); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var site = CreateSiteManifest(); + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin) + .Add(p => p.Document, document) + .Add(p => p.Site, site)); + + // Assert + cut.WaitForAssertion(() => + { + cut.FindAll("meta[name='twitter:site']").Count.ShouldBe(0); + cut.FindAll("meta[name='twitter:creator']").Count.ShouldBe(1); + }); + } + + [Fact] + public void Given_PageDocument_When_Rendered_Then_It_Should_Not_Render_TwitterCreator_MetaTag() + { + // Arrange + using var ctx = new BunitContext(); + var plugin = CreatePluginManifest(twitterSiteId: "@site", twitterCreatorId: "@creator"); + var document = CreateDocument(kind: ContentKind.Page, title: "About", slug: "/about", twitterHandle: "@handle"); + var site = CreateSiteManifest(); + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin) + .Add(p => p.Document, document) + .Add(p => p.Site, site)); + + // Assert + cut.WaitForAssertion(() => + { + cut.FindAll("meta[name='twitter:site']").Count.ShouldBe(1); + cut.FindAll("meta[name='twitter:creator']").Count.ShouldBe(0); + }); + } + + [Fact] + public void Given_TwitterHandleInMetadata_When_Rendered_Then_It_Should_Override_TwitterCreatorIdOption() + { + // Arrange + using var ctx = new BunitContext(); + var plugin = CreatePluginManifest(twitterSiteId: "@site", twitterCreatorId: "@from-options"); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world", twitterHandle: "@from-metadata"); + var site = CreateSiteManifest(); + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin) + .Add(p => p.Document, document) + .Add(p => p.Site, site)); + + // Assert + cut.WaitForAssertion(() => + { + cut.FindAll("meta[name='twitter:creator']").Count.ShouldBe(1); + cut.Find("meta[name='twitter:creator']").GetAttribute("content").ShouldBe("@from-metadata"); + }); + } + + [Fact] + public void Given_DocumentsCollection_When_Rendered_Then_It_Should_Use_SiteTitleAndDescription_And_Clear_TwitterCreator() + { + // Arrange + using var ctx = new BunitContext(); + var plugin = CreatePluginManifest(twitterSiteId: "@site", twitterCreatorId: "@creator"); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world", description: "Post description"); + var site = CreateSiteManifest(title: "Site title", description: "Site description"); + IEnumerable documents = new[] { document }; + + // Act + var cut = ctx.Render(ps => ps + .Add(p => p.Plugin, plugin) + .Add(p => p.Documents, documents) + .Add(p => p.Site, site)); + + // Assert + cut.WaitForAssertion(() => + { + cut.FindAll("meta[name='twitter:creator']").Count.ShouldBe(0); + + cut.Find("meta[property='og:title']").GetAttribute("content").ShouldBe("Site title"); + cut.Find("meta[property='og:description']").GetAttribute("content").ShouldBe("Site description"); + }); + } + + private static PluginManifest CreatePluginManifest(string? twitterSiteId, string? twitterCreatorId) + { + return new PluginManifest + { + Options = new Dictionary + { + { "TwitterSiteId", twitterSiteId }, + { "TwitterCreatorId", twitterCreatorId }, + } + }; + } + + private static ContentDocument CreateDocument( + ContentKind kind, + string title, + string slug, + string? description = "Document description", + string? heroImage = "/images/doc-hero.png", + string? twitterHandle = null) + { + return new ContentDocument + { + Kind = kind, + Metadata = new ContentMetadata + { + Title = title, + Slug = slug, + Description = description, + HeroImage = heroImage, + TwitterHandle = twitterHandle, + } + }; + } + + private static SiteManifest CreateSiteManifest( + string siteUrl = "https://example.com", + string baseUrl = "", + string title = "Site title", + string description = "Site description", + string locale = "en-US", + string heroImage = "/images/site-hero.png") + { + return new SiteManifest + { + SiteUrl = siteUrl, + BaseUrl = baseUrl, + Title = title, + Description = description, + Locale = locale, + HeroImage = heroImage, + }; + } +} diff --git a/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginHelperTests.cs b/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginHelperTests.cs new file mode 100644 index 0000000..4327aab --- /dev/null +++ b/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginHelperTests.cs @@ -0,0 +1,128 @@ +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.OpenGraph.Tests; + +public class OpenGraphPluginHelperTests +{ + [Theory] + [InlineData("https://example.com", "", "/hello-world", "https://example.com/hello-world")] + [InlineData("https://example.com/", "", "/hello-world", "https://example.com/hello-world")] + [InlineData("https://example.com", "/blog/", "/hello-world", "https://example.com/blog/hello-world")] + [InlineData("https://example.com/", "blog", "hello-world", "https://example.com/blog/hello-world")] + public void Given_DocumentAndSite_When_GetContentUrl_Invoked_Then_It_Should_Compose_Url( + string siteUrl, + string baseUrl, + string slug, + string expected) + { + // Arrange + var document = CreateDocument(slug); + var site = CreateSite(siteUrl, baseUrl); + + // Act + var result = OpenGraphPluginHelper.GetContentUrl(document, site); + + // Assert + result.ShouldBe(expected); + } + + [Theory] + [InlineData("https://example.com", "/blog/", "/", "https://example.com/blog")] + [InlineData("https://example.com/", "blog", "/", "https://example.com/blog")] + public void Given_SlugIsRoot_When_GetContentUrl_Invoked_Then_It_Should_Return_SiteRootWithoutTrailingSlash( + string siteUrl, + string baseUrl, + string slug, + string expected) + { + // Arrange + var document = CreateDocument(slug); + var site = CreateSite(siteUrl, baseUrl); + + // Act + var result = OpenGraphPluginHelper.GetContentUrl(document, site); + + // Assert + result.ShouldBe(expected); + } + + [Theory] + [InlineData("https://example.com", "", "/images/site.png", null, "https://example.com/images/site.png")] + [InlineData("https://example.com", "/blog/", "/images/site.png", null, "https://example.com/blog/images/site.png")] + [InlineData("https://example.com", "/blog/", "/images/site.png", "/images/post.png", "https://example.com/blog/images/post.png")] + [InlineData("https://example.com/", "blog", "/images/site.png", "images/post.png", "https://example.com/blog/images/post.png")] + public void Given_DocumentAndSite_When_GetHeroImageUrl_Invoked_Then_It_Should_Return_Expected( + string siteUrl, + string baseUrl, + string siteHeroImage, + string? contentHeroImage, + string expected) + { + // Arrange + var document = CreateDocument("/hello-world", heroImage: contentHeroImage); + var site = CreateSite(siteUrl, baseUrl, heroImage: siteHeroImage); + + // Act + var result = OpenGraphPluginHelper.GetHeroImageUrl(document, site); + + // Assert + result.ShouldBe(expected); + } + + [Fact] + public void Given_NullArguments_When_GetContentUrl_Invoked_Then_It_Should_Return_EmptyString() + { + // Arrange + ContentDocument? document = null; + SiteManifest? site = null; + + // Act + var result = OpenGraphPluginHelper.GetContentUrl(document, site); + + // Assert + result.ShouldBe(string.Empty); + } + + [Fact] + public void Given_NullArguments_When_GetHeroImageUrl_Invoked_Then_It_Should_Return_Slash() + { + // Arrange + ContentDocument? document = null; + SiteManifest? site = null; + + // Act + var result = OpenGraphPluginHelper.GetHeroImageUrl(document, site); + + // Assert + result.ShouldBe("/"); + } + + private static ContentDocument CreateDocument(string slug, string? heroImage = null) + { + return new ContentDocument + { + Kind = ContentKind.Post, + Metadata = new ContentMetadata + { + Title = "Title", + Slug = slug, + Description = "Description", + HeroImage = heroImage, + } + }; + } + + private static SiteManifest CreateSite(string siteUrl, string baseUrl, string heroImage = "/images/site.png") + { + return new SiteManifest + { + SiteUrl = siteUrl, + BaseUrl = baseUrl, + HeroImage = heroImage, + Title = "Site Title", + Description = "Site Description", + Locale = "en-US", + }; + } +} diff --git a/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginTests.cs b/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginTests.cs new file mode 100644 index 0000000..6e13b28 --- /dev/null +++ b/test/ScissorHands.Plugin.OpenGraph.Tests/OpenGraphPluginTests.cs @@ -0,0 +1,220 @@ +using System.Text.RegularExpressions; + +using ScissorHands.Core.Manifests; +using ScissorHands.Core.Models; + +namespace ScissorHands.Plugin.OpenGraph.Tests; + +public class OpenGraphPluginTests +{ + private static readonly Regex OpenGraphTitleRegex = new("property=\"og:title\"", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + [Theory] + [InlineData("Open Graph")] + public void When_Instantiated_Then_Name_Should_Be(string name) + { + // Arrange + var pg = new OpenGraphPlugin(); + + // Act + var result = pg.Name; + + // Assert + result.ShouldBe(name); + } + + [Theory] + [InlineData(typeof(TaskCanceledException))] + public void Given_CancellationToken_When_PostHtmlAsync_Invoked_Then_It_Should_Throw_TaskCanceledException(Type exception) + { + // Arrange + var pg = new OpenGraphPlugin(); + var html = string.Empty; + var document = new ContentDocument(); + var plugin = new PluginManifest(); + var site = new SiteManifest(); + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // Act + Func func = async () => await pg.PostHtmlAsync(html, document, plugin, site, cancellationTokenSource.Token); + + // Assert + func.ShouldThrowAsync(exception); + } + + [Theory] + [InlineData("Test")] + public async Task Given_ValidInputs_When_PostHtmlAsync_Invoked_Then_It_Should_Replace_Placeholder(string html) + { + // Arrange + var pg = new OpenGraphPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world", description: "Post description", heroImage: "/images/hero.png"); + var plugin = CreatePluginManifest(twitterSiteId: "@site", twitterCreatorId: "@creator"); + var site = CreateSiteManifest(siteUrl: "https://example.com", baseUrl: "", title: "My Blog", description: "Site description", locale: "en-US", heroImage: "/images/site-hero.png"); + + // Act + var result = await pg.PostHtmlAsync(html, document, plugin, site); + + // Assert + result.ShouldNotContain(""); + result.ShouldContain("property=\"og:title\""); + result.ShouldContain("property=\"og:description\""); + result.ShouldContain("property=\"og:locale\""); + result.ShouldContain("property=\"og:url\""); + result.ShouldContain("property=\"og:image\""); + result.ShouldContain("property=\"og:site_name\""); + result.ShouldContain("name=\"twitter:card\""); + result.ShouldContain("name=\"twitter:site\" content=\"@site\""); + result.ShouldContain("name=\"twitter:creator\" content=\"@creator\""); + result.ShouldNotContain("{{CONTENT_TITLE}}"); + result.ShouldNotContain("{{CONTENT_DESCRIPTION}}"); + result.ShouldNotContain("{{CONTENT_LOCALE}}"); + result.ShouldNotContain("{{CONTENT_URL}}"); + result.ShouldNotContain("{{CONTENT_HERO_IMAGE_URL}}"); + result.ShouldNotContain("{{SITE_NAME}}"); + } + + [Theory] + [InlineData("Test")] + public async Task Given_NoTwitterSiteId_When_PostHtmlAsync_Invoked_Then_It_Should_Not_Render_TwitterSiteTag(string html) + { + // Arrange + var pg = new OpenGraphPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(twitterSiteId: null, twitterCreatorId: "@creator"); + var site = CreateSiteManifest(); + + // Act + var result = await pg.PostHtmlAsync(html, document, plugin, site); + + // Assert + result.ShouldNotContain(""); + result.ShouldNotContain("name=\"twitter:site\""); + } + + [Theory] + [InlineData("Test")] + public async Task Given_DocumentIsNotPost_When_PostHtmlAsync_Invoked_Then_It_Should_Not_Render_TwitterCreatorTag(string html) + { + // Arrange + var pg = new OpenGraphPlugin(); + var document = CreateDocument(kind: ContentKind.Page, title: "About", slug: "/about", twitterHandle: "@handle"); + var plugin = CreatePluginManifest(twitterSiteId: "@site", twitterCreatorId: "@creator"); + var site = CreateSiteManifest(); + + // Act + var result = await pg.PostHtmlAsync(html, document, plugin, site); + + // Assert + result.ShouldContain("name=\"twitter:site\" content=\"@site\""); + result.ShouldNotContain("name=\"twitter:creator\""); + } + + [Theory] + [InlineData("Test")] + public async Task Given_TwitterHandleInMetadata_When_PostHtmlAsync_Invoked_Then_It_Should_Override_TwitterCreatorIdOption(string html) + { + // Arrange + var pg = new OpenGraphPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world", twitterHandle: "@from-metadata"); + var plugin = CreatePluginManifest(twitterSiteId: "@site", twitterCreatorId: "@from-options"); + var site = CreateSiteManifest(); + + // Act + var result = await pg.PostHtmlAsync(html, document, plugin, site); + + // Assert + result.ShouldContain("name=\"twitter:creator\" content=\"@from-metadata\""); + result.ShouldNotContain("name=\"twitter:creator\" content=\"@from-options\""); + } + + [Theory] + [InlineData("Test")] + public async Task Given_NoDocumentDescription_When_PostHtmlAsync_Invoked_Then_It_Should_FallbackTo_SiteDescription(string html) + { + // Arrange + var pg = new OpenGraphPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world", description: null); + var plugin = CreatePluginManifest(twitterSiteId: null, twitterCreatorId: null); + var site = CreateSiteManifest(description: "Site description"); + + // Act + var result = await pg.PostHtmlAsync(html, document, plugin, site); + // Assert + result.ShouldContain("property=\"og:description\" content=\"Site description\""); + result.ShouldContain("name=\"twitter:description\" content=\"Site description\""); + } + + [Theory] + [InlineData("", 2)] + public async Task Given_MultiplePlaceholders_When_PostHtmlAsync_Invoked_Then_It_Should_Replace_AllOccurrences(string html, int expected) + { + // Arrange + var pg = new OpenGraphPlugin(); + var document = CreateDocument(kind: ContentKind.Post, title: "Hello", slug: "/hello-world"); + var plugin = CreatePluginManifest(twitterSiteId: null, twitterCreatorId: null); + var site = CreateSiteManifest(); + + // Act + var result = await pg.PostHtmlAsync(html, document, plugin, site); + var count = OpenGraphTitleRegex.Count(result); + + // Assert + result.ShouldNotContain(""); + count.ShouldBe(expected); + } + + private static ContentDocument CreateDocument( + ContentKind kind, + string title, + string slug, + string? description = "Document description", + string? heroImage = "/images/doc-hero.png", + string? twitterHandle = null) + { + return new ContentDocument + { + Kind = kind, + Metadata = new ContentMetadata + { + Title = title, + Slug = slug, + Description = description, + HeroImage = heroImage, + TwitterHandle = twitterHandle, + } + }; + } + + private static PluginManifest CreatePluginManifest(string? twitterSiteId = null, string? twitterCreatorId = null) + { + return new PluginManifest + { + Options = new Dictionary + { + { "TwitterSiteId", twitterSiteId }, + { "TwitterCreatorId", twitterCreatorId }, + } + }; + } + + private static SiteManifest CreateSiteManifest( + string siteUrl = "https://example.com", + string baseUrl = "", + string title = "Site title", + string description = "Site description", + string locale = "en-US", + string heroImage = "/images/site-hero.png") + { + return new SiteManifest + { + SiteUrl = siteUrl, + BaseUrl = baseUrl, + Title = title, + Description = description, + Locale = locale, + HeroImage = heroImage, + }; + } +} diff --git a/test/ScissorHands.Plugin.OpenGraph.Tests/ScissorHands.Plugin.OpenGraph.Tests.csproj b/test/ScissorHands.Plugin.OpenGraph.Tests/ScissorHands.Plugin.OpenGraph.Tests.csproj new file mode 100644 index 0000000..eb2a74a --- /dev/null +++ b/test/ScissorHands.Plugin.OpenGraph.Tests/ScissorHands.Plugin.OpenGraph.Tests.csproj @@ -0,0 +1,36 @@ + + + + false + + net10.0 + enable + enable + + latest + + ScissorHands.Plugin.OpenGraph.Tests + ScissorHands.Plugin.OpenGraph.Tests + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 9388bb03e63d63f093a743deabc82c198a4a76e1 Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Sun, 21 Dec 2025 00:47:21 +1100 Subject: [PATCH 3/4] Update README --- README.md | 13 ++-- .../README.md | 27 ++++++- src/ScissorHands.Plugin.OpenGraph/README.md | 75 +++++++++++++++++++ 3 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 src/ScissorHands.Plugin.OpenGraph/README.md diff --git a/README.md b/README.md index 2f1bfcb..7f8d3ec 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Collection of the official plugins for ScissorHands.NET ## List of Plugins -| Name | Description | -|-------------------------------------------------------------------------|--------------------------------| -| [Google Analytics](./src/ScissorHands.Plugin.GoogleAnalytics/README.md) | Google Analytics script plugin | +| Name | Description | +|-------------------------------------------------------------------------|-------------------------| +| [Google Analytics](./src/ScissorHands.Plugin.GoogleAnalytics/README.md) | Google Analytics plugin | +| [Open Graph](./src/ScissorHands.Plugin.OpenGraph/README.md) | Open Graph plugin | ## Build Your Plugin @@ -43,17 +44,17 @@ Collection of the official plugins for ScissorHands.NET { public override string Name => "My Awesome ScissorHands Plugin"; - public override async Task PreMarkdownAsync(ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + public override async Task PreMarkdownAsync(ContentDocument document, PluginManifest plugin, SiteManifest site, CancellationToken cancellationToken = default) { // ADD LOGIC HERE } - public override async Task PostMarkdownAsync(ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + public override async Task PostMarkdownAsync(ContentDocument document, PluginManifest plugin, SiteManifest site, CancellationToken cancellationToken = default) { // ADD LOGIC HERE } - public override async Task PostHtmlAsync(string html, ContentDocument document, PluginManifest manifest, CancellationToken cancellationToken = default) + public override async Task PostHtmlAsync(string html, ContentDocument document, PluginManifest plugin, SiteManifest site, CancellationToken cancellationToken = default) { // ADD LOGIC HERE } diff --git a/src/ScissorHands.Plugin.GoogleAnalytics/README.md b/src/ScissorHands.Plugin.GoogleAnalytics/README.md index 27ff259..f2f3321 100644 --- a/src/ScissorHands.Plugin.GoogleAnalytics/README.md +++ b/src/ScissorHands.Plugin.GoogleAnalytics/README.md @@ -43,11 +43,30 @@ This plugin renders [Google Analytics](https://analytics.google.com) script. dotnet add package ScissorHands.Plugin.GoogleAnalytics --prerelease ``` -1. Add the placeholder, ``, to `MainLayout.razor`. +1. Add a UI component, `` with parameters, to `MainLayout.razor`. **It's strongly advised to place right after the opening `` tag.** + + ```razor + + + @code { + protected PluginManifest? GoogleAnalyticsPlugin { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + GoogleAnalyticsPlugin = Plugins?.SingleOrDefault(p => p.Name!.Equals("Google Analytics", StringComparison.OrdinalIgnoreCase)); + } + } + ``` + + > **NOTE**: Those `@Documents`, `@Document`, `@Theme` and `@Site` values are inherited, and the `@GoogleAnalyticsPlugin` value is calculated from the `OnInitializedAsync()` method. + +1. Alternatively, instead of the `` component, add the placeholder, ``, to `MainLayout.razor`. **It's strongly advised to place right after the opening `` tag**. ```html - + + - - + ... ``` diff --git a/src/ScissorHands.Plugin.OpenGraph/README.md b/src/ScissorHands.Plugin.OpenGraph/README.md new file mode 100644 index 0000000..6df9e32 --- /dev/null +++ b/src/ScissorHands.Plugin.OpenGraph/README.md @@ -0,0 +1,75 @@ +# ScissorHands.NET: Open Graph Plugin + +This plugin renders the [Open Graph](https://ogp.me/) tags. + +## GitHub Nuget Package Registry + +1. Set environment variables for GitHub NuGet Package Registry. + + ```bash + # zsh/bash + export GH_PACKAGE_USERNAME="" + export GH_PACKAGE_TOKEN="" + ``` + + ```powershell + # PowerShell + $env:GH_PACKAGE_USERNAME = "" + $env:GH_PACKAGE_TOKEN = "" + ``` + +## Getting Started + +1. Assuming that you've got a running [ScissorHands.NET](https://github.com/getscissorhands/Scissorhands.NET) app. +1. Update the plugin section of `appsettings.json` to add options. `TwitterSiteId` is the Twitter handle for the website, and `TwitterCreatorId` is the default Twitter handle for the content authors. + + ```jsonc + { + ... + "Plugins": [ + { + "Name": "Open Graph", + "Options": { + "TwitterSiteId": "@your_twitter_handle_site", + "TwitterCreatorId": "@your_twitter_handle_creator" + } + } + ] + } + ``` + +1. Add a NuGet package. + + ```bash + dotnet add package ScissorHands.Plugin.OpenGraph --prerelease + ``` + +1. Add a UI component, `` with parameters, to `MainLayout.razor`. + + ```razor + + + @code { + protected PluginManifest? OpenGraphPlugin { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + OpenGraphPlugin = Plugins?.SingleOrDefault(p => p.Name!.Equals("Open Graph", StringComparison.OrdinalIgnoreCase)); + } + } + ``` + + > **NOTE**: Those `@Documents`, `@Document`, `@Theme` and `@Site` values are inherited, and the `@OpenGraphPlugin` value is calculated from the `OnInitializedAsync()` method. + +1. Alternatively, instead of the `` component, add the placeholder, ``, to `MainLayout.razor`. + + ```html + + + ... + + ... + + ``` From bc460652193070403324752a4d1eeb448fb4ed9b Mon Sep 17 00:00:00 2001 From: Justin Yoo Date: Sun, 21 Dec 2025 00:50:41 +1100 Subject: [PATCH 4/4] Update README --- src/ScissorHands.Plugin.OpenGraph/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ScissorHands.Plugin.OpenGraph/README.md b/src/ScissorHands.Plugin.OpenGraph/README.md index 6df9e32..dbcf2eb 100644 --- a/src/ScissorHands.Plugin.OpenGraph/README.md +++ b/src/ScissorHands.Plugin.OpenGraph/README.md @@ -38,6 +38,8 @@ This plugin renders the [Open Graph](https://ogp.me/) tags. } ``` + > **NOTE**: If you don't have any of both, you can omit the property. For example, you can omit both properties like `"Options": {}`. + 1. Add a NuGet package. ```bash