diff --git a/CLAUDE.md b/CLAUDE.md index d3977f1..ac4ae3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,39 @@ This repo uses **release-please**. Since GitHub squash-merges use the PR title a - **Deployment**: Cloudflare Pages (consistent with rest of org) - **Domain**: `wavekat.com` — DNS to be pointed at Cloudflare Pages once site is ready +## SEO & GEO — every new page must be both + +This site is optimized for classic search (SEO) **and** generative answer engines (GEO — being quoted by ChatGPT, Perplexity, Google AI Overviews, Claude). The two overlap but aren't identical: SEO wants crawlable, well-described, linkable pages; GEO wants self-contained, factual, extractable passages an LLM can lift verbatim. Build for both on every page. + +### What's already wired (don't reinvent it) + +- **`Base.astro` is the SEO baseline** (established in #81, `feat(seo)`). It emits the canonical URL, ``, the `robots` directive (with a `noindex` prop to opt a page out of indexing), the full Open Graph + Twitter card set, light/dark `theme-color`, and the sitewide `Organization` + `WebSite` JSON-LD graph (`@id`s `https://wavekat.com/#organization` and `#website`). It also takes `ogType` / `article` props so child layouts can emit article semantics. Every layout (`Voice`, `Post`, `Docs`) wraps `Base`, so every page gets this for free. +- **Sitemap + robots are automatic.** `@astrojs/sitemap` generates `sitemap-index.xml` at build from the `site` in `astro.config.mjs`; `public/robots.txt` allows all crawlers (including AI crawlers — keep it open for GEO) and points at the sitemap. A new page under `src/pages/` is in the sitemap with zero extra work. +- **`og.png`** is generated 1200×630 from the brand SVG by `make sync`. Pages share it unless they pass their own `ogImage`. + +### Rules for every new page + +1. **Always pass a real `title` and `description`** to the layout — they drive ``, the meta description, OG/Twitter, and (in `Base`) the canonical. Description: one sentence, ~150–160 chars, leads with the concrete value, names the product. Never ship the layout default. +2. **Add page-type JSON-LD** as a `<script type="application/ld+json" set:html={JSON.stringify(...)} />` at the end of the page. Pick the type that fits and reference the org by `@id` (`{ '@id': 'https://wavekat.com/#organization' }`) rather than re-declaring it: + - Product/app page → `SoftwareApplication` (see `voice/index.astro`). + - Any page with a Q&A section → `FAQPage` built from the same array you render, so the on-page text and the schema never drift. + - Comparison / detail pages reached through a hierarchy → `BreadcrumbList`. + - Hub/listing pages → `ItemList` of the child URLs. + - Blog posts → `BlogPosting` (handled by `Post.astro`). +3. **Trailing-slash, absolute internal URLs** (`/voice/alternatives/linphone/`). The sub-nav active-state and the build both assume them. +4. **One `<h1>` per page**, then a descriptive `<h2>` per section. Headings are ranking *and* extraction signals — write them as the thing the section answers, not as labels. + +### GEO: write so an LLM can quote you + +- **Lead with a self-contained answer.** The first sentence under a heading should stand alone if lifted out of context — name the subject, state the fact. Don't make the reader (or the model) assemble the answer from three paragraphs. +- **Q&A blocks earn their keep twice** — they render as a human FAQ *and* feed `FAQPage` schema *and* are the single most-quoted structure in AI answers. Phrase questions the way a user would type them ("Can WaveKat Voice connect to the same SIP provider as Linphone?"), and answer in 1–3 plain sentences. +- **Comparison tables are extractable gold.** Keep cells short, factual, and parallel across rows; models lift table rows almost verbatim into "X vs Y" answers. +- **Be specific and honest.** Concrete specifics (platforms, versions, prices, "records every call automatically") get quoted; vague superlatives get skipped. On comparison pages, name what the competitor is genuinely good at — fair framing reads as a trustworthy source to both readers and models, and avoids the "marketing fluff" discount. +- **Keep entity naming consistent.** Always "WaveKat Voice" (not "the app", "Voice", "WaveKat" interchangeably) so engines bind the facts to one entity. Same for platform claims — match the truth in `voice/index.astro` (Mac and Linux today; Windows when there's demand). +- **Don't target a single platform in copy.** Voice is Mac + Linux today; write "your computer" / "desktop" in body copy and put the platforms in a table row or a "Mac & Linux" qualifier. Titles may still include "Mac" to catch the high-volume "… for Mac" queries, but never *exclude* Linux. + +When you add a page that doesn't fit the patterns above, mirror the closest existing one (`voice/alternatives/[slug].astro` is the current best example: clear `<h1>`, self-contained intro, comparison table, fair "what it is", Q&A, and `FAQPage` + `BreadcrumbList` schema). + ## Brand assets Logo SVGs come from `vendor/wavekat-brand` (git submodule — source of truth, never edit here). diff --git a/src/layouts/Voice.astro b/src/layouts/Voice.astro index d2744f3..5ba9323 100644 --- a/src/layouts/Voice.astro +++ b/src/layouts/Voice.astro @@ -13,11 +13,18 @@ const { title, description } = Astro.props; const subNav = [ { href: '/voice/', label: 'overview' }, { href: '/voice/use-cases/', label: 'use cases' }, + { href: '/voice/alternatives/', label: 'alternatives' }, { href: '/voice/download/', label: 'download' }, { href: '/voice/talk/', label: 'talk to us' }, ]; const currentPath = Astro.url.pathname.replace(/\/$/, '') + '/'; + +// A nav item is active on its own page and on any page beneath it (e.g. the +// per-competitor /voice/alternatives/<slug>/ pages keep "alternatives" lit). +// The overview href is everyone's prefix, so it only matches exactly. +const isActive = (href: string) => + currentPath === href || (href !== '/voice/' && currentPath.startsWith(href)); --- <Base title={`${title} — WaveKat`} description={description}> @@ -28,7 +35,7 @@ const currentPath = Astro.url.pathname.replace(/\/$/, '') + '/'; <!-- Sub-nav --> <nav class="flex flex-wrap items-center gap-3 mb-12"> {subNav.map((item) => { - const active = currentPath === item.href; + const active = isActive(item.href); return ( <a href={item.href} diff --git a/src/lib/voice-alternatives.ts b/src/lib/voice-alternatives.ts new file mode 100644 index 0000000..d7ef742 --- /dev/null +++ b/src/lib/voice-alternatives.ts @@ -0,0 +1,145 @@ +// Data for the /voice/alternatives/ comparison pages. +// +// Each entry becomes its own page (/voice/alternatives/<slug>/) targeting the +// "<name> alternative for Mac" search intent, plus a card on the hub. Adding a +// competitor is just appending an entry here — the hub and the [slug] route +// pick it up automatically. Keep the framing honest: name what the competitor +// is genuinely good at, then say plainly where WaveKat Voice is different. A +// comparison page that reads as a fair guide ranks and converts better than one +// that reads as a takedown. + +export interface ComparisonRow { + /** What capability this row is about — kept in phone-user language. */ + label: string; + /** How WaveKat Voice handles it. */ + wavekat: string; + /** How the competitor handles it — fair, not disparaging. */ + them: string; +} + +export interface AltFaq { + q: string; + a: string; +} + +export interface Alternative { + /** URL slug — /voice/alternatives/<slug>/. */ + slug: string; + /** Competitor's product name, as people search for it. */ + name: string; + /** One-line card summary on the hub. */ + tagline: string; + /** <title> / meta description for the page. */ + seoTitle: string; + seoDescription: string; + /** Hero copy. */ + heading: string; + intro: string; + /** Honest "what it is" — what the competitor is genuinely good at. */ + whatItIs: { summary: string; strengths: string[] }; + /** Side-by-side capability table. */ + comparison: ComparisonRow[]; + /** "Choose <them> if…" — kept fair so the page reads as a guide. */ + chooseThem: string[]; + /** "Choose WaveKat Voice if…" */ + chooseWavekat: string[]; + /** 2–3 Q&A for the on-page FAQ + FAQPage structured data. */ + faqs: AltFaq[]; +} + +export const alternatives: Alternative[] = [ + { + slug: 'linphone', + name: 'Linphone', + tagline: + 'The free, open-source SIP client. Capable and cross-platform — WaveKat Voice trades breadth for a focused desktop business phone that records and writes down every call.', + seoTitle: 'WaveKat Voice — a Linphone alternative for Mac & Linux', + seoDescription: + 'How WaveKat Voice compares to Linphone on Mac and Linux: a focused business phone that records and transcribes every call automatically, with guided provider setup. Free public beta.', + heading: 'A Linphone alternative for Mac & Linux', + intro: + 'Linphone is a capable, free SIP client that runs almost everywhere. If what you actually want is a desktop business phone that records and writes down every call — and sets up without hand-editing SIP fields — here is how the two compare on Mac and Linux.', + whatItIs: { + summary: + 'Linphone is a long-running, open-source SIP softphone from Belledonne Communications. It runs on Mac, Windows, Linux, iOS, and Android, and covers a lot of ground — voice and video calls, instant messaging, and end-to-end encryption — for free.', + strengths: [ + 'Free and open source, with no account required to use it', + 'Runs on virtually every platform, including mobile', + 'Voice, video, and chat in one client', + 'End-to-end encryption (ZRTP/SRTP) for the technically inclined', + ], + }, + comparison: [ + { + label: 'Records every call', + wavekat: 'Automatic — every call recorded and saved the moment you hang up.', + them: 'Manual recording per call; not a saved, browsable history by default.', + }, + { + label: 'Written transcript', + wavekat: 'Live transcript alongside the call, kept with the recording.', + them: 'No transcription.', + }, + { + label: 'Searchable call history', + wavekat: 'Every call lands in one history with its recording and transcript.', + them: 'Call log only — no recordings or transcripts attached.', + }, + { + label: 'Setting up your number', + wavekat: 'Pick your provider from a list and the settings are filled in for you.', + them: 'General-purpose SIP fields you configure yourself.', + }, + { + label: 'Where your data lives', + wavekat: 'On your computer by default; optional sign-in to sync to the web.', + them: 'On your device — it is a client; nothing is hosted for you.', + }, + { + label: 'Platforms', + wavekat: 'Mac and Linux today (Windows when there is demand).', + them: 'Mac, Windows, Linux, iOS, Android.', + }, + { + label: 'Video & chat', + wavekat: 'Focused on calls — no video or messaging.', + them: 'Voice, video, and instant messaging.', + }, + { + label: 'Price', + wavekat: 'Free during the public beta; paid later.', + them: 'Free and open source.', + }, + ], + chooseThem: [ + 'You want a free, open-source client with the source available', + 'You need the same app across Mac, Windows, Linux, and mobile', + 'You want video calls and chat in the same place as voice', + 'You are comfortable configuring SIP settings yourself', + ], + chooseWavekat: [ + 'You want every call recorded and written down automatically, with nothing to switch on', + 'You want one searchable history of calls, recordings, and transcripts', + 'You would rather pick your provider from a list than fill in SIP fields', + 'You want a focused desktop business phone, not a general-purpose VoIP toolkit', + ], + faqs: [ + { + q: 'Can WaveKat Voice connect to the same SIP provider as Linphone?', + a: 'Yes. Both are SIP softphones, so any provider that works with Linphone works with WaveKat Voice. The difference is setup: WaveKat Voice fills in the settings for common providers like Twilio, Telnyx, and 2talk, and lets you enter the details yourself for anything else.', + }, + { + q: 'Does Linphone record and transcribe calls like WaveKat Voice?', + a: 'Linphone can record an individual call when you start it manually, but it does not transcribe calls or keep a browsable history of recordings and transcripts. WaveKat Voice records every call automatically, writes a live transcript alongside it, and saves both to your call history without any setup.', + }, + { + q: 'Is WaveKat Voice open source like Linphone?', + a: 'No — WaveKat Voice is a commercial product, free during the public beta. Several of the building blocks underneath it are open source on our GitHub, but the Voice app itself is not.', + }, + ], + }, +]; + +export function getAlternative(slug: string): Alternative | undefined { + return alternatives.find((a) => a.slug === slug); +} diff --git a/src/pages/voice/alternatives/[slug].astro b/src/pages/voice/alternatives/[slug].astro new file mode 100644 index 0000000..9866267 --- /dev/null +++ b/src/pages/voice/alternatives/[slug].astro @@ -0,0 +1,196 @@ +--- +import Voice from '../../../layouts/Voice.astro'; +import TalkCTA from '../../../components/TalkCTA.astro'; +import VoiceDownload from '../../../components/VoiceDownload.astro'; +import { Check, ArrowLeft, ArrowRight } from '@lucide/astro'; +import { alternatives, type Alternative } from '../../../lib/voice-alternatives'; + +export function getStaticPaths() { + return alternatives.map((alt) => ({ + params: { slug: alt.slug }, + props: { alt }, + })); +} + +interface Props { + alt: Alternative; +} + +const { alt } = Astro.props; + +// On-page FAQ → FAQPage rich result, plus a breadcrumb so the comparison page +// shows its place under Voice in search listings. +const faqSchema = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: alt.faqs.map((f) => ({ + '@type': 'Question', + name: f.q, + acceptedAnswer: { '@type': 'Answer', text: f.a }, + })), +}; + +const breadcrumbSchema = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Voice', item: 'https://wavekat.com/voice/' }, + { + '@type': 'ListItem', + position: 2, + name: 'Alternatives', + item: 'https://wavekat.com/voice/alternatives/', + }, + { + '@type': 'ListItem', + position: 3, + name: alt.name, + item: `https://wavekat.com/voice/alternatives/${alt.slug}/`, + }, + ], +}; +--- + +<Voice title={alt.seoTitle} description={alt.seoDescription}> + <!-- Back to hub --> + <a + href="/voice/alternatives/" + class="group inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors mb-8" + > + <ArrowLeft class="w-3.5 h-3.5 transition-transform group-hover:-translate-x-0.5" /> + All alternatives + </a> + + <!-- Hero --> + <section class="relative mb-16 pt-2"> + <div + class="pointer-events-none absolute -top-24 -left-24 w-[28rem] h-[28rem] rounded-full opacity-20 blur-3xl -z-10" + style="background: radial-gradient(circle, #ff6d00 0%, transparent 70%);" + aria-hidden="true" + > + </div> + + <h1 class="text-2xl sm:text-3xl font-bold leading-[1.2] tracking-tight mb-6 text-gray-900 dark:text-white"> + {alt.heading} + </h1> + <p class="text-sm sm:text-base text-gray-500 dark:text-gray-400 leading-relaxed max-w-xl mb-8"> + {alt.intro} + </p> + + <div class="flex flex-wrap items-center gap-4"> + <VoiceDownload /> + <a + href="/voice/" + class="group inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors" + > + What WaveKat Voice does + <ArrowRight class="w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5" /> + </a> + </div> + </section> + + <!-- What the competitor is --> + <section class="mb-16"> + <h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase mb-5">What {alt.name} is</h2> + <p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed max-w-xl mb-5"> + {alt.whatItIs.summary} + </p> + <ul class="space-y-2.5"> + {alt.whatItIs.strengths.map((s) => ( + <li class="flex gap-2.5 text-sm text-gray-600 dark:text-gray-400 leading-relaxed"> + <Check class="w-4 h-4 mt-0.5 shrink-0" style="color: #ff6d00" /> + <span>{s}</span> + </li> + ))} + </ul> + </section> + + <!-- Comparison table --> + <section class="mb-16"> + <h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase mb-5"> + WaveKat Voice vs {alt.name} + </h2> + <div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10"> + <table class="w-full text-left border-collapse"> + <thead> + <tr class="border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-wk-surface"> + <th class="px-4 py-3 text-[10px] font-bold tracking-widest text-gray-400 uppercase w-1/4"> +   + </th> + <th class="px-4 py-3 text-[10px] font-bold tracking-widest uppercase" style="color: #ff6d00"> + WaveKat Voice + </th> + <th class="px-4 py-3 text-[10px] font-bold tracking-widest text-gray-400 uppercase"> + {alt.name} + </th> + </tr> + </thead> + <tbody> + {alt.comparison.map((row) => ( + <tr class="border-b border-gray-100 dark:border-white/5 last:border-0 align-top"> + <td class="px-4 py-3 text-xs font-bold text-gray-900 dark:text-white">{row.label}</td> + <td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-300 leading-relaxed">{row.wavekat}</td> + <td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 leading-relaxed">{row.them}</td> + </tr> + ))} + </tbody> + </table> + </div> + </section> + + <!-- Which to choose --> + <section class="mb-16"> + <h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase mb-5">Which one fits you</h2> + <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> + <div class="rounded-lg border p-5" style="border-color: #ff6d0040; background-color: #ff6d0008;"> + <span class="text-sm font-bold block mb-3 text-gray-900 dark:text-white"> + Choose WaveKat Voice if + </span> + <ul class="space-y-2.5"> + {alt.chooseWavekat.map((c) => ( + <li class="flex gap-2.5 text-xs text-gray-600 dark:text-gray-400 leading-relaxed"> + <Check class="w-3.5 h-3.5 mt-0.5 shrink-0" style="color: #ff6d00" /> + <span>{c}</span> + </li> + ))} + </ul> + </div> + <div class="rounded-lg border border-gray-200 dark:border-white/10 p-5 bg-gray-50 dark:bg-wk-surface"> + <span class="text-sm font-bold block mb-3 text-gray-900 dark:text-white"> + Choose {alt.name} if + </span> + <ul class="space-y-2.5"> + {alt.chooseThem.map((c) => ( + <li class="flex gap-2.5 text-xs text-gray-500 dark:text-gray-400 leading-relaxed"> + <Check class="w-3.5 h-3.5 mt-0.5 shrink-0 text-gray-400" /> + <span>{c}</span> + </li> + ))} + </ul> + </div> + </div> + </section> + + <!-- Q&A --> + <section class="mb-16"> + <h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase mb-5">Questions & answers</h2> + <div class="divide-y divide-gray-200 dark:divide-white/10 border-t border-b border-gray-200 dark:border-white/10"> + {alt.faqs.map((f) => ( + <details class="group"> + <summary class="flex items-center justify-between gap-4 cursor-pointer list-none py-4 text-sm font-bold text-gray-900 dark:text-white hover:opacity-80 transition-opacity"> + {f.q} + <span class="shrink-0 transition-transform group-open:rotate-45" style="color: #ff6d00" aria-hidden="true">+</span> + </summary> + <p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed pb-4 -mt-1 max-w-xl"> + {f.a} + </p> + </details> + ))} + </div> + </section> + + <TalkCTA /> + + <script type="application/ld+json" set:html={JSON.stringify(faqSchema)} /> + <script type="application/ld+json" set:html={JSON.stringify(breadcrumbSchema)} /> +</Voice> diff --git a/src/pages/voice/alternatives/index.astro b/src/pages/voice/alternatives/index.astro new file mode 100644 index 0000000..23d4284 --- /dev/null +++ b/src/pages/voice/alternatives/index.astro @@ -0,0 +1,61 @@ +--- +import Voice from '../../../layouts/Voice.astro'; +import TalkCTA from '../../../components/TalkCTA.astro'; +import { ArrowRight } from '@lucide/astro'; +import { alternatives } from '../../../lib/voice-alternatives'; + +// ItemList of the comparison pages — helps search engines see the hub as the +// parent of the individual "<name> alternative" pages. +const listSchema = { + '@context': 'https://schema.org', + '@type': 'ItemList', + itemListElement: alternatives.map((alt, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: `WaveKat Voice vs ${alt.name}`, + url: `https://wavekat.com/voice/alternatives/${alt.slug}/`, + })), +}; +--- + +<Voice + title="Softphone alternatives" + description="Comparing WaveKat Voice to other desktop softphones on Mac and Linux — how it stacks up against Linphone and more. A focused business phone that records and transcribes every call." +> + <section class="mb-12"> + <h1 class="text-2xl font-bold leading-snug mb-5 text-gray-900 dark:text-white"> + Looking at the alternatives + </h1> + <p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed max-w-xl"> + There are plenty of ways to make a call from your computer. Most are general-purpose SIP + clients. WaveKat Voice is narrower on purpose: a business phone that records and writes down + every call automatically, and sets up by picking your provider from a list. It runs on Mac and + Linux today, with Windows on the way. Here is how it compares. + </p> + </section> + + <section class="mb-16"> + <h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase mb-5">Compare</h2> + <div class="grid grid-cols-1 gap-3"> + {alternatives.map((alt) => ( + <a + href={`/voice/alternatives/${alt.slug}/`} + class="group block rounded-lg border border-gray-200 dark:border-white/10 p-5 bg-gray-50 dark:bg-wk-surface transition-all hover:-translate-y-0.5 hover:border-[#ff6d00]/40 hover:shadow-lg hover:shadow-[#ff6d00]/5" + > + <span class="text-sm font-bold block mb-1.5 text-gray-900 dark:text-white"> + WaveKat Voice vs {alt.name} + </span> + <p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mb-4">{alt.tagline}</p> + <span class="inline-flex items-center gap-1 text-xs text-gray-400 dark:text-gray-600 group-hover:text-gray-600 dark:group-hover:text-gray-400 transition-colors"> + See the comparison + <ArrowRight class="w-3 h-3 transition-transform group-hover:translate-x-0.5" /> + </span> + </a> + ))} + </div> + </section> + + <TalkCTA /> + + <script type="application/ld+json" set:html={JSON.stringify(listSchema)} /> +</Voice>