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
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Generated, committed assets must stay LF so the PR freshness guard
# (.github/workflows/build.yml regenerates them on a Linux runner) does not
# false-fail on line-ending differences.
assets/css/tailwind.css text eol=lf
assets/css/fontawesome-subset.css text eol=lf

# Binary assets — never apply text/EOL normalization.
*.woff2 binary
*.ttf binary
19 changes: 19 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ jobs:
EOF
fi

# Deploys ship the committed, purged assets/css/*.css and subsetted
# assets/fonts/fa-*-subset.woff2 as-is (deploy runs bare hugo, not the
# build:* scripts). This guard regenerates them and fails the PR if the
# committed CSS is stale, so markup/content changes that add Tailwind
# classes or fa-* icons can't merge without the regenerated assets.
# We diff the generated CSS (deterministic and the source of truth for
# which classes/icons are included), not the woff2 bytes, which can vary
# across subset-font versions.
- name: Verify generated assets are up to date
run: |
npm run build:css
npm run build:icons
if ! git diff --quiet -- assets/css/tailwind.css assets/css/fontawesome-subset.css; then
echo "::error::Generated CSS is out of date. Run 'npm run build:css' and 'npm run build:icons' locally and commit assets/css/*.css and assets/fonts/fa-*-subset.woff2."
git diff --stat -- assets/css/tailwind.css assets/css/fontawesome-subset.css
git --no-pager diff -- assets/css/fontawesome-subset.css
exit 1
fi

- name: Build with Hugo
env:
HUGO_ENVIRONMENT: production
Expand Down
76 changes: 76 additions & 0 deletions assets/css/fontawesome-subset.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
.fa,.fas,.fa-solid,.far,.fa-regular,.fab,.fa-brands{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}
.fa,.fas,.fa-solid{font-family:"Font Awesome 6 Free";font-weight:900}
.far,.fa-regular{font-family:"Font Awesome 6 Free";font-weight:400}
.fab,.fa-brands{font-family:"Font Awesome 6 Brands";font-weight:400}
.fa-apple:before{content:"\f179"}
.fa-arrow-right:before{content:"\f061"}
.fa-arrow-up:before{content:"\f062"}
.fa-arrow-up-right-from-square:before{content:"\f08e"}
.fa-bars:before{content:"\f0c9"}
.fa-book:before{content:"\f02d"}
.fa-book-open:before{content:"\f518"}
.fa-box-open:before{content:"\f49e"}
.fa-bullhorn:before{content:"\f0a1"}
.fa-bullseye:before{content:"\f140"}
.fa-calendar:before{content:"\f133"}
.fa-calendar-alt:before{content:"\f073"}
.fa-calendar-check:before{content:"\f274"}
.fa-calendar-days:before{content:"\f073"}
.fa-calendar-plus:before{content:"\f271"}
.fa-chalkboard-teacher:before{content:"\f51c"}
.fa-check-circle:before{content:"\f058"}
.fa-chevron-down:before{content:"\f078"}
.fa-chevron-left:before{content:"\f053"}
.fa-chevron-right:before{content:"\f054"}
.fa-clock:before{content:"\f017"}
.fa-cloud:before{content:"\f0c2"}
.fa-code:before{content:"\f121"}
.fa-comments:before{content:"\f086"}
.fa-database:before{content:"\f1c0"}
.fa-desktop:before{content:"\f390"}
.fa-diagram-project:before{content:"\f542"}
.fa-discord:before{content:"\f392"}
.fa-download:before{content:"\f019"}
.fa-external-link-alt:before{content:"\f35d"}
.fa-file-alt:before{content:"\f15c"}
.fa-file-code:before{content:"\f1c9"}
.fa-github:before{content:"\f09b"}
.fa-globe:before{content:"\f0ac"}
.fa-globe-europe:before{content:"\f7a2"}
.fa-google:before{content:"\f1a0"}
.fa-graduation-cap:before{content:"\f19d"}
.fa-handshake:before{content:"\f2b5"}
.fa-history:before{content:"\f1da"}
.fa-info-circle:before{content:"\f05a"}
.fa-laptop-code:before{content:"\f5fc"}
.fa-layer-group:before{content:"\f5fd"}
.fa-lightbulb:before{content:"\f0eb"}
.fa-link:before{content:"\f0c1"}
.fa-linkedin:before{content:"\f08c"}
.fa-linux:before{content:"\f17c"}
.fa-list:before{content:"\f03a"}
.fa-list-check:before{content:"\f0ae"}
.fa-location-dot:before{content:"\f3c5"}
.fa-map-marker-alt:before{content:"\f3c5"}
.fa-mastodon:before{content:"\f4f6"}
.fa-pen-nib:before{content:"\f5ad"}
.fa-play:before{content:"\f04b"}
.fa-play-circle:before{content:"\f144"}
.fa-plug:before{content:"\f1e6"}
.fa-podcast:before{content:"\f2ce"}
.fa-puzzle-piece:before{content:"\f12e"}
.fa-rss:before{content:"\f09e"}
.fa-search:before{content:"\f002"}
.fa-sliders-h:before{content:"\f1de"}
.fa-spotify:before{content:"\f1bc"}
.fa-star:before{content:"\f005"}
.fa-tag:before{content:"\f02b"}
.fa-tags:before{content:"\f02c"}
.fa-terminal:before{content:"\f120"}
.fa-ticket-alt:before{content:"\f3ff"}
.fa-times:before{content:"\f00d"}
.fa-twitter:before{content:"\f099"}
.fa-user-circle:before{content:"\f2bd"}
.fa-users:before{content:"\f0c0"}
.fa-windows:before{content:"\f17a"}
.fa-youtube:before{content:"\f167"}
2 changes: 1 addition & 1 deletion assets/css/tailwind.css

