Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ public/
# Hugo image-processing cache (regenerated on every build)
resources/_gen/

# Tailwind purge working dir (throwaway full build + downloaded source CSS)
tmp/

# Node / build tooling
node_modules/
package-lock.json
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,22 @@ The author taxonomy automatically:
Edit `baseof.html` CSS to customize.

### Tailwind CSS
All styling uses Tailwind CSS utility classes. Update `baseof.html` or create component-specific classes.
Styling uses Tailwind CSS utility classes. The site ships a **purged, self-hosted**
build (`assets/css/tailwind.css`, ~35 KB) instead of the full ~2.9 MB CDN file —
only the utility classes actually used anywhere in the built output are retained.

If you add a **new** Tailwind class in a template or content file, regenerate the
purged stylesheet and commit it:

```bash
npm install # one-time, for the purgecss devDependency
npm run build:css # rebuilds assets/css/tailwind.css (see scripts/build-tailwind.mjs)
```

You will notice a missing class immediately in `npm run dev` (dev serves the same
purged file). Classes injected only at build time (e.g. activity-dot colors from
`data/community_stats.json`) are pinned via the safelist in `purgecss.config.cjs`.
The CI deploy builds run bare `hugo`, so the committed file is what ships.

### Layouts
- Modify layouts in `themes/powershell-community/layouts/`
Expand Down Expand Up @@ -235,7 +250,8 @@ Check `data/community_stats.json` format and `last_updated` timestamp.

- **Hugo**: v0.128+ with Goldmark renderer
- **Node.js**: For npm scripts
- **Tailwind CSS**: v2.2.19 (via CDN)
- **Tailwind CSS**: v2.2.19 (purged + self-hosted via `npm run build:css`)
- **Inter**: self-hosted from `assets/fonts/` (`@font-face`, no Google Fonts)
- **FontAwesome**: v6.0.0 (via CDN)
- **Alpine.js**: v3.10.2 (via CDN)

Expand Down
1 change: 1 addition & 0 deletions assets/css/tailwind.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion content/_index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Welcome Automaters!"
description: "PowerShell.org is the community hub for PowerShell enthusiasts, automaters, and professionals."
description: "PowerShell.org is the community hub for PowerShell pros and automators — the podcast, the annual Summit, learning resources, and active community forums."
---

PowerShell.org serves as the central hub for the PowerShell community worldwide.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title:
title: "PowerShell Summit: 'I'm Feeling Lucky' Tickets on Sale, $400 Each"
authors:
- Don Jones
date: "2012-09-09T22:55:29+00:00"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title:
title: "PowerShell v3's New Simplified Syntax"
authors:
- Don Jones
date: "2012-10-26T18:02:31+00:00"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title:
title: "Why Puppet vs. DSC Isn't Even a Thing"
authors:
- Don Jones
date: "2014-05-14T13:06:15+00:00"
Expand Down
78 changes: 78 additions & 0 deletions docs/adr/0005-seo-metadata-and-purged-self-hosted-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# SEO metadata lives in the head; CSS and fonts are purged and self-hosted

The page `<head>` (`themes/powershell-community/layouts/_default/baseof.html`)
emits the full technical-SEO surface: a `rel=canonical`, Open Graph + Twitter
cards, `article:*` tags for posts, and JSON-LD via two partials
(`partials/schema-site.html` → `Organization` + `WebSite` on the home page,
`partials/schema-article.html` → `Article` + `BreadcrumbList` on `articles` and
`podcast` pages). `robots.txt` (`layouts/robots.txt`, enabled by
`enableRobotsTXT`) advertises the sitemap. Render-blocking third-party CSS is
replaced by a purged, self-hosted Tailwind build and self-hosted Inter.

Two load-bearing constraints shaped the asset half of this decision.

**The deploy builds run bare `hugo`.** Both `netlify.toml` and
`.github/workflows/deploy.yml` build with `hugo --minify`, not `npm run build`.
So a CSS step wired into npm would never run in CI. The purged stylesheet is
therefore a **committed artifact** (`assets/css/tailwind.css`, ~35 KB), not a
build-time output. It is regenerated by `npm run build:css`
(`scripts/build-tailwind.mjs`): download the pinned Tailwind 2.2.19 source, build
the whole site to a throwaway dir, and purge the source against that output
(`purgecss.config.cjs`). Hugo then `minify | fingerprint`s the committed file at
build time so the immutable `Cache-Control` in `netlify.toml` is safe.

