Plugins/seo-tag.ps1

<#
.SYNOPSIS
Built-in Hyde plugin that adds SEO metadata Liquid tag behavior.
 
.DESCRIPTION
`seo-tag` provides a Hyde equivalent of Jekyll SEO Tag behavior through a Liquid
tag implementation.
 
This plugin has no external installation requirements.
 
.PARAMETER Context
Plugin execution context supplied by Hyde.
 
.PARAMETER Install
Runs plugin installation flow.
 
For this plugin, installation is not required. The command returns `$true`.
 
.EXAMPLE
./src/Plugins/seo-tag.ps1 -Install
 
Returns `True` because no installation is needed.
#>

[CmdletBinding()]
param(
    $Context,

    [switch]$Install
)

if ($Install) {
    if ($VerbosePreference -eq 'Continue' -or $VerbosePreference -eq 'Inquire') {
        Write-Verbose "Plugin 'seo-tag' does not require installation."
    }

    return $true
}

# This built-in plugin provides a compact Hyde equivalent of the Jekyll SEO Tag plugin.
$null = $Context
@{
    Name = 'seo-tag'
    Liquid = @{
        Tags = @{
            seo = {
                param($Invocation)

                function Get-SeoValue {
                    param(
                        $InputObject,
                        [string]$Name
                    )

                    if ($null -eq $InputObject) {
                        return $null
                    }

                    if ($InputObject -is [hashtable] -and $InputObject.ContainsKey($Name)) {
                        return $InputObject[$Name]
                    }

                    $property = $InputObject.PSObject.Properties[$Name]
                    if ($null -ne $property) {
                        return $property.Value
                    }

                    return $null
                }

                function Get-SeoString {
                    param($Value)

                    if ($null -eq $Value) {
                        return ''
                    }

                    return [string]$Value
                }

                function Get-SeoAuthorName {
                    param($Author)

                    if ($null -eq $Author) {
                        return ''
                    }

                    if ($Author -is [string]) {
                        return $Author
                    }

                    $name = Get-SeoValue -InputObject $Author -Name 'name'
                    if (-not [string]::IsNullOrWhiteSpace([string]$name)) {
                        return [string]$name
                    }

                    return ''
                }

                function Resolve-SeoAbsoluteUrl {
                    param(
                        [string]$Value,
                        [string]$SiteUrl,
                        [string]$BaseUrl = ''
                    )

                    if ([string]::IsNullOrWhiteSpace($Value)) {
                        return ''
                    }

                    if ([System.Uri]::IsWellFormedUriString($Value, [System.UriKind]::Absolute)) {
                        return $Value
                    }

                    $normalizedPath = $Value.Trim()
                    if ([string]::IsNullOrWhiteSpace($SiteUrl)) {
                        return $normalizedPath
                    }

                    $combinedPath = if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
                        $normalizedPath
                    } else {
                        $BaseUrl.TrimEnd('/') + '/' + $normalizedPath.TrimStart('/')
                    }

                    return $SiteUrl.TrimEnd('/') + '/' + $combinedPath.TrimStart('/')
                }

                function Add-SeoMetaTag {
                    param(
                        [System.Collections.ArrayList]$Collection,
                        [string]$Name,
                        [string]$Content,
                        [string]$AttributeName = 'name'
                    )

                    if (-not [string]::IsNullOrWhiteSpace($Content)) {
                        [void]$Collection.Add('<meta ' + $AttributeName + '="' + [System.Net.WebUtility]::HtmlEncode($Name) + '" content="' + [System.Net.WebUtility]::HtmlEncode($Content) + '">')
                    }
                }

                # The SEO tag derives values from page metadata first, then falls back to site metadata.
                $site = & $Invocation.Helpers.ResolveVariable 'site'
                $page = & $Invocation.Helpers.ResolveVariable 'page'

                $siteTitle = Get-SeoString (Get-SeoValue -InputObject $site -Name 'title')
                $siteTagline = Get-SeoString (Get-SeoValue -InputObject $site -Name 'tagline')
                $siteDescription = Get-SeoString (Get-SeoValue -InputObject $site -Name 'description')
                $pageTitle = Get-SeoString (Get-SeoValue -InputObject $page -Name 'title')
                $pageDescription = Get-SeoString (Get-SeoValue -InputObject $page -Name 'description')
                $pageUrl = Get-SeoString (Get-SeoValue -InputObject $page -Name 'url')
                $baseUrl = Get-SeoString (Get-SeoValue -InputObject $site -Name 'baseurl')
                $siteUrl = Get-SeoString (Get-SeoValue -InputObject $site -Name 'url')
                if ([string]::IsNullOrWhiteSpace($siteUrl)) {
                    $github = Get-SeoValue -InputObject $site -Name 'github'
                    $siteUrl = Get-SeoString (Get-SeoValue -InputObject $github -Name 'url')
                }

                $pageImage = Get-SeoString (Get-SeoValue -InputObject $page -Name 'image')
                $siteLogo = Get-SeoString (Get-SeoValue -InputObject $site -Name 'logo')
                $siteTwitter = Get-SeoValue -InputObject $site -Name 'twitter'
                $siteFacebook = Get-SeoValue -InputObject $site -Name 'facebook'
                $siteSocial = Get-SeoValue -InputObject $site -Name 'social'
                $pageAuthor = Get-SeoValue -InputObject $page -Name 'author'
                $siteAuthor = Get-SeoValue -InputObject $site -Name 'author'
                $authorName = Get-SeoAuthorName -Author $(if ($null -ne $pageAuthor) { $pageAuthor } else { $siteAuthor })
                $locale = Get-SeoString (Get-SeoValue -InputObject $page -Name 'locale')
                if ([string]::IsNullOrWhiteSpace($locale)) {
                    $locale = Get-SeoString (Get-SeoValue -InputObject $page -Name 'lang')
                }
                if ([string]::IsNullOrWhiteSpace($locale)) {
                    $locale = Get-SeoString (Get-SeoValue -InputObject $site -Name 'locale')
                }
                if ([string]::IsNullOrWhiteSpace($locale)) {
                    $locale = Get-SeoString (Get-SeoValue -InputObject $site -Name 'lang')
                }
                if ([string]::IsNullOrWhiteSpace($locale)) {
                    $locale = 'en_US'
                }

                $fullTitle = if (-not [string]::IsNullOrWhiteSpace($pageTitle) -and -not [string]::IsNullOrWhiteSpace($siteTitle)) {
                    "$pageTitle | $siteTitle"
                } elseif (-not [string]::IsNullOrWhiteSpace($pageTitle)) {
                    $pageTitle
                } elseif (-not [string]::IsNullOrWhiteSpace($siteTitle) -and -not [string]::IsNullOrWhiteSpace($siteTagline)) {
                    "$siteTitle | $siteTagline"
                } elseif (-not [string]::IsNullOrWhiteSpace($siteTitle) -and -not [string]::IsNullOrWhiteSpace($siteDescription)) {
                    "$siteTitle | $siteDescription"
                } else {
                    $siteTitle
                }

                $description = if (-not [string]::IsNullOrWhiteSpace($pageDescription)) {
                    $pageDescription
                } else {
                    $siteDescription
                }

                $canonicalUrl = ''
                if (-not [string]::IsNullOrWhiteSpace($pageUrl)) {
                    $basePath = if ([string]::IsNullOrWhiteSpace($baseUrl)) {
                        $pageUrl
                    } else {
                        $baseUrl.TrimEnd('/') + '/' + $pageUrl.TrimStart('/')
                    }

                    $canonicalUrl = if ([string]::IsNullOrWhiteSpace($siteUrl)) {
                        $basePath
                    } else {
                        $siteUrl.TrimEnd('/') + '/' + $basePath.TrimStart('/')
                    }
                }

                $absoluteImageUrl = Resolve-SeoAbsoluteUrl -Value $pageImage -SiteUrl $siteUrl -BaseUrl $baseUrl
                $absoluteLogoUrl = Resolve-SeoAbsoluteUrl -Value $siteLogo -SiteUrl $siteUrl -BaseUrl $baseUrl
                $twitterCard = Get-SeoString (Get-SeoValue -InputObject $siteTwitter -Name 'card')
                if ([string]::IsNullOrWhiteSpace($twitterCard)) {
                    $twitterCard = 'summary'
                }

                $twitterUsername = Get-SeoString (Get-SeoValue -InputObject $siteTwitter -Name 'username')
                if (-not [string]::IsNullOrWhiteSpace($twitterUsername) -and -not $twitterUsername.StartsWith('@')) {
                    $twitterUsername = '@' + $twitterUsername.TrimStart('@')
                }

                $html = New-Object System.Collections.ArrayList
                if (-not [string]::IsNullOrWhiteSpace($fullTitle)) {
                    [void]$html.Add("<title>$([System.Net.WebUtility]::HtmlEncode($fullTitle))</title>")
                }

                Add-SeoMetaTag -Collection $html -Name 'description' -Content $description

                if (-not [string]::IsNullOrWhiteSpace($canonicalUrl)) {
                    [void]$html.Add('<link rel="canonical" href="' + [System.Net.WebUtility]::HtmlEncode($canonicalUrl) + '">')
                }

                Add-SeoMetaTag -Collection $html -Name 'author' -Content $authorName
                Add-SeoMetaTag -Collection $html -Name 'google-site-verification' -Content (Get-SeoString (Get-SeoValue -InputObject $site -Name 'google_site_verification'))

                $webmasterVerifications = Get-SeoValue -InputObject $site -Name 'webmaster_verifications'
                Add-SeoMetaTag -Collection $html -Name 'google-site-verification' -Content (Get-SeoString (Get-SeoValue -InputObject $webmasterVerifications -Name 'google'))
                Add-SeoMetaTag -Collection $html -Name 'msvalidate.01' -Content (Get-SeoString (Get-SeoValue -InputObject $webmasterVerifications -Name 'bing'))
                Add-SeoMetaTag -Collection $html -Name 'alexaVerifyID' -Content (Get-SeoString (Get-SeoValue -InputObject $webmasterVerifications -Name 'alexa'))
                Add-SeoMetaTag -Collection $html -Name 'yandex-verification' -Content (Get-SeoString (Get-SeoValue -InputObject $webmasterVerifications -Name 'yandex'))
                Add-SeoMetaTag -Collection $html -Name 'baidu-site-verification' -Content (Get-SeoString (Get-SeoValue -InputObject $webmasterVerifications -Name 'baidu'))
                Add-SeoMetaTag -Collection $html -Name 'facebook-domain-verification' -Content (Get-SeoString (Get-SeoValue -InputObject $webmasterVerifications -Name 'facebook'))

                # JSON-LD: site and page metadata
                $jsonLdGraph = New-Object System.Collections.ArrayList

                if (-not [string]::IsNullOrWhiteSpace($siteUrl)) {
                    [void]$jsonLdGraph.Add(@{
                        "@type" = 'WebSite'
                        "url" = $siteUrl
                        "name" = $siteTitle
                        "description" = $siteDescription
                    })
                }

                $organizationLinks = Get-SeoValue -InputObject $siteSocial -Name 'links'
                if ($organizationLinks -is [System.Collections.IEnumerable] -and $organizationLinks -isnot [string]) {
                    $organizationLinks = @($organizationLinks)
                } else {
                    $organizationLinks = @()
                }

                if (-not [string]::IsNullOrWhiteSpace($siteTitle) -and (
                        -not [string]::IsNullOrWhiteSpace($absoluteLogoUrl) -or
                        $organizationLinks.Count -gt 0 -or
                        -not [string]::IsNullOrWhiteSpace((Get-SeoString (Get-SeoValue -InputObject $siteSocial -Name 'name')))
                    )) {
                    $organization = @{
                        "@type" = 'Organization'
                        "name" = if (-not [string]::IsNullOrWhiteSpace((Get-SeoString (Get-SeoValue -InputObject $siteSocial -Name 'name')))) { Get-SeoString (Get-SeoValue -InputObject $siteSocial -Name 'name') } else { $siteTitle }
                    }

                    if (-not [string]::IsNullOrWhiteSpace($siteUrl)) {
                        $organization['url'] = $siteUrl
                    }

                    if (-not [string]::IsNullOrWhiteSpace($absoluteLogoUrl)) {
                        $organization['logo'] = $absoluteLogoUrl
                    }

                    if ($organizationLinks.Count -gt 0) {
                        $organization['sameAs'] = $organizationLinks
                    }

                    [void]$jsonLdGraph.Add($organization)
                }

                if (-not [string]::IsNullOrWhiteSpace($canonicalUrl)) {
                    $webPage = @{
                        "@type" = if ((Get-SeoString (Get-SeoValue -InputObject $page -Name 'collection')) -eq 'posts') { 'Article' } else { 'WebPage' }
                        "url" = $canonicalUrl
                        "name" = $fullTitle
                        "description" = $description
                        "inLanguage" = $locale
                    }

                    if (-not [string]::IsNullOrWhiteSpace($absoluteImageUrl)) {
                        $webPage['image'] = $absoluteImageUrl
                    }

                    if (-not [string]::IsNullOrWhiteSpace($authorName)) {
                        $webPage['author'] = @{
                            "@type" = 'Person'
                            "name" = $authorName
                        }
                    }

                    [void]$jsonLdGraph.Add($webPage)
                }

                if ($jsonLdGraph.Count -gt 0) {
                    $jsonLdPayload = @{
                        "@context" = 'https://schema.org'
                        "@graph" = @($jsonLdGraph)
                    }

                    $jsonLdString = $jsonLdPayload | ConvertTo-Json -Depth 8 -Compress
                    [void]$html.Add('<script type="application/ld+json">' + $jsonLdString + '</script>')
                }

                # Open Graph metadata
                $ogTitle = $fullTitle
                $ogDescription = $description
                $ogUrl = if (-not [string]::IsNullOrWhiteSpace($canonicalUrl)) { $canonicalUrl } else { '' }

                Add-SeoMetaTag -Collection $html -Name 'og:title' -Content $ogTitle -AttributeName 'property'
                Add-SeoMetaTag -Collection $html -Name 'og:description' -Content $ogDescription -AttributeName 'property'
                Add-SeoMetaTag -Collection $html -Name 'og:site_name' -Content $siteTitle -AttributeName 'property'
                Add-SeoMetaTag -Collection $html -Name 'og:url' -Content $ogUrl -AttributeName 'property'
                Add-SeoMetaTag -Collection $html -Name 'og:locale' -Content $locale -AttributeName 'property'
                Add-SeoMetaTag -Collection $html -Name 'og:image' -Content $absoluteImageUrl -AttributeName 'property'
                [void]$html.Add('<meta property="og:type" content="' + $(if ((Get-SeoString (Get-SeoValue -InputObject $page -Name 'collection')) -eq 'posts') { 'article' } else { 'website' }) + '">')

                # Twitter Summary Card metadata
                Add-SeoMetaTag -Collection $html -Name 'twitter:card' -Content $twitterCard
                Add-SeoMetaTag -Collection $html -Name 'twitter:title' -Content $ogTitle
                Add-SeoMetaTag -Collection $html -Name 'twitter:description' -Content $ogDescription
                Add-SeoMetaTag -Collection $html -Name 'twitter:url' -Content $ogUrl
                Add-SeoMetaTag -Collection $html -Name 'twitter:site' -Content $twitterUsername
                Add-SeoMetaTag -Collection $html -Name 'twitter:image' -Content $absoluteImageUrl

                # Facebook insights metadata
                Add-SeoMetaTag -Collection $html -Name 'fb:app_id' -Content (Get-SeoString (Get-SeoValue -InputObject $siteFacebook -Name 'app_id')) -AttributeName 'property'
                Add-SeoMetaTag -Collection $html -Name 'article:publisher' -Content (Get-SeoString (Get-SeoValue -InputObject $siteFacebook -Name 'publisher')) -AttributeName 'property'

                $facebookAdmins = Get-SeoValue -InputObject $siteFacebook -Name 'admins'
                if ($facebookAdmins -is [System.Collections.IEnumerable] -and $facebookAdmins -isnot [string]) {
                    foreach ($facebookAdmin in @($facebookAdmins)) {
                        Add-SeoMetaTag -Collection $html -Name 'fb:admins' -Content (Get-SeoString $facebookAdmin) -AttributeName 'property'
                    }
                } else {
                    Add-SeoMetaTag -Collection $html -Name 'fb:admins' -Content (Get-SeoString $facebookAdmins) -AttributeName 'property'
                }

                return ($html -join [Environment]::NewLine)
            }
        }
    }
}