Merge: Confluence campaign promos + critical-path E2E suite

Make the Confluence promo calendar the single source of truth for partner
campaign promos (bun run pull-campaigns -> campaigns.ts), keep site-owned
promos in firstParty.ts, and add a lean Playwright E2E suite covering the
front page, downloads, and the promo banner date-window logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Teetow
2026-05-27 19:01:16 +02:00
17 changed files with 1763 additions and 426 deletions

6
.gitignore vendored
View File

@@ -1,6 +1,12 @@
# build output
dist/
# playwright e2e artifacts
test-results/
playwright-report/
blob-report/
playwright/.cache/
# netlify local state
.netlify/

View File

@@ -1,6 +1,6 @@
# About this repo
This is the source of https://www.audacityteam.org. It uses [Astro](https://docs.astro.build/en/getting-started/).
This is the source of [www.audacityteam.org](https://www.audacityteam.org). It uses [Astro](https://docs.astro.build/en/getting-started/).
This repo works with npm or Bun.
@@ -15,3 +15,65 @@ Translations are not yet supported. Please join our [dev discord](https://discor
## Security
This is a static website, with no user input. As such, regular vulnerabilities likely won't affect us. That said, it probably is good to run `npx @astrojs/upgrade` (or `bunx @astrojs/upgrade`) every now and then to update astro to fix security vulnerabilities.
## Promos
Promos shown on the site come from two places:
- **Campaign promos** (MuseHub partners) are the single source of truth on the
watched Confluence "Promo Calendar" page. They are generated into
[`src/assets/data/promos/campaigns.ts`](src/assets/data/promos/campaigns.ts) by
the pull command below — **never edit that file by hand.**
- **First-party promos** (the Audio.com exit popup, Audacity 4 alpha/video, the
survey banner) do not live on Confluence and are maintained by hand in
[`src/assets/data/promos/firstParty.ts`](src/assets/data/promos/firstParty.ts).
[`promotions.ts`](src/assets/data/promotions.ts) merges the two; the React/Astro
components consume only that merged map.
### Pulling campaign promos from Confluence
```sh
bun run pull-campaigns # write campaigns.ts
bun run pull-campaigns --dry-run # print the module instead of writing
```
The pipeline:
1. Fetches the watched page's storage HTML.
2. Deterministically extracts the calendar table, preserving destination URLs
verbatim (UTM params must never change).
3. Resolves the year-less date ranges and keeps only **active and upcoming**
promos — past rows are dropped (listed under `ignoredEntries` in the output).
4. Maps each row to a typed `PromoData` entry using fixed site conventions
(banner ⇄ "Top/Promo Banner", video ⇄ "Video embed"; MuseScore-only and
"Taken down" rows are skipped).
5. Validates that every emitted URL traces back to the source page.
6. Writes `campaigns.ts`. **It never commits or pushes — review the diff yourself.**
Required environment variables (see `.env`):
- `CONFLUENCE_CAMPAIGN_PAGE_URL` (or pass `--page-url <url>`)
- `CONFLUENCE_PERSONAL_TOKEN`
#### Optional LLM copy cleanup
The calendar copy is human-authored. Pass `--clean-copy` to run an LLM pass that
may fix typos and shorten copy — it never touches URLs, dates, types, or
tracking, and re-runs the URL integrity check afterwards. It is **off by
default** so output stays faithful to Confluence.
```sh
bun run pull-campaigns --clean-copy
```
Backend is auto-selected (the local `claude` CLI if on `PATH`, else an
OpenAI-compatible API). Override with:
- `CAMPAIGN_LLM_BACKEND=claude|api|none`
- `CAMPAIGN_CLAUDE_COMMAND` (default `claude`)
- `CAMPAIGN_LLM_MODEL` (or `OPENAI_MODEL`)
- `CAMPAIGN_LLM_API_KEY` (or `OPENAI_API_KEY`)
- `CAMPAIGN_LLM_API_URL` (default: OpenAI chat completions)
Other flags: `--output <file>`, `--help`.

View File

@@ -16,6 +16,7 @@
"astro-compressor": "^1.2.0",
"astro-icon": "^1.1.5",
"astro-lazy-youtube-embed": "^0.5.5",
"astro-microsoft-clarity-integration": "^1.1.7",
"classnames": "^2.5.1",
"platform": "^1.3.6",
"react": "^18.3.1",
@@ -25,6 +26,7 @@
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@iconify-json/fa6-brands": "^1.2.6",
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.19",
"@types/bun": "^1.3.9",
"@types/platform": "^1.3.6",
@@ -256,6 +258,8 @@
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
@@ -408,6 +412,8 @@
"astro-lazy-youtube-embed": ["astro-lazy-youtube-embed@0.5.5", "", { "peerDependencies": { "astro": "^2.0.0 || ^3.0.0-beta || ^4.0.0-beta || ^5.0.0-beta || ^6.0.0-beta" } }, "sha512-eus7WTAxRT76Z4CJWgSubMI/hLDTkZA5iqzAFarJPnkoYxgBD8Wew0L1iAcbIuCUAUwWv/fw6xE7qeKmmgYStg=="],
"astro-microsoft-clarity-integration": ["astro-microsoft-clarity-integration@1.1.7", "", { "peerDependencies": { "astro": "^4.0.0 || ^5.0.0 || ^6.0.0" } }, "sha512-HMYatKSxtquY4+j6HAEVre3XcqnIOyue61ouW+f3iD3GOsbgCSkqiJIRRTXBmN4oVpFvZAf0Kl1R6Lw9MNrmRg=="],
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": "bin/autoprefixer" }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
@@ -594,7 +600,7 @@
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -908,6 +914,10 @@
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -1334,6 +1344,8 @@
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"sitemap/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
@@ -1352,6 +1364,8 @@
"vite/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": "bin/esbuild" }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -1412,6 +1426,8 @@
"parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"tailwindcss/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],

41
e2e/download.spec.ts Normal file
View File

@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
test.describe("downloads", () => {
test("/download lists the three OS targets", async ({ page }) => {
await page.goto("/download");
await expect(
page.getByRole("heading", { name: "Downloads" }),
).toBeVisible();
// Each OS link also appears in the footer, so scope to the first (the card).
await expect(
page.locator('a[href="/download/windows"]').first(),
).toBeVisible();
await expect(page.locator('a[href="/download/mac"]').first()).toBeVisible();
await expect(
page.locator('a[href="/download/linux"]').first(),
).toBeVisible();
});
test("/download/windows offers a real Windows installer", async ({
page,
}) => {
await page.goto("/download/windows");
// The DownloadCard hydrates an <a> pointing at the published installer.
const installer = page
.locator('a[href*="github.com/audacity/audacity/releases"][href$=".exe"]')
.first();
await expect(installer).toBeVisible();
await expect(installer).toHaveAttribute("href", /audacity-win-.*\.exe$/);
});
test("/download/mac offers a real macOS installer", async ({ page }) => {
await page.goto("/download/mac");
const installer = page
.locator('a[href*="github.com/audacity/audacity/releases"][href$=".dmg"]')
.first();
await expect(installer).toBeVisible();
});
});

46
e2e/home.spec.ts Normal file
View File

@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
// A Windows UA so the OS-aware download links resolve to the Windows installer.
test.use({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
});
test.describe("front page", () => {
test("loads with the hero and a real Windows installer link", async ({
page,
}) => {
await page.goto("/");
await expect(page).toHaveTitle(/Audacity/i);
await expect(
page.getByRole("heading", {
name: "The world's most popular app to record and edit audio",
}),
).toBeVisible();
// The hero download button hydrates and exposes the real installer for the
// detected OS (Windows here).
const installer = page.locator('a[href*="audacity-win-"]').first();
await expect(installer).toBeVisible();
await expect(installer).toHaveAttribute("href", /audacity-win-.*\.exe$/);
});
test("renders the featured release video section", async ({ page }) => {
await page.goto("/");
// ReleaseVideo mounts two SplitFeaturedVideo slots; the first-party
// Audacity 4 video occupies slot 1.
await expect(
page
.getByRole("heading", { name: "How we're building Audacity 4" })
.first(),
).toBeVisible();
});
test("primary navigation exposes the downloads link", async ({ page }) => {
await page.goto("/");
const nav = page.getByRole("link", { name: "Downloads" }).first();
await expect(nav).toBeVisible();
await expect(nav).toHaveAttribute("href", "/download");
});
});

101
e2e/promo-banner.spec.ts Normal file
View File