Large diffs are not rendered by default.

Binary file added assets/fonts/fa-brands-subset.woff2
Binary file not shown.
Binary file added assets/fonts/fa-solid-subset.woff2
Binary file not shown.
File renamed without changes
File renamed without changes
20 changes: 16 additions & 4 deletions docs/adr/0005-seo-metadata-and-purged-self-hosted-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,22 @@ styles," and two parts of it are load-bearing and must not be weakened:
## 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.
result. `npm run dev` serves the same purged file, so a missing class usually
shows up locally — but a forgotten regen is only *reliably* caught in CI (see
below). Prism stays on the CDN, so a preconnect is kept for it.
- **A forgotten regen is caught by CI, not just locally.**
`.github/workflows/build.yml` runs `build:css` + `build:icons` on every PR and
fails if the committed `assets/css/tailwind.css` or
`assets/css/fontawesome-subset.css` differs from a fresh build — added after a
`w-auto` reached production unstyled. It diffs the deterministic generated CSS
(the source of truth for which classes/icons are bundled), not the woff2 bytes,
which vary across `subset-font` versions.
- **FontAwesome is now a self-hosted, purged subset.** `npm run build:icons`
(`scripts/build-icons.mjs`) scans the built HTML for `fa-*` classes, resolves
them via FontAwesome's metadata (including FA5-era aliases so legacy names are
not silently dropped), and emits `assets/css/fontawesome-subset.css` +
`assets/fonts/fa-*-subset.woff2` (~11 KB total vs ~270 KB of CDN webfonts). Same
committed-artifact model as the Tailwind bundle, under the same CI guard.
- **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;
Expand Down
47 changes: 47 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,50 @@
for = "/*.js"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

# Fonts are fingerprinted by Hugo (content hash in filename) — safe to cache forever.
[[headers]]
for = "/*.ttf"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
for = "/*.woff2"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

# Images are not fingerprinted — cache for 30 days without immutable so updates propagate.
[[headers]]
for = "/*.png"
[headers.values]
Cache-Control = "public, max-age=2592000"

