diff --git a/docs/using/seo-metadata.md b/docs/using/seo-metadata.md new file mode 100644 index 0000000..56ef670 --- /dev/null +++ b/docs/using/seo-metadata.md @@ -0,0 +1,18 @@ +# SEO metadata + +OGP renders shared metadata in its existing Hugo templates. No template override or extension framework is required for these settings. + +| Setting | Behaviour | +| --- | --- | +| `params.seo_title` | Optional complete homepage title. Defaults to the site title, independently of the description. Language-specific site parameters can supply translated titles. Guide titles keep their existing guide-first edition format. | +| Page `og_image`, then `params.og_image`, then `params.logo_image` | Social image precedence. Hugo resolves relative image paths against the build's base URL; absolute URLs are preserved. Without an image, image tags are omitted. OGP does not guess image dimensions or image descriptions. | +| `params.logo_image` | Publisher logo in WebSite and CreativeWork structured data. Omitted when unconfigured. Use an existing asset or absolute image URL. | +| Page `guide_license` | Explicit publication licence text, also emitted in metadata and CreativeWork structured data with Markdown formatting removed. If absent, no licence is inferred from website copyright or another edition. | + +Social page URLs use Hugo's page permalink. Configure the build base URL for the intended host. This does not change canonical URL policy. + +CreativeWork authors use the same existing contributor discovery and `founder: true`, `role: creator` selection as the visible guide creators. Existing edition filtering is retained. A person's explicit URL takes precedence over their GitHub profile; a missing URL is omitted. If no creators are known, the author property is omitted. + +OGP does not advertise a `/search?q=…` search endpoint. Its existing search interface is unchanged. Historical editions do not claim to be based on the latest edition. These changes introduce no new adaptation relationship configuration. + +Publication discovery, reader templates, translation/PDF availability, routes, canonicals, sitemap/hreflang generation and XML/JSON feeds retain their existing behaviour. diff --git a/readme.md b/readme.md index a0e8142..a1c1d12 100644 --- a/readme.md +++ b/readme.md @@ -175,6 +175,7 @@ If you need help, include the command, finding and relevant report in a [GitHub - [Sample preview](https://blue-field-06cea8c03-preview.westeurope.6.azurestaticapps.net/) — the shared preview environment when deployed. PR previews use their own URL, provided by the deployment comment. - [First-time site setup](docs/using/first-adoption.md) — policy, existing files and deployment setup. +- [SEO metadata](docs/using/seo-metadata.md) — homepage titles, social images, publisher logos, authors and publication licences. - [Platform development](docs/platform-development.md) — build this repository, run the sample locally and understand releases. - [Workflow dependency locking](docs/platform-development.md#workflow-dependency-lockfile) — regenerate, verify and review Actions dependency locks when changing platform workflows. - [Execution plan and current progress](docs/architecture/open-guide-platform-execution-plan.md). diff --git a/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/creativework.html b/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/creativework.html index f43226a..6aeeec1 100644 --- a/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/creativework.html +++ b/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/creativework.html @@ -21,25 +21,34 @@ "datePublished": "{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}", "dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}", {{- $foundingContributors := partial "functions/get-contributors.html" . -}} - {{- $foundingContributors := where $foundingContributors "founding" true -}} + {{- $foundingContributors = where $foundingContributors "founder" true -}} + {{- $foundingContributors = where $foundingContributors "role" "creator" -}} + {{- with $foundingContributors }} "author": [ - {{- range $index, $creator := $foundingContributors -}} + {{- range $index, $creator := sort . "weight" -}} {{- if $index }},{{ end }} { "@type": "Person", - "name": "{{ $creator.name }}", + "name": "{{ $creator.name }}" + {{- if $creator.url }}, "url": {{ $creator.url }} + {{- else if $creator.githubUsername }}, + "url": {{ printf "https://github.com/%s" $creator.githubUsername }} + {{- end }} } {{- end }} ], + {{- end }} "publisher": { "@type": "Organization", "name": "{{ .Site.Title }}", - "url": {{ $siteURL }}, + "url": {{ $siteURL }} + {{ with .Site.Params.logo_image }}, "logo": { "@type": "ImageObject", - "url": {{ printf "%s%s" $siteURL "/images/logo.png" }} + "url": {{ . | absURL }} } + {{ end }} }, "mainEntityOfPage": { "@type": "WebPage", @@ -65,7 +74,9 @@ ], "keywords": "{{ delimit .Keywords ", " ", and " }}", "inLanguage": "{{ .Site.Language.Locale | default "en" }}", - "license": "© {{ now.Format "2006" }} {{ .Site.Title }}", + {{ with .Params.guide_license }} + "license": {{ . | markdownify | plainify }}, + {{ end }} "isPartOf": { "@type": "WebSite", "name": "{{ .Site.Title }}", diff --git a/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/website.html b/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/website.html index 0d8f1e0..813bb99 100644 --- a/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/website.html +++ b/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/JSON-LD/website.html @@ -11,19 +11,13 @@ "publisher": { "@type": "Organization", "name": "{{ .Site.Title }}", - "url": {{ $siteURL }}, + "url": {{ $siteURL }} + {{ with .Site.Params.logo_image }}, "logo": { "@type": "ImageObject", - "url": {{ printf "%s%s" $siteURL "/images/logo.png" }} + "url": {{ . | absURL }} } - }, - "potentialAction": { - "@type": "SearchAction", - "target": { - "@type": "EntryPoint", - "urlTemplate": {{ printf "%s%s" $siteURL "/search?q={search_term_string}" }} - }, - "query-input": "required name=search_term_string" + {{ end }} }, "sameAs": [ {{ .Site.Params.githubUrl }} diff --git a/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/components/seo/guide-structured-data.html b/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/components/seo/guide-structured-data.html index f0c0790..46dcf90 100644 --- a/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/components/seo/guide-structured-data.html +++ b/system/OpenGuidePlatform.Hugo.Guides/layouts/_partials/components/seo/guide-structured-data.html @@ -7,8 +7,6 @@ {{- if eq .Type "guide" -}} {{- $currentVersion := partial "functions/get-guide-version.html" . -}} {{- if $currentVersion -}} - {{- $latestVersion := partial "functions/get-latest-version" . -}} - {{- $isLatestVersion := eq $currentVersion $latestVersion -}} {{- $canonicalUrl := partial "functions/get-canonical-url.html" . -}} {{- $fullCanonicalUrl := printf "%s%s" .Site.Params.siteProdUrl $canonicalUrl -}} @@ -21,13 +19,6 @@ "datePublished": "{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}", "dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}", "version": "{{ $currentVersion }}", - {{- if not $isLatestVersion }} - "isBasedOn": { - "@type": "TechArticle", - "url": "{{ $fullCanonicalUrl }}", - "name": "{{ .Title }} (Latest Version)" - }, - {{- end }} "url": "{{ $fullCanonicalUrl }}", "mainEntityOfPage": { "@type": "WebPage", diff --git a/system/OpenGuidePlatform.Hugo.Guides/layouts/baseof.html b/system/OpenGuidePlatform.Hugo.Guides/layouts/baseof.html index 7749e65..81983be 100644 --- a/system/OpenGuidePlatform.Hugo.Guides/layouts/baseof.html +++ b/system/OpenGuidePlatform.Hugo.Guides/layouts/baseof.html @@ -20,7 +20,7 @@ {{ $title := .Title | default .Site.Title }} {{ if .IsHome }} - {{ $title = printf "%s - %s" .Site.Title .Site.Params.Description }} + {{ $title = .Site.Params.seo_title | default .Site.Title }} {{ else }} {{ $topSection := .Site.GetPage (printf "/%s" .Section) }} {{ if eq $topSection.Type "guide" }} @@ -76,28 +76,31 @@ {{ partial "components/seo/guide-version-links.html" . }} {{/* Open Graph / Facebook Meta Tags */}} + {{ $socialImage := .Params.og_image | default .Site.Params.og_image | default .Site.Params.logo_image }} - + - - - - + {{ with $socialImage }} + + {{ end }} {{/* Twitter Card Meta Tags */}} - + - - + {{ with $socialImage }} + + {{ end }} {{/* Additional Meta Tags */}} - + {{ with .Params.guide_license }} + + {{ end }} {{- if eq (.Site.Language.LanguageDirection | default "ltr") "rtl" }} diff --git a/tests/Core/HugoSeoMetadata.Tests.ps1 b/tests/Core/HugoSeoMetadata.Tests.ps1 new file mode 100644 index 0000000..5d24382 --- /dev/null +++ b/tests/Core/HugoSeoMetadata.Tests.ps1 @@ -0,0 +1,116 @@ +BeforeAll { + $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $module = Join-Path $root 'system/OpenGuidePlatform.Hugo.Guides' + $fixture = Join-Path $TestDrive 'seo' + foreach ($directory in @('themes/guides/layouts/_partials','layouts/guide','content/guide/2025.1','content/guide/2024.1','content/unknown/2025.1','data/contributions')) { + [IO.Directory]::CreateDirectory("$fixture/$directory") | Out-Null + } + Copy-Item "$module/layouts/_partials/*" "$fixture/themes/guides/layouts/_partials/" -Recurse + Copy-Item "$module/layouts/baseof.html" "$fixture/themes/guides/layouts/baseof.html" + # Keep the complete production head; suppress unrelated body components only. + $wrapper = '{{ define "menu" }}{{ end }}{{ define "breadcrumbs" }}{{ end }}{{ define "main" }}{{ .Content }}{{ end }}' + foreach ($layout in @('index.html','guide/single.html','guide/list.html')) { + [IO.File]::WriteAllText("$fixture/layouts/$layout", $wrapper) + } + [IO.File]::WriteAllText("$fixture/data/contributions/guide.yaml", @' +- name: 'Creator "One"' + role: creator + founder: true + githubUsername: creator-one + contributions: ['2025.1', '2024.1'] + weight: 1 +- name: Creator Two + role: creator + founder: true + contributions: ['2025.1'] + weight: 2 +- name: Contributor Only + role: contributor + founder: true + contributions: ['2025.1'] +'@) + foreach ($guide in @('guide','unknown')) { + [IO.File]::WriteAllText("$fixture/content/$guide/_index.md", "---`ntitle: Fixture $guide`ntype: guide`n---") + } + foreach ($language in @('en','ja')) { + [IO.File]::WriteAllText("$fixture/content/guide/2025.1/index.$language.md", "---`ntitle: Current guide`ntype: guide`ndate: 2025-01-01`nguide_license: 'CC BY 4.0'`nog_image: https://cdn.example/edition.png`n---`nUnchanged guide body.") + [IO.File]::WriteAllText("$fixture/content/guide/2024.1/index.$language.md", "---`ntitle: Older guide`ntype: guide`ndate: 2024-01-01`n---`nHistorical guide body.") + } + [IO.File]::WriteAllText("$fixture/content/unknown/2025.1/index.md", "---`ntitle: Unknown authors`ntype: guide`ndate: 2025-01-01`n---`nBody.") + function Build-SeoFixture([string]$name, [string]$extra) { + [IO.File]::WriteAllText("$fixture/hugo.yaml", @" +baseURL: https://fixture.example/ +title: Fixture site +theme: guides +defaultContentLanguage: en +disableKinds: [taxonomy, term, RSS, sitemap] +languages: + en: {weight: 1} + ja: {weight: 2} +params: + description: A long description which must not become the homepage title. + keywords: guides + siteProdUrl: https://fixture.example +$extra +"@) + $log = @(& hugo --source $fixture --destination "$fixture/$name" 2>&1) + if ($LASTEXITCODE -ne 0 -or @($log | Where-Object { $_ -match '^ERROR' }).Count) { + throw "SEO fixture failed: $($log -join [Environment]::NewLine)" + } + } + function Read-SeoPage([string]$path) { [IO.File]::ReadAllText("$fixture/$path") } + function Read-Schema([string]$html, [string]$type) { + @([regex]::Matches($html, '(?s)') | ForEach-Object { + ConvertFrom-Json $_.Groups[1].Value + }) | Where-Object { $_.'@type' -eq $type } + } + Build-SeoFixture 'configured' " logo_image: /images/brand.png`n og_image: /images/social.png`n seo_title: Editorial homepage title" + Build-SeoFixture 'fallback' ' logo_image: https://cdn.example/logo.png' + Build-SeoFixture 'absent' '' +} + +Describe 'Rendered SEO metadata' { + It 'uses a separate homepage title with site-name fallback' { + (Read-SeoPage 'configured/index.html') | Should -Match 'Editorial homepage title' + (Read-SeoPage 'fallback/index.html') | Should -Match 'Fixture site' + (Read-SeoPage 'fallback/index.html') | Should -Match 'name="description" content="A long description' + } + It 'uses configured publisher logos and removes the nonexistent search endpoint' { + $schema = Read-Schema (Read-SeoPage 'configured/index.html') 'WebSite' + $schema.publisher.logo.url | Should -Be 'https://fixture.example/images/brand.png' + $schema.PSObject.Properties.Name | Should -Not -Contain 'potentialAction' + (Read-Schema (Read-SeoPage 'absent/index.html') 'WebSite').publisher.PSObject.Properties.Name | Should -Not -Contain 'logo' + } + It 'uses absolute social URLs, page then site image overrides, and logo fallback' { + $homepageHtml = Read-SeoPage 'configured/index.html' + $homepageHtml | Should -Match 'property="og:url" content="https://fixture.example/"' + $homepageHtml | Should -Match 'property="og:image" content="https://fixture.example/images/social.png"' + $homepageHtml | Should -Not -Match 'og:image:(width|height)' + (Read-SeoPage 'fallback/index.html') | Should -Match 'name="twitter:image" content="https://cdn.example/logo.png"' + (Read-SeoPage 'absent/index.html') | Should -Not -Match '(property="og:image"|name="twitter:image")' + $translated = Read-SeoPage 'configured/ja/guide/2025.1/index.html' + $translated | Should -Match 'name="twitter:url" content="https://fixture.example/ja/guide/2025.1/"' + $translated | Should -Match 'property="og:image" content="https://cdn.example/edition.png"' + } + It 'uses the existing version-filtered founding creators and preserves quoted names' { + $current = Read-Schema (Read-SeoPage 'configured/guide/2025.1/index.html') 'CreativeWork' + @($current.author).Count | Should -Be 2 + $current.author[0].name | Should -Be 'Creator "One"' + $current.author[0].url | Should -Be 'https://github.com/creator-one' + $current.author[1].PSObject.Properties.Name | Should -Not -Contain 'url' + $current.publisher.logo.url | Should -Be 'https://fixture.example/images/brand.png' + $older = Read-Schema (Read-SeoPage 'configured/guide/2024.1/index.html') 'CreativeWork' + @($older.author).Count | Should -Be 1 + (Read-Schema (Read-SeoPage 'configured/unknown/2025.1/index.html') 'CreativeWork').PSObject.Properties.Name | Should -Not -Contain 'author' + } + It 'emits only explicit publication licensing and no fabricated edition ancestry' { + $current = Read-SeoPage 'configured/guide/2025.1/index.html' + (Read-Schema $current 'CreativeWork').license | Should -Be 'CC BY 4.0' + $current | Should -Match 'name="license" content="CC BY 4.0"' + $older = Read-SeoPage 'configured/guide/2024.1/index.html' + (Read-Schema $older 'CreativeWork').PSObject.Properties.Name | Should -Not -Contain 'license' + (Read-Schema $older 'TechArticle').PSObject.Properties.Name | Should -Not -Contain 'isBasedOn' + $older | Should -Not -Match 'name="license"' + $older | Should -Match 'Fixture guide \| January 2024 \| Fixture site' + } +}