fix(web): SEO fixes from Search Console and Lighthouse audit (#1039)

* fix(web): SEO fixes from Search Console and Lighthouse audit

Technical:
- Emit BreadcrumbList JSON-LD from the same trail as the visual breadcrumbs
  on every SeoSubpageShell page.
- absoluteUrl() no longer appends a trailing slash to file paths. The
  Organization and publisher logo URLs pointed at /vocalinux.png/, a 404.
- Fetch the GitHub star count once at build time in next.config.js and
  drop the client-side api.github.com fetch and preconnect (Lighthouse
  "third-party code" on every page). Falls back to the "GitHub" label.
- Fix h1 -> h3 heading skips on 11 pages by promoting the leading card
  headings to h2; new .subpage-card-title keeps the old h3 sizing.
- Header and footer logo get alt="" next to the visible "Vocalinux" text.
- Footer links every guide, distro and feature page, so the pages added in
  #1038 and /vs-nerd-dictation/ (Discovered, not indexed) and /writers/
  (no inbound links) are linked sitewide.

Titles and descriptions:
- Rewrite titles/descriptions for pages with high impressions and low CTR
  in Search Console (home, FAQ, compare, languages, open-source, GPU,
  alternatives, offline, install) to lead with the queries they rank for.
- Distro pages use "<Distro> Speech to Text: Offline Voice Typing Setup"
  as the search title; the on-page h1 is unchanged.
- Remove brand repetition and shorten 75+ character titles.
- Bump sitemap lastmod for the rewritten pages.

* fix(web): keep home install card titles styled

The home page's install cards still use h3; only /install/ moved to h2.
Style both so the home cards keep their size and spacing.
This commit is contained in:
Jatin K Malik
2026-11-01 01:39:01 -07:00
committed by GitHub
parent 6389e81d45
commit f7147bdb44
37 changed files with 221 additions and 103 deletions
+3
View File
@@ -576,6 +576,9 @@ jobs:
- name: Install and build
working-directory: ./web
env:
# next.config.js reads the repo star count at build time
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci || npm install
npm run deploy
+29 -1
View File
@@ -1,3 +1,5 @@
import { PHASE_PRODUCTION_BUILD } from 'next/constants.js';
// @ts-ignore
/** @type {import("next").NextConfig} */
const config = {
@@ -70,4 +72,30 @@ const config = {
return config;
},
};
export default config;
// The header star count is fetched once per production build and inlined, so
// visitors never call api.github.com (Lighthouse flagged it as third-party
// code on every page). Any failure falls back to the plain "GitHub" label.
async function fetchGitHubStars() {
try {
const res = await fetch('https://api.github.com/repos/VocaHQ/vocalinux', {
headers: process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {},
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return '';
const data = await res.json();
return data.stargazers_count ? String(data.stargazers_count) : '';
} catch {
return '';
}
}
export default async function nextConfig(phase) {
if (phase !== PHASE_PRODUCTION_BUILD) return config;
return {
...config,
env: { NEXT_PUBLIC_GITHUB_STARS: await fetchGitHubStars() },
};
}
+12 -12
View File
@@ -2,37 +2,37 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://vocalinux.com/</loc>
<lastmod>2026-06-11</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://vocalinux.com/install/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://vocalinux.com/install/ubuntu/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://vocalinux.com/install/fedora/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://vocalinux.com/install/arch/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://vocalinux.com/compare/</loc>
<lastmod>2026-06-11</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
@@ -50,7 +50,7 @@
</url>
<url>
<loc>https://vocalinux.com/faq/</loc>
<lastmod>2026-06-11</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
@@ -80,14 +80,14 @@
</url>
<url>
<loc>https://vocalinux.com/alternatives/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<!-- New SEO pages -->
<url>
<loc>https://vocalinux.com/languages/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
@@ -129,13 +129,13 @@
</url>
<url>
<loc>https://vocalinux.com/gpu-acceleration/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://vocalinux.com/open-source/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
@@ -153,7 +153,7 @@
</url>
<url>
<loc>https://vocalinux.com/offline/</loc>
<lastmod>2026-02-22</lastmod>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
+2 -2
View File
@@ -56,9 +56,9 @@ const comparisonTable = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Vocalinux Alternatives - Linux Voice Dictation Comparison",
title: "Linux Alternatives to Dragon, Otter.ai and Speechnotes",
description:
"Compare Vocalinux to Dragon, Otter.ai, Google Voice Typing, and other speech-to-text alternatives. See why Vocalinux is the best choice for Linux voice dictation.",
"Looking for Dragon NaturallySpeaking, Otter.ai or Google Voice Typing on Linux? Compare offline Linux dictation options, including Vocalinux and Nerd Dictation.",
path: "/alternatives",
keywords: [
"vocalinux alternatives",
+1 -1
View File
@@ -165,7 +165,7 @@ export default function AutostartPage() {
className="rounded-[12px] border border-border bg-background p-5"
>
<Icon className="mb-3 h-6 w-6 text-primary" />
<h3 className="mb-2 font-semibold">{method.method}</h3>
<h2 className="subpage-card-title mb-2 font-semibold">{method.method}</h2>
<p className="text-sm text-muted-foreground">{method.description}</p>
</div>
);
+1 -1
View File
@@ -519,7 +519,7 @@ const getTypeStyles = (type: string) => {
};
export const metadata: Metadata = buildPageMetadata({
title: "Vocalinux Changelog - Release History",
title: "Changelog and Release Notes",
description:
"Track Vocalinux release history and version updates. See what's new in each version of our Linux voice dictation software.",
path: "/changelog",
+2 -2
View File
@@ -81,9 +81,9 @@ const engineTable = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Whisper.cpp vs Whisper vs VOSK vs Remote API for Linux",
title: "Vosk vs Whisper vs whisper.cpp: Linux Speech Engines",
description:
"Compare whisper.cpp, Whisper, VOSK, and Remote API for Linux speech-to-text. See speed, hardware requirements, privacy tradeoffs, and best use cases.",
"Vosk vs Whisper vs whisper.cpp for offline Linux speech to text, plus Faster Whisper, Parakeet and Remote API: speed, accuracy, hardware needs and privacy.",
path: "/compare",
keywords: [
"whisper.cpp vs vosk",
+3 -3
View File
@@ -164,7 +164,7 @@ export default function CustomDictionaryPage() {
<section className="mb-12 grid gap-4 sm:grid-cols-3">
<div className="rounded-[12px] border border-border bg-background p-5">
<BookOpen className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Custom terms</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Custom terms</h2>
<p className="text-sm text-muted-foreground">
A plain text list, one term per line, passed to the engine as
context so it is more likely to spell those words your way.
@@ -175,7 +175,7 @@ export default function CustomDictionaryPage() {
className="mb-3 h-6 w-6 text-primary"
aria-hidden="true"
/>
<h3 className="mb-2 font-semibold">Transcript corrections</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Transcript corrections</h2>
<p className="text-sm text-muted-foreground">
Heard-as and replace-with pairs applied to every finished
transcript, whatever the engine.
@@ -183,7 +183,7 @@ export default function CustomDictionaryPage() {
</div>
<div className="rounded-[12px] border border-border bg-background p-5">
<Code className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Post-processing script</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Post-processing script</h2>
<p className="text-sm text-muted-foreground">
Any executable that reads text on stdin and writes the replacement
on stdout.
+3 -3
View File
@@ -179,7 +179,7 @@ export default function DictationPadPage() {
<section className="mb-12 grid gap-4 sm:grid-cols-3">
<div className="rounded-[12px] border border-border bg-background p-5">
<Monitor className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Who it is for</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Who it is for</h2>
<p className="text-sm text-muted-foreground">
Wayland users whose compositor blocks or garbles injected text, and
anyone who wants to read and fix a draft before it reaches the real
@@ -188,7 +188,7 @@ export default function DictationPadPage() {
</div>
<div className="rounded-[12px] border border-border bg-background p-5">
<Copy className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">How text gets out</h3>
<h2 className="subpage-card-title mb-2 font-semibold">How text gets out</h2>
<p className="text-sm text-muted-foreground">
Copy All puts the whole pad on the clipboard. You can also select
part of the text and copy it like any other text box.
@@ -196,7 +196,7 @@ export default function DictationPadPage() {
</div>
<div className="rounded-[12px] border border-border bg-background p-5">
<Keyboard className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">What stays the same</h3>
<h2 className="subpage-card-title mb-2 font-semibold">What stays the same</h2>
<p className="text-sm text-muted-foreground">
Your shortcut, engine, transcript corrections and post-processing
script all work as before. Only the destination of the text changes.
+2 -2
View File
@@ -125,9 +125,9 @@ const faqCategories = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Frequently Asked Questions - Vocalinux Voice Dictation",
title: "Linux Dictation FAQ: Offline, Wayland, GPU and Privacy",
description:
"Get answers to common questions about Vocalinux Linux voice dictation. Learn about installation, privacy, features, and troubleshooting.",
"Does Vocalinux work offline or on Wayland? How much RAM does it need, and which engines and languages does it support? Answers on install, privacy and GPU use.",
path: "/faq",
keywords: [
"vocalinux faq",
+1 -1
View File
@@ -179,7 +179,7 @@ export default function ForDevelopersPage() {
key={benefit.title}
className="rounded-[12px] border border-border bg-background p-5 text-center"
>
<h3 className="mb-2 font-semibold">{benefit.title}</h3>
<h2 className="subpage-card-title mb-2 font-semibold">{benefit.title}</h2>
<p className="text-sm text-muted-foreground">{benefit.description}</p>
</div>
))}
+2 -2
View File
@@ -130,9 +130,9 @@ const hardwareRequirements = [
];
export const metadata: Metadata = buildPageMetadata({
title: "GPU Accelerated Voice Dictation - AMD, Intel, NVIDIA | Vocalinux",
title: "Vulkan GPU Speech to Text on Linux: AMD, Intel, NVIDIA",
description:
"GPU-accelerated speech recognition for Linux. Support for AMD, Intel, and NVIDIA GPUs via Vulkan. Compare CPU vs GPU performance and model sizes.",
"Run whisper.cpp speech recognition on your GPU with Vulkan on Linux: AMD, Intel and NVIDIA. CPU vs GPU speed, model sizes, and automatic CPU fallback.",
path: "/gpu-acceleration",
keywords: [
"GPU voice dictation",
+3 -1
View File
@@ -431,7 +431,9 @@ export async function generateMetadata({
const config = distroConfig[distro];
return buildPageMetadata({
title: config.title,
// Search title leads with the query people type ("ubuntu speech to text");
// the on-page h1 stays config.title.
title: `${config.name} Speech to Text: Offline Voice Typing Setup`,
description: config.description,
path: `/install/${distro}`,
keywords: distroKeywords[distro],
+7 -7
View File
@@ -67,7 +67,7 @@ const distroGuides = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Install Voice Dictation on Linux (Ubuntu, Fedora, Arch)",
title: "Install Speech to Text on Linux (Ubuntu, Fedora, Arch)",
description:
"Linux voice dictation install guides for Ubuntu, Debian, Fedora, Arch, openSUSE, Mint, Pop!_OS, NixOS, and Flatpak. Start offline speech-to-text.",
path: "/install",
@@ -108,7 +108,7 @@ export default function InstallGuidesPage() {
rel="noopener noreferrer"
>
<p className="install-alt-kicker">No installer</p>
<h3>AppImage</h3>
<h2>AppImage</h2>
<p>
x86_64 or aarch64 from GitHub Releases. No root. Host
text-injection tools are still required.
@@ -120,7 +120,7 @@ export default function InstallGuidesPage() {
rel="noopener noreferrer"
>
<p className="install-alt-kicker">New</p>
<h3>Native packages</h3>
<h2>Native packages</h2>
<p>
.deb for Debian 12+ and Ubuntu 24.04+. .rpm for Fedora.
x86_64 and aarch64 on every release.
@@ -131,7 +131,7 @@ export default function InstallGuidesPage() {
href="/flatpak/"
>
<p className="install-alt-kicker">Auto-updates</p>
<h3>Flatpak</h3>
<h2>Flatpak</h2>
<p>
Install once from the VocaHQ remote and{" "}
<code>flatpak update</code> carries each stable release. Or
@@ -144,7 +144,7 @@ export default function InstallGuidesPage() {
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Snap Store</p>
<h3>Snap</h3>
<h2>Snap</h2>
<p>
<code>snap install vocalinux --edge</code>. amd64 only for
now; a .snap is attached to every release too.
@@ -156,7 +156,7 @@ export default function InstallGuidesPage() {
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Arch</p>
<h3>AUR</h3>
<h2>AUR</h2>
<p>
<code>yay -S vocalinux</code>, or vocalinux-bin for the
prebuilt package.
@@ -168,7 +168,7 @@ export default function InstallGuidesPage() {
rel="noopener noreferrer"
>
<p className="install-alt-kicker">From source</p>
<h3>Build it yourself</h3>
<h2>Build it yourself</h2>
<p>
Clone the repo and run <code>./install.sh</code>. The install
guide has the full source path.
+3 -3
View File
@@ -306,9 +306,9 @@ const features = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Multilingual Voice Dictation - 30+ Languages | Vocalinux",
title: "Linux Speech to Text in 30+ Languages, Offline",
description:
"Dictate in 30+ languages including Spanish, French, German, Hungarian, Japanese, Korean, Arabic, and more. Free offline multilingual voice typing for Linux.",
"Offline voice typing on Linux in 30+ languages, including Spanish, French, German, Hungarian, Japanese, Korean and Arabic. Full language list and model picks.",
path: "/languages",
keywords: [
"multilingual voice dictation",
@@ -383,7 +383,7 @@ export default function LanguagesPage() {
className="rounded-[12px] border border-border bg-background p-6"
>
<Icon className="mb-3 h-8 w-8 text-primary" />
<h3 className="mb-2 font-semibold">{feature.title}</h3>
<h2 className="subpage-card-title mb-2 font-semibold">{feature.title}</h2>
<p className="text-sm text-muted-foreground">{feature.description}</p>
</div>
);
+4 -5
View File
@@ -23,11 +23,11 @@ export const viewport = {
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "Vocalinux: Offline Voice Dictation for Linux",
default: "Vocalinux: Offline Speech to Text & Voice Dictation for Linux",
template: "%s | Vocalinux",
},
description:
"Free and open-source voice dictation for Linux. Convert speech to text with whisper.cpp, VOSK, Remote API servers, Silero VAD, X11, and Wayland.",
"Free, open-source speech to text for Linux. Offline voice dictation and voice typing in any app with whisper.cpp, VOSK or Parakeet, on X11 and Wayland.",
applicationName: SITE_NAME,
keywords: [
"voice dictation linux",
@@ -80,7 +80,7 @@ export const metadata: Metadata = {
locale: "en_US",
url: absoluteUrl("/"),
siteName: SITE_NAME,
title: "Vocalinux: Offline Voice Dictation for Linux",
title: "Vocalinux: Offline Speech to Text & Voice Dictation for Linux",
description:
"Voice dictation for Linux with local engines or an optional Remote API. Install in minutes and dictate in any app on Ubuntu, Fedora, Arch, X11, or Wayland.",
images: [
@@ -94,7 +94,7 @@ export const metadata: Metadata = {
},
twitter: {
card: "summary_large_image",
title: "Vocalinux: Offline Voice Dictation for Linux",
title: "Vocalinux: Offline Speech to Text & Voice Dictation for Linux",
description:
"Speech-to-text for Linux with whisper.cpp and VOSK. Local engines by default, optional Remote API, open source.",
images: [DEFAULT_OG_IMAGE_PATH],
@@ -178,7 +178,6 @@ export default function RootLayout({
return (
<html lang="en" className="scroll-smooth" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://api.github.com" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(siteWideJsonLd) }}
+3 -3
View File
@@ -225,7 +225,7 @@ export default function MultilingualDictationPage() {
<section className="mb-12 grid gap-4 sm:grid-cols-3">
<div className="rounded-[12px] border border-border bg-background p-5">
<Globe className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Auto-detect</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Auto-detect</h2>
<p className="text-sm text-muted-foreground">
The engine guesses the language for each utterance. Easiest to set
up, but short clips can be guessed wrong.
@@ -233,7 +233,7 @@ export default function MultilingualDictationPage() {
</div>
<div className="rounded-[12px] border border-border bg-background p-5">
<Keyboard className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Language shortcuts</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Language shortcuts</h2>
<p className="text-sm text-muted-foreground">
A dedicated key per language. Explicit and reliable, for one
dictation at a time.
@@ -241,7 +241,7 @@ export default function MultilingualDictationPage() {
</div>
<div className="rounded-[12px] border border-border bg-background p-5">
<Languages className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Follow keyboard layout</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Follow keyboard layout</h2>
<p className="text-sm text-muted-foreground">
Switch layout to switch dictation language. Best on GNOME, where the
active layout can be read.
+3 -3
View File
@@ -76,9 +76,9 @@ const sensitiveUseCases = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Offline Voice Dictation - Local Engines | Vocalinux",
title: "Offline Speech to Text for Linux: No Cloud Needed",
description:
"Local engines run on your Linux machine with no cloud account. Remote API is optional if you want a server you configure.",
"Offline speech to text on Linux. whisper.cpp, VOSK and other local engines transcribe on your machine after a one-time model download. No cloud account.",
path: "/offline",
keywords: [
"offline voice dictation",
@@ -149,7 +149,7 @@ export default function OfflinePage() {
<div className={`mb-3 inline-flex rounded-lg p-2 ${benefit.iconBg}`}>
<Icon className={`h-5 w-5 ${benefit.iconColor}`} />
</div>
<h3 className="mb-2 font-semibold">{benefit.title}</h3>
<h2 className="subpage-card-title mb-2 font-semibold">{benefit.title}</h2>
<p className="text-sm text-muted-foreground">{benefit.description}</p>
</article>
);
+2 -2
View File
@@ -85,9 +85,9 @@ const techStack = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Open Source Voice Dictation for Linux | Vocalinux",
title: "Open Source Dictation Software for Linux (AGPL-3.0)",
description:
"100% free and open source voice dictation for Linux. AGPL-3.0 licensed, no vendor lock-in, transparent code, community-driven development. Your voice data stays private.",
"Free, open source dictation software for Linux under AGPL-3.0. Offline speech recognition with whisper.cpp or VOSK, no required account, all code on GitHub.",
path: "/open-source",
keywords: [
"open source voice dictation",
+3 -3
View File
@@ -68,7 +68,7 @@ export default function PrivacyPage() {
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
<Lock className="h-6 w-6 text-primary" />
</div>
<h3 className="mb-2 text-xl font-semibold">Local engines</h3>
<h2 className="subpage-card-title mb-2 text-xl font-semibold">Local engines</h2>
<p className="text-sm text-muted-foreground">
Local engines do not need the internet after the model is downloaded. Processing stays
on your device unless you turn on Remote API.
@@ -79,7 +79,7 @@ export default function PrivacyPage() {
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
<Server className="h-6 w-6 text-primary" />
</div>
<h3 className="mb-2 text-xl font-semibold">No Cloud Processing</h3>
<h2 className="subpage-card-title mb-2 text-xl font-semibold">No Cloud Processing</h2>
<p className="text-sm text-muted-foreground">
Local engines do not send your voice to our servers. Remote API sends audio only to the
URL you configure.
@@ -90,7 +90,7 @@ export default function PrivacyPage() {
<div className="mb-4 inline-flex rounded-[12px] bg-primary/10 p-3">
<Eye className="h-6 w-6 text-primary" />
</div>
<h3 className="mb-2 text-xl font-semibold">Open Source</h3>
<h2 className="subpage-card-title mb-2 text-xl font-semibold">Open Source</h2>
<p className="text-sm text-muted-foreground">
Fully open source under AGPL-3.0. Inspect the code yourself on GitHub. The installed
app does not send usage telemetry.
+1 -1
View File
@@ -103,7 +103,7 @@ const settingsShots: Screenshot[] = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Vocalinux Screenshots | Linux Voice Dictation UI",
title: "Screenshots: Linux Voice Dictation App UI",
description:
"See Vocalinux in action: system tray, log viewer, and settings pages for speech model, dictation, audio, performance, application, advanced options, and about/updates. Flip between light and dark app shots.",
path: "/screenshots",
+1 -1
View File
@@ -100,7 +100,7 @@ function Mic(props: { className?: string }) {
}
export const metadata: Metadata = buildPageMetadata({
title: "Keyboard Shortcuts & Voice Commands - Vocalinux",
title: "Keyboard Shortcuts and Voice Commands",
description:
"Learn Vocalinux keyboard shortcut modes and voice commands. Use toggle mode or push-to-talk, then customize activation and command behavior.",
path: "/shortcuts",
+4 -4
View File
@@ -174,9 +174,9 @@ export default function TranscribeAudioFilesPage() {
<section className="mb-12 grid gap-4 sm:grid-cols-3">
<div className="rounded-[12px] border border-border bg-background p-5">
<Mic className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">
<h2 className="subpage-card-title mb-2 font-semibold">
For recordings, not live dictation
</h3>
</h2>
<p className="text-sm text-muted-foreground">
File transcription is separate from the dictation pipeline. It never
types into other apps and does not change your dictation model.
@@ -184,7 +184,7 @@ export default function TranscribeAudioFilesPage() {
</div>
<div className="rounded-[12px] border border-border bg-background p-5">
<Users className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Speaker turns, English only</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Speaker turns, English only</h2>
<p className="text-sm text-muted-foreground">
The model is <code>small.en-tdrz</code>. It is English-only, so use
it for English recordings.
@@ -192,7 +192,7 @@ export default function TranscribeAudioFilesPage() {
</div>
<div className="rounded-[12px] border border-border bg-background p-5">
<Lock className="mb-3 h-6 w-6 text-primary" aria-hidden="true" />
<h3 className="mb-2 font-semibold">Local processing</h3>
<h2 className="subpage-card-title mb-2 font-semibold">Local processing</h2>
<p className="text-sm text-muted-foreground">
Audio is decoded and transcribed on your computer. Nothing is sent
to a server.
+1 -1
View File
@@ -168,7 +168,7 @@ const troubleshootingItems = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Troubleshooting Guide - Vocalinux Voice Dictation",
title: "Linux Voice Dictation Troubleshooting Guide",
description:
"Fix common Vocalinux issues: microphone problems, text injection, installation errors, and performance. Step-by-step solutions for Linux voice dictation.",
path: "/troubleshooting",
+1 -1
View File
@@ -92,7 +92,7 @@ const useCases = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Use Cases - Who Uses Vocalinux Voice Dictation",
title: "Use Cases for Linux Voice Dictation",
description:
"Discover how developers, writers, students, and professionals use Vocalinux for Linux voice dictation. Find your use case and boost productivity.",
path: "/use-cases",
+1 -1
View File
@@ -67,7 +67,7 @@ const comparisonRows = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Vocalinux vs Nerd Dictation: Best Offline Voice Dictation for Linux",
title: "Nerd Dictation Alternative for Linux | Vocalinux",
description:
"Comparing Vocalinux and Nerd Dictation for offline Linux voice dictation. CLI vs GUI, GPU vs CPU-only, Wayland support, and more.",
path: "/vs-nerd-dictation",
+1 -1
View File
@@ -176,7 +176,7 @@ export default function WaylandPage() {
className="rounded-[12px] border border-border bg-background p-6"
>
<Icon className="mb-3 h-8 w-8 text-primary" />
<h3 className="mb-2 font-semibold">{feature.title}</h3>
<h2 className="subpage-card-title mb-2 font-semibold">{feature.title}</h2>
<p className="text-sm text-muted-foreground">
{feature.description}
</p>
+1 -1
View File
@@ -61,7 +61,7 @@ const modelComparison = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Whisper Model Sizes Explained: Tiny, Base, Small, Medium, Large for Linux",
title: "Whisper Model Sizes: Tiny to Large for Linux Dictation",
description:
"Complete guide to Whisper model sizes for Linux voice dictation. Compare tiny vs base vs small vs medium vs large models - download size, RAM usage, accuracy, GPU requirements, and speed.",
path: "/whisper-model-guide",
+1 -1
View File
@@ -70,7 +70,7 @@ const integrations = [
];
export const metadata: Metadata = buildPageMetadata({
title: "Voice Dictation for Writers on Linux: Dictate Novels, Articles & Notes",
title: "Voice Dictation for Writers on Linux",
description:
"Complete guide to voice dictation for writers on Linux. How to dictate novels, blog posts, and notes using Vocalinux. Hands-free writing workflow tips.",
path: "/writers",
@@ -129,4 +129,9 @@ describe('VocalinuxLogo', () => {
const img = screen.getByAltText('Vocalinux');
expect(img).toHaveAttribute('loading', 'eager');
});
it('uses empty alt text when decorative', () => {
const { container } = render(<VocalinuxLogo decorative />);
expect(container.querySelector('img')).toHaveAttribute('alt', '');
});
});
+10 -2
View File
@@ -53,13 +53,21 @@ export const VocalinuxLogo: React.FC<{
width?: number;
height?: number;
priority?: boolean;
}> = ({ className = "", width = 32, height = 32, priority = false }) => {
/** Set when the visible "Vocalinux" text sits next to the mark. */
decorative?: boolean;
}> = ({
className = "",
width = 32,
height = 32,
priority = false,
decorative = false,
}) => {
return (
// SVG lockup; next/image is for raster.
// eslint-disable-next-line @next/next/no-img-element
<img
src="/brand/vocalinux-mark-circle.svg"
alt="Vocalinux"
alt={decorative ? "" : "Vocalinux"}
width={width}
height={height}
className={className}
+34 -13
View File
@@ -4,6 +4,7 @@ import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { SiteChrome } from "@/components/site-chrome";
import { absoluteUrl } from "@/lib/seo";
const pageTitles: Record<string, string> = {
"/install": "Install Guides",
@@ -72,20 +73,40 @@ function Breadcrumbs() {
};
});
// Same trail as the visible breadcrumbs, so the markup matches the page.
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [{ href: "/", label: "Home" }, ...crumbs].map(
(crumb, index) => ({
"@type": "ListItem",
position: index + 1,
name: crumb.label,
item: absoluteUrl(crumb.href),
}),
),
};
return (
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link href="/">Home</Link>
{crumbs.map((crumb, index) => (
<React.Fragment key={crumb.href}>
<span aria-hidden="true">/</span>
{index === crumbs.length - 1 ? (
<span>{crumb.label}</span>
) : (
<Link href={crumb.href}>{crumb.label}</Link>
)}
</React.Fragment>
))}
</nav>
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link href="/">Home</Link>
{crumbs.map((crumb, index) => (
<React.Fragment key={crumb.href}>
<span aria-hidden="true">/</span>
{index === crumbs.length - 1 ? (
<span>{crumb.label}</span>
) : (
<Link href={crumb.href}>{crumb.label}</Link>
)}
</React.Fragment>
))}
</nav>
</>
);
}
+19 -15
View File
@@ -14,7 +14,9 @@ import {
type NavLink,
} from "@/lib/site-nav";
const GITHUB_API_REPO_URL = `https://api.github.com/repos/${GITHUB_REPO_URL.replace("https://github.com/", "")}`;
// Star count is fetched once at build time (next.config.js) so visitors never
// call api.github.com. Empty or missing falls back to the "GitHub" label.
const GITHUB_STARS = Number(process.env.NEXT_PUBLIC_GITHUB_STARS) || null;
function SmartLink({
href,
@@ -63,7 +65,7 @@ export function SiteChrome({
variant?: "home" | "page";
}) {
const [open, setOpen] = useState(false);
const [stars, setStars] = useState<number | null>(null);
const stars = GITHUB_STARS;
const navId = useId();
const nav = variant === "home" ? homeNav : pageNav;
const ctaHref = variant === "home" ? "#install" : "/#install";
@@ -91,15 +93,6 @@ export function SiteChrome({
};
}, []);
useEffect(() => {
fetch(GITHUB_API_REPO_URL)
.then((res) => res.json())
.then((data: { stargazers_count?: number }) => {
if (data.stargazers_count) setStars(data.stargazers_count);
})
.catch(() => setStars(null));
}, []);
return (
<div className="min-h-[100dvh] max-w-[100vw] overflow-x-clip">
<a className="skip-link" href="#main">
@@ -109,7 +102,13 @@ export function SiteChrome({
<header className="site-header">
<div className="shell header-inner">
<Link href="/" className="brand" aria-label="Vocalinux home">
<VocalinuxLogo width={28} height={28} className="h-7 w-7" priority />
<VocalinuxLogo
width={28}
height={28}
className="h-7 w-7"
priority
decorative
/>
<span>Vocalinux</span>
</Link>
@@ -128,7 +127,7 @@ export function SiteChrome({
rel="noopener noreferrer"
aria-label={
stars !== null
? `Vocalinux on GitHub, ${stars.toLocaleString()} stars`
? `Vocalinux on GitHub, ${stars.toLocaleString("en-US")} stars`
: "Vocalinux on GitHub"
}
>
@@ -138,7 +137,7 @@ export function SiteChrome({
fill="currentColor"
aria-hidden="true"
/>
<span>{stars !== null ? stars.toLocaleString() : "GitHub"}</span>
<span>{stars !== null ? stars.toLocaleString("en-US") : "GitHub"}</span>
</a>
<button
className="nav-toggle"
@@ -192,7 +191,12 @@ export function SiteChrome({
<div className="footer-inner">
<div className="footer-brand">
<Link href="/" className="brand">
<VocalinuxLogo width={28} height={28} className="h-7 w-7" />
<VocalinuxLogo
width={28}
height={28}
className="h-7 w-7"
decorative
/>
<span>Vocalinux</span>
</Link>
<p>
+23
View File
@@ -0,0 +1,23 @@
import { absoluteUrl } from "../seo";
describe("absoluteUrl", () => {
it("returns the site root for /", () => {
expect(absoluteUrl("/")).toBe("https://vocalinux.com/");
});
it("adds the trailing slash pages are served with", () => {
expect(absoluteUrl("/install/debian")).toBe(
"https://vocalinux.com/install/debian/",
);
expect(absoluteUrl("/faq/")).toBe("https://vocalinux.com/faq/");
});
it("leaves file URLs without a trailing slash", () => {
expect(absoluteUrl("/vocalinux.png")).toBe(
"https://vocalinux.com/vocalinux.png",
);
expect(absoluteUrl("/og-image.png")).toBe(
"https://vocalinux.com/og-image.png",
);
});
});
+7
View File
@@ -25,6 +25,13 @@ const normalizePath = (path: string): string => {
}
const trimmed = path.replace(/^\/+|\/+$/g, "");
// Files such as /vocalinux.png must not get the page trailing slash:
// /vocalinux.png/ is a 404 on GitHub Pages.
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return `/${trimmed}`;
}
return `/${trimmed}/`;
};
+18 -3
View File
@@ -48,17 +48,24 @@ export const footerGroups: { title: string; links: NavLink[] }[] = [
{ href: "/screenshots/", label: "Screenshots" },
{ href: "/#engines", label: "Engines" },
{ href: "/compare/", label: "Compare engines" },
{ href: "/parakeet/", label: "Parakeet engine" },
{ href: "/faster-whisper/", label: "Faster Whisper" },
{ href: "/alternatives/", label: "Alternatives" },
{ href: "/vs-nerd-dictation/", label: "vs Nerd Dictation" },
],
},
{
title: "Install",
links: [
{ href: "/#install", label: "Installation" },
{ href: "/install/", label: "Distro guides" },
{ href: "/install/ubuntu/", label: "Ubuntu" },
{ href: "/install/debian/", label: "Debian" },
{ href: "/install/fedora/", label: "Fedora" },
{ href: "/install/arch/", label: "Arch" },
{ href: "/install/debian/", label: "Debian" },
{ href: "/install/linux-mint/", label: "Linux Mint" },
{ href: "/install/pop-os/", label: "Pop!_OS" },
{ href: "/install/opensuse/", label: "openSUSE" },
{ href: "/install/nixos/", label: "NixOS" },
{ href: "/flatpak/", label: "Flatpak" },
],
},
@@ -67,9 +74,14 @@ export const footerGroups: { title: string; links: NavLink[] }[] = [
links: [
{ href: "/offline/", label: "Offline" },
{ href: "/wayland/", label: "Wayland" },
{ href: "/hyprland-sway/", label: "Hyprland and Sway" },
{ href: "/pipewire/", label: "PipeWire" },
{ href: "/gpu-acceleration/", label: "GPU acceleration" },
{ href: "/whisper-model-guide/", label: "Whisper models" },
{ href: "/desktop-reliability/", label: "Reliability" },
{ href: "/remote-api/", label: "Remote API" },
{ href: "/voice-typing-vscode/", label: "VS Code" },
{ href: "/writers/", label: "For writers" },
],
},
{
@@ -78,14 +90,17 @@ export const footerGroups: { title: string; links: NavLink[] }[] = [
{ href: "/dictation-pad/", label: "Dictation Pad" },
{ href: "/transcribe-audio-files/", label: "Transcribe audio files" },
{ href: "/custom-dictionary/", label: "Custom dictionary" },
{ href: "/multilingual-dictation/", label: "Multilingual dictation" },
{ href: "/global-shortcuts/", label: "Global shortcuts" },
{ href: "/parakeet/", label: "Parakeet engine" },
{ href: "/shortcuts/", label: "Voice commands" },
{ href: "/local-gateway/", label: "Local gateway" },
],
},
{
title: "Project",
links: [
{ href: "/faq/", label: "FAQ" },
{ href: "/troubleshooting/", label: "Troubleshooting" },
{ href: "/privacy/", label: "Privacy" },
{ href: "/changelog/", label: "Changelog" },
{ href: GITHUB_REPO_URL, label: "Source" },
+4 -1
View File
@@ -1522,7 +1522,8 @@
font-size: clamp(1.5rem, 3vw, 1.85rem);
}
.subpage-content h3 {
.subpage-content h3,
.subpage-content .subpage-card-title {
margin-top: 1.6rem;
font-size: 1.15rem;
letter-spacing: -0.03em;
@@ -1726,6 +1727,8 @@
text-transform: uppercase;
}
/* /install/ uses h2 (heading order); the home page cards sit under an h2. */
.install-alt h2,
.install-alt h3 {
margin: 0.35rem 0 0;
font-size: 1.2rem;