@@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test";
import { campaignBannerPromos } from "../src/assets/data/promos/campaigns";
/**
* Exercises the promo banner's date-window logic by faking "today".
*
* Data-driven from the generated campaigns.ts so it keeps working as the
* Confluence calendar changes: we pick a real, currently-defined campaign
* banner, then move the browser clock into and past its window.
*/
const ymd = (iso: string) => iso.slice(0, 10);
const addDays = (iso: string, n: number) => {
const d = new Date(`${ymd(iso)}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
};
const within = (day: string, start?: string, end?: string) =>
(!start || start <= day) && (!end || day <= end);
type Banner = {
id: string;
message: string;
startDate?: string;
endDate?: string;
};
// Active banners with a CTA (what PromoBanner can actually render).
const activeBanners: Banner[] = Object.entries(campaignBannerPromos)
.filter(
([, p]) => p.isActive !== false && Boolean(p.cta) && p.type === "banner",
)
.map(([id, p]) => ({
id,
message: p.message,
startDate: p.startDate,
endDate: p.endDate,
}));
// Target = a fully-dated banner with the latest end (most future-proof). We
// also look for a day inside its window that no *other* active banner covers,
// so selection is deterministic and we can assert its exact copy.
const dated = activeBanners.filter((b) => b.startDate && b.endDate);
const target = dated.sort((a, b) => (a.endDate! < b.endDate! ? 1 : -1))[0];
let soloDay: string | undefined;
if (target) {
const others = activeBanners.filter((b) => b.id !== target.id);
for (
let day = target.startDate!;
day <= target.endDate!;
day = addDays(day, 1)
) {
if (others.every((o) => !within(day, o.startDate, o.endDate))) {
soloDay = day;
break;
}
}
}
// A desktop OS so the banner's osTargets (Windows / OS X) are satisfied.
test.use({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
});
test.describe("promo banner date windows", () => {
test.skip(
!target,
"No dated campaign banner in campaigns.ts to exercise (calendar may be empty).",
);
test("renders an active campaign banner inside its window", async ({
page,
}) => {
const day = soloDay ?? target.startDate!;
await page.clock.setFixedTime(new Date(`${day}T12:00:00Z`));
await page.goto("/");
const banner = page.locator("#promo-banner");
await expect(banner).toBeVisible();
await expect(page.locator("#promo-button")).toHaveAttribute("href", /.+/);
// When the day is exclusive to the target, selection is deterministic and
// we can assert its exact copy reached the DOM.
if (soloDay) {
await expect(banner.getByText(target.message)).toBeVisible();
}
});
test("drops a campaign banner the day after its window ends", async ({
page,
}) => {
const expired = addDays(target.endDate!, 1);
await page.clock.setFixedTime(new Date(`${expired}T12:00:00Z`));
await page.goto("/");
// Hydration settles, then the expired promo must be absent from the DOM.
await expect(page.getByText(target.message)).toHaveCount(0);
});
});

View File

@@ -5,7 +5,9 @@
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"test": "bun test",
"pull-campaigns": "bun run scripts/pull-campaigns.ts",
"test": "bun test src scripts",
"test:e2e": "playwright test",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
@@ -35,6 +37,7 @@
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@iconify-json/fa6-brands": "^1.2.6",
"@playwright/test": "^1.60.0",
"@tailwindcss/typography": "^0.5.19",
"@types/bun": "^1.3.9",
"@types/platform": "^1.3.6",

33
playwright.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = 4321;
const BASE_URL = `http://localhost:${PORT}`;
/**
* Lean critical-path E2E suite. Runs against the Astro dev server (no Confluence
* access needed at runtime — promo data is read from the committed
* src/assets/data/promos/campaigns.ts). See e2e/*.spec.ts.
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "bun run dev",
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View File

@@ -0,0 +1,359 @@
import { describe, expect, test } from "bun:test";
import {
assertUrlsFromSource,
buildCampaignBundle,
cellText,
extractPromoTable,
formatReport,
mapRowToPromo,
parseCliArgs,
parseConfluencePageReference,
renderCampaignModule,
resolveTimeline,
toCamelCaseId,
type ConfluencePagePayload,
type ExtractedRow,
} from "./pull-campaigns";
const TABLE_HTML = `
<table>
<tbody>
<tr><th>Dates (From - to )</th><th>Partner/product</th><th>Copy</th><th>Audacity</th><th>MuseScore.org</th><th>Results</th><th>Notes</th></tr>
<tr>
<td>May 28-June 11</td>
<td><p>URL: <a href="https://www.musehub.com/plugin/denoiser?utm_source=au-web-banner&amp;utm_medium=au-banner">link</a></p></td>
<td><p>Denoiser: The smart noise removal tool for clean audio.</p></td>
<td><p>Top Banner</p></td>
<td>N/A</td><td></td><td></td>
</tr>
<tr>
<td>Apr 29 to May 13</td>
<td><a href="https://www.musehub.com/course/audacity-explained?utm_source=au-web-banner">link</a></td>
<td>Audacity Explained: The complete guide. CTA Copy: Learn more</td>
<td>Top Banner</td>
<td>N/A</td><td></td><td></td>
</tr>
<tr>
<td>Feb 18 to xx</td>
<td><a href="https://www.musehub.com/app/overtune-studio?utm_source=au-web">link</a></td>
<td>Overtune: record your vocals.</td>
<td><p>Video embed Video URL: https://www.youtube.com/watch?v=A4jPvCdbrKA</p></td>
<td>N/A</td><td></td><td></td>
</tr>
<tr>
<td>Oct 21 to 29</td>
<td><a href="https://www.musehub.com/app/mooz?utm_source=mss-web">link</a></td>
<td>MOOZ video platform</td>
<td>N/A</td>
<td>Promo banner</td><td></td><td></td>
</tr>
</tbody>
</table>`;
const TODAY = new Date("2026-05-27T00:00:00.000Z");
describe("parseCliArgs", () => {
test("parses flags and custom output", () => {
const parsed = parseCliArgs([
"--dry-run",
"--clean-copy",
"--output",
"tmp/promos.ts",
"--page-url",
"https://confluence.example.com/spaces/TEAM/pages/123456789/example",
]);
expect(parsed.dryRun).toBe(true);
expect(parsed.cleanCopy).toBe(true);
expect(parsed.outputPath.endsWith("tmp/promos.ts")).toBe(true);
expect(parsed.pageUrl).toContain("123456789");
});
test("rejects unknown arguments", () => {
expect(() => parseCliArgs(["--nope"])).toThrow("Unknown argument");
});
});
describe("parseConfluencePageReference", () => {
test("extracts origin and page id", () => {
expect(
parseConfluencePageReference(
"https://confluence.example.com/spaces/TEAM/pages/123456789/Title+Here",
),
).toEqual({
origin: "https://confluence.example.com",
pageId: "123456789",
pageUrl:
"https://confluence.example.com/spaces/TEAM/pages/123456789/Title+Here",
});
});
test("throws when no page id is present", () => {
expect(() =>
parseConfluencePageReference("https://confluence.example.com/dashboard"),
).toThrow();
});
});
describe("cellText", () => {
test("strips tags, decodes entities, collapses whitespace", () => {
expect(cellText("<p>SOAP &amp; Capture<br/>now</p>")).toBe(
"SOAP & Capture now",
);
});
});
describe("extractPromoTable", () => {
test("returns one row per data row with URLs collected", () => {
const rows = extractPromoTable(TABLE_HTML);
expect(rows).toHaveLength(4);
expect(rows[0].dates).toBe("May 28-June 11");
expect(rows[0].placement).toBe("Top Banner");
expect(rows[0].urls).toContain(
"https://www.musehub.com/plugin/denoiser?utm_source=au-web-banner&utm_medium=au-banner",
);
});
test("returns [] when the calendar table is missing", () => {
expect(extractPromoTable("<p>no table here</p>")).toEqual([]);
});
});
describe("resolveTimeline", () => {
test("anchors the newest row near today and walks years backwards", () => {
const rows = extractPromoTable(TABLE_HTML);
const timeline = resolveTimeline(rows, TODAY);
expect(timeline[0]).toEqual({
startDate: "2026-05-28",
endDate: "2026-06-11",
});
expect(timeline[1]).toEqual({
startDate: "2026-04-29",
endDate: "2026-05-13",
});
// Open-ended ("to xx") yields a start with no end.
expect(timeline[2]).toEqual({ startDate: "2026-02-18" });
// Older row resolves into a prior year, not a future one.
expect(timeline[3].startDate?.startsWith("2025-")).toBe(true);
});
test("rolls a cross-year range's end into the next year", () => {
const rows: ExtractedRow[] = [
{
dates: "Dec 3 to Feb 18",
product: "",
copy: "X",
placement: "Top Banner",
urls: [],
},
];
const [resolved] = resolveTimeline(rows, TODAY);
expect(resolved.startDate?.endsWith("-12-03")).toBe(true);
const startYear = Number(resolved.startDate?.slice(0, 4));
expect(resolved.endDate).toBe(`${startYear + 1}-02-18`);
});
});
describe("toCamelCaseId", () => {
test("camelCases and expands ampersands", () => {
expect(toCamelCaseId("SOAP Voice Clean & Capture")).toBe(
"soapVoiceCleanAndCapture",
);
expect(toCamelCaseId("Denoiser")).toBe("denoiser");
});
});
describe("mapRowToPromo", () => {
test("maps a banner row with derived tracking and exact URL", () => {
const rows = extractPromoTable(TABLE_HTML);
const mapped = mapRowToPromo(rows[0], {
startDate: "2026-05-28",
endDate: "2026-06-11",
});
expect(mapped?.id).toBe("denoiser");
expect(mapped?.promo.type).toBe("banner");
expect(mapped?.promo.osTargets).toEqual(["Windows", "OS X"]);
expect(mapped?.promo.cta?.link).toContain("utm_medium=au-banner");
expect(mapped?.promo.tracking?.name).toBe("Denoiser MuseHub");
});
test("uses the inline CTA Copy label", () => {
const rows = extractPromoTable(TABLE_HTML);
const mapped = mapRowToPromo(rows[1], {});
expect(mapped?.promo.cta?.text).toBe("Learn more");
});
test("maps a video row, deriving embed + thumbnail from the watch URL", () => {
const rows = extractPromoTable(TABLE_HTML);
const mapped = mapRowToPromo(rows[2], { startDate: "2026-02-18" });
expect(mapped?.promo.type).toBe("video");
expect(mapped?.promo.slot).toBe(2);
expect(mapped?.promo.video?.videoURL).toBe(
"https://www.youtube-nocookie.com/embed/A4jPvCdbrKA?autoplay=1",
);
expect(mapped?.promo.video?.placeholderImage).toBe(
"https://i.ytimg.com/vi/A4jPvCdbrKA/maxresdefault.jpg",
);
});
test("skips rows that are not Audacity placements", () => {
const rows = extractPromoTable(TABLE_HTML);
expect(mapRowToPromo(rows[3], {})).toBeNull(); // MOOZ: Audacity = N/A
});
});
describe("buildCampaignBundle", () => {
test("keeps active/upcoming promos and reports the rest as ignored", () => {
const rows = extractPromoTable(TABLE_HTML);
const timeline = resolveTimeline(rows, TODAY);
const bundle = buildCampaignBundle(rows, timeline, "2026-05-27");
expect(Object.keys(bundle.bannerPromos)).toEqual(["denoiser"]);
expect(Object.keys(bundle.videoPromos)).toEqual(["overtune"]);
// Audacity Explained ended 2026-05-13 -> dropped as past.
expect(
bundle.ignoredEntries.some((entry) =>
entry.includes("audacityExplained"),
),
).toBe(true);
// MOOZ row is MuseScore-only (Audacity = N/A) -> dropped as non-Audacity.
expect(
bundle.ignoredEntries.some((entry) => entry.includes("Oct 21 to 29")),
).toBe(true);
});
});
describe("assertUrlsFromSource", () => {
const sourceUrls = ["https://www.musehub.com/plugin/denoiser?x=1"];
test("accepts URLs present verbatim in the source", () => {
const bundle = {
bannerPromos: {
denoiser: {
type: "banner" as const,
message: "x",
cta: {
text: "Go",
link: "https://www.musehub.com/plugin/denoiser?x=1",
},
},
},
videoPromos: {},
summary: "",
ignoredEntries: [],
};
expect(() => assertUrlsFromSource(bundle, sourceUrls)).not.toThrow();
});
test("rejects a destination URL that is not in the source", () => {
const bundle = {
bannerPromos: {
denoiser: {
type: "banner" as const,
message: "x",
cta: {
text: "Go",
link: "https://www.musehub.com/plugin/denoiser?x=2",
},
},
},
videoPromos: {},
summary: "",
ignoredEntries: [],
};
expect(() => assertUrlsFromSource(bundle, sourceUrls)).toThrow(
"not found in Confluence",
);
});
test("accepts derived YouTube embed/thumbnail when the id is in the source", () => {
const bundle = {
bannerPromos: {},
videoPromos: {
overtune: {
type: "video" as const,
message: "x",
video: {
placeholderImage:
"https://i.ytimg.com/vi/A4jPvCdbrKA/maxresdefault.jpg",
imageAltText: "x",
videoURL:
"https://www.youtube-nocookie.com/embed/A4jPvCdbrKA?autoplay=1",
},
},
},
summary: "",
ignoredEntries: [],
};
expect(() =>
assertUrlsFromSource(bundle, [
"https://www.youtube.com/watch?v=A4jPvCdbrKA",
]),
).not.toThrow();
});
});
describe("formatReport", () => {
test("reports what was written and lists the ignored rows", () => {
const page: ConfluencePagePayload = {
origin: "https://confluence.example.com",
pageId: "123456789",
pageUrl:
"https://confluence.example.com/spaces/TEAM/pages/123456789/Title",
storageHtml: "",
title: "Promo calendar",
version: 61,
};
const report = formatReport(
page,
{
bannerPromos: {},
videoPromos: {},
summary: "2 banner + 1 video promo(s)",
ignoredEntries: ["Apr 29 to May 13 — audacityExplained (past)"],
},
"/out/campaigns.ts",
);
expect(report).toContain("Source: Promo calendar (version 61)");
expect(report).toContain("2 banner + 1 video promo(s)");
expect(report).toContain("Wrote /out/campaigns.ts");
expect(report).toContain("Ignored rows");
expect(report).toContain("audacityExplained (past)");
});
});
describe("renderCampaignModule", () => {
test("renders typed exports without leaking source URL or report into the file", () => {
const rendered = renderCampaignModule({
bannerPromos: {
denoiser: {
type: "banner",
isActive: true,
message: "Denoiser",
cta: {
text: "Get it",
link: "https://www.musehub.com/plugin/denoiser",
},
},
},
videoPromos: {},
summary: "1 banner + 0 video promo(s)",
ignoredEntries: ["should not appear in file"],
});
expect(rendered).toContain("campaignBannerPromos");
expect(rendered).toContain("campaignVideoPromos");
expect(rendered).toContain("Denoiser");
expect(rendered).toContain("do not edit");
// No URL of any kind may land in the comment header — that is where an
// internal source link would otherwise leak. (Public promo URLs live in
// the data below, not in comments.)
const commentLines = rendered
.split("\n")
.filter((line) => line.trimStart().startsWith("//"));
expect(commentLines.some((line) => /https?:\/\//.test(line))).toBe(false);
// The sync report (summary + ignored rows) is not baked into the file.
expect(rendered).not.toContain("should not appear in file");
});
});

904
scripts/pull-campaigns.ts Normal file
View File

@@ -0,0 +1,904 @@
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { promisify } from "node:util";
import type { PromoData } from "../src/assets/data/promos/types";
/**
* Campaign promo pull pipeline.
*
* Confluence is the single source of truth for partner campaign promos. The
* watched page holds a reverse-chronological "Promo Calendar" table. This
* script:
*
* 1. Fetches the page storage HTML.
* 2. Deterministically extracts the calendar table into structured rows,
* preserving destination URLs verbatim (UTM params must never change).
* 3. Resolves the (year-less) date ranges via a monotonic walk anchored on
* today, then keeps only currently-active and upcoming promos.
* 4. Maps each row to a typed PromoData entry using fixed site conventions.
* 5. Optionally runs an LLM pass that may only tidy human-authored copy
* (typos, length) — never URLs, dates, types, or tracking. Skipped when
* no backend is available, and a no-op when the copy is already clean.
* 6. Validates that every emitted URL appears verbatim in the source page.
* 7. Renders src/assets/data/promos/campaigns.ts (or prints it with --dry-run).
*
* The script never commits or pushes. Review the generated diff yourself.
*/
const DEFAULT_OUTPUT_PATH = resolve(
import.meta.dir,
"../src/assets/data/promos/campaigns.ts",
);
const DEFAULT_CLAUDE_COMMAND = "claude";
const DEFAULT_LLM_API_URL = "https://api.openai.com/v1/chat/completions";
// Site conventions that Confluence does not encode. Centralised so the
// generated output stays consistent and reviewable.
const PARTNER_OS_TARGETS = ["Windows", "OS X"];
const DEFAULT_PRIORITY = 50;
const VIDEO_SLOT = 2; // slot 1 is reserved for the first-party Audacity 4 video
const BANNER_CTA_FALLBACK = "Get it on MuseHub";
const BANNER_TRACKING = {
category: "Promo CTA",
action: "Promo CTA button",
} as const;
const VIDEO_TRACKING = {
category: "Video embed",
action: "Watch release video",
} as const;
const MONTHS: Record<string, number> = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
};
export type CliOptions = {
cleanCopy: boolean;
dryRun: boolean;
help: boolean;
outputPath: string;
pageUrl?: string;
};
export type ConfluencePageReference = {
pageId: string;
pageUrl: string;
origin: string;
};
export type ConfluencePagePayload = ConfluencePageReference & {
title: string;
version: number;
storageHtml: string;
};
/** A single row of the calendar table, reduced to plain text + collected URLs. */
export type ExtractedRow = {
dates: string;
product: string;
copy: string;
placement: string; // the "Audacity" column
urls: string[];
};
export type ResolvedDates = {
/** ISO YYYY-MM-DD, or undefined for an open-ended ("to xx") range. */
startDate?: string;
endDate?: string;
};
export type CampaignBundle = {
bannerPromos: Record<string, PromoData>;
videoPromos: Record<string, PromoData>;
summary: string;
ignoredEntries: string[];
};
type ClaudeCliConfig = { command: string; model?: string };
type ApiConfig = { apiKey: string; apiUrl: string; model: string };
type LlmBackend =
| { kind: "none" }
| { kind: "claude"; config: ClaudeCliConfig }
| { kind: "api"; config: ApiConfig };
type ConfluenceContentResponse = {
title?: string;
version?: { number?: number };
body?: { storage?: { value?: string } };
};
const execFileAsync = promisify(execFile);
const HELP_TEXT = `Pull the Confluence promo calendar into src/assets/data/promos/campaigns.ts.
Usage:
bun run pull-campaigns [--dry-run] [--clean-copy] [--page-url <url>] [--output <file>]
Required env:
CONFLUENCE_CAMPAIGN_PAGE_URL (or pass --page-url)
CONFLUENCE_PERSONAL_TOKEN
Flags:
--dry-run print the generated module instead of writing it
--clean-copy run an LLM pass that may tidy human-authored copy (typos,
length). Off by default so the output stays faithful to
Confluence. URLs, dates, types, and tracking are never touched.
Optional env (only used with --clean-copy):
CAMPAIGN_LLM_BACKEND=claude|api|none default: claude if on PATH, else none
CAMPAIGN_CLAUDE_COMMAND default: claude
CAMPAIGN_LLM_MODEL claude/api model override
CAMPAIGN_LLM_API_KEY (or OPENAI_API_KEY)
CAMPAIGN_LLM_API_URL default: OpenAI chat completions
The script never commits or pushes. Review the generated diff before shipping.
`;
// ---------------------------------------------------------------------------
// CLI + Confluence fetch
// ---------------------------------------------------------------------------
export function parseCliArgs(args: string[]): CliOptions {
const options: CliOptions = {
cleanCopy: false,
dryRun: false,
help: false,
outputPath: DEFAULT_OUTPUT_PATH,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
} else if (arg === "--dry-run") {
options.dryRun = true;
} else if (arg === "--clean-copy") {
options.cleanCopy = true;
} else if (arg === "--output") {
const value = args[(index += 1)];
if (!value) throw new Error("Missing value for --output");
options.outputPath = resolve(process.cwd(), value);
} else if (arg === "--page-url") {
const value = args[(index += 1)];
if (!value) throw new Error("Missing value for --page-url");
options.pageUrl = value;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
export function parseConfluencePageReference(
pageUrl: string,
): ConfluencePageReference {
const parsedUrl = new URL(pageUrl);
const parts = parsedUrl.pathname.split("/").filter(Boolean);
const pagesIndex = parts.findIndex((part) => part === "pages");
const pageId = pagesIndex >= 0 ? parts[pagesIndex + 1] : undefined;
if (!pageId || !/^\d+$/.test(pageId)) {
throw new Error(
`Could not determine Confluence page id from URL: ${pageUrl}`,
);
}
return {
pageId,
pageUrl,
origin: `${parsedUrl.protocol}//${parsedUrl.host}`,
};
}
export async function fetchConfluencePage(
reference: ConfluencePageReference,
token: string,
fetchImpl: typeof fetch = fetch,
): Promise<ConfluencePagePayload> {
const apiUrl = `${reference.origin}/rest/api/content/${reference.pageId}?expand=title,body.storage,version`;
const response = await fetchImpl(apiUrl, {
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
if (!response.ok) {
const bodyText = await response.text();
throw new Error(
`Confluence request failed (${response.status} ${response.statusText}): ${bodyText.slice(0, 400)}`,
);
}
const payload = (await response.json()) as ConfluenceContentResponse;
const title = payload.title;
const version = payload.version?.number;
const storageHtml = payload.body?.storage?.value;
if (!title || typeof version !== "number" || !storageHtml) {
throw new Error(
"Confluence response is missing title, version, or body.storage.value",
);
}
return { ...reference, title, version, storageHtml };
}
// ---------------------------------------------------------------------------
// HTML helpers + table extraction
// ---------------------------------------------------------------------------
export function decodeEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
}
/** Strip tags from a fragment, decode entities, collapse whitespace. */
export function cellText(fragment: string): string {
const withBreaks = fragment.replace(/<br\s*\/?>/gi, " ");
const withoutTags = withBreaks.replace(/<[^>]+>/g, " ");
return decodeEntities(withoutTags).replace(/\s+/g, " ").trim();
}
function collectUrls(fragment: string): string[] {
const urls = new Set<string>();
for (const match of fragment.matchAll(/href="([^"]+)"/g)) {
urls.add(decodeEntities(match[1]));
}
// Plain-text URLs that were never linked.
for (const match of fragment.matchAll(/https?:\/\/[^\s"<)]+/g)) {
urls.add(decodeEntities(match[0]));
}
return [...urls];
}
/**
* Locate the promo calendar table (identified by its "Partner/product" header)
* and return one ExtractedRow per data row. Returns [] if the table is absent.
*/
export function extractPromoTable(storageHtml: string): ExtractedRow[] {
const anchor = storageHtml.indexOf("Partner/product");
if (anchor < 0) return [];
const tableStart = storageHtml.lastIndexOf("<table", anchor);
const tableEnd = storageHtml.indexOf("</table>", anchor);
if (tableStart < 0 || tableEnd < 0) return [];
const table = storageHtml.slice(tableStart, tableEnd);
const rowFragments = table.split(/(?=<tr\b)/i).slice(1);
const rows: ExtractedRow[] = [];
for (const rowFragment of rowFragments) {
// Skip the header row (uses <th>).
if (/<th\b/i.test(rowFragment)) continue;
const cells = rowFragment.split(/(?=<td\b)/i).slice(1);
if (cells.length < 4) continue;
rows.push({
dates: cellText(cells[0]),
product: cellText(cells[1]),
copy: cellText(cells[2]),
placement: cellText(cells[3]),
urls: [
...collectUrls(cells[1]),
...collectUrls(cells[2]),
...collectUrls(cells[3]),
],
});
}
return rows;
}
// ---------------------------------------------------------------------------
// Date resolution
// ---------------------------------------------------------------------------
type MonthDay = { month: number; day: number };
function parseMonthDay(token: string): MonthDay | null {
const match = token.toLowerCase().match(/([a-z]{3})[a-z]*\.?\s+(\d{1,2})/);
if (!match) return null;
const month = MONTHS[match[1]];
const day = Number(match[2]);
if (month === undefined || day < 1 || day > 31) return null;
return { month, day };
}
/** True when the end of a range is open ("to xx", "to xxx", blank). */
function hasOpenEnd(dates: string): boolean {
const tail = dates.split(/\s*(?:-|to|)\s*/i).pop() ?? "";
return parseMonthDay(tail) === null && /x{2,}|^\s*$/.test(tail.trim() + " ");
}
function isoDate(year: number, md: MonthDay): string {
const month = String(md.month + 1).padStart(2, "0");
const day = String(md.day).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Resolve year-less date ranges over the whole (reverse-chronological) table.
*
* The newest promos sit at the top, so dates strictly decrease as we walk down.
* We anchor the first row to the year that puts its start closest to `today`,
* then for each subsequent row pick the latest year keeping it no later than
* the previous (newer) row. End months earlier than their start month roll the
* end into the following year.
*/
export function resolveTimeline(
rows: ExtractedRow[],
today = new Date(),
): ResolvedDates[] {
const todayMs = today.getTime();
let ceilingMs = Number.POSITIVE_INFINITY; // previous (newer) row's start
let anchored = false;
return rows.map((row) => {
const [startToken, ...rest] = row.dates.split(/\s*(?:-|to|)\s*/i);
const startMd = parseMonthDay(startToken);
if (!startMd) return {};
const endMd = parseMonthDay(rest.join(" "));
const open = !endMd && hasOpenEnd(row.dates);
// Candidate years to consider for the start, newest first.
const baseYear = today.getFullYear();
let startYear: number;
if (!anchored) {
// Anchor: minimise distance from today.
startYear = [baseYear - 1, baseYear, baseYear + 1].reduce(
(best, year) => {
const diff = Math.abs(
new Date(isoDate(year, startMd)).getTime() - todayMs,
);
const bestDiff = Math.abs(
new Date(isoDate(best, startMd)).getTime() - todayMs,
);
return diff < bestDiff ? year : best;
},
baseYear,
);
anchored = true;
} else {
// Latest year keeping the end no later than the newer row's start.
startYear = baseYear + 1;
while (startYear > baseYear - 20) {
const endYear =
endMd && endMd.month < startMd.month ? startYear + 1 : startYear;
const refMd = endMd ?? startMd;
if (new Date(isoDate(endYear, refMd)).getTime() <= ceilingMs) break;
startYear -= 1;
}
}
const startDate = isoDate(startYear, startMd);
ceilingMs = new Date(startDate).getTime();
if (open) return { startDate };
if (!endMd) return { startDate };
const endYear = endMd.month < startMd.month ? startYear + 1 : startYear;
return { startDate, endDate: isoDate(endYear, endMd) };
});
}
// ---------------------------------------------------------------------------
// Row -> PromoData mapping
// ---------------------------------------------------------------------------
type PromoType = "banner" | "video" | "skip";
function classifyPlacement(placement: string): PromoType {
const value = placement.toLowerCase();
if (value.includes("taken down")) return "skip";
if (value.includes("video")) return "video";
if (value.includes("banner")) return "banner";
return "skip"; // "N/A", blank, MuseScore-only
}
export function toCamelCaseId(label: string): string {
const words = label
.replace(/&/g, " and ")
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.split(/\s+/);
if (words.length === 0) return "promo";
return words
.map((word, index) =>
index === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join("");
}
/** Product name = text before the first colon in the copy, else the URL slug. */
function deriveProductName(row: ExtractedRow): string {
const colon = row.copy.indexOf(":");
if (colon > 0 && colon < 60) return row.copy.slice(0, colon).trim();
const url = row.urls[0];
if (url) {
const slug = url.split(/[?#]/)[0].split("/").filter(Boolean).pop() ?? "";
return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
return "Promo";
}
/** Split copy into the displayed message and an optional CTA label. */
function splitCopy(copy: string): { message: string; ctaText?: string } {
const match = copy.match(/cta(?:\s*copy)?\s*:\s*(.+)$/i);
if (!match) return { message: copy.trim() };
return {
message: copy.slice(0, match.index).trim(),
ctaText: match[1].trim(),
};
}
function firstMuseHubUrl(urls: string[]): string | undefined {
return urls.find((url) => url.includes("musehub.com"));
}
function youtubeId(urls: string[]): string | undefined {
for (const url of urls) {
const match = url.match(/(?:youtu\.be\/|v=|embed\/|\/vi\/)([\w-]{6,})/);
if (match) return match[1];
}
return undefined;
}
/** Convert one extracted row + resolved dates into a keyed PromoData, or null. */
export function mapRowToPromo(
row: ExtractedRow,
dates: ResolvedDates,
): { id: string; promo: PromoData } | null {
const type = classifyPlacement(row.placement);
if (type === "skip") return null;
const product = deriveProductName(row);
const id = toCamelCaseId(product);
const { message, ctaText } = splitCopy(row.copy);
if (!message) return null;
const dateFields: Partial<PromoData> = {};
if (dates.startDate) dateFields.startDate = dates.startDate;
if (dates.endDate) dateFields.endDate = dates.endDate;
if (type === "video") {
const videoId = youtubeId(row.urls);
const ctaLink = firstMuseHubUrl(row.urls);
if (!videoId) return null; // a video promo with no video is unusable
const promo: PromoData = {
type: "video",
isActive: true,
priority: DEFAULT_PRIORITY,
slot: VIDEO_SLOT,
...dateFields,
message,
tracking: { ...VIDEO_TRACKING, name: product },
video: {
placeholderImage: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
imageAltText: `Video thumbnail: ${product}`,
videoURL: `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1`,
},
...(ctaLink
? { cta: { text: ctaText ?? BANNER_CTA_FALLBACK, link: ctaLink } }
: {}),
};
return { id, promo };
}
const ctaLink = firstMuseHubUrl(row.urls) ?? row.urls[0];
if (!ctaLink) return null; // a banner with no destination is unusable
const promo: PromoData = {
type: "banner",
isActive: true,
priority: DEFAULT_PRIORITY,
osTargets: [...PARTNER_OS_TARGETS],
...dateFields,
message,
tracking: { ...BANNER_TRACKING, name: `${product} MuseHub` },
cta: { text: ctaText ?? BANNER_CTA_FALLBACK, link: ctaLink },
};
return { id, promo };
}
// ---------------------------------------------------------------------------
// Bundle assembly
// ---------------------------------------------------------------------------
/** Keep promos that are still active today or scheduled for the future. */
function isCurrentOrUpcoming(promo: PromoData, today: string): boolean {
if (promo.endDate && promo.endDate < today) return false;
return true;
}
export function buildCampaignBundle(
rows: ExtractedRow[],
timeline: ResolvedDates[],
today = new Date().toISOString().slice(0, 10),
): CampaignBundle {
const bannerPromos: Record<string, PromoData> = {};
const videoPromos: Record<string, PromoData> = {};
const ignoredEntries: string[] = [];
const usedIds = new Set<string>();
rows.forEach((row, index) => {
const mapped = mapRowToPromo(row, timeline[index] ?? {});
if (!mapped) {
if (row.dates || row.copy) {
ignoredEntries.push(`${row.dates}${row.product || row.copy}`.trim());
}
return;
}
if (!isCurrentOrUpcoming(mapped.promo, today)) {
ignoredEntries.push(`${row.dates}${mapped.id} (past)`);
return;
}
// Ensure unique keys when product names collide.
let key = mapped.id;
let suffix = 2;
while (usedIds.has(key)) key = `${mapped.id}${suffix++}`;
usedIds.add(key);
if (mapped.promo.type === "video") videoPromos[key] = mapped.promo;
else bannerPromos[key] = mapped.promo;
});
const bannerCount = Object.keys(bannerPromos).length;
const videoCount = Object.keys(videoPromos).length;
const summary = `${bannerCount} banner + ${videoCount} video promo(s) active or upcoming as of ${today}`;
return { bannerPromos, videoPromos, summary, ignoredEntries };
}
// ---------------------------------------------------------------------------
// URL integrity
// ---------------------------------------------------------------------------
function promoUrls(promo: PromoData): string[] {
const urls: string[] = [];
if (promo.cta?.link) urls.push(promo.cta.link);
if (promo.video?.videoURL) urls.push(promo.video.videoURL);
if (promo.video?.placeholderImage) urls.push(promo.video.placeholderImage);
return urls;
}
/**
* Guarantee every emitted destination URL traces back to the source page.
* Derived YouTube embed/thumbnail URLs are allowed when the video id appears
* in a source URL. Throws on any URL we cannot account for.
*/
export function assertUrlsFromSource(
bundle: CampaignBundle,
sourceUrls: string[],
): void {
const source = new Set(sourceUrls);
const sourceIds = new Set(
sourceUrls.map((url) => youtubeId([url])).filter(Boolean),
);
const check = (url: string) => {
if (source.has(url)) return;
const id = youtubeId([url]);
if (id && sourceIds.has(id)) return; // derived embed/thumbnail
throw new Error(`Emitted URL not found in Confluence source: ${url}`);
};
for (const promo of [
...Object.values(bundle.bannerPromos),
...Object.values(bundle.videoPromos),
]) {
promoUrls(promo).forEach(check);
}
}
// ---------------------------------------------------------------------------
// Optional LLM copy cleanup
// ---------------------------------------------------------------------------
export function resolveLlmBackend(env: NodeJS.ProcessEnv): LlmBackend {
const requested = env.CAMPAIGN_LLM_BACKEND;
if (requested === "none") return { kind: "none" };
const apiKey = env.CAMPAIGN_LLM_API_KEY ?? env.OPENAI_API_KEY;
const model = env.CAMPAIGN_LLM_MODEL;
if (requested === "api") {
if (!apiKey)
throw new Error("CAMPAIGN_LLM_BACKEND=api requires an API key.");
return {
kind: "api",
config: {
apiKey,
apiUrl: env.CAMPAIGN_LLM_API_URL ?? DEFAULT_LLM_API_URL,
model: model ?? env.OPENAI_MODEL ?? "gpt-4o-mini",
},
};
}
const command = env.CAMPAIGN_CLAUDE_COMMAND ?? DEFAULT_CLAUDE_COMMAND;
const claudePath = typeof Bun !== "undefined" ? Bun.which(command) : null;
if (requested === "claude") {
if (!claudePath)
throw new Error(`Claude CLI not found on PATH (${command}).`);
return {
kind: "claude",
config: { command: claudePath, ...(model ? { model } : {}) },
};
}
// Auto: prefer claude, then API, else skip.
if (claudePath)
return {
kind: "claude",
config: { command: claudePath, ...(model ? { model } : {}) },
};
if (apiKey) {
return {
kind: "api",
config: {
apiKey,
apiUrl: env.CAMPAIGN_LLM_API_URL ?? DEFAULT_LLM_API_URL,
model: model ?? env.OPENAI_MODEL ?? "gpt-4o-mini",
},
};
}
return { kind: "none" };
}
type CopyEntry = { id: string; message: string };
function collectCopyEntries(bundle: CampaignBundle): CopyEntry[] {
return [
...Object.entries(bundle.bannerPromos),
...Object.entries(bundle.videoPromos),
].map(([id, promo]) => ({ id, message: promo.message }));
}
function applyCleanedCopy(bundle: CampaignBundle, cleaned: CopyEntry[]): void {
const byId = new Map(cleaned.map((entry) => [entry.id, entry.message]));
for (const promo of [
...Object.entries(bundle.bannerPromos),
...Object.entries(bundle.videoPromos),
]) {
const [id, value] = promo;
const message = byId.get(id);
// Only accept rewrites that keep the copy URL-free; never lose content.
if (message && message.trim() && !/https?:\/\//.test(message)) {
value.message = message.trim();
}
}
}
const CLEANUP_PROMPT = `You tidy human-authored promo copy for the Audacity website.
Return JSON: {"entries":[{"id":"...","message":"..."}]} with the same ids.
For each message you may fix typos, fix capitalisation, and trim it to a single
concise sentence or two. Do NOT invent facts, add URLs, change product names,
or change meaning. If a message is already clean, return it unchanged.`;
function buildCleanupInput(entries: CopyEntry[]): string {
return `${CLEANUP_PROMPT}\n\nInput:\n${JSON.stringify({ entries }, null, 2)}`;
}
function parseCleanupResponse(text: string): CopyEntry[] {
const trimmed = text
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "");
const parsed = JSON.parse(trimmed) as { entries?: unknown };
if (!Array.isArray(parsed.entries)) return [];
return parsed.entries.flatMap((entry) =>
entry &&
typeof entry === "object" &&
typeof (entry as CopyEntry).id === "string" &&
typeof (entry as CopyEntry).message === "string"
? [{ id: (entry as CopyEntry).id, message: (entry as CopyEntry).message }]
: [],
);
}
async function runClaudeCleanup(
entries: CopyEntry[],
config: ClaudeCliConfig,
): Promise<CopyEntry[]> {
// `--tools` is variadic, so keep it ahead of another flag (never directly
// before the positional prompt, or it would swallow the prompt).
const args = ["-p", "--tools", "", "--output-format", "json"];
if (config.model) args.push("--model", config.model);
args.push(buildCleanupInput(entries));
const { stdout } = await execFileAsync(config.command, args, {
maxBuffer: 20 * 1024 * 1024,
});
const envelope = JSON.parse(String(stdout)) as { result?: unknown };
const result = typeof envelope.result === "string" ? envelope.result : "";
return parseCleanupResponse(result);
}
async function runApiCleanup(
entries: CopyEntry[],
config: ApiConfig,
fetchImpl: typeof fetch = fetch,
): Promise<CopyEntry[]> {
const response = await fetchImpl(config.apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
temperature: 0,
response_format: { type: "json_object" },
messages: [{ role: "user", content: buildCleanupInput(entries) }],
}),
});
if (!response.ok) {
throw new Error(
`LLM cleanup failed (${response.status} ${response.statusText})`,
);
}
const payload = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
return parseCleanupResponse(payload.choices?.[0]?.message?.content ?? "");
}
export async function cleanCopy(
bundle: CampaignBundle,
backend: LlmBackend,
): Promise<void> {
if (backend.kind === "none") return;
const entries = collectCopyEntries(bundle);
if (entries.length === 0) return;
const cleaned =
backend.kind === "claude"
? await runClaudeCleanup(entries, backend.config)
: await runApiCleanup(entries, backend.config);
applyCleanedCopy(bundle, cleaned);
}
// ---------------------------------------------------------------------------
// Render + write
// ---------------------------------------------------------------------------
export function renderCampaignModule(bundle: CampaignBundle): string {
// One-line banner: warn against hand-edits. The internal Confluence URL is
// deliberately NOT emitted here (it lives in .env); source provenance is
// printed to stdout at pull time instead.
return [
'// Generated by "bun run pull-campaigns" — do not edit. Source of truth is the Confluence promo calendar (see .env).',
'import type { PromoData } from "./types";',
"",
`export const campaignBannerPromos: Record<string, PromoData> = ${JSON.stringify(bundle.bannerPromos, null, 2)};`,
"",
`export const campaignVideoPromos: Record<string, PromoData> = ${JSON.stringify(bundle.videoPromos, null, 2)};`,
"",
].join("\n");
}
async function writeGeneratedModule(
outputPath: string,
content: string,
): Promise<void> {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, content, "utf8");
}
// ---------------------------------------------------------------------------
// Orchestration
// ---------------------------------------------------------------------------
export async function runPullCampaigns(
args: string[],
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
const options = parseCliArgs(args);
if (options.help) {
process.stdout.write(HELP_TEXT);
return;
}
const pageUrl = options.pageUrl ?? env.CONFLUENCE_CAMPAIGN_PAGE_URL;
if (!pageUrl) {
throw new Error(
"Missing Confluence page URL. Set CONFLUENCE_CAMPAIGN_PAGE_URL or pass --page-url.",
);
}
const token = env.CONFLUENCE_PERSONAL_TOKEN;
if (!token) throw new Error("Missing CONFLUENCE_PERSONAL_TOKEN.");
const page = await fetchConfluencePage(
parseConfluencePageReference(pageUrl),
token,
);
const rows = extractPromoTable(page.storageHtml);
if (rows.length === 0) {
throw new Error(
"Could not find the promo calendar table on the Confluence page.",
);
}
const timeline = resolveTimeline(rows);
const bundle = buildCampaignBundle(rows, timeline);
if (options.cleanCopy) {
const backend = resolveLlmBackend(env);
if (backend.kind === "none") {
process.stderr.write(
"--clean-copy requested but no LLM backend is available; using source copy.\n",
);
} else {
try {
await cleanCopy(bundle, backend);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`LLM copy cleanup skipped: ${message}\n`);
}
}
}
const sourceUrls = rows.flatMap((row) => row.urls);
assertUrlsFromSource(bundle, sourceUrls);
const moduleText = renderCampaignModule(bundle);
if (options.dryRun) {
process.stdout.write(moduleText);
return;
}
await writeGeneratedModule(options.outputPath, moduleText);
process.stdout.write(formatReport(page, bundle, options.outputPath));
}
/** The sync report for the operator: source, what was written, what was dropped. */
export function formatReport(
page: ConfluencePagePayload,
bundle: CampaignBundle,
outputPath: string,
): string {
const lines = [
`Source: ${page.title} (version ${page.version})`,
bundle.summary,
`Wrote ${outputPath}`,
];
if (bundle.ignoredEntries.length > 0) {
lines.push("", "Ignored rows (past, or not an Audacity placement):");
lines.push(...bundle.ignoredEntries.map((entry) => ` - ${entry}`));
}
return `${lines.join("\n")}\n`;
}
if (import.meta.main) {
runPullCampaigns(Bun.argv.slice(2)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}

View File

@@ -1,280 +0,0 @@
import type { PromoData } from "./types";
export const bannerPromos: Record<string, PromoData> = {
audacity4Alpha: {
type: "banner",
isActive: false,
priority: 50,
suppressOnPaths: ["/next", "/download"],
message: "Want a peek at our next big release?",
cta: {
text: "Try the Audacity 4 Alpha",
link: "/next",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Audacity 4 Alpha",
},
styles: {
container: "bg-[#0f004d]",
message: "text-gray-100",
button: "bg-[#ff3254] hover:bg-[#ff1a3c] text-white",
},
},
voiceByAuribus: {
type: "banner",
isActive: false,
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"AI powered professional vocals. Transform any track with Voice by Auribus!",
styles: {
container: "bg-yellow-300",
message: "text-gray-900",
button: "bg-gray-100 hover:bg-white",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Voice by Auribus MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/auribus?utm_source=au-web&utm_medium=au-banner&utm_campaign=au-web-mh-web-auribus",
},
},
soapVoiceCleaner: {
type: "banner",
isActive: false,
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"Soap Voice Cleaner: Professional spoken voice in 3 simple clicks!",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Soap MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/soap-voice-cleaner?utm_source=au-web-banner-mh-web&utm_medium=soap-voice-cleaner&utm_campaign=au-web-banner-mh-web-soap-voice-cleaner&utm_id=au-web-banner",
},
},
soapVoiceCleanCapture: {
type: "banner",
isActive: true,
startDate: "2026-05-14",
endDate: "2026-05-28",
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"SOAP Voice Clean & Capture: Professional voice + AI transcription in 2 simple clicks!",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "SOAP Voice Clean & Capture MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/bundle/soap-voice-recording-bundle?utm_source=au-web-banner&utm_medium=au-banner",
},
},
aceStudio2: {
type: "banner",
isActive: false,
priority: 50,
osTargets: ["Windows"],
message:
"ACE Studio 2.0 is here! The all-in-one workstation for AI voices, instruments and generative tools",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "ACE Studio 2.0 MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/app/ace-studio?utm_source=au-web-banner-mh-web&utm_medium=ace-studio-2&utm_campaign=au-web-banner-mh-web-ace-studio-2&utm_id=au-web-banner",
},
},
soapTranscriber: {
type: "banner",
isActive: false,
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"SOAP Transcriber: One click voice to text. Clean and instant transcripts. Made for Audacity.",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "SOAP Transcriber MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/soap-transcriber?utm_source=au-web-banner-mh-web&utm_medium=soap-transcriber&utm_campaign=au-web-banner-mh-web-soap-transcriber&utm_id=au-web-banner",
},
},
overtuneBanner: {
type: "banner",
isActive: false,
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"Overtune: The beat maker for rappers and singers. Sing, polish, and share.",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Overtune MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/app/overtune-studio?utm_source=au-web-banner-mh-web&utm_medium=overtune-2&utm_campaign=au-web-banner-mh-web-overtune-2r&utm_id=au-web-banner",
},
},
ampknob: {
type: "banner",
isActive: false,
priority: 50,
osTargets: ["Windows", "OS X"],
message: "Heavy guitar tone in seconds. One knob, no distractions.",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Ampknob Revc MuseHub",
},
cta: {
text: "Try for free",
link: "https://www.musehub.com/plugin/ampknob-revc?utm_source=audacity&utm_medium=web&utm_campaign=auampknob-revc",
},
},
trinityEQ: {
type: "banner",
isActive: false,
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"Trinity EQ: The precision EQ for mixing and mastering. Shape, warm, and refine your sound.",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Trinity EQ MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/trinity-eq?utm_source=au-web-banner-mh-web&utm_medium=trinity-eq&utm_campaign=au-web-banner-mh-web-trinity-eq&utm_id=au-web-banner",
},
},
audacityExplained: {
type: "banner",
isActive: true,
startDate: "2026-04-29",
endDate: "2026-05-13",
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"Audacity Explained®: The complete guide to Audacity. Record, edit, and produce audio with ease.",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Audacity Explained MuseHub",
},
cta: {
text: "Learn more",
link: "https://www.musehub.com/course/audacity-explained?utm_source=au-web-banner&utm_medium=audacity-explained",
},
},
speakerDiarizationPro: {
type: "banner",
isActive: true,
startDate: "2026-04-14",
endDate: "2026-04-28",
priority: 50,
osTargets: ["Windows", "OS X"],
message:
"Speaker Diarization Pro: The AI tool for splitting multi-speaker audio. Detect, separate, and export voices instantly.",
styles: {
container: "bg-yellow-300",
message: "text-gray-900 font-bold",
button:
"font-bold border-2 border-gray-900 bg-gray-900 text-white hover:bg-yellow-300 hover:text-gray-900 hover:border-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Speaker Diarization Pro MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/speaker-diarization-pro?utm_source=au-web-banner-mh-web&utm_medium=speaker-diarization-pro&utm_campaign=au-web-banner-mh-web-speaker-diarization-pro&utm_id=au-web-banner",
},
},
survey: {
type: "banner",
isActive: false,
priority: 50,
message: "3 minute survey:\nHelp us understand what features you want next",
styles: {
container: "bg-yellow-300",
message: "text-lg text-gray-900",
button:
"h-10 bg-gray-100 hover:bg-white border border-gray-900 text-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Survey CTA button",
name: "Go to Survey",
},
cta: {
text: "Take the survey",
link: "https://docs.google.com/forms/d/e/1FAIpQLScxH_f64JPCWt5nwqa8MTPXfmi453mqYwy1xZFPF_mx9mYkNw/viewform",
},
},
};