[[headers]]
for = "/*.jpg"
[headers.values]
Cache-Control = "public, max-age=2592000"

[[headers]]
for = "/*.jpeg"
[headers.values]
Cache-Control = "public, max-age=2592000"

[[headers]]
for = "/*.svg"
[headers.values]
Cache-Control = "public, max-age=2592000"

[[headers]]
for = "/*.webp"
[headers.values]
Cache-Control = "public, max-age=2592000"

[[headers]]
for = "/*.gif"
[headers.values]
Cache-Control = "public, max-age=2592000"

[[headers]]
for = "/*.ico"
[headers.values]
Cache-Control = "public, max-age=2592000"
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
"dev": "hugo server -D --disableFastRender",
"build": "hugo --gc --minify",
"build:css": "node scripts/build-tailwind.mjs",
"build:icons": "node scripts/build-icons.mjs",
"preview": "hugo server --environment production"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"hugo-extended": "^0.155.1",
"node-fetch": "^3.3.2",
"purgecss": "^5.0.0"
"purgecss": "^5.0.0",
"subset-font": "^2.5.0"
}
}
}
138 changes: 138 additions & 0 deletions scripts/build-icons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env node
// Regenerates the self-hosted Font Awesome subset: assets/css/fontawesome-subset.css
// plus assets/fonts/fa-{solid,brands,regular}-subset.woff2 containing ONLY the
// glyphs the built site actually uses (a few KB vs ~270 KB of CDN webfonts).
//
// Prereq: `npm install` (needs @fortawesome/fontawesome-free + subset-font devDeps).
// Run after changing markup/content that introduces new `fa-*` icon classes:
// npm run build:icons
// then commit the regenerated assets/css/fontawesome-subset.css and the
// assets/fonts/fa-*-subset.woff2 files. CI ships the committed files as-is
// (deploy builds run bare `hugo`, not this script) — same model as build:css.
//
// Detection mirrors PurgeCSS: we scan the fully built HTML output, so icons
// pulled from content frontmatter or data files are captured automatically.
// Routing/codepoints come from Font Awesome's own metadata (icon-families.json),
// including FA5-era alias names (e.g. fa-ticket-alt -> ticket-simple) so legacy
// class names in the markup are not silently dropped.
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { resolve, join } from 'node:path';
import subsetFont from 'subset-font';

const FA = 'node_modules/@fortawesome/fontawesome-free';
const BASE_URL = 'https://powershell.org';
const SRC_DIR = 'tmp/icons-src';
const CSS_OUT = 'assets/css/fontawesome-subset.css';
const FONT_OUT = {
solid: 'assets/fonts/fa-solid-subset.woff2',
brands: 'assets/fonts/fa-brands-subset.woff2',
regular: 'assets/fonts/fa-regular-subset.woff2',
};
const FONT_SRC = {
solid: 'fa-solid-900.ttf',
brands: 'fa-brands-400.ttf',
regular: 'fa-regular-400.ttf',
};

// Icons injected only at runtime (JS/Alpine) that never appear in static HTML.
// Keep minimal — anything rendered server-side is detected automatically.
const SAFELIST = [];

// 1. Build a token -> { unicode, styles } lookup from FA metadata, registering
// each icon under its canonical name AND its alias names.
const meta = JSON.parse(readFileSync(join(FA, 'metadata', 'icon-families.json'), 'utf8'));
const lookup = new Map();
for (const [name, e] of Object.entries(meta)) {
const styles = new Set((e.familyStylesByLicense?.free ?? []).map((s) => s.style));
if (styles.size === 0) continue;
const record = { unicode: e.unicode, styles };
lookup.set(name, record);
for (const alias of e.aliases?.names ?? []) if (!lookup.has(alias)) lookup.set(alias, record);
}