**Purging silently drops any class it cannot find as a literal token** — no build
error, just elements that render unstyled in production. The config
(`purgecss.config.cjs`) is the only thing between "smaller CSS" and "broken
styles," and two parts of it are load-bearing and must not be weakened:

- The **custom `defaultExtractor`**. PurgeCSS's stock extractor splits candidate
tokens on `:` and `/`, which would shred every Tailwind variant
(`lg:grid-cols-3`, `hover:bg-blue-300`, `w-1/2`). The regex keeps those
characters; drop or "simplify" it and the site loses its responsive/state
utilities sitewide, silently.
- The **safelist**. Runtime-toggled classes (Alpine `:class`, JS `classList` —
`hidden`, `transform rotate-180`, the YouTube-facade `absolute inset-0 …`)
survive because they appear as literals in the built HTML, but are pinned
anyway against markup churn. The genuine gap is classes injected from data that
only exists at deploy time — the activity-dot colors written into
`data/community_stats.json` by `.github/scripts/fetch-discourse-activity.js`
(and the `deploy.yml` fallback), which never appear in the committed build and
are pinned explicitly.

## Considered options

- **Keep the CDN links** (full Tailwind ~2.9 MB + FontAwesome + Prism, all
render-blocking; Inter via a `@import` in an inline `<style>`) — rejected. The
unpurged Tailwind download alone dominates first paint, and an `@import` is the
worst case for blocking when Inter ships in `assets/fonts/` already.
- **PostCSS/Tailwind compiled in the Hugo build** (`css.PostCSS`) — rejected. It
needs committed `node_modules` (tailwindcss + postcss) and a JIT migration off
v2; the deploy builds don't run npm, so it would not execute in CI anyway.
- **Regenerate via Tailwind v3/v4** — rejected. v3's palette and resets differ
from the v2 classes the markup was authored against, risking silent visual
drift. Purging the *exact* v2 file keeps retained rules byte-identical.
- **Purge the committed v2 file against the built site; self-host Inter** —
chosen. No visual change for used classes, ~99% smaller CSS, one third-party
render-blocking origin removed, and the Google Fonts round-trip eliminated.

## Consequences

- **Adding a new Tailwind class requires `npm run build:css`** and committing the
result. The failure mode is visible, not silent: `npm run dev` serves the same
purged file, so a missing class shows up locally before it ships. FontAwesome
and Prism stay on the CDN (FontAwesome's CSS has relative `../webfonts/` font
refs that would break if naively rehosted), so a preconnect is kept for them.
- **A new build-time data class must be added to the safelist.** Anything driven
by `community_stats.json` (or future data) that isn't present in committed
markup will be purged unless pinned. The status-color palette is already pinned;
`bg-orange-500` in the `deploy.yml` fallback is a no-op because orange is not in
the stock Tailwind v2 palette — it never rendered, on the CDN or after.
- **Inter ships only weights 400 and 700** (the two files in `assets/fonts/`).
Heavier display weights used by `.gotham-black` (900) synthesize from 700; this
was visually verified on the hero as acceptable. Adding a weight means adding
the font file and a matching `@font-face`.
- **JSON-LD is gated by section.** `Article`/`BreadcrumbList` render for `articles`
and `podcast`; a new content section that should carry article schema must be
added to the guard in `baseof.html` and the breadcrumb branch in
`schema-article.html`.
5 changes: 4 additions & 1 deletion hugo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ title: 'PowerShell.org - Welcome Automaters!'
theme: 'powershell-community'
timeZone: UTC

# Enable RSS feeds
# Enable RSS feeds and robots.txt
enableRSSFeed: true
enableRobotsTXT: true

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

# Social links
social:
Expand Down
4 changes: 4 additions & 0 deletions layouts/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
User-agent: *
Disallow:

Sitemap: {{ "sitemap.xml" | absURL }}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
"scripts": {
"dev": "hugo server -D --disableFastRender",
"build": "hugo --gc --minify",
"build:css": "node scripts/build-tailwind.mjs",
"preview": "hugo server --environment production"
},
"devDependencies": {
"hugo-extended": "^0.155.1",
"node-fetch": "^3.3.2"
"node-fetch": "^3.3.2",
"purgecss": "^5.0.0"
}
}
28 changes: 28 additions & 0 deletions purgecss.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Strips unused Tailwind 2.2.19 rules against the fully built site.
// Retained rules are byte-identical to the CDN file, so there is no visual
// change for classes actually used anywhere in the output.
//
// Regenerate after changing markup that introduces new Tailwind classes:
// npm run build:css (see scripts/build-tailwind.mjs)
// then commit the updated assets/css/tailwind.css.
module.exports = {
content: ['tmp/purge-src/**/*.html', 'tmp/purge-src/**/*.js'],
css: ['tmp/tailwind-src.css'],
// Tailwind-aware extractor: keep `:` `/` `.` `%` in candidate class tokens.
defaultExtractor: (content) => content.match(/[\w-/:%.\[\]!]+/g) || [],
// Classes toggled only at runtime (Alpine `:class`, JS classList) — these do
// appear as literals in the built HTML, but pin them so markup churn is safe.
safelist: {
standard: [
'hidden', 'block', 'flex',
'transform', 'absolute', 'inset-0', 'w-full', 'h-full',
/^rotate-/, /^translate-/, /^opacity-/, /^scale-/,
// Activity-dot colors injected at build time from data/community_stats.json
// (see .github/scripts/fetch-discourse-activity.js + deploy.yml fallback).
// These never appear in committed markup, so pin the whole status palette.
'bg-blue-500', 'bg-green-500', 'bg-purple-500',
'bg-orange-500', 'bg-red-500', 'bg-yellow-500',
],
},
output: 'assets/css/tailwind.css',
};
47 changes: 47 additions & 0 deletions scripts/build-tailwind.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
// Regenerates assets/css/tailwind.css — the Tailwind 2.2.19 utility set purged
// down to only the classes the built site actually uses (~35 KB vs ~2.9 MB).
//
// Prereq: `npm install` (needs the `purgecss` devDependency).
// Run after changing markup that introduces new Tailwind classes:
// npm run build:css
// then commit the updated assets/css/tailwind.css. CI ships the committed file
// as-is (the deploy builds run bare `hugo`, not this script).
//
// Steps: download the pinned Tailwind source, build the whole site to a throwaway
// dir, then purge the source against that output using purgecss.config.cjs.
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { PurgeCSS } from 'purgecss';

const TAILWIND_VERSION = '2.2.19';
const SRC_URL = `https://cdn.jsdelivr.net/npm/tailwindcss@${TAILWIND_VERSION}/dist/tailwind.min.css`;
const BASE_URL = 'https://powershell.org';

const config = createRequire(import.meta.url)(resolve('purgecss.config.cjs'));

mkdirSync('tmp', { recursive: true });

console.log(`> downloading Tailwind ${TAILWIND_VERSION}`);
const res = await fetch(SRC_URL);
if (!res.ok) throw new Error(`failed to download ${SRC_URL}: ${res.status}`);
writeFileSync('tmp/tailwind-src.css', Buffer.from(await res.arrayBuffer()));

// baseof.html does `resources.Get "css/tailwind.css"`, so the input build needs
// the file to exist. On a fresh checkout it's committed; only bootstrap a
// placeholder when missing (class usage in the HTML is independent of its bytes).
if (!existsSync(config.output)) {
mkdirSync(resolve(config.output, '..'), { recursive: true });
writeFileSync(config.output, '/* placeholder — regenerated below */');
}

console.log('> building site to tmp/purge-src');
execSync(`hugo --destination tmp/purge-src --baseURL ${BASE_URL}`, { stdio: 'inherit' });

console.log('> purging unused Tailwind rules');
const [result] = await new PurgeCSS().purge(config);
writeFileSync(config.output, result.css);

console.log(`> done — ${config.output} regenerated (${result.css.length} bytes)`);
85 changes: 62 additions & 23 deletions themes/powershell-community/layouts/_default/baseof.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,26 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} - {{ .Site.Title }}{{ end }}</title>
<meta name="description"
content="{{ if .Description }}{{ .Description }}{{ else if .IsHome }}{{ .Site.Params.description }}{{ else }}{{ .Summary | truncate 155 }}{{ end }}">

