Skip to content

Commit ab548d0

Browse files
committed
feat(seo): technical metadata, structured data, and purged self-hosted assets
Head SEO (baseof.html): - add rel=canonical and og:locale on every page - fix home og:title (was the brandless page title); de-duplicate article <title> via a new params.brand suffix - emit article:published_time/modified_time/author/section/tag for posts - switch Twitter tags to name= and add twitter:site/creator - prefer og_description for social cards, keep full description for SERP Structured data (new partials, gated by section): - schema-site.html: Organization + WebSite on the home page - schema-article.html: Article + BreadcrumbList on articles and podcasts Crawl: - enable robots.txt advertising the sitemap (layouts/robots.txt) Performance: - replace the ~2.9 MB Tailwind CDN file with a ~35 KB purged, self-hosted build served minify|fingerprint via Hugo Pipes (assets/css/tailwind.css) - self-host Inter from assets/fonts via @font-face + preload; drop the render-blocking Google Fonts @import; add CDN preconnect hints - regen tooling: purgecss.config.cjs + scripts/build-tailwind.mjs (npm run build:css)
1 parent f683b78 commit ab548d0

10 files changed

Lines changed: 231 additions & 25 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ public/
44
# Hugo image-processing cache (regenerated on every build)
55
resources/_gen/
66

7+
# Tailwind purge working dir (throwaway full build + downloaded source CSS)
8+
tmp/
9+
710
# Node / build tooling
811
node_modules/
912
package-lock.json

‎assets/css/tailwind.css‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎hugo.yaml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ title: 'PowerShell.org - Welcome Automaters!'
44
theme: 'powershell-community'
55
timeZone: UTC
66

7-
# Enable RSS feeds
7+
# Enable RSS feeds and robots.txt
88
enableRSSFeed: true
9+
enableRobotsTXT: true
910

1011
# Updated pagination syntax for Hugo v0.128+
1112
pagination:
@@ -105,6 +106,8 @@ menu:
105106
params:
106107
description: "PowerShell.org is the community hub for PowerShell enthusiasts, automaters, and professionals. Join us for podcasts, summits, learning resources, and vibrant community discussions."
107108
author: "PowerShell.org Community"
109+
brand: "PowerShell.org"
110+
twitter_handle: "@powershell_org"
108111

109112
# Social links
110113
social:

‎layouts/robots.txt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
User-agent: *
2+
Disallow:
3+
4+
Sitemap: {{ "sitemap.xml" | absURL }}

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
"scripts": {
77
"dev": "hugo server -D --disableFastRender",
88
"build": "hugo --gc --minify",
9+
"build:css": "node scripts/build-tailwind.mjs",
910
"preview": "hugo server --environment production"
1011
},
1112
"devDependencies": {
1213
"hugo-extended": "^0.155.1",
13-
"node-fetch": "^3.3.2"
14+
"node-fetch": "^3.3.2",
15+
"purgecss": "^5.0.0"
1416
}
1517
}

‎purgecss.config.cjs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Strips unused Tailwind 2.2.19 rules against the fully built site.
2+
// Retained rules are byte-identical to the CDN file, so there is no visual
3+
// change for classes actually used anywhere in the output.
4+
//
5+
// Regenerate after changing markup that introduces new Tailwind classes:
6+
// npm run build:css (see scripts/build-tailwind.mjs)
7+
// then commit the updated assets/css/tailwind.css.
8+
module.exports = {
9+
content: ['tmp/purge-src/**/*.html', 'tmp/purge-src/**/*.js'],
10+
css: ['tmp/tailwind-src.css'],
11+
// Tailwind-aware extractor: keep `:` `/` `.` `%` in candidate class tokens.
12+
defaultExtractor: (content) => content.match(/[\w-/:%.\[\]!]+/g) || [],
13+
// Classes toggled only at runtime (Alpine `:class`, JS classList) — these do
14+
// appear as literals in the built HTML, but pin them so markup churn is safe.
15+
safelist: {
16+
standard: [
17+
'hidden', 'block', 'flex',
18+
'transform', 'absolute', 'inset-0', 'w-full', 'h-full',
19+
/^rotate-/, /^translate-/, /^opacity-/, /^scale-/,
20+
// Activity-dot colors injected at build time from data/community_stats.json
21+
// (see .github/scripts/fetch-discourse-activity.js + deploy.yml fallback).
22+
// These never appear in committed markup, so pin the whole status palette.
23+
'bg-blue-500', 'bg-green-500', 'bg-purple-500',
24+
'bg-orange-500', 'bg-red-500', 'bg-yellow-500',
25+
],
26+
},
27+
output: 'assets/css/tailwind.css',
28+
};

