Skip to content
Closed
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
56 changes: 53 additions & 3 deletions components/ads/slot-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ export function SlotManager({
const [repoChoices, setRepoChoices] = useState<
{ owner: string; repo: string; installation_id: number }[] | null
>(null);
// Set when the installer can't find a document shell on its own. The
// publisher knows where theirs is, so we ask rather than dead-ending, and
// remember which repo the failed attempt was against so the retry matches.
const [askPath, setAskPath] = useState(false);
const [targetPath, setTargetPath] = useState("");
const [lastRepo, setLastRepo] = useState<{
owner: string;
repo: string;
installation_id: number;
} | null>(null);

const snippets = slot ? snippetsFor(fmt, slot.id, origin) : [];
// The first recipe is the canonical one for each unit, so an unset selection
Expand Down Expand Up @@ -187,15 +197,19 @@ export function SlotManager({
}
}

async function submitPr(pick?: { owner: string; repo: string; installation_id: number }) {
async function submitPr(
pick?: { owner: string; repo: string; installation_id: number },
path?: string,
) {
if (!slot) return;
const repo = pick ?? lastRepo ?? undefined;
setPrBusy(true);
setPrMsg(null);
try {
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({ ...(repo ?? {}), ...(path ? { target_path: path } : {}) }),
});
const json = await res.json();
if (!res.ok) {
Expand All @@ -207,8 +221,12 @@ export function SlotManager({
return;
}
setRepoChoices(null);
if (repo) setLastRepo(repo);
const r = json.data;
setPrMsg({ ok: true, text: r.detail, url: r.prUrl });
// Discovery came up empty (or the path given was wrong): ask for the
// file instead of leaving the publisher with a message and no button.
setAskPath(Boolean(r.needsTargetPath));
setPrMsg({ ok: !r.needsTargetPath, text: r.detail, url: r.prUrl });
} catch (e) {
setPrMsg({ ok: false, text: e instanceof Error ? e.message : "Network error." });
} finally {
Expand Down Expand Up @@ -343,6 +361,38 @@ export function SlotManager({
</span>
</div>

{askPath && (
<div className="mt-2 rounded border border-[var(--color-border)] p-2 text-xs">
<div className="mb-1 text-[var(--color-muted)]">
Which file closes your <code>&lt;/body&gt;</code>? Repo-relative, e.g.{" "}
<code>src/app.html</code> or <code>apps/web/app/layout.tsx</code>.
</div>
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const path = targetPath.trim();
if (path) submitPr(undefined, path);
}}
>
<input
className="input flex-1 font-mono text-xs"
value={targetPath}
onChange={(e) => setTargetPath(e.target.value)}
placeholder="app/layout.tsx"
aria-label="Path to the file that closes the document"
/>
<button
type="submit"
className="btn btn-primary text-xs"
disabled={prBusy || !targetPath.trim()}
>
{prBusy ? "Opening PR…" : "Install here"}
</button>
</form>
</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
28 changes: 25 additions & 3 deletions lib/github/install-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export interface InstallAdResult {
path?: string;
/** CSP config files patched so the ad unit isn't blocked. */
cspPaths?: string[];
/** Discovery found nothing: the UI should ask for a file path and retry. */
needsTargetPath?: boolean;
detail: string;
}

Expand Down Expand Up @@ -163,7 +165,17 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
targetPath = candidates[0]?.path;
}
if (!targetPath) {
return { status: "noop", detail: "No layout/template file with a </body> tag was found in the repo." };
// Discovery probes the canonical layout paths, every template-shaped file
// in the repo tree, and code search. When all three come up empty the
// publisher knows where their shell is and we don't, so say so and let
// them name it (the caller passes it back as targetPath).
return {
status: "noop",
needsTargetPath: true,
detail:
`No file with a </body> tag was found in ${input.owner}/${input.repo} on ${base}. ` +
"If your document shell lives somewhere unusual, give the file path and we'll install there.",
};
}

const file = await getFileContent({
Expand All @@ -174,7 +186,12 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
ref: base,
});
if (!file) {
return { status: "noop", path: targetPath, detail: `File not found: ${targetPath}` };
return {
status: "noop",
path: targetPath,
needsTargetPath: true,
detail: `File not found on ${base}: ${targetPath}`,
};
}

// Layout may already carry the embed (e.g. a re-run, or the publisher pasted
Expand All @@ -185,7 +202,12 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
? null
: injectBeforeBodyClose(file.content, embed, file.path);
if (!alreadyInstalled && !updated) {
return { status: "noop", path: file.path, detail: `No <body> tag in ${file.path}.` };
return {
status: "noop",
path: file.path,
needsTargetPath: true,
detail: `No </body> tag in ${file.path}, so there's nowhere to put the units. Try another file.`,
};
}

// Patch the site's CSP so the browser can load /ad.js, reach /api/ads/serve,
Expand Down
Loading