// Pick the webfont that should carry a given icon (the site uses fas/fab, so
// prefer solid over regular for dual-style icons).
function fontFor(styles) {
if (styles.has('brands')) return 'brands';
if (styles.has('solid')) return 'solid';
if (styles.has('regular')) return 'regular';
return null;
}

// 2. Bootstrap placeholder outputs so the scan build's resources.Get calls don't
// fail on a fresh checkout (the HTML markup is independent of these bytes).
for (const f of [CSS_OUT, ...Object.values(FONT_OUT)]) {
if (!existsSync(f)) {
mkdirSync(resolve(f, '..'), { recursive: true });
writeFileSync(f, f.endsWith('.css') ? '/* placeholder — regenerated below */' : Buffer.alloc(0));
}
}

// 3. Build the site so we can scan the real output HTML.
console.log('> building site to', SRC_DIR);
execSync(`hugo --destination ${SRC_DIR} --baseURL ${BASE_URL}`, { stdio: 'inherit' });

// 4. Collect every `fa-*` token from the built HTML.
function htmlFiles(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...htmlFiles(p));
else if (entry.name.endsWith('.html')) out.push(p);
}
return out;
}
const tokens = new Set(SAFELIST);
for (const file of htmlFiles(SRC_DIR)) {
const html = readFileSync(file, 'utf8');
const re = /\bfa-([a-z0-9-]+)\b/g;
let m;
while ((m = re.exec(html))) tokens.add(m[1]);
}

// 5. Resolve tokens to real icons and group by webfont. Tokens that don't match
// a Font Awesome icon (utility classes like fa-fw / fa-lg, unrelated `fa-*`
// strings) are simply ignored.
const used = { solid: new Map(), brands: new Map(), regular: new Map() };
for (const token of tokens) {
const rec = lookup.get(token);
if (!rec) continue;
const font = fontFor(rec.styles);
if (font) used[font].set(token, rec.unicode);
}

// 6. Subset each non-empty font to its used glyphs.
for (const style of ['solid', 'brands', 'regular']) {
const icons = used[style];
if (icons.size === 0) {
// No icons for this style — don't ship an empty font (and clean up a stale one).
if (existsSync(FONT_OUT[style])) rmSync(FONT_OUT[style]);
continue;
}
const text = [...new Set(icons.values())].map((cp) => String.fromCodePoint(parseInt(cp, 16))).join('');
const src = readFileSync(join(FA, 'webfonts', FONT_SRC[style]));
const out = await subsetFont(src, text, { targetFormat: 'woff2' });
writeFileSync(FONT_OUT[style], out);
console.log(`> ${style}: ${icons.size} classes -> ${FONT_OUT[style]} (${(out.length / 1024).toFixed(1)} KB)`);
}

// 7. Emit the subset CSS: base rules + only the used per-icon `content` rules,
// keyed by the class name actually used (alias names included).
// @font-face is declared in baseof.html so the woff2 URLs can be fingerprinted.
let css = [
'.fa,.fas,.fa-solid,.far,.fa-regular,.fab,.fa-brands{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}',
'.fa,.fas,.fa-solid{font-family:"Font Awesome 6 Free";font-weight:900}',
'.far,.fa-regular{font-family:"Font Awesome 6 Free";font-weight:400}',
'.fab,.fa-brands{font-family:"Font Awesome 6 Brands";font-weight:400}',
].join('\n');
const all = new Map([...used.solid, ...used.brands, ...used.regular]);
for (const [name, cp] of [...all].sort((a, b) => a[0].localeCompare(b[0]))) {
css += `\n.fa-${name}:before{content:"\\${cp}"}`;
}
writeFileSync(CSS_OUT, css + '\n');

console.log(
`> done — ${all.size} classes (${used.solid.size} solid, ${used.brands.size} brands, ${used.regular.size} regular)`
);
console.log(`> ${CSS_OUT} (${(Buffer.byteLength(css) / 1024).toFixed(1)} KB)`);
Loading
Loading