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
4 changes: 0 additions & 4 deletions app/api/ads/slots/[id]/install-embed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
import { getOrMintInstallationToken } from "@/lib/github/installations";
import { installAdEmbed } from "@/lib/github/install-ad";
import { AD_FORMAT_IDS } from "@/lib/ads/formats";

export const runtime = "nodejs";

Expand All @@ -24,8 +23,6 @@ const bodySchema = z.object({
repo: z.string().min(1).optional(),
installation_id: z.number().int().positive().optional(),
target_path: z.string().max(500).optional(),
// Which ad size to install. Defaults to the medium rectangle in the installer.
format: z.enum(AD_FORMAT_IDS as [string, ...string[]]).optional(),
});

type BoundRepo = {
Expand Down Expand Up @@ -144,7 +141,6 @@ export async function POST(request: NextRequest, ctx: { params: Promise<{ id: st
owner: owner!,
repo: repo!,
slotId,
format: body.format,
targetPath: body.target_path,
});
await finalize({
Expand Down
19 changes: 14 additions & 5 deletions components/ads/slot-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function SlotManager({
const res = await fetch(`/api/ads/slots/${slot.id}/install-embed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...(pick ?? {}), format: fmt }),
body: JSON.stringify(pick ?? {}),
});
const json = await res.json();
if (!res.ok) {
Expand Down Expand Up @@ -269,17 +269,26 @@ export function SlotManager({
<pre className="mt-2 overflow-x-auto rounded border border-[var(--color-border)] bg-black/30 p-3 text-xs">
{embed}
</pre>
<div className="mt-2 flex flex-wrap items-center gap-2">
<div className="mt-2">
<button className="btn text-xs" onClick={copy}>
{copied ? "Copied!" : "Copy embed"}
</button>
<button className="btn text-xs" onClick={() => submitPr()} disabled={prBusy}>
{prBusy ? "Opening PR…" : "Submit PR to install"}
</button>
</div>
</>
)}

{/* Auto-install drops a unit for every size before </body> — we
can't safely guess where each belongs, so the publisher keeps
or moves whichever they want. */}
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-[var(--color-border)] pt-3">
<button className="btn text-xs" onClick={() => submitPr()} disabled={prBusy}>
{prBusy ? "Opening PR…" : "Submit PR to install all sizes"}
</button>
<span className="text-xs text-[var(--color-muted)]">
Adds every size above <code>&lt;/body&gt;</code>; keep the ones you want.
</span>
</div>

{repoChoices && (
<div className="mt-2 rounded border border-[var(--color-border)] p-2 text-xs">
<div className="mb-1 text-[var(--color-muted)]">Choose a repo:</div>
Expand Down
94 changes: 38 additions & 56 deletions lib/github/install-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
hasDirective,
looksLikeCsp,
} from "./install-tracker";
import { PUBLISHER_FORMAT_IDS } from "@/lib/ads/formats";

const AD_ORIGIN = env.siteUrl.replace(/\/$/, "");
const BRANCH_PREFIX = "crawlproof/install-ad-embed";
Expand Down Expand Up @@ -67,7 +68,6 @@ export interface InstallAdInput {
owner: string;
repo: string;
slotId: string;
format?: string;
rootPath?: string;
/** Explicit target file; skips discovery when set. */
targetPath?: string;
Expand All @@ -84,25 +84,32 @@ export interface InstallAdResult {
detail: string;
}

const DEFAULT_FORMAT = "banner_300x250";

function rawEmbed(slotId: string, format: string): string {
return `<div data-cp-ad data-slot="${slotId}" data-format="${format}"></div>\n <script src="${AD_ORIGIN}/ad.js" async></script>`;
function isJsx(path: string): boolean {
return /\.(tsx|jsx)$/.test(path);
}

// JSX/TSX layouts: a self-closing div + next/script <Script>. React renders
// the valueless data attribute as data-cp-ad="true"; the [data-cp-ad] selector
// still matches.
function nextEmbed(slotId: string, format: string): string {
return `<div data-cp-ad="" data-slot="${slotId}" data-format="${format}" />\n <Script src="${AD_ORIGIN}/ad.js" strategy="afterInteractive" />`;
// A single ad unit for one size. JSX/TSX layouts get a self-closing div; React
// renders the valueless data attribute as data-cp-ad="true", which the
// [data-cp-ad] selector still matches.
function unitDiv(slotId: string, format: string, path: string): string {
return isJsx(path)
? `<div data-cp-ad="" data-slot="${slotId}" data-format="${format}" />`
: `<div data-cp-ad data-slot="${slotId}" data-format="${format}"></div>`;
}

function isJsx(path: string): boolean {
return /\.(tsx|jsx)$/.test(path);
// The one /ad.js loader shared by every unit on the page.
function loaderScript(path: string): string {
return isJsx(path)
? `<Script src="${AD_ORIGIN}/ad.js" strategy="afterInteractive" />`
: `<script src="${AD_ORIGIN}/ad.js" async></script>`;
}

function embedForPath(slotId: string, format: string, path: string): string {
return isJsx(path) ? nextEmbed(slotId, format) : rawEmbed(slotId, format);
// We can't reliably tell where each size belongs without understanding the
// page, so the auto-installer drops a unit for every available size stacked
// before </body>, plus a single loader. Publishers move/keep whichever they
// want; empty units simply don't render.
function embedBlock(slotId: string, formats: readonly string[], path: string): string {
return [...formats.map((f) => unitDiv(slotId, f, path)), loaderScript(path)].join("\n");
}

// Already installed if this slot's embed OR our /ad.js is present.
Expand All @@ -127,45 +134,20 @@ function injectBeforeBodyClose(content: string, embed: string, path: string): st
const prefix = content.slice(0, idx);
const lineStart = prefix.lastIndexOf("\n") + 1;
const indent = prefix.slice(lineStart).match(/^\s*/)?.[0] ?? "";
let updated = `${prefix}${indent} ${embed}\n${indent}${content.slice(idx)}`;
if (isJsx(path) && /<Script\b/.test(embed) && !/from\s+["']next\/script["']/.test(updated)) {
// Indent every line of the (possibly multi-unit) block to the </body> depth.
const block = embed
.split("\n")
.map((line) => `${indent} ${line}`)
.join("\n");
let updated = `${prefix}${block}\n${indent}${content.slice(idx)}`;
if (isJsx(path) && /<Script\b/.test(block) && !/from\s+["']next\/script["']/.test(updated)) {
updated = addNextScriptImport(updated);
}
return updated;
}

// Inject right after the opening <body …> tag — the "right place" for a
// leaderboard, which reads best across the top of the page rather than jammed
// at the very bottom before </body>. Best-effort: callers fall back to
// injectBeforeBodyClose when there's no <body> tag (e.g. a React fragment).
function injectAfterBodyOpen(content: string, embed: string, path: string): string | null {
const match = content.match(/<body\b[^>]*>/i);
if (!match || match.index == null) return null;
const openEnd = match.index + match[0].length;
const lineStart = content.lastIndexOf("\n", match.index) + 1;
const indent = content.slice(lineStart, match.index).match(/^\s*/)?.[0] ?? "";
let updated = `${content.slice(0, openEnd)}\n${indent} ${embed}${content.slice(openEnd)}`;
if (isJsx(path) && /<Script\b/.test(embed) && !/from\s+["']next\/script["']/.test(updated)) {
updated = addNextScriptImport(updated);
}
return updated;
}

// Sizes that want to sit at the top of the page instead of before </body>.
const TOP_PLACED_FORMATS = new Set<string>(["banner_728x90"]);

// Choose where a format's embed lands. Leaderboards go up top; everything else
// (rectangle, mobile, text link) drops in before </body>. Always falls back to
// the other strategy so a missing <body>/<body …> never blocks the install.
function injectEmbed(content: string, embed: string, path: string, format: string): string | null {
if (TOP_PLACED_FORMATS.has(format)) {
return injectAfterBodyOpen(content, embed, path) ?? injectBeforeBodyClose(content, embed, path);
}
return injectBeforeBodyClose(content, embed, path);
}

export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdResult> {
const format = input.format ?? DEFAULT_FORMAT;
const formats = PUBLISHER_FORMAT_IDS;
const repoMeta = await getRepo({ token: input.token, owner: input.owner, repo: input.repo });
const base = repoMeta.default_branch;

Expand Down Expand Up @@ -198,10 +180,10 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
// Layout may already carry the embed (e.g. a re-run, or the publisher pasted
// it by hand). We still open a PR when there's a CSP file to patch.
const alreadyInstalled = hasAdReference(file.content, input.slotId);
const embed = embedForPath(input.slotId, format, file.path);
const embed = embedBlock(input.slotId, formats, file.path);
const updated = alreadyInstalled
? null
: injectEmbed(file.content, embed, file.path, format);
: injectBeforeBodyClose(file.content, embed, file.path);
if (!alreadyInstalled && !updated) {
return { status: "noop", path: file.path, detail: `No <body> tag in ${file.path}.` };
}
Expand Down Expand Up @@ -242,7 +224,7 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
repo: input.repo,
path: file.path,
branch,
message: "Add CrawlProof ad unit",
message: "Add CrawlProof ad units",
contentUtf8: updated,
sha: file.sha,
});
Expand Down Expand Up @@ -272,18 +254,18 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
repo: input.repo,
head: branch,
base,
title: updated ? "Add CrawlProof ad unit" : "Allow CrawlProof ads in CSP",
title: updated ? "Add CrawlProof ad units" : "Allow CrawlProof ads in CSP",
body: [
"This PR adds the CrawlProof ad unit so this site can show network ads and earn crypto for clicks.",
"This PR adds the CrawlProof ad units so this site can show network ads and earn crypto for clicks.",
"",
`- Slot: \`${input.slotId}\``,
`- Format: \`${format}\``,
`- Sizes: ${formats.map((f) => `\`${f}\``).join(", ")}`,
updated
? `- Injected into \`${file.path}\` before \`</body>\`.`
: `- Ad embed already present in \`${file.path}\`.`,
? `- Injected all sizes into \`${file.path}\` before \`</body>\`. Move or delete any you don't want — empty units simply don't render.`
: `- Ad units already present in \`${file.path}\`.`,
cspBody,
"",
"The unit renders inside a sandboxed iframe and never blocks page load. Manage the slot at " +
"Each unit renders inside a sandboxed iframe and never blocks page load. Manage the slot at " +
`${AD_ORIGIN}/ads/slots`,
].join("\n"),
});
Expand Down
15 changes: 9 additions & 6 deletions tests/contract/install-ad.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,27 +130,30 @@ describe("installAdEmbed", () => {
expect(written).toContain("next.config.ts");
});

it("places a leaderboard at the top of the page (after <body>), not before </body>", async () => {
it("installs every available size before </body> with a single loader", async () => {
github.files.set(
"app/layout.tsx",
"export default function RootLayout({ children }) {\n return <html><body><main>{children}</main></body></html>;\n}\n",
"export default function RootLayout({ children }) {\n return <html><body>{children}</body></html>;\n}\n",
);

await installAdEmbed({
token: "token",
owner: "owner",
repo: "repo",
slotId: "slot-abc",
format: "banner_728x90",
});

const write = github.putFile.mock.calls.find((c) => c[0].path === "app/layout.tsx");
expect(write).toBeDefined();
const content = write![0].contentUtf8 as string;
// The embed carries the requested format and lands before <main>, i.e. right
// after <body> rather than at the very bottom of the page.
// A unit for every publisher size…
expect(content).toContain('data-format="banner_300x250"');
expect(content).toContain('data-format="banner_728x90"');
expect(content.indexOf("data-cp-ad")).toBeLessThan(content.indexOf("<main>"));
// …a single shared /ad.js loader for all of them…
expect(content.match(/ad\.js/g)?.length).toBe(1);
// …and everything lands above </body>.
expect(content.indexOf("data-cp-ad")).toBeLessThan(content.indexOf("</body>"));
expect(content.lastIndexOf("data-cp-ad")).toBeLessThan(content.indexOf("</body>"));
});

it("no-ops when the embed exists and no CSP needs changes", async () => {
Expand Down
Loading