View File

@@ -0,0 +1,69 @@
// Generated by "bun run pull-campaigns" — do not edit. Source of truth is the Confluence promo calendar (see .env).
import type { PromoData } from "./types";
export const campaignBannerPromos: Record<string, PromoData> = {
denoiser: {
type: "banner",
isActive: true,
priority: 50,
osTargets: ["Windows", "OS X"],
startDate: "2026-05-28",
endDate: "2026-06-11",
message:
"Denoiser: The smart noise removal tool for clean audio. Detect, reduce, and restore sound without artifacts.",
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Denoiser MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/denoiser?utm_source=au-web-banner&utm_medium=au-banner",
},
},
soapVoiceCleanAndCapture: {
type: "banner",
isActive: true,
priority: 50,
osTargets: ["Windows", "OS X"],
startDate: "2026-05-14",
endDate: "2026-05-28",
message:
"SOAP Voice Clean & Capture: Professional voice + AI transcription in 2 simple clicks!",
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "SOAP Voice Clean & Capture MuseHub",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/bundle/soap-voice-recording-bundle?utm_source=au-web-banner&utm_medium=au-banner",
},
},
};
export const campaignVideoPromos: Record<string, PromoData> = {
overtuneStudio: {
type: "video",
isActive: true,
priority: 50,
slot: 2,
startDate: "2026-02-18",
message:
"Overtune Record your vocals on top of premium beats! Polish, personalize and share with ease",
tracking: {
category: "Video embed",
action: "Watch release video",
name: "Overtune Studio",
},
video: {
placeholderImage: "https://i.ytimg.com/vi/A4jPvCdbrKA/maxresdefault.jpg",
imageAltText: "Video thumbnail: Overtune Studio",
videoURL: "https://www.youtube-nocookie.com/embed/A4jPvCdbrKA?autoplay=1",
},
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/app/overtune-studio?utm_source=au-web&utm_medium=au-web-video&utm_campaign=au-web-mh-web-overtune-2",
},
},
};

