feat(seo): per-page OG/Twitter/JSON-LD metadata (#202)

Closes #187. Adds canonical links, descriptions, Open Graph and Twitter card metadata, plus Article JSON-LD for weekly and topic pages so Morbo's #190 distribution launch can rely on rich social previews. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 25, 2026 at 22:56 UTC e30d7fded57b11401cef4fb906731fd6c51bc6a0
3 files changed +53 -10
hugo.toml
+2
@@ -21,6 +21,8 @@ rssLimit = 20
21
22 [params]
23 ga_measurement_id = ""
24 + description = 'SquadScope turns GitHub activity into readable weekly, monthly, and yearly trend reports for busy developers and tech leads.'
25 + default_social_image = '/images/squadscope-social-card.png'
26 defaultTheme = 'auto'
27 disableSpecial1stPost = true
28 ShowReadingTime = true
layouts/partials/head.html
+1 -10
@@ -7,8 +7,7 @@
7 <meta name="robots" content="noindex, nofollow">
8 {{- end }}
9
10 -{{- /* Title */}}
11 -<title>{{ if .IsHome }}{{ else }}{{ if .Title }}{{ .Title }} | {{ end }}{{ end }}{{ site.Title }}</title>
10 +{{- partial "seo.html" . -}}
11
12 {{- /* Meta */}}
13 {{- if .IsHome }}
@@ -18,11 +17,6 @@
17 {{- range $i, $e := .Params.keywords }}{{ if $i }}, {{ end }}{{ $e }}{{ end }} {{- else }}
18 {{- range $i, $e := .Params.tags }}{{ if $i }}, {{ end }}{{ $e }}{{ end }} {{- end -}}">
19 {{- end }}
21 -<meta name="description" content="{{- with .Description }}{{ . }}{{- else }}{{- if or .IsPage .IsSection}}
22 - {{- .Summary | default (printf "%s - %s" .Title site.Title) }}{{- else }}
23 - {{- with site.Params.description }}{{ . }}{{- end }}{{- end }}{{- end -}}">
24 -<meta name="author" content="{{ (partial "author.html" . ) }}">
25 -<link rel="canonical" href="{{ if .Params.canonicalURL -}} {{ trim .Params.canonicalURL " " }} {{- else -}} {{ .Permalink }} {{- end }}">
20 {{- if site.Params.analytics.google.SiteVerificationTag }}
21 <meta name="google-site-verification" content="{{ site.Params.analytics.google.SiteVerificationTag }}">
22 {{- end }}
@@ -197,7 +191,4 @@
191 {{- /* Misc */}}
192 {{- if hugo.IsProduction | or (eq site.Params.env "production") }}
193 {{- partial "google_analytics.html" . }}
200 -{{- partial "templates/opengraph.html" . }}
201 -{{- partial "templates/twitter_cards.html" . }}
202 -{{- partial "templates/schema_json.html" . }}
194 {{- end -}}
layouts/partials/seo.html new
+50
@@ -0,0 +1,50 @@
1 +{{- $title := cond .IsHome .Site.Title (printf "%s | %s" .Title .Site.Title) -}}
2 +{{- $siteDescription := .Site.Params.description | default "SquadScope turns GitHub activity into weekly tech trend analysis for developers and tech leads." -}}
3 +{{- $description := .Description | default .Summary | default $siteDescription | plainify | htmlUnescape | chomp -}}
4 +{{- $description = $description | truncate 155 -}}
5 +{{- $defaultImage := .Site.Params.default_social_image | default "/images/squadscope-social-card.png" -}}
6 +{{- /* Placeholder social card path: Calculon will provide the 1200×630 image asset separately. */ -}}
7 +{{- $imageParam := .Params.featured_image | default $defaultImage -}}
8 +{{- $image := $imageParam -}}
9 +{{- if not (or (hasPrefix $imageParam "http://") (hasPrefix $imageParam "https://")) -}}
10 +{{- $image = (strings.TrimPrefix "/" $imageParam) | absURL -}}
11 +{{- end -}}
12 +{{- $publisherLogo := $defaultImage -}}
13 +{{- if not (or (hasPrefix $publisherLogo "http://") (hasPrefix $publisherLogo "https://")) -}}
14 +{{- $publisherLogo = (strings.TrimPrefix "/" $publisherLogo) | absURL -}}
15 +{{- end -}}
16 +{{- $isArticle := or (and .IsPage (eq .Section "weekly")) (eq .Type "topics") -}}
17 +{{- $type := cond $isArticle "article" "website" -}}
18 +
19 +<title>{{ $title }}</title>
20 +<meta name="description" content="{{ $description }}">
21 +<meta name="author" content="{{ .Site.Params.author | default "SquadScope" }}">
22 +<link rel="canonical" href="{{ .Permalink }}">
23 +
24 +<meta property="og:title" content="{{ $title }}">
25 +<meta property="og:description" content="{{ $description }}">
26 +<meta property="og:image" content="{{ $image }}">
27 +<meta property="og:url" content="{{ .Permalink }}">
28 +<meta property="og:type" content="{{ $type }}">
29 +<meta property="og:site_name" content="{{ .Site.Title }}">
30 +
31 +<meta name="twitter:card" content="summary_large_image">
32 +<meta name="twitter:title" content="{{ $title }}">
33 +<meta name="twitter:description" content="{{ $description }}">
34 +<meta name="twitter:image" content="{{ $image }}">
35 +
36 +{{- if $isArticle }}
37 +{{- $publisher := dict "@type" "Organization" "name" "SquadScope" "logo" (dict "@type" "ImageObject" "url" $publisherLogo) -}}
38 +{{- $schema := dict
39 + "@context" "https://schema.org"
40 + "@type" "Article"
41 + "headline" .Title
42 + "description" $description
43 + "image" $image
44 + "url" .Permalink
45 + "datePublished" (.Date.Format "2006-01-02T15:04:05Z07:00")
46 + "author" (dict "@type" "Organization" "name" "SquadScope")
47 + "publisher" $publisher
48 +-}}
49 +<script type="application/ld+json">{{ $schema | jsonify | safeJS }}</script>
50 +{{- end }}