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 .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
.DS_Store

# vercel project link (root-level; frontend/ has its own)
.vercel/

# git history backups (e.g. pre-rewrite-backup-*.bundle)
*.bundle

# brand assets (scripts/capture-assets.mjs): commit the WebP + still PNGs as the
# source-of-truth; the heavy GIF/APNG are regenerable, and frames are scratch.
exports/**/*.gif
exports/**/*.apng.png
frontend/.capture-frames/

# Agent worktrees and local agent state
.claude/
32 changes: 32 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ icon/image story coherent off the back of it:
- [ ] Consider using the minted assets as the real project icons / OG images / repo avatars
- [ ] Light + dark (inverted) variants where it makes sense

## Web-analytics table coverage (2026-08-28, ops X37)

Production events carry no `$pathname` and no `$pageleave`, so PostHog's Page /
Entry / Exit tables render empty and every session counts as a zero-duration
bounce. The wiring for both landed on dev as PR #60 (sanitizer allowlists
`$pageleave` and stamps `$pathname` from the already-sanitized `path`) and
ships with the next promotion — the items below are what's left after that.

- [ ] Verify post-deploy that `$pathname` shows on fresh pageviews and the
Page/Entry/Exit tables populate (board: CodesWhat Sites Health,
project 558033, dashboard 2044260)
- [ ] `$pageleave` volume note (resolved 2026-08-28, standard `19a0af1`): a
pageleave-to-pageview ratio well under 100% is structural, not a bug —
`$pageview` fires per client-side route change, `$pageleave` once per
document lifetime. drydock measured the envelope: the sanitizer guard
eats nothing. Don't "fix" the ratio here.
- [ ] Post-deploy canary caveat: posthog-js bot detection (`isLikelyBot`
checks `navigator.webdriver` and `userAgentData.brands`) may silently
drop Playwright-driven visits before `before_send` runs; contested
(portwing's landed), so a Playwright canary's silence proves nothing
either way. Verify with a real browser or by reading the deployed chunk
for the property names.
- [ ] Acquisition data: decided org-wide in `CodesWhat/ops`
`standards/analytics.md` ("Acquisition data and consent", as of
`c161ebc`) — that file is the authority. Shape: `save_campaign_params:
true` with `gclid`/`fbclid`/`msclkid` excluded; `save_referrer: true`
with the sanitizer forwarding `$referring_domain` only (drop it unless
it's a bare hostname, never copy `$referrer`); geo stays off (cookieless
strips the IP upstream, PostHog #48660). No banner needed. Implement
AFTER the `$pathname`/`$pageleave` promotion is verified in production,
so the two changes are separately attributable.

## Telemetry and badge audit (2026-08-14)

- The main website has no visible external provider badge surface to migrate.
Expand Down
5 changes: 3 additions & 2 deletions frontend/app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,11 @@ export async function POST(request: Request) {
);
}

// Handle other errors
// Handle other errors. Log the upstream detail server-side only; never
// pass a third-party response field through to the client.
console.error("EmailOctopus error:", data);
return NextResponse.json(
{ error: data.detail || "Failed to subscribe. Please try again." },
{ error: "Failed to subscribe. Please try again." },
{ status: response.status },
);
}
Expand Down
7 changes: 3 additions & 4 deletions frontend/app/robots.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { MetadataRoute } from "next";
import { BASE_URL } from "@/lib/site-config";

export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://codeswhat.com";

return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/_next/"],
disallow: ["/api/", "/admin/", "/_next/", "/studio/"],
},
sitemap: `${baseUrl}/sitemap.xml`,
sitemap: `${BASE_URL}/sitemap.xml`,
};
}
19 changes: 3 additions & 16 deletions frontend/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
import type { MetadataRoute } from "next";
import { BASE_URL } from "@/lib/site-config";

export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://codeswhat.com";

// Add your routes here as you create new pages
const routes = [
{
url: baseUrl,
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 1,
},
// Example of additional pages (uncomment and modify as needed):
// {
// url: `${baseUrl}/about`,
// url: `${BASE_URL}/about`,
// lastModified: new Date(),
// changeFrequency: 'monthly' as const,
// priority: 0.8,
// },
// {
// url: `${baseUrl}/services`,
// lastModified: new Date(),
// changeFrequency: 'monthly' as const,
// priority: 0.8,
// },
// {
// url: `${baseUrl}/contact`,
// lastModified: new Date(),
// changeFrequency: 'yearly' as const,
// priority: 0.5,
// },
];