View File

@@ -0,0 +1,115 @@
import type { PromoData } from "./types";
import audioComPromoImage from "../../img/promo/audacity-audiocom-promo.png";
/**
* First-party, site-owned promos.
*
* These are NOT partner promos and do not live on the Confluence promo
* calendar, so they are maintained here by hand. Everything that DOES appear
* on Confluence is generated into ./campaigns.ts by `bun run pull-campaigns` — do
* not duplicate campaign promos here.
*/
const AUDIO_COM_EXIT_POPUP_IMAGE_SRC =
typeof audioComPromoImage === "string"
? audioComPromoImage
: audioComPromoImage.src;
export const firstPartyPromos: Record<string, PromoData> = {
audacity4Alpha: {
type: "banner",
isActive: false,
priority: 50,
suppressOnPaths: ["/next", "/download"],
message: "Want a peek at our next big release?",
cta: {
text: "Try the Audacity 4 Alpha",
link: "/next",
},
tracking: {
category: "Promo CTA",
action: "Promo CTA button",
name: "Audacity 4 Alpha",
},
styles: {
container: "bg-[#0f004d]",
message: "text-gray-100",
button: "bg-[#ff3254] hover:bg-[#ff1a3c] text-white",
},
},
survey: {
type: "banner",
isActive: false,
priority: 50,
message: "3 minute survey:\nHelp us understand what features you want next",
styles: {
container: "bg-yellow-300",
message: "text-lg text-gray-900",
button:
"h-10 bg-gray-100 hover:bg-white border border-gray-900 text-gray-900",
},
tracking: {
category: "Promo CTA",
action: "Survey CTA button",
name: "Go to Survey",
},
cta: {
text: "Take the survey",
link: "https://docs.google.com/forms/d/e/1FAIpQLScxH_f64JPCWt5nwqa8MTPXfmi453mqYwy1xZFPF_mx9mYkNw/viewform",
},
},
audacity4Video: {
type: "video",
isActive: true,
priority: 50,
slot: 1,
message: "How we're building Audacity 4",
tracking: {
category: "Video embed",
action: "Watch release video",
name: "How we're building Audacity 4",
},
video: {
placeholderImage: "https://i.ytimg.com/vi/QYM3TWf_G38/maxresdefault.jpg",
imageAltText: "Video thumbnail: How we're building Audacity 4",
videoURL: "https://www.youtube-nocookie.com/embed/QYM3TWf_G38?autoplay=1",
},
},
audioComExitPopup: {
type: "exit-popup",
isActive: true,
priority: 50,
message:
"Use Audio.com to back up your projects, and share them from anywhere!",
cta: {
text: "Join Audio.com",
link: "https://audio.com/audacity/auth/sign-in?mtm_campaign=audacityteamorg&mtm_content=exit-intent-popup",
},
popupOptions: {
title: "Leave before setting up cloud storage?",
routeAllowlist: [],
displayMode: "modal",
promoImageSrc: AUDIO_COM_EXIT_POPUP_IMAGE_SRC,
promoImageAlt: "Audio.com promotion",
dismissText: "Leave site",
policy: {
minDwellMs: 3000,
},
impressionTracking: {
category: "Exit Intent",
action: "exit_intent_impression",
name: "audio.com Exit Intent Popup",
},
dismissTracking: {
category: "Exit Intent",
action: "exit_intent_dismiss",
name: "audio.com Exit Intent Popup",
},
},
tracking: {
category: "Exit Intent",
action: "exit_intent_cta_click",
name: "audio.com Exit Intent Popup",
},
},
};

