diff --git a/.gitignore b/.gitignore index 64ad6bd..a2cfc8c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ dist/ .astro/ +# Generated by scripts/optimize-images.mjs at build time +public/assets/opt/ +src/data/image-manifest.json # Secrets — only .env.example is committed .env diff --git a/astro.config.mjs b/astro.config.mjs index 2b7f72e..781983d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,6 +1,7 @@ // Static Astro site for BlueCap Strategies. CI: .gitea/workflows/ci.yml (Gitea Actions). import { defineConfig } from "astro/config"; import sitemap from "@astrojs/sitemap"; +import { rehypeResponsiveImages } from "./src/utils/images.mjs"; const site = process.env.PUBLIC_SITE_URL ?? "https://www.bluecapstrategies.com"; @@ -8,6 +9,10 @@ export default defineConfig({ site, output: "static", trailingSlash: "always", + markdown: { + // Article column is ~880px wide on desktop, full width below the 960px breakpoint. + rehypePlugins: [[rehypeResponsiveImages, { sizes: "(max-width: 960px) 100vw, 880px" }]], + }, integrations: [ sitemap({ filter: (page) => !page.includes("/thank-you/"), diff --git a/docs/launch-checklist.md b/docs/launch-checklist.md index b6814b4..adcb384 100644 --- a/docs/launch-checklist.md +++ b/docs/launch-checklist.md @@ -140,7 +140,12 @@ not worth a force-push). sitemap but emits no `noindex` meta tag. - [ ] `article:published_time` / `article:modified_time` OG tags on insights posts. - [ ] `lastmod` in the sitemap from content dates. -- [ ] Lighthouse ≥ 95 on SEO / Perf / Best-Practices / Accessibility. +- [x] Lighthouse ≥ 95 (2026-10-23). Before, live mobile: Perf 82–86, A11y 95, SEO 92–100, LCP 4.3–4.9 s. + After (local build, mobile): Perf 96–99, A11y 100, SEO 100, LCP 1.8–2.4 s. Responsive AVIF/WebP via + `scripts/optimize-images.mjs` (runs in `npm run build`) + `components/Picture.astro` + a rehype plugin + for Markdown images; contrast fixes on the footer CTA and the active service-nav item; homepage + "Learn More" gets screen-reader context. New photos need no extra step: anything in + `public/assets/live-site/` gets variants; render it with `` (or Markdown) to use them. - [ ] Umami/GA custom event for contact-form submits (today the `/thank-you/` pageview is the conversion signal). - [ ] Optional: move `bluecapstrategies.com` into Pangolin's `config.yml` `domains:` so it shows diff --git a/package-lock.json b/package-lock.json index d04e137..4103dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@astrojs/check": "^0.9.4", "@playwright/test": "^1.60.0", "prettier": "^3.5.3", + "sharp": "0.34.5", "typescript": "^5.8.3" } }, @@ -773,8 +774,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -2626,8 +2627,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -4952,9 +4953,9 @@ "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", diff --git a/package.json b/package.json index 2683ce2..7b51d6b 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "private": true, "type": "module", "scripts": { - "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0", - "build": "ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build", + "dev": "npm run images && ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0", + "build": "npm run images && ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview --host 0.0.0.0", + "images": "node scripts/optimize-images.mjs", "typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check", "lint": "ASTRO_TELEMETRY_DISABLED=1 astro check", "seo:lint": "node scripts/seo-lint.mjs", @@ -26,6 +27,7 @@ "@astrojs/check": "^0.9.4", "@playwright/test": "^1.60.0", "prettier": "^3.5.3", + "sharp": "0.34.5", "typescript": "^5.8.3" } } diff --git a/public/llms.txt b/public/llms.txt index afc9af4..e6b1937 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -10,7 +10,7 @@ Public sections: - /focus-areas/: Core focus areas. - /about-us/: Firm vision, mission, values, and approach. - /contact/: Contact form, phone, and email. -- /insights/: Prepared future publishing section. +- /insights/: Articles and analysis (coming soon). This file is supplemental. The complete public content is available in crawlable HTML pages with metadata and structured data. diff --git a/scripts/optimize-images.mjs b/scripts/optimize-images.mjs new file mode 100644 index 0000000..43e52f8 --- /dev/null +++ b/scripts/optimize-images.mjs @@ -0,0 +1,68 @@ +// Pre-generates responsive AVIF + WebP variants of the site's raster images, plus a manifest the +// components read (src/utils/images.ts, the rehype plugin in astro.config.mjs). +// +// Why not astro:assets: every image here is referenced by a /public path string — from pages, +// content-collection frontmatter, and Markdown bodies — and og:image needs those stable JPEG URLs. +// This keeps the originals untouched (fallback + social cards) and adds the modern formats beside them. +// +// Output (generated, gitignored): public/assets/opt/*.{avif,webp} and src/data/image-manifest.json. +// File names carry a content hash, so nginx's 1-year immutable cache on /assets can never serve a +// stale variant after a source image changes. Existing outputs are reused, so reruns are fast. +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; + +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const publicDir = path.join(root, "public"); +const outDir = path.join(publicDir, "assets", "opt"); +const manifestPath = path.join(root, "src", "data", "image-manifest.json"); + +// Sources: every photo in live-site/ (not icons/ — tiny PNGs, nothing to gain) and the logo. +const sources = [ + ...(await readdir(path.join(publicDir, "assets", "live-site"))) + .filter((f) => /\.(jpe?g|png)$/i.test(f)) + .map((f) => `/assets/live-site/${f}`), + "/assets/bluecap-logo.png", +]; + +// Candidate widths; any at or above the original's width are replaced by the original width. +const widths = [480, 800, 1200, 1600]; +// The logo only ever renders at <=190px wide, so one 2x-retina size is enough. Keyed by file name: +// live-site/ also holds a copy of it. +const widthOverrides = { "bluecap-logo.png": [480] }; +// Tuned 2026-10-23 on the busiest photo (img_0062): AVIF q42 is visually indistinguishable from +// a q90 JPEG at 800px and ~half the bytes of WebP; effort 2 encodes ~2x faster than 4 for ~3% +// more bytes, which keeps CI builds quick. +const formats = { + avif: { quality: 42, effort: 2 }, + webp: { quality: 65 }, +}; + +await mkdir(outDir, { recursive: true }); +const manifest = {}; + +for (const src of sources) { + const input = await readFile(path.join(publicDir, src)); + const { width, height } = await sharp(input).metadata(); + const hash = createHash("sha256").update(input).digest("hex").slice(0, 8); + const base = path.basename(src).replace(/\.[^.]+$/, ""); + const sizes = widthOverrides[path.basename(src)] ?? [...new Set([...widths.filter((w) => w < width), width])]; + + const entry = { width, height, avif: [], webp: [] }; + for (const w of sizes) { + for (const [format, options] of Object.entries(formats)) { + const file = `${base}.${hash}-${w}.${format}`; + const dest = path.join(outDir, file); + if (!existsSync(dest)) { + await sharp(input).resize({ width: w }).toFormat(format, options).toFile(dest); + } + entry[format].push({ src: `/assets/opt/${file}`, width: w }); + } + } + manifest[src] = entry; +} + +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +console.log(`optimize-images: ${sources.length} images -> ${path.relative(root, outDir)}`); diff --git a/src/components/Footer.astro b/src/components/Footer.astro index dae57ef..0f138eb 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,11 +1,12 @@ --- +import Picture from "@components/Picture.astro"; import { nav, serviceNav, site } from "@data/site"; ---