mirror of
https://github.com/audacity/audacity.github.io.git
synced 2026-05-31 19:51:18 -05:00
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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,12 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# playwright e2e artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
playwright/.cache/
|
||||
|
||||
# netlify local state
|
||||
.netlify/
|
||||
|
||||
|
||||
64
README.md
64
README.md
@@ -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`.
|
||||
|
||||
18
bun.lock
18
bun.lock
@@ -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
41
e2e/download.spec.ts
Normal 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
46
e2e/home.spec.ts
Normal 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
101
e2e/promo-banner.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
33
playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
359
scripts/pull-campaigns.test.ts
Normal file
359
scripts/pull-campaigns.test.ts
Normal 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&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 & 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
904
scripts/pull-campaigns.ts
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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;
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
69
src/assets/data/promos/campaigns.ts
Normal file
69
src/assets/data/promos/campaigns.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
115
src/assets/data/promos/firstParty.ts
Normal file
115
src/assets/data/promos/firstParty.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Reference in New Issue
Block a user