View File

@@ -1,47 +0,0 @@
import type { PromoData } from "./types";
import audioComPromoImage from "../../img/promo/audacity-audiocom-promo.png";
const AUDIO_COM_EXIT_POPUP_IMAGE_SRC =
typeof audioComPromoImage === "string"
? audioComPromoImage
: audioComPromoImage.src;
export const popupPromos: Record<string, PromoData> = {
audioComExitPopup: {
type: "exit-popup",
isActive: true,
priority: 50,
message:
"Use Audio.com to back up your projects, and share them from anywhere!",
cta: {
text: "Join Audio.com",
link: "https://audio.com/audacity/auth/sign-in?mtm_campaign=audacityteamorg&mtm_content=exit-intent-popup",
},
popupOptions: {
title: "Leave before setting up cloud storage?",
routeAllowlist: [],
displayMode: "modal",
promoImageSrc: AUDIO_COM_EXIT_POPUP_IMAGE_SRC,
promoImageAlt: "Audio.com promotion",
dismissText: "Leave site",
policy: {
minDwellMs: 3000,
},
impressionTracking: {
category: "Exit Intent",
action: "exit_intent_impression",
name: "audio.com Exit Intent Popup",
},
dismissTracking: {
category: "Exit Intent",
action: "exit_intent_dismiss",
name: "audio.com Exit Intent Popup",
},
},
tracking: {
category: "Exit Intent",
action: "exit_intent_cta_click",
name: "audio.com Exit Intent Popup",
},
},
};

