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: 4 additions & 0 deletions app/api/ads/slots/[id]/install-embed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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 @@ -23,6 +24,8 @@ 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 @@ -141,6 +144,7 @@ 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
67 changes: 52 additions & 15 deletions components/ads/slot-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createSlot, setSlotStatus, saveSlotPayout, requestPayout } from "@/app/actions/ads";
import { PUBLISHER_FORMAT_IDS, formatSpec, type AdFormatId } from "@/lib/ads/formats";

// The paste-once embed for a given size. data-format tells /ad.js which creative
// to request; the medium rectangle stays the default the auto-installer uses.
function embedFor(slotId: string, format: AdFormatId, origin: string): string {
return `<div data-cp-ad data-slot="${slotId}" data-format="${format}"></div>\n<script src="${origin}/ad.js" async></script>`;
}

type Project = { id: string; name: string; url: string };
type Slot = {
Expand Down Expand Up @@ -73,15 +80,16 @@ export function SlotManager({
const [addr, setAddr] = useState(slot?.payout_address ?? "");
const [currency, setCurrency] = useState(slot?.payout_currency ?? "usdc_pol");
const [copied, setCopied] = useState(false);
// Which size's embed is currently revealed, and whether the code block is open.
const [fmt, setFmt] = useState<AdFormatId>(PUBLISHER_FORMAT_IDS[0]);
const [showCode, setShowCode] = useState(true);
const [prBusy, setPrBusy] = useState(false);
const [prMsg, setPrMsg] = useState<{ ok: boolean; text: string; url?: string } | null>(null);
const [repoChoices, setRepoChoices] = useState<
{ owner: string; repo: string; installation_id: number }[] | null
>(null);

const embed =
slot &&
`<div data-cp-ad data-slot="${slot.id}" data-format="banner_300x250"></div>\n<script src="${origin}/ad.js" async></script>`;
const embed = slot ? embedFor(slot.id, fmt, origin) : null;

function enable() {
start(async () => {
Expand Down Expand Up @@ -155,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 ?? {}),
body: JSON.stringify({ ...(pick ?? {}), format: fmt }),
});
const json = await res.json();
if (!res.ok) {
Expand Down Expand Up @@ -229,19 +237,48 @@ export function SlotManager({
)}
<div>
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
Embed — paste on your page
Embed — pick a size, paste on your page
</div>
<pre className="mt-1 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">
<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>
{/* One button per available size. Clicking reveals that size's code;
clicking the open size again collapses it. */}
<div className="mt-2 flex flex-wrap gap-2">
{PUBLISHER_FORMAT_IDS.map((id) => {
const spec = formatSpec(id);
const open = id === fmt && showCode;
return (
<button
key={id}
className={`btn text-xs ${open ? "btn-primary" : ""}`}
aria-expanded={open}
onClick={() => {
if (id === fmt) {
setShowCode((s) => !s);
} else {
setFmt(id);
setShowCode(true);
}
}}
>
{spec.label} · {spec.w}×{spec.h}
</button>
);
})}
</div>
{showCode && embed && (
<>
<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">
<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>
</>
)}

{repoChoices && (
<div className="mt-2 rounded border border-[var(--color-border)] p-2 text-xs">
Expand Down
6 changes: 6 additions & 0 deletions lib/ads/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export const AD_FORMATS = [
export type AdFormatId = (typeof AD_FORMATS)[number]["id"];
export const AD_FORMAT_IDS = AD_FORMATS.map((f) => f.id) as AdFormatId[];

// Sizes offered to publishers on the Monetize page — the ones they can copy an
// embed for and install. A subset of AD_FORMATS that grows as each size is
// surfaced (one PR per size). Keep the medium rectangle first: it's the safe
// default the auto-installer falls back to.
export const PUBLISHER_FORMAT_IDS: AdFormatId[] = ["banner_300x250", "banner_728x90"];

export function formatSpec(id: AdFormatId) {
return AD_FORMATS.find((f) => f.id === id) ?? AD_FORMATS[0];
}
Expand Down
34 changes: 32 additions & 2 deletions lib/github/install-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,36 @@ function injectBeforeBodyClose(content: string, embed: string, path: string): st
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 repoMeta = await getRepo({ token: input.token, owner: input.owner, repo: input.repo });
Expand Down Expand Up @@ -171,9 +201,9 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
const embed = embedForPath(input.slotId, format, file.path);
const updated = alreadyInstalled
? null
: injectBeforeBodyClose(file.content, embed, file.path);
: injectEmbed(file.content, embed, file.path, format);
if (!alreadyInstalled && !updated) {
return { status: "noop", path: file.path, detail: `No </body> tag in ${file.path}.` };
return { status: "noop", path: file.path, detail: `No <body> tag in ${file.path}.` };
}

// Patch the site's CSP so the browser can load /ad.js, reach /api/ads/serve,
Expand Down
23 changes: 23 additions & 0 deletions tests/contract/install-ad.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,29 @@ describe("installAdEmbed", () => {
expect(written).toContain("next.config.ts");
});

it("places a leaderboard at the top of the page (after <body>), not before </body>", async () => {
github.files.set(
"app/layout.tsx",
"export default function RootLayout({ children }) {\n return <html><body><main>{children}</main></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.
expect(content).toContain('data-format="banner_728x90"');
expect(content.indexOf("data-cp-ad")).toBeLessThan(content.indexOf("<main>"));
});

it("no-ops when the embed exists and no CSP needs changes", async () => {
github.files.set(
"app/layout.tsx",
Expand Down
Loading