‎scripts/build-tailwind.mjs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env node
2+
// Regenerates assets/css/tailwind.css — the Tailwind 2.2.19 utility set purged
3+
// down to only the classes the built site actually uses (~35 KB vs ~2.9 MB).
4+
//
5+
// Prereq: `npm install` (needs the `purgecss` devDependency).
6+
// Run after changing markup that introduces new Tailwind classes:
7+
// npm run build:css
8+
// then commit the updated assets/css/tailwind.css. CI ships the committed file
9+
// as-is (the deploy builds run bare `hugo`, not this script).
10+
//
11+
// Steps: download the pinned Tailwind source, build the whole site to a throwaway
12+
// dir, then purge the source against that output using purgecss.config.cjs.
13+
import { execSync } from 'node:child_process';
14+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
15+
import { createRequire } from 'node:module';
16+
import { resolve } from 'node:path';
17+
import { PurgeCSS } from 'purgecss';
18+
19+
const TAILWIND_VERSION = '2.2.19';
20+
const SRC_URL = `https://cdn.jsdelivr.net/npm/tailwindcss@${TAILWIND_VERSION}/dist/tailwind.min.css`;
21+
const BASE_URL = 'https://powershell.org';
22+
23+
const config = createRequire(import.meta.url)(resolve('purgecss.config.cjs'));
24+
25+
mkdirSync('tmp', { recursive: true });
26+
27+
console.log(`> downloading Tailwind ${TAILWIND_VERSION}`);
28+
const res = await fetch(SRC_URL);
29+
if (!res.ok) throw new Error(`failed to download ${SRC_URL}: ${res.status}`);
30+
writeFileSync('tmp/tailwind-src.css', Buffer.from(await res.arrayBuffer()));
31+
32+
// baseof.html does `resources.Get "css/tailwind.css"`, so the input build needs
33+
// the file to exist. On a fresh checkout it's committed; only bootstrap a
34+
// placeholder when missing (class usage in the HTML is independent of its bytes).
35+
if (!existsSync(config.output)) {
36+
mkdirSync(resolve(config.output, '..'), { recursive: true });
37+
writeFileSync(config.output, '/* placeholder — regenerated below */');
38+
}
39+
40+
console.log('> building site to tmp/purge-src');
41+
execSync(`hugo --destination tmp/purge-src --baseURL ${BASE_URL}`, { stdio: 'inherit' });
42+
43+
console.log('> purging unused Tailwind rules');
44+
const [result] = await new PurgeCSS().purge(config);
45+
writeFileSync(config.output, result.css);
46+
47+
console.log(`> done — ${config.output} regenerated (${result.css.length} bytes)`);

‎themes/powershell-community/layouts/_default/baseof.html‎

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,26 @@
44
<head>
55
<meta charset="utf-8">
66
<meta name="viewport" content="width=device-width, initial-scale=1">
7-
<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} - {{ .Site.Title }}{{ end }}</title>
8-
<meta name="description"
9-
content="{{ if .Description }}{{ .Description }}{{ else if .IsHome }}{{ .Site.Params.description }}{{ else }}{{ .Summary | truncate 155 }}{{ end }}">
7+
8+
{{- $brand := .Site.Params.brand | default .Site.Title -}}
9+
{{- $ogTitle := cond .IsHome .Site.Title .Title -}}
10+
{{- $title := cond .IsHome .Site.Title (printf "%s — %s" .Title $brand) -}}
11+
{{- $metaDesc := .Site.Params.description -}}
12+
{{- if .Description }}{{ $metaDesc = .Description }}{{ else if not .IsHome }}{{ $metaDesc = (.Summary | plainify | truncate 160) }}{{ end -}}
13+
{{- $socialDesc := $metaDesc -}}
14+
{{- with .Params.og_description }}{{ $socialDesc = . }}{{ end -}}
15+
{{- $ogImage := partial "og-image.html" . -}}
16+
{{- $interReg := resources.Get "fonts/Inter-Regular.ttf" | fingerprint -}}
17+
{{- $interBold := resources.Get "fonts/Inter-Bold.ttf" | fingerprint -}}
18+
19+
<title>{{ $title }}</title>
20+
<meta name="description" content="{{ $metaDesc }}">
21+
<link rel="canonical" href="{{ .Permalink }}">
22+
23+
<!-- Resource hints -->
24+
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
25+
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
26+
<link rel="preload" href="{{ $interBold.RelPermalink }}" as="font" type="font/ttf" crossorigin>
1027

1128
<!-- Favicon -->
1229
<link rel="icon" type="image/x-icon" href="/favicon.ico">
@@ -20,38 +37,61 @@
2037
<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
2138
{{ end -}}
2239