{{- $brand := .Site.Params.brand | default .Site.Title -}}
{{- $ogTitle := cond .IsHome .Site.Title .Title -}}
{{- $title := cond .IsHome .Site.Title (printf "%s — %s" .Title $brand) -}}
{{- $metaDesc := .Site.Params.description -}}
{{- if .Description }}{{ $metaDesc = .Description }}{{ else if not .IsHome }}{{ $metaDesc = (.Summary | plainify | truncate 160) }}{{ end -}}
{{- $socialDesc := $metaDesc -}}
{{- with .Params.og_description }}{{ $socialDesc = . }}{{ end -}}
{{- $ogImage := partial "og-image.html" . -}}
{{- $interReg := resources.Get "fonts/Inter-Regular.ttf" | fingerprint -}}
{{- $interBold := resources.Get "fonts/Inter-Bold.ttf" | fingerprint -}}

<title>{{ $title }}</title>
<meta name="description" content="{{ $metaDesc }}">
<link rel="canonical" href="{{ .Permalink }}">

<!-- Resource hints -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<link rel="preload" href="{{ $interBold.RelPermalink }}" as="font" type="font/ttf" crossorigin>

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

<!-- Social sharing image: dynamic per-article card when `og_title` is set,
else `image` front matter, else the site default (see partials/og-image.html) -->
{{ $ogImage := partial "og-image.html" . }}

<!-- Open Graph / Facebook -->
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:type" content="{{ cond .IsPage "article" "website" }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:title" content="{{ .Title }}">
<meta property="og:description"
content="{{ if .Description }}{{ .Description }}{{ else }}{{ .Summary | truncate 155 }}{{ end }}">
<meta property="og:title" content="{{ $ogTitle }}">
<meta property="og:description" content="{{ $socialDesc }}">
<meta property="og:site_name" content="{{ .Site.Title }}">
<meta property="og:locale" content="en_US">
<meta property="og:image" content="{{ $ogImage }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ .Title }} — PowerShell.org, the home of the PowerShell community">
<meta property="og:image:alt" content="{{ $ogTitle }} — PowerShell.org, the home of the PowerShell community">
{{- if and .IsPage (in (slice "articles" "podcast") .Section) }}
<meta property="article:published_time" content="{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}">
<meta property="article:modified_time" content="{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}">
{{- range .Params.authors }}
<meta property="article:author" content="{{ . }}">
{{- end }}
{{- with .Params.categories }}
<meta property="article:section" content="{{ index . 0 }}">
{{- end }}
{{- range .Params.tags }}
<meta property="article:tag" content="{{ . }}">
{{- end }}
{{- end }}

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="{{ .Permalink }}">
<meta property="twitter:title" content="{{ .Title }}">
<meta property="twitter:description"
content="{{ if .Description }}{{ .Description }}{{ else }}{{ .Summary | truncate 155 }}{{ end }}">
<meta property="twitter:image" content="{{ $ogImage }}">

<!-- CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="{{ .Permalink }}">
<meta name="twitter:title" content="{{ $ogTitle }}">
<meta name="twitter:description" content="{{ $socialDesc }}">
<meta name="twitter:image" content="{{ $ogImage }}">
{{- with .Site.Params.twitter_handle }}
<meta name="twitter:site" content="{{ . }}">
<meta name="twitter:creator" content="{{ . }}">
{{- end }}

<!-- Structured data (JSON-LD) -->
{{ if .IsHome }}{{ partial "schema-site.html" . }}{{ end }}
{{ if and .IsPage (in (slice "articles" "podcast") .Section) }}{{ partial "schema-article.html" . }}{{ end }}

<!-- CSS — Tailwind purged against the built site, minified + fingerprinted -->
{{ $tailwind := resources.Get "css/tailwind.css" | minify | fingerprint }}
<link href="{{ $tailwind.RelPermalink }}" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/themes/prism-tomorrow.min.css" rel="stylesheet">
<link href="/css/alerts.css" rel="stylesheet">
<link href="/css/code-copy.css" rel="stylesheet">
{{ with .Site.Params.algolia }}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@algolia/algoliasearch-netlify-frontend@1/dist/algoliasearchNetlify.css" />{{ end }}

<!-- Self-hosted Inter (replaces render-blocking Google Fonts @import) -->
<style>
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: swap; src: url('{{ $interReg.RelPermalink }}') format('truetype'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: swap; src: url('{{ $interBold.RelPermalink }}') format('truetype'); }
</style>

<!-- Custom CSS -->
<style>
/* Prose typography styles (Tailwind CDN doesn't include @tailwindcss/typography) */
Expand Down Expand Up @@ -175,8 +215,7 @@
overflow: hidden;
}

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

/* Gotham fallback styling */
.gotham-black {
Expand Down
Loading
Loading