return routes;
Expand Down
10 changes: 9 additions & 1 deletion frontend/instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ if (typeof window !== "undefined" && posthogConfig) {
api_host: posthogConfig.apiHost,
ui_host: posthogConfig.uiHost,
capture_pageview: false,
capture_pageleave: false,
// posthog-js only emits $pageleave when capture_pageleave === true, or
// when it's "if_capture_pageview" AND capture_pageview is enabled
// (posthog-js posthog-core.ts, _shouldCapturePageleave). capture_pageview
// is false here because pageviews are captured by hand above, so this has
// to be an explicit true rather than the default. sanitizeEvent in
// lib/posthog-privacy.ts rebuilds $pageleave the same way it rebuilds
// $pageview; without that branch flipping this alone would drop every
// $pageleave silently.
capture_pageleave: true,
autocapture: false,
rageclick: false,
disable_session_recording: true,
Expand Down
14 changes: 12 additions & 2 deletions frontend/lib/posthog-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type EventInput = {
};

type SanitizedEvent = {
event: "$pageview" | "cta activated" | "$web_vitals";
event: "$pageview" | "$pageleave" | "cta activated" | "$web_vitals";
properties: Record<string, boolean | number | string>;
timestamp?: Date;
uuid?: string;
Expand Down Expand Up @@ -138,10 +138,20 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null {
const values = properties as Record<string, unknown>;
const common = createCommonProperties(values);
if (common === null) return null;
if (event === "$pageview") {
// posthog-js emits $pageleave itself once capture_pageleave is true;
// nothing in this codebase calls it directly. It has to be rebuilt here
// like every other envelope — before this branch existed, $pageleave fell
// through to the `return null` below and was dropped silently, which is
// why flipping capture_pageleave on the init options alone fixes nothing.
// $pathname is set to the already-sanitized `path` rather than the raw
// pathname so PostHog's Web analytics Page / Entry page / Exit page
// tables — which key off $pathname — resolve without leaking any route
// outside ALLOWED_ROUTES.
if (event === "$pageview" || event === "$pageleave") {
return createSanitizedEvent(eventInput, event, {
...common,
$current_url: `${PRODUCTION_ORIGIN}${common.path}`,
$pathname: common.path,
});
}

Expand Down
9 changes: 6 additions & 3 deletions frontend/lib/site-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const SITE_CONFIG = {
/** Twitter/X profile URL (used in JSON-LD sameAs). */
twitterUrl: "https://x.com/codeswhat",
/** Brand logo in /public. */
logo: "/logos/codeswhat-logo-green.png",
logo: "/icon-512x512.png",
/** Maker credit. */
author: { name: "Scott Benson", url: "https://scottbenson.dev" },
} as const;
Expand All @@ -34,9 +34,12 @@ export const GITHUB_URL = `https://github.com/${githubOrg}`;
/**
* Site base URL. Prefers NEXT_PUBLIC_SITE_URL (Vercel/preview deploys), falls
* back to the production domain. `||` (not `??`) so a set-but-empty env var
* falls back too.
* falls back too. Trailing slashes are stripped so path concatenation can't
* produce `//` URLs when the env var is set with one.
*/
export const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || `https://${SITE_CONFIG.domain}`;
export const BASE_URL = (
process.env.NEXT_PUBLIC_SITE_URL || `https://${SITE_CONFIG.domain}`
).replace(/\/+$/, "");

/** Social/OG card — generated by scripts/generate-og.mjs into public/og.png. */
export const OG_IMAGE = {
Expand Down
1 change: 1 addition & 0 deletions frontend/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
18 changes: 18 additions & 0 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ const posthogProxy = "https://e.codeswhat.com";
const themeScriptHash = `sha256-${createHash("sha256").update(THEME_INIT_SCRIPT).digest("base64")}`;

const nextConfig: NextConfig = {
async redirects() {
return [
// The stable production *.vercel.app aliases serve the full site; send
// them to the canonical domain. Hash-suffixed preview URLs don't match.
{
source: "/:path*",
has: [
{
type: "host",
value:
"(codeswhat-website|codeswhat-website-codeswhat|codeswhat-website-git-main-codeswhat)\\.vercel\\.app",
},
],
destination: "https://codeswhat.com/:path*",
permanent: true,
},
];
},
async headers() {
return [
{
Expand Down
Loading
Loading