diff --git a/.env.example b/.env.example
index d9967c5..17a2c88 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,9 @@
# Public site origin used for SEO metadata, sitemap, and robots.txt.
BASE_URL=https://guide.sudloh.com
+# Public AdSense publisher client ID. Leave unset to disable ads locally.
+NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=ca-pub-9687404323559597
+
# Local-only demo mode serves sanitized fixtures and bypasses external services.
# Keep this false in every deployed environment.
BUZZ_DEMO_MODE=false
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 581bd04..8255476 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -10,6 +10,7 @@ on:
env:
BASE_URL: https://guide.sudloh.com
+ NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID: ca-pub-9687404323559597
REGISTRY_IMAGE: registry.neko-piranha.ts.net/astral/buzz-sheet
DEPLOY_NAMESPACE: buzz-sheet
KUBE_API_SERVER: https://100.112.189.33:6443/
@@ -32,6 +33,7 @@ jobs:
docker build \
--target app \
--build-arg BASE_URL="$BASE_URL" \
+ --build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID" \
--build-arg NEXT_DEPLOYMENT_ID=${{ gitea.sha }} \
--build-arg VCS_REF=${{ gitea.sha }} \
--tag ${{ env.REGISTRY_IMAGE }}:${{ gitea.sha }} \
diff --git a/Dockerfile b/Dockerfile
index 918e1f8..4f44637 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,8 +23,10 @@ FROM node:22-bookworm-slim AS next-builder
WORKDIR /app
ARG NEXT_DEPLOYMENT_ID=container-build
ARG BASE_URL
+ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID
ENV BASE_URL=${BASE_URL}
ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID}
+ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
diff --git a/app/Ads.txt b/app/Ads.txt
deleted file mode 100644
index 0552972..0000000
--- a/app/Ads.txt
+++ /dev/null
@@ -1 +0,0 @@
-google.com, pub-9687404323559597, DIRECT, f08c47fec0942fa0
\ No newline at end of file
diff --git a/app/[character]/[page]/page.tsx b/app/[character]/[page]/page.tsx
index 0bd61b1..3fee79f 100644
--- a/app/[character]/[page]/page.tsx
+++ b/app/[character]/[page]/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
+import { GoogleAdsense } from "@/components/public/google-adsense";
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public";
@@ -25,5 +26,10 @@ export default async function GuideSectionPage({ params }: { params: Params }) {
const { character, page } = await params;
const guide = await getCachedPublicGuide(character);
if (!guide || !publicSections(guide).some((section) => section.slug === page)) notFound();
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/app/[character]/page.tsx b/app/[character]/page.tsx
index 61e0acb..f70b63f 100644
--- a/app/[character]/page.tsx
+++ b/app/[character]/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
+import { GoogleAdsense } from "@/components/public/google-adsense";
import { StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public";
@@ -31,5 +32,10 @@ export default async function GuideOverviewPage({
}) {
const guide = await getCachedPublicGuide((await params).character);
if (!guide) notFound();
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/app/page.tsx b/app/page.tsx
index d34fa5c..0ab4767 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,6 +2,7 @@ import { connection } from "next/server";
import { Suspense } from "react";
import { LiveRefresh } from "@/components/live-refresh";
+import { GoogleAdsense } from "@/components/public/google-adsense";
import { SiteFooter } from "@/components/public/site-footer";
import { StructuredDirectory } from "@/components/public/structured-directory";
import { SiteHeader } from "@/components/public/site-header";
@@ -16,6 +17,7 @@ async function DirectoryContent() {
export default function HomePage() {
return (
+
diff --git a/components/public/google-adsense.tsx b/components/public/google-adsense.tsx
new file mode 100644
index 0000000..f86357a
--- /dev/null
+++ b/components/public/google-adsense.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import Script from "next/script";
+
+import { authClient } from "@/lib/auth/client";
+
+const clientId = process.env.NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID?.trim();
+
+export function GoogleAdsense() {
+ const { data: session, error, isPending } = authClient.useSession();
+
+ if (
+ process.env.NODE_ENV !== "production" ||
+ !clientId ||
+ isPending ||
+ error ||
+ session
+ ) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/public/ads.txt b/public/ads.txt
new file mode 100644
index 0000000..1ea23ef
--- /dev/null
+++ b/public/ads.txt
@@ -0,0 +1 @@
+google.com, pub-9687404323559597, DIRECT, f08c47fec0942fa0
diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts
index f471b99..dc65b66 100644
--- a/tests/deployment-contract.test.ts
+++ b/tests/deployment-contract.test.ts
@@ -91,7 +91,12 @@ describe("production deployment contract", () => {
expect(dockerfile.match(/^USER 1000:1000$/gmu)).toHaveLength(2);
expect(dockerfile).toContain('ENTRYPOINT ["bun", "scripts/migrate.ts"]');
expect(dockerfile).toContain('CMD ["bun", "server.js"]');
- expect(dockerfile).toContain("ARG BASE_URL\nENV BASE_URL=${BASE_URL}");
+ expect(dockerfile).toContain("ARG BASE_URL");
+ expect(dockerfile).toContain("ENV BASE_URL=${BASE_URL}");
+ expect(dockerfile).toContain("ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID");
+ expect(dockerfile).toContain(
+ "ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}",
+ );
expect(dockerfile).toContain("backend/discord.ts");
expect(dockerfile).toContain("./worker/discord.js");
});
@@ -106,6 +111,9 @@ describe("production deployment contract", () => {
expect(workflow).toContain("kubectl kustomize k8s/");
expect(workflow).toContain("--target app");
expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"');
+ expect(workflow).toContain(
+ '--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID"',
+ );
expect(workflow).toContain('\\"BASE_URL\\":\\"$BASE_URL\\"');
expect(workflow).toContain("--target migration");
expect(workflow).not.toContain("platforms: linux/arm64");
@@ -167,6 +175,9 @@ describe("environment template contract", () => {
expect(environmentExample).toMatch(new RegExp(`^${key}=`, "mu"));
}
expect(environmentExample).toContain("BUZZ_DEMO_MODE=false");
+ expect(environmentExample).toContain(
+ "NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=ca-pub-9687404323559597",
+ );
});
it("runs Discord catalog sync as an isolated worker", async () => {
diff --git a/tests/public-performance.test.ts b/tests/public-performance.test.ts
index 5409473..c06afce 100644
--- a/tests/public-performance.test.ts
+++ b/tests/public-performance.test.ts
@@ -55,4 +55,25 @@ describe("public rendering performance", () => {
sitemap: "https://guide.example.test/sitemap.xml",
});
});
+
+ it("loads AdSense only after confirming the visitor is signed out", async () => {
+ const [adsense, home, guide, guideSection, adsTxt] = await Promise.all([
+ repositoryFile("components/public/google-adsense.tsx"),
+ repositoryFile("app/page.tsx"),
+ repositoryFile("app/[character]/page.tsx"),
+ repositoryFile("app/[character]/[page]/page.tsx"),
+ repositoryFile("public/ads.txt"),
+ ]);
+
+ expect(adsense).toContain("authClient.useSession()");
+ expect(adsense).toContain('process.env.NODE_ENV !== "production"');
+ expect(adsense).toMatch(/isPending \|\|\s+error \|\|\s+session/u);
+ expect(adsense).toContain("pagead2.googlesyndication.com");
+ expect(home).toContain("");
+ expect(guide).toContain("");
+ expect(guideSection).toContain("");
+ expect(adsTxt.trim()).toBe(
+ "google.com, pub-9687404323559597, DIRECT, f08c47fec0942fa0",
+ );
+ });
});