From 44c93868ccdd516896f07d0d7a69891056f901ff Mon Sep 17 00:00:00 2001 From: Teetow Date: Wed, 27 May 2026 17:24:03 +0200 Subject: [PATCH 1/2] feat: pull campaign promos from Confluence Make the Confluence "Promo Calendar" page the single source of truth for MuseHub partner campaign promos, and reshape the promo data layer around a clear superset/subset split: - promos (umbrella) = campaigns (Confluence-sourced, time-boxed) + first-party (hand-authored, evergreen). - `bun run pull-campaigns` (scripts/pull-campaigns.ts) fetches the page, deterministically extracts the calendar table (URLs preserved verbatim), resolves year-less date ranges, keeps only active/upcoming promos, maps them to typed PromoData, validates every emitted URL traces back to source, and writes src/assets/data/promos/campaigns.ts. Opt-in --clean-copy runs an LLM pass that may only tidy copy text. Never commits or pushes. - Site-owned promos (audio.com exit popup, Audacity 4 alpha/video, survey) move to src/assets/data/promos/firstParty.ts. - Delete legacy banners.ts / videos.ts / popups.ts; promotions.ts now merges firstParty + campaign promos only. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 64 +- package.json | 1 + scripts/pull-campaigns.test.ts | 359 +++++++++++ scripts/pull-campaigns.ts | 904 +++++++++++++++++++++++++++ src/assets/data/promos/banners.ts | 280 --------- src/assets/data/promos/campaigns.ts | 69 ++ src/assets/data/promos/firstParty.ts | 115 ++++ src/assets/data/promos/popups.ts | 47 -- src/assets/data/promos/videos.ts | 86 --- src/assets/data/promotions.ts | 11 +- 10 files changed, 1516 insertions(+), 420 deletions(-) create mode 100644 scripts/pull-campaigns.test.ts create mode 100644 scripts/pull-campaigns.ts delete mode 100644 src/assets/data/promos/banners.ts create mode 100644 src/assets/data/promos/campaigns.ts create mode 100644 src/assets/data/promos/firstParty.ts delete mode 100644 src/assets/data/promos/popups.ts delete mode 100644 src/assets/data/promos/videos.ts diff --git a/README.md b/README.md index ea0b2c0..1992509 100644 --- a/README.md +++ b/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 `) +- `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 `, `--help`. diff --git a/package.json b/package.json index ce31066..03ff5c6 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "astro dev", "start": "astro dev", + "pull-campaigns": "bun run scripts/pull-campaigns.ts", "test": "bun test", "build": "astro build", "preview": "astro preview", diff --git a/scripts/pull-campaigns.test.ts b/scripts/pull-campaigns.test.ts new file mode 100644 index 0000000..4018b2b --- /dev/null +++ b/scripts/pull-campaigns.test.ts @@ -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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Dates (From - to )Partner/productCopyAudacityMuseScore.orgResultsNotes
May 28-June 11

URL: link

Denoiser: The smart noise removal tool for clean audio.

Top Banner

N/A
Apr 29 to May 13linkAudacity Explained: The complete guide. CTA Copy: Learn moreTop BannerN/A
Feb 18 to xxlinkOvertune: record your vocals.

Video embed Video URL: https://www.youtube.com/watch?v=A4jPvCdbrKA

N/A
Oct 21 to 29linkMOOZ video platformN/APromo banner
`; + +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("

SOAP & Capture
now

")).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("

no table here

")).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"); + }); +}); diff --git a/scripts/pull-campaigns.ts b/scripts/pull-campaigns.ts new file mode 100644 index 0000000..f5c4f5e --- /dev/null +++ b/scripts/pull-campaigns.ts @@ -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 = { + 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; + videoPromos: Record; + 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 ] [--output ] + +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 { + 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(//gi, " "); + const withoutTags = withBreaks.replace(/<[^>]+>/g, " "); + return decodeEntities(withoutTags).replace(/\s+/g, " ").trim(); +} + +function collectUrls(fragment: string): string[] { + const urls = new Set(); + 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("", anchor); + if (tableStart < 0 || tableEnd < 0) return []; + + const table = storageHtml.slice(tableStart, tableEnd); + const rowFragments = table.split(/(?=). + if (/ 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 = {}; + 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 = {}; + const videoPromos: Record = {}; + const ignoredEntries: string[] = []; + const usedIds = new Set(); + + 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 { + // `--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 { + 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 { + 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 = ${JSON.stringify(bundle.bannerPromos, null, 2)};`, + "", + `export const campaignVideoPromos: Record = ${JSON.stringify(bundle.videoPromos, null, 2)};`, + "", + ].join("\n"); +} + +async function writeGeneratedModule( + outputPath: string, + content: string, +): Promise { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, content, "utf8"); +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +export async function runPullCampaigns( + args: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise { + 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; + }); +} diff --git a/src/assets/data/promos/banners.ts b/src/assets/data/promos/banners.ts deleted file mode 100644 index 6f0f016..0000000 --- a/src/assets/data/promos/banners.ts +++ /dev/null @@ -1,280 +0,0 @@ -import type { PromoData } from "./types"; - -export const bannerPromos: Record = { - 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", - }, - }, -}; diff --git a/src/assets/data/promos/campaigns.ts b/src/assets/data/promos/campaigns.ts new file mode 100644 index 0000000..48b0cac --- /dev/null +++ b/src/assets/data/promos/campaigns.ts @@ -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 = { + 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 = { + 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", + }, + }, +}; diff --git a/src/assets/data/promos/firstParty.ts b/src/assets/data/promos/firstParty.ts new file mode 100644 index 0000000..70b46f4 --- /dev/null +++ b/src/assets/data/promos/firstParty.ts @@ -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 = { + 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", + }, + }, +}; diff --git a/src/assets/data/promos/popups.ts b/src/assets/data/promos/popups.ts deleted file mode 100644 index 82213c6..0000000 --- a/src/assets/data/promos/popups.ts +++ /dev/null @@ -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 = { - 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", - }, - }, -}; diff --git a/src/assets/data/promos/videos.ts b/src/assets/data/promos/videos.ts deleted file mode 100644 index ad3e80d..0000000 --- a/src/assets/data/promos/videos.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { PromoData } from "./types"; - -export const videoPromos: Record = { - 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", - }, - }, -}; diff --git a/src/assets/data/promotions.ts b/src/assets/data/promotions.ts index 526ee08..8624a18 100644 --- a/src/assets/data/promotions.ts +++ b/src/assets/data/promotions.ts @@ -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 = { - ...bannerPromos, - ...videoPromos, - ...popupPromos, + ...firstPartyPromos, + ...campaignBannerPromos, + ...campaignVideoPromos, }; export default promoData; From 7ac0c48493f036061f4f2ae58cab333a12affa82 Mon Sep 17 00:00:00 2001 From: Teetow Date: Wed, 27 May 2026 18:56:39 +0200 Subject: [PATCH 2/2] test: add lean critical-path E2E suite (Playwright) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three flows that must never silently break: - front page: hero + real Windows installer link, release video, nav. - downloads: /download OS targets, and real Windows/macOS installer links on the per-OS pages. - promo banner: fake the browser clock to prove the date-window logic holds — an active campaign banner renders inside its window and disappears the day after it ends. Data-driven from campaigns.ts so it survives future pulls. Setup: playwright.config.ts runs against `bun run dev`; `bun test` is scoped to src/scripts so it ignores the .spec.ts files; e2e artifacts gitignored (and the stale tracked test-results/ untracked). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 +++ bun.lock | 18 ++++++- e2e/download.spec.ts | 41 +++++++++++++++ e2e/home.spec.ts | 46 ++++++++++++++++ e2e/promo-banner.spec.ts | 101 ++++++++++++++++++++++++++++++++++++ package.json | 4 +- playwright.config.ts | 33 ++++++++++++ test-results/.last-run.json | 4 -- 8 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 e2e/download.spec.ts create mode 100644 e2e/home.spec.ts create mode 100644 e2e/promo-banner.spec.ts create mode 100644 playwright.config.ts delete mode 100644 test-results/.last-run.json diff --git a/.gitignore b/.gitignore index 903223c..251f762 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # build output dist/ +# playwright e2e artifacts +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ + # netlify local state .netlify/ diff --git a/bun.lock b/bun.lock index 3d02435..3fb6fde 100644 --- a/bun.lock +++ b/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=="], diff --git a/e2e/download.spec.ts b/e2e/download.spec.ts new file mode 100644 index 0000000..56c8e3f --- /dev/null +++ b/e2e/download.spec.ts @@ -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 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(); + }); +}); diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts new file mode 100644 index 0000000..20662ee --- /dev/null +++ b/e2e/home.spec.ts @@ -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"); + }); +}); diff --git a/e2e/promo-banner.spec.ts b/e2e/promo-banner.spec.ts new file mode 100644 index 0000000..7bcfa8a --- /dev/null +++ b/e2e/promo-banner.spec.ts @@ -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); + }); +}); diff --git a/package.json b/package.json index 03ff5c6..f3118df 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "astro dev", "start": "astro dev", "pull-campaigns": "bun run scripts/pull-campaigns.ts", - "test": "bun test", + "test": "bun test src scripts", + "test:e2e": "playwright test", "build": "astro build", "preview": "astro preview", "astro": "astro", @@ -36,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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..08d7321 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index f740f7c..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -}