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
20 changes: 17 additions & 3 deletions lib/ads/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStat

export { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStatus };
import { getOrCreateDefaultOrg } from "@/lib/orgs";
import { generateAdCreatives, cleanSummary, type AdCreative, type AdSummary } from "@/lib/ads/creative";
import { generateAdCreatives, cleanSummary, creativesFromCopy, templateCopy, summaryDomain, type AdCreative, type AdSummary } from "@/lib/ads/creative";
import { extractSiteBrand, type SiteBrand } from "@/lib/ads/brand";
import { DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";

export type CampaignSummary = {
Expand Down Expand Up @@ -116,11 +117,24 @@ export async function createCampaignForUrl(input: {
};
}

let generated: Awaited<ReturnType<typeof generateAdCreatives>>;
let generated: { brand: SiteBrand; creatives: AdCreative[]; summary: AdSummary | null; provider: string };
try {
generated = await generateAdCreatives(request.url, { supabase: sb });
} catch (err) {
return { ok: false, status: 502, error: err instanceof Error ? `Could not write ads for that URL: ${err.message}` : "Could not write ads for that URL." };
// No model with credit, or one that failed: the page's own words are the
// copy. A campaign that could not open would mean a post with no ad.
try {
const brand = await extractSiteBrand(request.url);
const copy = templateCopy(brand);
generated = {
brand,
creatives: creativesFromCopy(brand, copy, brand.ogImage),
summary: copy.summaryShort ? { short: cleanSummary(copy.summaryShort, 400), long: "", domain: summaryDomain(brand.url || request.url) } : null,
provider: `template (${err instanceof Error ? err.message.slice(0, 80) : "generator failed"})`,
};
} catch (inner) {
return { ok: false, status: 502, error: inner instanceof Error ? `Could not read that URL: ${inner.message}` : "Could not read that URL." };
}
}
if (!generated.creatives.length) return { ok: false, status: 502, error: "No creatives could be written for that URL." };

Expand Down
43 changes: 43 additions & 0 deletions lib/ads/creative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,49 @@ function buildUserPrompt(brand: SiteBrand): string {
.join("\n");
}

/**
* Copy written from the page alone, with no model in the loop.
*
* The generator needs an AI provider with credit, and when both providers are
* out (a spend cap, an empty balance) every campaign created from the API or
* the CLI would fail — which for a campaign that opens itself the moment a
* blog post is published means the post runs no ad at all. A page's own title
* and description are honest copy: they are what the page says about itself.
* Not as sharp as generated copy, and editable in the dashboard like any
* other creative.
*/
export function templateCopy(brand: SiteBrand): AdCopy {
const clean = (s: string) => (s ?? "").replace(/\s+/g, " ").trim();
// "NicheDB — sources in, feeds out" → "NicheDB"; the tail is usually the site name or a tagline.
const title = clean(brand.title).split(/\s+[—–|·]\s+/)[0] || clean(brand.title) || brand.domain;
const headline = title.length > 48 ? `${title.slice(0, 47).replace(/\s+\S*$/, "")}` : title;
const shortWords = headline.split(" ").slice(0, 4).join(" ");
const shortHeadline = shortWords.length > 28 ? shortWords.slice(0, 28).replace(/\s+\S*$/, "") : shortWords;
const description = clean(brand.description) || clean(brand.text).split(/(?<=[.!?])\s+/)[0] || `Read more on ${brand.domain}.`;
const body = description.length > 130 ? `${description.slice(0, 129).replace(/\s+\S*$/, "")}…` : description;
const bgColor = brand.themeColor && HEX.test(brand.themeColor) ? brand.themeColor.toLowerCase() : "#0b0d10";
const accentColor = brand.palette.find((c) => HEX.test(c) && c.toLowerCase() !== bgColor) ?? "#6ee7b7";
return {
headline: headline || brand.domain,
shortHeadline: shortHeadline || headline.slice(0, 28) || brand.domain,
body,
ctaText: "Learn more",
bgColor,
fgColor: "#e7e9ee",
accentColor,
lightBgColor: null,
lightFgColor: null,
lightAccentColor: null,
summaryShort: clean(brand.description).slice(0, 400),
summaryLong: "",
} as unknown as AdCopy;
}

/** The generator's creative set from a copy set; exported for the template path. */
export function creativesFromCopy(brand: SiteBrand, copy: AdCopy, heroUrl: string | null): AdCreative[] {
return copyToCreatives(brand, copy, heroUrl);
}

function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null): AdCreative[] {
const bg = safeHex(copy.bgColor, brand.themeColor && HEX.test(brand.themeColor) ? brand.themeColor : "#0b0d10");
const fg = safeHex(copy.fgColor, "#e7e9ee");
Expand Down
51 changes: 51 additions & 0 deletions tests/ads-template-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { templateCopy, creativesFromCopy, AD_FORMAT_IDS } from "@/lib/ads/creative";
import type { SiteBrand } from "@/lib/ads/brand";

const brand: SiteBrand = {
url: "https://nichedb.dev/",
domain: "nichedb.dev",
title: "NicheDB — sources in, feeds out",
description: "An open, ever-growing database of real-time data. Follow a feed, get told. Web, RSS, API, CLI and MCP.",
text: "NicheDB is a platform for databases that only ever grow.",
logoUrl: null,
ogImage: "https://nichedb.dev/icons/icon-512x512.png",
themeColor: "#12161f",
palette: ["#12161f", "#6ee7b7", "#ffffff"],
};

describe("template copy, for when no model has credit", () => {
it("is the page's own words, within the creative limits", () => {
const copy = templateCopy(brand);
expect(copy.headline).toBe("NicheDB");
expect(copy.shortHeadline).toBe("NicheDB");
expect(copy.body.length).toBeLessThanOrEqual(130);
expect(copy.body.startsWith("An open, ever-growing database")).toBe(true);
expect(copy.ctaText).toBe("Learn more");
expect(copy.bgColor).toBe("#12161f");
// The accent is the first palette colour that is not the background.
expect(copy.accentColor).toBe("#6ee7b7");
expect(copy.summaryShort).toBe(brand.description);
});

it("clips a long title on a word and falls back to the domain", () => {
const long = templateCopy({ ...brand, title: "A very long page title that keeps going well past the headline limit for banners" });
expect(long.headline.length).toBeLessThanOrEqual(48);
expect(long.headline.endsWith(" ")).toBe(false);
expect(long.shortHeadline.split(" ").length).toBeLessThanOrEqual(4);
const bare = templateCopy({ ...brand, title: "", description: "", text: "", themeColor: null, palette: [] });
expect(bare.headline).toBe("nichedb.dev");
expect(bare.body).toBe("Read more on nichedb.dev.");
expect(bare.bgColor).toBe("#0b0d10");
});

it("yields one creative per format, with the page image as the hero", () => {
const creatives = creativesFromCopy(brand, templateCopy(brand), brand.ogImage);
expect(creatives.map((c) => c.format)).toEqual(AD_FORMAT_IDS);
for (const creative of creatives) {
expect(creative.imageUrl).toBe(brand.ogImage);
expect(creative.headline.length).toBeGreaterThan(0);
expect(creative.lightBgColor).toBeTruthy();
}
});
});
Loading