perf: responsive AVIF/WebP images; a11y contrast and link-text fixes
- scripts/optimize-images.mjs generates hashed AVIF/WebP variants at build time (sharp) - Picture.astro + rehype plugin serve them via <picture> with srcset/sizes; hero gets fetchpriority=high - footer CTA and active service-nav item meet WCAG AA contrast - homepage Learn More link gets screen-reader-only context - llms.txt: insights described as coming soon
This commit is contained in:
1 parent
002845c880
commit
59caa8e838
14 files changed
+225
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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/"),
|
||||
|
||||
@@ -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 `<Picture>` (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
|
||||
|
||||
Generated
+4
-3
@@ -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",
|
||||
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
@@ -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)}`);
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
import Picture from "@components/Picture.astro";
|
||||
import { nav, serviceNav, site } from "@data/site";
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<section class="footer-brand" aria-label="BlueCap Strategies summary">
|
||||
<img src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="190" height="57" />
|
||||
<Picture src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="190" height="57" sizes="175px" loading="lazy" />
|
||||
<p>
|
||||
Transforming environmental challenges into investable opportunities through strategy,
|
||||
capital, and implementation support for aquatic resources.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
import Picture from "@components/Picture.astro";
|
||||
import { nav, serviceNav } from "@data/site";
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<a class="brand" href="/" aria-label="BlueCap Strategies home">
|
||||
<img src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" />
|
||||
<Picture src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" sizes="190px" />
|
||||
</a>
|
||||
<nav class="desktop-nav" aria-label="Primary navigation">
|
||||
<a href="/">Home</a>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
import Picture from "@components/Picture.astro";
|
||||
|
||||
type Props = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
@@ -24,7 +26,7 @@ const hasPhoto = Boolean(image);
|
||||
{
|
||||
variant === "cover" ? (
|
||||
<section class="page-cover">
|
||||
<img
|
||||
<Picture
|
||||
class="hero-bg"
|
||||
src={image ?? "/assets/live-site/hero-mangrove.jpg"}
|
||||
alt=""
|
||||
@@ -32,6 +34,7 @@ const hasPhoto = Boolean(image);
|
||||
width="1140"
|
||||
height="713"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<div class="page-cover-inner">
|
||||
{eyebrow && <p class="eyebrow light">{eyebrow}</p>}
|
||||
@@ -64,7 +67,9 @@ const hasPhoto = Boolean(image);
|
||||
<h1>{title}</h1>
|
||||
{lead && <p>{lead}</p>}
|
||||
</div>
|
||||
{hasPhoto && <img src={image} alt={imageAlt} width="1280" height="854" loading="eager" />}
|
||||
{hasPhoto && image && (
|
||||
<Picture src={image} alt={imageAlt} width="1280" height="854" loading="eager" fetchpriority="high" sizes="(max-width: 960px) 100vw, 560px" />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
// <img> for a /public image path, upgraded to <picture> with AVIF/WebP srcsets when
|
||||
// scripts/optimize-images.mjs has variants for it. The original stays as the <img> fallback.
|
||||
// `sizes` should describe the rendered width so phones fetch a phone-sized file.
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import { imageVariants } from "@utils/images.mjs";
|
||||
|
||||
type Props = Omit<HTMLAttributes<"img">, "src" | "srcset" | "sizes"> & {
|
||||
src: string;
|
||||
sizes?: string;
|
||||
};
|
||||
|
||||
const { src, sizes = "100vw", width, height, ...attrs } = Astro.props;
|
||||
const variants = imageVariants(src);
|
||||
---
|
||||
|
||||
{
|
||||
variants ? (
|
||||
<picture>
|
||||
<source type="image/avif" srcset={variants.avif} sizes={sizes} />
|
||||
<source type="image/webp" srcset={variants.webp} sizes={sizes} />
|
||||
<img src={src} width={width ?? variants.width} height={height ?? variants.height} decoding="async" {...attrs} />
|
||||
</picture>
|
||||
) : (
|
||||
<img src={src} width={width} height={height} {...attrs} />
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getCollection } from "astro:content";
|
||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||
import ContactCta from "@components/ContactCta.astro";
|
||||
import Picture from "@components/Picture.astro";
|
||||
import { pageSchema } from "@utils/schema";
|
||||
|
||||
const services = (await getCollection("services")).sort((a, b) => a.data.order - b.data.order);
|
||||
@@ -27,7 +28,7 @@ const description =
|
||||
jsonLd={[pageSchema({ title: "BlueCap Strategies", description, pathname: "/" })]}
|
||||
>
|
||||
<section class="home-hero">
|
||||
<img
|
||||
<Picture
|
||||
class="hero-bg"
|
||||
src="/assets/live-site/hero-mangrove.jpg"
|
||||
alt=""
|
||||
@@ -35,6 +36,7 @@ const description =
|
||||
width="1140"
|
||||
height="713"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<div class="hero-inner">
|
||||
<div class="hero-card">
|
||||
@@ -138,7 +140,7 @@ const description =
|
||||
allows us to adapt and solve the most complex challenges with both insight and decisive
|
||||
action.
|
||||
</p>
|
||||
<a class="button" href="/about-us/">Learn More</a>
|
||||
<a class="button" href="/about-us/">Learn More<span class="sr-only"> about BlueCap Strategies</span></a>
|
||||
</div>
|
||||
<div class="stats-grid" aria-label="BlueCap Strategies experience metrics">
|
||||
<div class="stat"><strong>27 Years</strong><span>of senior-level experience working with institutional capital providers</span></div>
|
||||
@@ -172,11 +174,13 @@ const description =
|
||||
))}
|
||||
</div>
|
||||
<div class="services-media">
|
||||
<img
|
||||
<Picture
|
||||
src="/assets/live-site/img_0067.jpg"
|
||||
alt="Aquaculture baskets suspended in clear coastal water"
|
||||
width="1080"
|
||||
height="721"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 960px) 100vw, 590px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +206,7 @@ const description =
|
||||
<article class="focus-card">
|
||||
{img && (
|
||||
<a class="focus-media" href={`/${area.slug}/`}>
|
||||
<img src={img.src} alt={area.data.title} width={img.width} height={img.height} loading="lazy" />
|
||||
<Picture src={img.src} alt={area.data.title} width={img.width} height={img.height} loading="lazy" sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, 290px" />
|
||||
</a>
|
||||
)}
|
||||
<h3><a href={`/${area.slug}/`}>{area.data.title}</a></h3>
|
||||
|
||||
+14
-2
@@ -3,6 +3,7 @@
|
||||
--blue: #0074a9; /* exact original azure */
|
||||
--box: #f3f5f9; /* light box background (matches original .et_pb_column) */
|
||||
--cyan: #3ca2bd;
|
||||
--cyan-deep: #287a90; /* --cyan darkened to 4.9:1 against white text (WCAG AA) */
|
||||
--green: #81b29a; /* muted sage — subheads (exact original rgb(129,178,154)) */
|
||||
--box-blue: #a9d2eb; /* light-blue metric boxes (exact original rgb(169,210,235)) */
|
||||
--ink: #1b2b36;
|
||||
@@ -42,6 +43,17 @@ img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* <picture> wrappers (components/Picture.astro, Markdown images) must not affect layout: the
|
||||
<img> inside keeps behaving as a direct child (absolute hero backgrounds, sticky media). */
|
||||
picture {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* ...which would otherwise promote the <source> tags to grid/flex items (extra gaps). */
|
||||
picture > source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
}
|
||||
@@ -939,7 +951,7 @@ p {
|
||||
|
||||
.detail-nav li a[aria-current="page"] {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
color: var(--navy); /* white on sage is 2.4:1; navy is 5.6:1 (WCAG AA) */
|
||||
}
|
||||
|
||||
.page-detail {
|
||||
@@ -1139,7 +1151,7 @@ textarea:focus {
|
||||
.footer-cta {
|
||||
width: fit-content;
|
||||
margin-top: 1rem !important;
|
||||
background: var(--cyan);
|
||||
background: var(--cyan-deep);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Responsive-image lookup over the manifest written by scripts/optimize-images.mjs.
|
||||
// Plain JS (not TS) because astro.config.mjs loads it too, for the Markdown rehype plugin.
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const manifestPath = path.join(process.cwd(), "src", "data", "image-manifest.json");
|
||||
|
||||
/** @type {Record<string, { width: number; height: number; avif: { src: string; width: number }[]; webp: { src: string; width: number }[] }> | undefined} */
|
||||
let manifest;
|
||||
|
||||
function load() {
|
||||
if (!manifest) {
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
} catch {
|
||||
// No manifest (optimize-images not run): callers fall back to the plain original image.
|
||||
manifest = {};
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive variants for a /public image path, or undefined if it has none.
|
||||
* @param {string} src
|
||||
*/
|
||||
export function imageVariants(src) {
|
||||
const entry = load()[src];
|
||||
if (!entry) return undefined;
|
||||
/** @param {{ src: string; width: number }[]} list */
|
||||
const srcset = (list) => list.map((v) => `${v.src} ${v.width}w`).join(", ");
|
||||
return { width: entry.width, height: entry.height, avif: srcset(entry.avif), webp: srcset(entry.webp) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehype plugin: wraps Markdown <img> tags that have variants in a <picture> with AVIF/WebP
|
||||
* sources, and adds intrinsic dimensions (no layout shift) plus lazy loading.
|
||||
* @param {{ sizes?: string }} [options]
|
||||
*/
|
||||
export function rehypeResponsiveImages({ sizes = "(max-width: 900px) 100vw, 50vw" } = {}) {
|
||||
/** @param {any} node */
|
||||
const walk = (node) => {
|
||||
if (!node.children) return;
|
||||
node.children = node.children.map((child) => {
|
||||
walk(child);
|
||||
if (child.type !== "element" || child.tagName !== "img") return child;
|
||||
const variants = imageVariants(String(child.properties?.src ?? ""));
|
||||
if (!variants) return child;
|
||||
const img = {
|
||||
...child,
|
||||
properties: {
|
||||
...child.properties,
|
||||
width: variants.width,
|
||||
height: variants.height,
|
||||
loading: "lazy",
|
||||
decoding: "async",
|
||||
},
|
||||
};
|
||||
const source = (/** @type {string} */ type, /** @type {string} */ srcset) => ({
|
||||
type: "element",
|
||||
tagName: "source",
|
||||
properties: { type, srcSet: srcset, sizes },
|
||||
children: [],
|
||||
});
|
||||
return {
|
||||
type: "element",
|
||||
tagName: "picture",
|
||||
properties: {},
|
||||
children: [source("image/avif", variants.avif), source("image/webp", variants.webp), img],
|
||||
};
|
||||
});
|
||||
};
|
||||
return (/** @type {any} */ tree) => walk(tree);
|
||||
}
|
||||
Reference in new issue
Block a user