Initial commit: BlueCap Strategies Astro site

Static Astro 5 site for BlueCap Strategies with exact live-site content
parity, deployed to k3s via GitOps (ArgoCD).

- Page copy in Markdown content collections (services, focus-areas, pages,
  insights) with a Zod SEO schema enforced at build time
- Homepage + About restored to exact live copy; real live-site imagery
- Build-time SEO linter (scripts/seo-lint.mjs) and Playwright e2e suite
- Multi-stage Dockerfile (nginx serves dist/) and Kustomize manifests (k8s/)
- Per-agent robots.txt; config-driven PostHog + Umami analytics scaffold
- ADRs and engineering docs under docs/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gitea_adminandClaude Opus 4.8 committed 2026-07-15 17:40:46 -04:00
commit cd91c4b97c
92 files changed
+10246

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# ADR-0001: GitOps deployment via ArgoCD (not Flux)
**Status:** Accepted — 2026-07-15
## Context
The site deploys to the homelab k3s cluster. GitHub must never receive kubeconfig or direct
cluster access. An earlier plan specified Flux, but Flux was never implemented, and the homelab
**already runs ArgoCD** (namespace `argocd`, active 21d+) managing other workloads.
## Decision
Use **ArgoCD** as the GitOps reconciler. Model: local edit → GitHub Actions builds + pushes image
to GHCR (`<git-sha>` immutable tags) → CI commits the tag into `k8s/` → ArgoCD auto-syncs the
Kustomize manifests → k3s rolls the Deployment → Traefik serves. Manifests stay as plain Kustomize
under `k8s/` (ArgoCD consumes `k8s/kustomization.yaml` natively; no Helm chart).
## Consequences
- No second GitOps controller to run; reuses existing, understood infra.
- Cluster pulls from Git + GHCR; GitHub never touches the cluster.
- Requires: `git init` + GitHub remote (not yet done), a GHCR image path, and an ArgoCD
`Application` CR (direct or via the homelab app-of-apps).
- Rollback = `git revert` + push, or ArgoCD history rollback.
- Full detail in `docs/gitops-deployment-strategy.md`.
@@ -0,0 +1,30 @@
# ADR-0002: Page copy in Astro Content Collections (Markdown)
**Status:** Accepted — 2026-07-15
## Context
Body copy currently lives inside `.ts` files (`src/data/services.ts`, `src/data/focusAreas.ts`)
as quoted string properties — painful to edit, no spellcheck/preview, and prose is trapped in
code. Separately, the rebuild paraphrased much of the live-site copy, and Phase 2 requires
restoring exact copy (see `docs/parity-checklist.md`). Doing both at once avoids a double
migration.
## Decision
Move prose-heavy page copy **out of `.ts` and into Astro Content Collections as Markdown**, with
typed frontmatter for structured/SEO fields. Split of responsibility:
- **Prose** (service/focus/about body, insights) → Markdown body rendered through a shared layout.
- **Structured/visual data** (metrics grid, nav, service icon+blurb cards, CTAs) → typed
frontmatter or kept in `.ts` where it drives layout/components.
Exact-copy restoration (Phase 2) lands directly in this format, not into the old `.ts` shape.
## Consequences
- Editing copy no longer means editing code; enables an eventual owner-editing workflow.
- Consistent rendering/flow via one styled layout (reduces, not increases, formatting risk).
- Requires content collections for `services`, `focusAreas`, and standalone `pages`, plus
refactoring the `[slug]` routes to read from collections.
- Pairs with ADR-0003 (the collection schema encodes SEO rules).
+30
View File
@@ -0,0 +1,30 @@
# ADR-0003: Enforce SEO rules with Zod + build linter (not Pydantic)
**Status:** Accepted — 2026-07-15
## Context
We want SEO/AEO rules (title/description length, canonical, OG image, structured data, single H1,
alt text) to be *enforced*, not aspirational. The question of a Pydantic-style validator came up,
but this is a TypeScript/Astro build — Pydantic is Python and would mean bolting a foreign runtime
onto a JS pipeline.
## Decision
Enforce in two layers, both native to the stack:
1. **Zod schema** on content-collection frontmatter (Astro Content Collections already run on Zod).
Encodes: title ≤ 55 chars, description 120–158, valid canonical, required OG image, `noindex`
flag, `primaryTopic`. Violations **fail `npm run build`**.
2. **Build-time SEO linter** (`scripts/seo-lint.mjs`) over `dist/**/*.html` for rules Zod can't see
in frontmatter: exactly one `<h1>`, heading order, canonical/title/description present, non-empty
`alt` on every `<img>`, every JSON-LD block parses. Runs in CI (and locally via `npm run seo:lint`).
Do **not** introduce Python/Pydantic.
## Consequences
- Every commit is validated before ArgoCD deploys; "the rules are followed" is guaranteed by CI.
- Rules live with the content schema and the build, in one language.
- Requires the content collections from ADR-0002 to carry the shared `seo` schema.
- Detailed rules in `docs/seo-aeo-optimization.md` §1.
@@ -0,0 +1,30 @@
# ADR-0004: Reuse the monylog stack for ops monitoring (not Uptime Kuma)
**Status:** Accepted — 2026-07-15
## Context
An earlier note proposed deploying Uptime Kuma in-cluster. Inspection showed the homelab already
runs **monylog** (`10.66.15.21`, Ansible role `pglta`): a full self-hosted LGTM + Alertmanager
stack (Prometheus, Grafana, Loki, Tempo, Alertmanager → Pushover/ntfy → phone), deliberately
**external to k8s** so it alerts even when the cluster is down. Grafana Alloy (k3s DaemonSet)
already remote-writes cluster metrics (incl. kube-state-metrics) and ships all pod logs to
monylog Loki.
## Decision
**Reuse monylog; do not deploy Uptime Kuma or any new alerting channel.** BlueCap pod/deployment
metrics and nginx logs already flow in. Add only:
1. `blackbox_exporter` to the monylog compose + a Prometheus scrape job probing the in-cluster
Service (app health) and the public URL (edge-path uptime + TLS expiry).
2. A `web-bluecap.yml` alert rules file (SiteDown, CertExpiringSoon <14d, High5xx,
DeploymentDegraded, PodRestarting).
3. A `service: bluecap` label so Alertmanager routes to existing Pushover; a Grafana dashboard.
## Consequences
- No duplicate monitoring/alerting infra; one pane of glass, alerts already reach the phone.
- Changes land in the Homelab Ansible repo (`roles/monylog`), not this repo.
- monylog being off-cluster gives a genuine external vantage for public-URL probing.
- Detail in `docs/seo-aeo-optimization.md` §6a and `docs/gitops-deployment-strategy.md` §4.3.
@@ -0,0 +1,31 @@
# ADR-0005: Visitor analytics — PostHog + Umami side-by-side trial
**Status:** Accepted — 2026-07-15
## Context
The owner leans toward richer data (PostHog: autocapture, funnels, session replay) but the site is
a low-traffic B2B advisory brochure with a Digital-ESG / low-energy positioning, and self-hosting
PostHog is resource-heavy. Rather than decide in the abstract, the owner chose to **trial both**
and compare. This is product/visitor analytics — distinct from the monylog ops telemetry (ADR-0004).
## Decision
Run **PostHog and Umami side-by-side initially** to evaluate which fits. Recommended trial setup:
- **PostHog — Cloud free tier** (1M events/mo) rather than self-host, to avoid loading the homelab
during evaluation; reverse-proxy ingest through our domain to reduce ad-blocker loss.
- **Umami — self-hosted** in k3s (lightweight, cookieless, no consent banner).
Both are wired through a single config-driven `Analytics.astro` component in `BaseLayout`, gated by
env vars, so either can be toggled and a winner kept without code churn. Track the same event set
in both (consultation-CTA clicks, per-service/focus CTA source, insights reads, contact submits).
## Consequences
- Temporary dual instrumentation (slightly more script weight during the trial).
- **Privacy/consent must cover the superset:** PostHog (esp. session replay) likely triggers
cookie-consent + privacy-policy obligations; add these before enabling PostHog in production.
- Decision criteria: insight value vs. weight/consent/ESG fit. Revisit with a follow-up ADR naming
the winner and the retirement of the other.
- Detail in `docs/seo-aeo-optimization.md` §6b.
+26
View File
@@ -0,0 +1,26 @@
# ADR-0006: Launch a blog at /insights/
**Status:** Accepted — 2026-07-15
## Context
The live WordPress site has **no published blog** (0 posts, 0 `project` entries) but does have an
**unpublished, empty "Insights & Resources" draft** (`page_id=303`) — a blog scaffolded but never
launched. The rebuild already has an `/insights/` section (routes, RSS, content collection, one
example draft). The parity plan and SEO/AEO plan both identify thought leadership as a core
authority/GEO asset.
## Decision
**Launch the blog at `/insights/`** in the rebuild. Keep the existing Astro content-collection
scaffold (Markdown/MDX, RSS, tags, canonical for cross-posts). Publish only **real,
owner-reviewed** content — no fabricated or unreviewed AI-generated articles. Candidate first
pieces: PFAS remediation as a capital-formation problem; scaling marine conservation finance;
making island/coastal infrastructure investable.
## Consequences
- Gives AEO/GEO a surface to be cited from; `Article`/`BlogPosting` schema per SEO plan §3.
- Content is owner-gated — the section can ship empty or with 1–2 real posts; do not backfill with
placeholder articles.
- Insights carry `publishDate`/`modifiedDate`; RSS and sitemap `lastmod` derive from these.
+16
View File
@@ -0,0 +1,16 @@
# Architecture Decision Records
Short, immutable records of significant decisions on the BlueCap Strategies rebuild. Each ADR
captures the context, the decision, and its consequences at a point in time. Supersede rather than
edit: to change a decision, add a new ADR and mark the old one `Superseded by ADR-XXXX`.
Format: Status · Context · Decision · Consequences. Statuses: Proposed · Accepted · Superseded.
| ADR | Title | Status |
|-----|-------|--------|
| [0001](0001-gitops-with-argocd.md) | GitOps deployment via ArgoCD (not Flux) | Accepted |
| [0002](0002-page-copy-in-content-collections.md) | Page copy in Astro Content Collections (Markdown) | Accepted |
| [0003](0003-seo-enforcement-with-zod.md) | Enforce SEO rules with Zod + build linter (not Pydantic) | Accepted |
| [0004](0004-reuse-monylog-for-monitoring.md) | Reuse the monylog stack for ops monitoring (not Uptime Kuma) | Accepted |
| [0005](0005-analytics-posthog-and-umami-trial.md) | Visitor analytics: PostHog + Umami side-by-side trial | Accepted |
| [0006](0006-launch-insights-blog.md) | Launch a blog at /insights/ | Accepted |
+78
View File
@@ -0,0 +1,78 @@
# Content Deviations
Intentional differences between the live WordPress site (`docs/live-site-content-inventory.md`)
and the Astro rebuild, logged per the parity plan. Updated **2026-07-15** after the copy
migration to Markdown collections (ADR-0002).
## Service & focus-area detail pages — exact copy restored
- The 8 service detail pages and 4 focus-area detail pages now carry **verbatim live-site copy** in
`src/content/services/*.md` and `src/content/focus-areas/*.md`. Each service page: H1 (service
name) → H2 (exact tagline) → the exact three body paragraphs, matching the live structure.
- **Removed invented content** from the earlier rebuild: the paraphrased `sections[]` sub-headings
and the "Typical outputs" / `outcomes[]` lists were **not present on the live site** and have been
dropped to honor the no-invention rule. They may return later as owner-approved "Typical outputs"
modules (parity plan Phase 5).
- **Focus-area detail pages** use the exact single paragraph from the homepage "Our Core Focus
Areas" section — the only live copy that exists for these (the live `/focus-areas/` page is empty).
No additional copy was invented.
## Metadata (not visible body copy)
- **SEO meta descriptions** (`description` frontmatter, 120–158 chars) are newly written marketing
metadata, not live body copy. Paraphrase is acceptable for meta descriptions; they are enforced
by the Zod schema (ADR-0003).
- `summary` frontmatter reuses the **exact** live `/services/` overview paragraph (services) and the
**exact** homepage focus paragraph (focus areas); it drives the overview and homepage cards.
## Typography
- Curly apostrophes/quotes from WordPress were normalized to **straight** apostrophes in the Markdown
bodies (e.g., "client's" not "client's"). Meaning and wording are unchanged.
## Homepage — exact copy restored (2026-07-15)
- `src/pages/index.astro` now carries **exact live copy**: hero paragraph, the full "How We Work"
section (intro + Increasing the Supply of Capital + Bridging Supply and Demand + Cultivating
Capital-Ready Initiatives incl. the "step in as sponsor" paragraph), the About teaser, the four
metrics (27 Years / $10+ billion / $3.5+ billion / 275+ Projects and Initiatives, with exact
supporting text), and the Core Focus Areas intro.
- **Additive (not live copy):** the "Our Services" section keeps a short connective subhead +
intro sentence (the live homepage lists only service titles). No fabricated claims.
- **Default OG / hero fallback images** repointed to real photos (`img_0067`); the contact hero uses
`img_0058`. The four generic placeholder images were **deleted** from `public/assets/`.
- **Omitted defect:** the live "Follow Our Quest" lorem-ipsum block remains removed.
- H1 wording is exact; live renders RESTORATION/PRESERVATION/RESPONSIBLE USE in all-caps, the
rebuild uses sentence case (typographic only).
## About Us — exact copy restored + migrated to Markdown (2026-07-15)
- Migrated to `src/content/pages/about.md` (ADR-0002) with **verbatim** copy for all ten sections
and every paragraph. The sticky in-page nav is derived from the h2 headings (restores the live
anchor nav). The live "andrestore" typo is corrected to "and restore".
- Added hero heading ("Finance, innovation, and stewardship…") — the live About page has no hero
heading; this is an additive structural heading, not body copy.
## Imagery — real live-site assets imported (2026-07-15)
- Real assets downloaded to `public/assets/live-site/` (+ `/icons`). The real logo replaced the
placeholder `public/assets/bluecap-logo.png`. Homepage hero (`img_0067`), homepage About band
(`img_0053`), About hero (`img_0062`), Services hero (`img_0075`), Focus/detail heroes
(`img_0070`) now use real photography. The 8 service cards render their real icons (decorative,
`aria-hidden`).
- `IMG_0075.webp` was JPEG-mislabeled on the live site; saved as `img_0075.jpg`.
## About Us imagery — interleaved (2026-07-15)
- Eight real live-site photos are now interleaved through the About sections (`img1`, `img_0055`,
`img_0054`, `img_0072`, `img_0053`, `img_0058`, `img_0075`, `img_0070`), plus `img_0062` as the
hero — matching the live page's ~9-photo visual rhythm. Alt text is descriptive (live alts were
empty). Order approximates the live placement; exact per-section pairing is best-effort.
## Known placeholders / not-yet-restored (tracked in parity-checklist.md)
- In-body About images are referenced by URL from Markdown, so they ship without intrinsic
width/height (minor CLS); revisit if a Lighthouse CLS budget requires it.
- Per-page **1200×630 OG images** (SEO plan §2) still to be generated; pages currently share the
`img_0067` default.
- Contact/defect items unchanged (see `bcs-website-owner-decisions`).
+83
View File
@@ -0,0 +1,83 @@
# Deployment
## Local Commands
```bash
npm install
npm run dev
npm run typecheck
npm run build
npm run preview
```
## Configuration
Set these at build time:
```bash
PUBLIC_SITE_URL=https://www.bluecapstrategies.com
PUBLIC_CONTACT_FORM_ENDPOINT=https://your-form-endpoint.example
```
`PUBLIC_CONTACT_FORM_ENDPOINT` can point to Formspree, a webhook, ntfy, or a self-hosted form
receiver. Without it, the contact form validates fields and opens an email draft to
`info@bluecapstrategies.com`.
## Container
```bash
docker build -t bluecap-strategies-website:latest .
docker run --rm -p 8080:80 bluecap-strategies-website:latest
```
## k3s
Production deployment should use the pull-based GitOps model documented in
`docs/gitops-deployment-strategy.md`.
The intended flow is:
```text
local edit -> git push -> GitHub Actions test/build/publish -> Git manifest image tag update -> ArgoCD reconciles k3s
```
ArgoCD is already running in the homelab k3s cluster, so it is the reconciler (not Flux). GitHub
Actions should not receive kubeconfig or direct access to the homelab cluster.
Manual `kubectl` use should be limited to bootstrapping the ArgoCD Application, read-only
inspection, and emergency operations. If you intentionally choose to apply the current manifests
by hand during a controlled bootstrap test, the command would be:
```bash
kubectl apply -k k8s/
```
Do not treat that as the normal production deployment process.
The manifests assume Traefik ingress is available. Read-only research confirmed Traefik and
cert-manager are installed, with a ready `letsencrypt-prod` ClusterIssuer. However, TLS and ingress
hardening should remain documented rather than applied until the Pangolin/Newt edge path is
confirmed.
Items to confirm before production ingress changes:
- Whether Pangolin/Newt terminates TLS, passes TLS through, or proxies HTTPS to Traefik.
- Whether Pangolin/Newt preserves `Host` headers for the BlueCap hostnames.
- Whether cert-manager's Cloudflare DNS-01 token can issue certificates for `bluecapstrategies.com`.
- Whether the canonical production host should be apex or `www`.
## DNS Cutover
Before cutover:
- Build and push the final container image.
- Confirm the GitHub Actions workflow publishes the GHCR image and updates the desired image tag.
- Confirm ArgoCD reconciles the `bluecap-strategies` workload from Git.
- Confirm the ingress or edge route answers for `www.bluecapstrategies.com` and `bluecapstrategies.com`.
- Lower DNS TTL on the existing records.
- Point BlueCap DNS to the existing Pangolin/Newt public edge, or to the chosen production edge if
that architecture changes.
- Configure apex-to-canonical redirect at the selected layer: Cloudflare, Pangolin/Newt, Traefik,
or nginx.
- Verify `/`, `/services/`, `/about-us/`, `/contact/`, `/sitemap-index.xml`, and `/robots.txt`
after DNS propagation.
+193
View File
@@ -0,0 +1,193 @@
# BlueCap Strategies GitOps Deployment Strategy (ArgoCD)
## Goal
Deploy the BlueCap Strategies Astro site to the existing k3s cluster using GitOps, without
giving GitHub Actions direct access to the homelab cluster.
The homelab already runs **ArgoCD** in k3s, so this strategy uses ArgoCD as the reconciler
rather than standing up a second GitOps controller (Flux). ArgoCD pulls from Git and GHCR; the
cluster is never exposed to GitHub.
Desired operating model:
1. Edit the site locally in this repository.
2. Commit changes.
3. Push to `main`.
4. GitHub Actions runs tests and builds a container image.
5. GitHub Actions pushes the image to GHCR, tagged with the immutable `<git-sha>`.
6. Git is updated with the new desired image tag (CI commits the tag into `k8s/`).
7. ArgoCD, running inside k3s, detects the Git change.
8. ArgoCD syncs the Kubernetes manifests (Kustomize).
9. k3s pulls the new image and rolls the website deployment.
10. Traefik serves the updated site publicly.
GitHub never receives kubeconfig or direct network access to the homelab cluster. The cluster
pulls from Git and the image registry.
## Prerequisites (not yet done)
- **This repository is not yet a git repo.** GitOps requires a remote (GitHub) that both CI and
ArgoCD can read. `git init`, push to a GitHub repo, then wire ArgoCD to that repo.
- Decide the GHCR image path (replace the placeholder `ghcr.io/your-org/...` in
`k8s/deployment.yaml`).
## Cluster Findings
Read-only `kubectl` inspection previously showed:
- k3s API reachable at `10.66.15.30:6443`.
- Ingress controller: `traefik`. Ingress class: `traefik`.
- cert-manager installed with a `letsencrypt-prod` ClusterIssuer.
- Existing namespaces for media, nextcloud, monitoring, etc.
- No `bluecap-strategies` namespace yet.
- **ArgoCD is already installed and managing other workloads in the cluster.**
Edge / DNS / TLS (research 2026-06-05):
- Edge routing uses a Pangolin/Newt path (`dav.rodgersweb.com` via Caddy).
- `bluecapstrategies.com` DNS is managed by Cloudflare.
- TLS handled by cert-manager via a Cloudflare DNS-01 token.
## Repository & Image Strategy
- **Mono-repo:** Keep site source and k8s manifests in the same repository.
- **GHCR:** Use GitHub Container Registry for images.
- **Immutable tags:** Deploy via `<git-sha>` tags, never `latest`. `latest` defeats ArgoCD's
ability to detect and record what is actually running.
- **Manifests:** Plain Kustomize under `k8s/` (already present). ArgoCD consumes
`k8s/kustomization.yaml` natively — no Helm chart required.
## Phase 1: ArgoCD Application (Foundational GitOps)
Register the site as an ArgoCD `Application`. Two common patterns; pick one to match how the
homelab already organizes ArgoCD:
- **Direct Application** — a single `Application` CR pointing at this repo's `k8s/` path.
- **App-of-apps** — add this `Application` to the existing root/app-of-apps repo if the homelab
uses that pattern.
Example direct Application (store in the homelab ArgoCD config repo, or apply once to bootstrap):
```yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: bluecap-website
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/<owner>/bcs-website.git
targetRevision: main
path: k8s
destination:
server: https://kubernetes.default.svc
namespace: bluecap-strategies
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
`prune: true` removes resources deleted from Git; `selfHeal: true` reverts manual drift;
`CreateNamespace=true` lets ArgoCD create `bluecap-strategies` (or keep `namespace.yaml` in
`k8s/` — both work, don't do both destructively).
## Image Update Strategy
Two options — this strategy recommends **Option A** for simplicity and auditability:
### Option A (recommended): CI commits the image tag
GitHub Actions, after pushing the image, patches the image tag in `k8s/` (via
`kustomize edit set image` or a `newTag` field) and commits back to `main`. ArgoCD auto-syncs the
new tag. Every deploy is a Git commit traceable to a source SHA — clean rollback via `git revert`.
Add to `k8s/kustomization.yaml`:
```yaml
images:
- name: ghcr.io/<owner>/bluecap-strategies-website
newTag: <ci-sets-this>
```
### Option B: ArgoCD Image Updater
Install ArgoCD Image Updater and annotate the Application to watch the GHCR repo and write back
the tag. Removes the CI commit-back step but adds a controller and registry-credential wiring.
Only adopt if the homelab already runs Image Updater for other apps.
## Phase 2: CI Pipeline (GitHub Actions)
Workflow on push to `main`:
1. **Quality gate:** `npm ci`, `npm run typecheck`, `npm run build`.
2. **E2E validation:** `npm run test:e2e` (Playwright) where browser deps are available.
3. **Publish:** build the Docker image, push to GHCR tagged `<git-sha>`.
4. **GitOps sync (Option A):** `kustomize edit set image ...=ghcr.io/<owner>/...:<git-sha>`,
commit and push to `main`. ArgoCD reconciles.
CI never touches the cluster — it only writes to GHCR and Git.
## Phase 3: Staging (Launch Readiness)
Avoid testing in production. Add a `k8s/overlays/staging/` Kustomize overlay and a second ArgoCD
Application tracking it.
- **Target:** `staging.bluecapstrategies.com`
- **Goal:** let the owner review content/insights changes before they hit the live site.
## Phase 4: Elite Authority Infrastructure (9/10 Standard)
### 1. Secret hygiene — Git-managed credentials
Avoid manual `kubectl create secret`. Use **Sealed Secrets** or **External Secrets Operator** so
GHCR pull tokens and contact-form keys are encrypted in Git and the site is reproducible from
source alone. Match whichever the homelab ArgoCD stack already standardizes on.
### 2. Edge authority — Cloudflare hardening
- **WAF & protection:** enable Cloudflare WAF to block automated probes and DDoS.
- **Edge caching:** "Cache Everything" for static Astro assets so most traffic serves from
Cloudflare's edge regardless of homelab bandwidth.
- **Cloudflare Tunnels (optional):** consider `cloudflared` to remove open router ports.
### 3. Uptime intelligence (observability) — reuse the `monylog` stack
The homelab already runs a full LGTM + Alertmanager stack on the `monylog` box (`10.66.15.21`,
Ansible role `pglta`), external to k8s. **Do not deploy Uptime Kuma.** Alloy already remote-writes
BlueCap pod/deployment metrics (kube-state-metrics) and ships nginx pod logs to Loki. To finish:
- **Add `blackbox_exporter`** to the monylog compose + a Prometheus scrape job probing the
in-cluster Service and the public URL (uptime, HTTP status, TLS-expiry, latency).
- **Add a `web-bluecap.yml` alert rules file** (SiteDown, CertExpiringSoon <14d, High5xx,
DeploymentDegraded, PodRestarting) mirroring the existing host-infra rules.
- **Alerting** already flows to **Pushover** (OpenBao creds) with ntfy fallback → phone; add a
`service: bluecap` label. **Grafana** already present for dashboards. ArgoCD sync/health is an
extra deploy-level signal. See `docs/seo-aeo-optimization.md` §6a for detail.
### 4. Digital ESG — verified sustainability
- Verify Green Web status if the homelab uses renewable energy.
- Use Astro's `Image` component to minimize data transfer.
- Document the low-energy architecture in `llms.txt` or a footer "Digital ESG" note.
### 5. Security performance (A+ rating)
- Harden `deploy/nginx.conf` toward an A+ on `securityheaders.com`: robust CSP, HSTS,
X-Frame-Options.
- TLS 1.3 only; cert-manager keeps certs well ahead of expiry.
## Verification & Rollback
### Rollback
GitOps makes rollbacks surgical:
1. `git revert <commit-sha>`
2. `git push origin main`
3. ArgoCD reconciles the previous known-good state automatically. (Or use ArgoCD's
History/Rollback UI to pin an earlier synced revision.)
### Automated checks
- `npm run test:e2e` (pre-deployment, in CI)
- K8s readiness/liveness probes on `/healthz` (during rollout)
- ArgoCD health status + Uptime Kuma (post-deployment)
## Open Items Before Production Ingress
- Confirm whether Pangolin/Newt terminates, passes through, or proxies TLS to Traefik, and
whether it preserves `Host` headers for the BlueCap hostnames.
- Confirm cert-manager's Cloudflare DNS-01 token can issue for `bluecapstrategies.com`.
- Decide canonical host (apex vs `www`) and where the apex→canonical redirect lives (Cloudflare,
Pangolin/Newt, Traefik, or nginx).
+237
View File
@@ -0,0 +1,237 @@
# SEO, AEO & AI-Crawler Optimization Plan
Requirements and rules for optimizing the BlueCap Strategies rebuild for classic search (SEO),
answer engines / AI citation (AEO/GEO), and AI-crawler acceptance. Written **2026-07-15** against
mid-2026 best practices (see Sources). Where a rule can be machine-enforced, it is defined as a
**Zod schema constraint** or a **build-time check** so the build fails when copy violates it —
this is the TypeScript/Astro equivalent of Pydantic validation (do **not** add Python/Pydantic to
a JS build; Astro Content Collections already run on Zod).
## 0. Guiding principles (2026 reality check)
1. **HTML is the source of truth, not `llms.txt`.** AI search crawlers (GPTBot, ClaudeBot,
PerplexityBot, OAI-SearchBot, Google-Extended) overwhelmingly **skip `/llms.txt` and crawl
rendered HTML directly**; Google confirmed it does not support llms.txt. Keep our `llms.txt`
(low cost, ~10% of sites have one) but invest first in semantic HTML, structured data, and
crawlable copy. Our Astro output is **fully static** — 100% of meaningful copy is in initial
HTML, which is the single biggest AEO advantage over the old JS-heavy WordPress/Elementor site.
2. **Structured data drives citation.** Schema lets answer engines verify *who we are*, *what a
page covers*, and *which facts are reliable to cite*. FAQ schema has among the highest AI
citation rates (question/answer mirrors how engines present answers).
3. **Answer-ready structure wins.** Clear headings, direct definitional first sentences, and
self-contained paragraphs get extracted; buried or paraphrased marketing prose does not.
4. **E-E-A-T via the principal.** Boutique-advisory credibility is entity-driven — a real
`Person` entity (Brad Rodgers) with `knowsAbout`, linked to the `Organization`, is a core GEO
asset. (Gated on owner-approved bio — see parity plan Phase 5.)
## 1. Programmatic enforcement (Zod + build linter)
### 1a. Content-collection SEO schema (fails the build)
Extend `src/content.config.ts`. Every collection (pages, services, focusAreas, insights) shares
an `seo` shape with enforced limits:
```ts
import { z } from "astro:content";
export const seo = z.object({
// <title> renders as "`title` | BlueCap Strategies" — keep the full SERP title ≤ ~60 chars
title: z.string().min(10).max(55),
// meta description + OG description; 120–158 is the safe SERP/AI-snippet window
description: z.string().min(120).max(158),
// canonical is derived from slug, but allow override for cross-posted insights
canonicalUrl: z.string().url().optional(),
// every page must ship a social share image (absolute path under /assets)
ogImage: z.string().startsWith("/assets/").default("/assets/og/default.jpg"),
// primary entity/topic — powers internal linking + schema keywords
primaryTopic: z.string(),
noindex: z.boolean().default(false),
});
```
Rules encoded here (build fails on violation): title length, description length window, OG image
present, canonical is a valid URL. This is the "rules are being followed" guarantee you asked
about — enforced on every `npm run build` in CI before ArgoCD ever sees the image.
### 1b. Build-time SEO linter (what Zod can't see)
Zod validates frontmatter, not rendered HTML. Add a small check (a Node script run in CI, and/or
Playwright assertions in `tests/`) that fails the build if any built page in `dist/` violates:
- Exactly **one `<h1>`** per page.
- Heading hierarchy has no skipped levels (no `<h4>` before an `<h2>`).
- `<title>`, `<meta name="description">`, `<link rel="canonical">` all present and non-empty.
- Every `<img>` has non-empty `alt` (parity plan already requires this).
- At least one JSON-LD block present, and **every** JSON-LD block `JSON.parse`s.
- Internal links resolve (no 404s to our own routes).
- Canonical host matches the chosen production host (apex vs `www` — see §5).
Recommended: a `scripts/seo-lint.mjs` that walks `dist/**/*.html` with a lightweight parser, plus
keep the existing Playwright suite for link/CTA coverage.
## 2. Per-page technical SEO requirements
Already implemented in `src/layouts/BaseLayout.astro` (keep + enforce):
- `<title>`, `<meta name="description">`, `<link rel="canonical">`
- Open Graph (`og:type/title/description/url/image`) + Twitter `summary_large_image`
- JSON-LD injection slot (Organization + WebSite globally, page schema per route)
- `lang="en"`, viewport, skip-link, RSS `<link rel="alternate">`
To add/verify:
- [ ] **Per-page OG images** — currently every page falls back to `/assets/water-strategy.jpg`.
Generate topic-specific OG images (services, focus areas, insights) at 1200×630.
- [ ] **`robots` meta** wired to the `noindex` frontmatter flag (for `/thank-you/`, staging).
- [ ] **`article:published_time` / `modified_time`** OG tags on insights posts.
- [ ] **Breadcrumbs** (visible + `BreadcrumbList` schema) on services/focus/insights detail pages.
- [ ] **Sitemap** already via `@astrojs/sitemap` (excludes `/thank-you/`); add `lastmod` from
content dates for insights.
- [ ] **Trailing-slash consistency** — config uses `trailingSlash: "always"`; ensure canonical +
internal links all match to avoid duplicate-URL dilution.
## 3. Structured data (JSON-LD) matrix
| Page / type | Required schema | Notes |
|-------------|-----------------|-------|
| Global | `Organization`, `WebSite` | Already in `src/utils/schema.ts`. Add `sameAs` (real socials), `logo`, `contactPoint` (phone/email), `areaServed`. |
| Homepage | `Organization` + `WebSite` (+ optional `SearchAction`) | — |
| `/services/` + each service | `Service` (with `provider` → Organization, `serviceType`, `areaServed`) | One per detail page. |
| `/focus-areas/` + each focus | `Service` or `DefinedTerm`/`Article` | Focus areas double as topic-authority hubs. |
| About / Brad Rodgers | `Person` + `AboutPage`; `Person.worksFor` → Organization; `knowsAbout[]` | **Owner-approved facts only.** Core GEO/E-E-A-T asset. |
| Insights posts | `Article`/`BlogPosting` (author=Person, publisher=Organization, dates, `headline`, `image`) | Powers AI citation of thought leadership. |
| Insights index | `Blog` / `CollectionPage` | — |
| Contact | `ContactPage` + `Organization.contactPoint` | Use real values, never the WP placeholders. |
| FAQ sections | `FAQPage` — **only where the same Q&A is visibly rendered** | Highest AI-citation rate. Never add invisible FAQ schema. |
| Breadcrumbs | `BreadcrumbList` | Detail pages. |
Rule: **structured data must reflect content that is visibly present on the page.** No hidden-text
schema, no unsupported claims, no fabricated review/rating markup.
## 4. AEO / GEO content rules
Applied during Phase 2 copy restoration and Phase 5+ enhancements (must not violate the
no-paraphrasing parity rule on restored copy):
- **Answer-first**: each section's opening sentence should stand alone as a direct answer /
definition (e.g., "Marine conservation finance is …"). Extractable by answer engines.
- **Semantic headings** phrased as the questions/topics people ask ("What makes PFAS remediation a
capital formation problem?").
- **FAQ modules** on high-intent authority pages (PFAS economics, marine conservation finance) —
visible Q&A + `FAQPage` schema. Owner-approved answers only.
- **Entity consistency**: use canonical terminology everywhere — *catalytic capital*, *aquatic
resources*, *marine & freshwater ecosystems*, *PFAS remediation*, *marine conservation finance*,
*environmental markets*, *coastal resilience*. Feeds `primaryTopic` + internal linking.
- **Internal linking graph**: services ↔ focus areas ↔ insights ↔ principal bio. Dense, relevant
internal links are a top GEO signal.
- **Freshness**: insights carry `publishDate`/`modifiedDate`; surface "Updated" dates.
- **Statistics & specifics**: the homepage metrics (27 years, $10B+, $3.5B+, 275+) are exactly the
kind of concrete, citable facts answer engines favor — keep them prominent and, where true,
attributable.
## 5. AI-crawler policy (`robots.txt`) — **owner decision required**
Current `public/robots.txt` = `Allow: /` for everyone. 2026 best practice is **explicit per-agent
rules**, splitting *search/answer* bots (that can cite and send traffic) from *training* bots
(that ingest content into model weights). Decision matrix:
| Bot | Job | Recommendation |
|-----|-----|----------------|
| `OAI-SearchBot`, `ChatGPT-User` | ChatGPT search index + live citation fetch | **Allow** |
| `Claude-SearchBot` | Claude web-search citations | **Allow** |
| `PerplexityBot` | Perplexity answer citations | **Allow** |
| `Google-Extended` | Gemini + Google AI Overviews | **Allow** (maximizes AI visibility) |
| `Googlebot`, `Bingbot` | Classic search | **Allow** |
| `GPTBot` | OpenAI model *training* | Owner choice — allow = max reach, disallow = opt out of training |
| `ClaudeBot` | Anthropic model *training* | Owner choice (same trade-off) |
| `Bytespider`, stealth scrapers | Known to ignore robots.txt | Consider **edge/WAF block** (Cloudflare) — robots.txt won't stop them |
**Recommended default for an authority-seeking advisory firm: allow all search/answer + classic
bots (maximize citation and discoverability); training bots are a values call.** Because BlueCap
wants to be cited as an authority, allowing everything is the simplest high-visibility posture.
Non-compliant scrapers are handled at the Cloudflare edge (see gitops doc §4.2), not robots.txt.
Keep `llms.txt` (already present, low cost) but treat it as supplemental, not load-bearing.
## 6. Measurement & Monitoring — **recommendation, needs sign-off**
Three distinct layers; today none is decided.
### 6a. Uptime / infrastructure — reuse the existing `monylog` stack (do NOT deploy Uptime Kuma)
The homelab already runs a full self-hosted **LGTM + Alertmanager** stack on the `monylog` box
(`10.66.15.21`, Ansible role `pglta`): Prometheus (15d retention, remote-write receiver), Grafana,
Loki, Tempo, Alertmanager → **Pushover** (creds from OpenBao) with **ntfy** fallback → phone. It
runs **outside k8s on purpose** so it still alerts when the cluster is down. Grafana Alloy runs as
a k3s DaemonSet and remote-writes cluster metrics + ships all pod logs to monylog.
**Already flowing for BlueCap (zero work):**
- **Pod/deployment health** — Alloy scrapes kube-state-metrics + annotated pods → Prometheus.
`kube_deployment_status_replicas_unavailable`, restarts, OOMKills are queryable now.
- **nginx logs** — Alloy ships all pod logs to Loki. 5xx rate / errors available via LogQL now.
**To add (small):**
1. **blackbox_exporter** — the one gap (not in the monylog compose). Add as a compose service +
a Prometheus scrape job. Probe the in-cluster Service (app health) **and** the public URL (full
edge-path uptime + TLS expiry). monylog is off-cluster → probing the public URL is a genuine
external vantage. Metrics: `probe_success`, `probe_http_status_code`,
`probe_ssl_earliest_cert_expiry`, latency.
2. **`web-bluecap.yml` alert rules** (mirror the existing host-infra rules pattern): `SiteDown`
(`probe_success==0` for 2m), `CertExpiringSoon` (<14d), `High5xx` (Loki/blackbox),
`DeploymentDegraded`, `PodRestarting`.
3. **Alertmanager** already routes to Pushover — just add a `service: bluecap` label; no new
channel. **Grafana** already present → build a BlueCap uptime/latency/5xx/pod-health dashboard.
- **ArgoCD** health/sync status as an additional deploy-level signal.
### 6b. Visitor & behavior analytics — PostHog + Umami side-by-side trial (ADR-0005)
Decided: **run PostHog and Umami side-by-side initially** and keep whichever fits best. This is
**product/visitor analytics**, distinct from the `monylog` ops telemetry in §6a. Trial setup:
- **PostHog — Cloud free tier** (1M events/mo) for the trial, not self-host (self-hosting needs
ClickHouse/Kafka/etc.; PostHog's docs steer small users to Cloud). Reverse-proxy ingest through
our domain to reduce ad-blocker loss. Gives autocapture, funnels, session replay.
- **Umami — self-hosted** in k3s: cookieless, ~2 KB, no consent banner, always-on baseline
(pageviews, sources, devices, custom events). Aligns with the Digital-ESG positioning.
- Both wired through one config-driven `Analytics.astro` in `BaseLayout`, gated by env vars, so
either can be toggled and the winner kept without code churn. Same event set in both:
consultation-CTA clicks, per-service/focus CTA source (ties to the segmented-CTA strategy),
insights reads, contact submits.
- **Consent/privacy must cover the superset** — PostHog (esp. session replay) likely triggers
cookie-consent + privacy-policy obligations; add these **before** enabling PostHog in production.
Umami alone needs no banner.
- **Avoid GA4** unless Google Ads attribution is later required.
- Evaluate on insight value vs. weight/consent/ESG fit; record the winner in a follow-up ADR.
### 6c. Search & AI-citation visibility
- **Google Search Console** + **Bing Webmaster Tools** (free, essential) — submit sitemap, monitor
impressions/queries/coverage, Core Web Vitals.
- **AI-citation tracking** (emerging, optional): periodically test brand/topic prompts across
ChatGPT/Perplexity/Google AI Overviews and log whether BlueCap is cited. Lightweight manual
tracker to start; dedicated GEO tools are immature — don't over-invest yet.
- **Performance budget**: Lighthouse in CI; target 95+ SEO/Perf/Best-Practices/Accessibility.
Static Astro should hit this easily; guard against regressions from images (use Astro `Image`).
## 7. Pre-launch SEO checklist
- [ ] Zod SEO schema in place; build fails on title/description/OG/canonical violations.
- [ ] `scripts/seo-lint.mjs` (single H1, heading order, canonical, alt text, JSON-LD parses) green in CI.
- [ ] Every page: unique title, description in window, canonical, OG image, valid JSON-LD.
- [ ] Organization/WebSite/Service/Person/Article/Breadcrumb schema per §3, all visible-content-backed.
- [ ] `robots.txt` per §5 (owner-decided); `sitemap-index.xml` correct; `/thank-you/` + staging `noindex`.
- [ ] Canonical host decided (apex vs `www`) + redirect configured at the edge.
- [ ] Search Console + Bing verified, sitemap submitted.
- [ ] Analytics deployed (Umami) + consultation/CTA events firing.
- [ ] Uptime Kuma + external check live and alerting.
- [ ] Lighthouse ≥ 95 across the board; no CLS from unsized images.
## Sources (verified 2026-07-15)
- [The State of llms.txt in 2026 — aeo.press](https://www.aeo.press/ai/the-state-of-llms-txt-in-2026)
- [llms.txt Explained (May 2026) — Codersera](https://codersera.com/blog/llms-txt-complete-guide-2026/)
- [Structured Data for AEO and GEO: Schema Markup Guide 2026 — Kurieta](https://kurieta.com/schema-for-aeo-geo/)
- [Answer Engine Optimization: Complete AEO Guide [2026] — Frase](https://www.frase.io/blog/what-is-answer-engine-optimization-the-complete-guide-to-getting-cited-by-ai)
- [Are FAQ Schemas Important for AI Search, GEO & AEO? — Frase](https://www.frase.io/blog/faq-schema-ai-search-geo-aeo)
- [The AI User-Agent Landscape in 2026: A Complete Reference — No Hacks](https://nohacks.co/blog/ai-user-agents-landscape-2026)
- [AI Crawlers Explained: GPTBot, ClaudeBot, PerplexityBot (2026) — Anagram](https://www.anagram.ai/blog/ai-crawlers-explained-gptbot-claudebot-perplexitybot-and-how-to-let-them-in-2026)
- [Robots.txt for AI Crawlers in 2026 — Cubitrek](https://cubitrek.com/blog/robots-txt-2026-managing-ai-crawler-budgets)