View File

@@ -1,86 +0,0 @@
import type { PromoData } from "./types";
export const videoPromos: Record<string, PromoData> = {
audacity4Video: {
type: "video",
isActive: true,
priority: 50,
slot: 1,
message: "How we're building Audacity 4",
tracking: {
category: "Video embed",
action: "Watch release video",
name: "How we're building Audacity 4",
},
video: {
placeholderImage: "https://i.ytimg.com/vi/QYM3TWf_G38/maxresdefault.jpg",
imageAltText: "Video thumbnail: How we're building Audacity 4",
videoURL: "https://www.youtube-nocookie.com/embed/QYM3TWf_G38?autoplay=1",
},
},
playgrndFxVideo: {
type: "video",
isActive: false,
priority: 50,
slot: 2,
message:
"Install once. Access tons of powerful plugins. Blend for infinite creativity.",
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/playgrnd-fx?utm_source=au-web&utm_medium=mh-web-cta&utm_campaign=au-web-mh-web-playgrnd-fx",
},
tracking: {
category: "Video embed",
action: "Watch release video",
name: "PLAYGRND FX",
},
video: {
placeholderImage: "https://i.ytimg.com/vi/UGiJCTu67Ak/maxresdefault.jpg",
imageAltText: "Video thumbnail: PLAYGRND FX",
videoURL: "https://www.youtube-nocookie.com/embed/UGiJCTu67Ak?autoplay=1",
},
},
landrFxVoiceVideo: {
type: "video",
isActive: false,
priority: 50,
slot: 2,
message: "One knob for polished studio quality vocals",
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/plugin/landr-fx-voice?utm_source=au-web&utm_medium=au-web-video&utm_campaign=au-web-mh-web-landr-fx-voice",
},
tracking: {
category: "Video embed",
action: "Watch release video",
name: "LANDR FX Voice",
},
video: {
placeholderImage: "https://i.ytimg.com/vi/JKAvMrLpIRI/maxresdefault.jpg",
imageAltText: "Video thumbnail: LANDR FX Voice",
videoURL: "https://www.youtube-nocookie.com/embed/JKAvMrLpIRI?autoplay=1",
},
},
overtuneVideo: {
type: "video",
isActive: true,
priority: 50,
slot: 2,
message:
"Record your vocals on top of premium beats! Polish, personalize and share with ease",
cta: {
text: "Get it on MuseHub",
link: "https://www.musehub.com/app/overtune-studio?utm_source=au-web&utm_medium=au-web-video&utm_campaign=au-web-mh-web-overtune-2",
},
tracking: {
category: "Video embed",
action: "Watch release video",
name: "Overtune",
},
video: {
placeholderImage: "https://i.ytimg.com/vi/A4jPvCdbrKA/hqdefault.jpg",
imageAltText: "Video thumbnail: Overtune",
videoURL: "https://www.youtube-nocookie.com/embed/A4jPvCdbrKA?autoplay=1",
},
},
};

View File

@@ -8,9 +8,8 @@ import type {
} from "./promos/types";
import { getFilteredPromos, isPromoDateActive } from "./promos/types";
import { bannerPromos } from "./promos/banners";
import { videoPromos } from "./promos/videos";
import { popupPromos } from "./promos/popups";
import { firstPartyPromos } from "./promos/firstParty";
import { campaignBannerPromos, campaignVideoPromos } from "./promos/campaigns";
export type {
PromoType,
@@ -24,9 +23,9 @@ export type {
export { getFilteredPromos, isPromoDateActive };
const promoData: Record<string, PromoData> = {
...bannerPromos,
...videoPromos,
...popupPromos,
...firstPartyPromos,
...campaignBannerPromos,
...campaignVideoPromos,
};
export default promoData;

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}