23-
<!-- Social sharing image: dynamic per-article card when `og_title` is set,
24-
else `image` front matter, else the site default (see partials/og-image.html) -->
25-
{{ $ogImage := partial "og-image.html" . }}
26-
2740
<!-- Open Graph / Facebook -->
28-
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
41+
<meta property="og:type" content="{{ cond .IsPage "article" "website" }}">
2942
<meta property="og:url" content="{{ .Permalink }}">
30-
<meta property="og:title" content="{{ .Title }}">
31-
<meta property="og:description"
32-
content="{{ if .Description }}{{ .Description }}{{ else }}{{ .Summary | truncate 155 }}{{ end }}">
43+
<meta property="og:title" content="{{ $ogTitle }}">
44+
<meta property="og:description" content="{{ $socialDesc }}">
3345
<meta property="og:site_name" content="{{ .Site.Title }}">
46+
<meta property="og:locale" content="en_US">
3447
<meta property="og:image" content="{{ $ogImage }}">
3548
<meta property="og:image:width" content="1200">
3649
<meta property="og:image:height" content="630">
37-
<meta property="og:image:alt" content="{{ .Title }} — PowerShell.org, the home of the PowerShell community">
50+
<meta property="og:image:alt" content="{{ $ogTitle }} — PowerShell.org, the home of the PowerShell community">
51+
{{- if and .IsPage (in (slice "articles" "podcast") .Section) }}
52+
<meta property="article:published_time" content="{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}">
53+
<meta property="article:modified_time" content="{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}">
54+
{{- range .Params.authors }}
55+
<meta property="article:author" content="{{ . }}">
56+
{{- end }}
57+
{{- with .Params.categories }}
58+
<meta property="article:section" content="{{ index . 0 }}">
59+
{{- end }}
60+
{{- range .Params.tags }}
61+
<meta property="article:tag" content="{{ . }}">
62+
{{- end }}
63+
{{- end }}
3864

3965
<!-- Twitter -->
40-
<meta property="twitter:card" content="summary_large_image">
41-
<meta property="twitter:url" content="{{ .Permalink }}">
42-
<meta property="twitter:title" content="{{ .Title }}">
43-
<meta property="twitter:description"
44-
content="{{ if .Description }}{{ .Description }}{{ else }}{{ .Summary | truncate 155 }}{{ end }}">
45-
<meta property="twitter:image" content="{{ $ogImage }}">
46-
47-
<!-- CSS -->
48-
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
66+
<meta name="twitter:card" content="summary_large_image">
67+
<meta name="twitter:url" content="{{ .Permalink }}">
68+
<meta name="twitter:title" content="{{ $ogTitle }}">
69+
<meta name="twitter:description" content="{{ $socialDesc }}">
70+
<meta name="twitter:image" content="{{ $ogImage }}">
71+
{{- with .Site.Params.twitter_handle }}
72+
<meta name="twitter:site" content="{{ . }}">
73+
<meta name="twitter:creator" content="{{ . }}">
74+
{{- end }}
75+
76+
<!-- Structured data (JSON-LD) -->
77+
{{ if .IsHome }}{{ partial "schema-site.html" . }}{{ end }}
78+
{{ if and .IsPage (in (slice "articles" "podcast") .Section) }}{{ partial "schema-article.html" . }}{{ end }}
79+
80+
<!-- CSS — Tailwind purged against the built site, minified + fingerprinted -->
81+
{{ $tailwind := resources.Get "css/tailwind.css" | minify | fingerprint }}
82+
<link href="{{ $tailwind.RelPermalink }}" rel="stylesheet">
4983
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
5084
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/themes/prism-tomorrow.min.css" rel="stylesheet">
5185
<link href="/css/alerts.css" rel="stylesheet">
5286
<link href="/css/code-copy.css" rel="stylesheet">
5387
{{ with .Site.Params.algolia }}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@algolia/algoliasearch-netlify-frontend@1/dist/algoliasearchNetlify.css" />{{ end }}
5488

89+
<!-- Self-hosted Inter (replaces render-blocking Google Fonts @import) -->
90+
<style>
91+
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap; src: url('{{ $interReg.RelPermalink }}') format('truetype'); }
92+
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap; src: url('{{ $interBold.RelPermalink }}') format('truetype'); }
93+
</style>
94+
5595
<!-- Custom CSS -->
5696
<style>
5797
/* Prose typography styles (Tailwind CDN doesn't include @tailwindcss/typography) */
@@ -175,8 +215,7 @@
175215
overflow: hidden;
176216
}
177217

178-
/* Import Gotham or use system fallbacks */
179-
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
218+
/* Inter is self-hosted via @font-face above; this is the fallback stack */
180219

181220
/* Gotham fallback styling */
182221
.gotham-black {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{{- /*
2+
schema-article.html — Article + BreadcrumbList JSON-LD for content pages.
3+
Emitted from baseof.html for the `articles` and `podcast` sections.
4+
*/ -}}
5+
{{- $brand := .Site.Params.brand | default .Site.Title -}}
6+
{{- $ogImage := partial "og-image.html" . -}}
7+
{{- $logo := absURL "images/logo.png" -}}
8+
9+
{{- /* authors as schema.org Person nodes, linked to their author page when one exists */ -}}
10+
{{- $authors := slice -}}
11+
{{- range .Params.authors -}}
12+
{{- $person := dict "@type" "Person" "name" . -}}
13+
{{- with site.GetPage (printf "/authors/%s" (. | urlize)) -}}
14+
{{- $person = merge $person (dict "url" .Permalink) -}}
15+
{{- end -}}
16+
{{- $authors = $authors | append $person -}}
17+
{{- end -}}
18+
19+
{{- $article := dict
20+
"@type" "Article"
21+
"headline" .Title
22+
"description" ($.Description | default ($.Summary | plainify | truncate 160))
23+
"image" $ogImage
24+
"datePublished" (.Date.Format "2006-01-02T15:04:05Z07:00")
25+
"dateModified" (.Lastmod.Format "2006-01-02T15:04:05Z07:00")
26+
"publisher" (dict "@type" "Organization" "name" $brand "logo" (dict "@type" "ImageObject" "url" $logo))
27+
"mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink)
28+
"wordCount" .WordCount
29+
-}}
30+
{{- with $authors }}{{ $article = merge $article (dict "author" .) }}{{ end -}}
31+
{{- with .Params.categories }}{{ $article = merge $article (dict "articleSection" (index . 0)) }}{{ end -}}
32+
{{- with .Params.tags }}{{ $article = merge $article (dict "keywords" (delimit . ", ")) }}{{ end -}}
33+
34+
{{- /* breadcrumb: Home > (Articles|Podcast) > this page */ -}}
35+
{{- $crumbs := slice (dict "@type" "ListItem" "position" 1 "name" "Home" "item" (absURL "/")) -}}
36+
{{- if eq .Section "podcast" -}}
37+
{{- $crumbs = $crumbs | append (dict "@type" "ListItem" "position" 2 "name" "Podcast" "item" (absURL "/podcast/")) -}}
38+
{{- else -}}
39+
{{- $crumbs = $crumbs | append (dict "@type" "ListItem" "position" 2 "name" "Articles" "item" (absURL "/articles/")) -}}
40+
{{- end -}}
41+
{{- $crumbs = $crumbs | append (dict "@type" "ListItem" "position" 3 "name" .Title "item" .Permalink) -}}
42+
{{- $breadcrumb := dict "@type" "BreadcrumbList" "itemListElement" $crumbs -}}
43+
44+
{{- $graph := dict "@context" "https://schema.org" "@graph" (slice $article $breadcrumb) -}}
45+
<script type="application/ld+json">{{ $graph | jsonify (dict "indent" " ") | safeJS }}</script>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{{- /*
2+
schema-site.html — Organization + WebSite JSON-LD for the home page.
3+
*/ -}}
4+
{{- $brand := .Site.Params.brand | default .Site.Title -}}
5+
{{- $logo := absURL "images/logo.png" -}}
6+
{{- $orgId := printf "%s#organization" .Site.BaseURL -}}
7+
8+
{{- /* sameAs: only canonical org profiles, not invite/redirect links */ -}}
9+
{{- $sameAs := slice -}}
10+
{{- with .Site.Params.social -}}
11+
{{- range (slice .twitter .youtube .github .bluesky) -}}
12+
{{- with . }}{{ $sameAs = $sameAs | append . }}{{ end -}}
13+
{{- end -}}
14+
{{- end -}}
15+
16+
{{- $org := dict
17+
"@type" "Organization"
18+
"@id" $orgId
19+
"name" $brand
20+
"url" .Site.BaseURL
21+
"logo" (dict "@type" "ImageObject" "url" $logo)
22+
-}}
23+
{{- with $sameAs }}{{ $org = merge $org (dict "sameAs" .) }}{{ end -}}
24+
25+
{{- $website := dict
26+
"@type" "WebSite"
27+
"@id" (printf "%s#website" .Site.BaseURL)
28+
"name" .Site.Title
29+
"url" .Site.BaseURL
30+
"publisher" (dict "@id" $orgId)
31+
-}}
32+
33+
{{- $graph := dict "@context" "https://schema.org" "@graph" (slice $org $website) -}}
34+
<script type="application/ld+json">{{ $graph | jsonify (dict "indent" " ") | safeJS }}</script>

0 commit comments

Comments
 (0)