Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ jobs:
- run: npm run cf:build
env:
SYNC_DOCS_TOKEN: ${{ secrets.SYNC_DOCS_TOKEN }}

# Catch broken internal links and locale leaks before they ship. Runs
# against the full build above (docs synced), so /docs/voice/* is checked.
- run: npm run check:links
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ i18n is **global, URL-driven infrastructure**, not a per-section feature. `src/l
- `translatedRoutes` — which base paths exist in which non-default locale. This is what makes a page "translated": `hreflang`, the sitemap alternates, and the switcher targets all read from it.
- **hreflang, the switcher, and the sitemap are automatic.** `buildAlternates()` emits the reciprocal set (+ region aliases + `x-default`) only for pages in `translatedRoutes`; untranslated pages get **no** hreflang (so we never claim a translation that doesn't exist) but **still show the switcher** (it falls back to the locale home, never a 404). The sitemap `i18n` map in `astro.config.mjs` mirrors the slug↔code mapping.
- **A "suggest, don't force" banner** (in `Base.astro`) offers the visitor's browser language when this page has it, in the target language, and remembers dismissal in `localStorage`. Never auto-redirect — it breaks SEO/crawling and traps shared-machine users.
- **In-body links are NOT localized automatically.** The chrome localizes itself, but links written *inside* a page (`.astro` body, blog markdown) are taken verbatim — so a hand-written `/voice/download/` in a `/zh/` page leaks the reader back to English. When you write an internal link on a localized page, prefix it with the locale (`/zh/voice/download/`) **if that page is translated**; leave it unprefixed only for default-locale-only targets (e.g. the English-only `/docs/**`). `scripts/check-links.js` (`npm run check:links`, run in CI after the build) guards both failure modes — it fails the build on any broken internal link **and** on any localized page that links to a default-locale URL whose localized twin exists. Run `npm run build && npm run check:links` locally before publishing link-heavy changes (use `SYNC_DOCS=1 WAVEKAT_LOCAL_REPOS=<path>` so the private `/docs/voice/*` get checked too).

### Naming rules (these are deliberate — follow them)

Expand Down Expand Up @@ -133,6 +134,39 @@ git submodule update --remote vendor/wavekat-brand
make sync
```

## Product screenshots

Real, localized app screenshots from wavekat-voice's screenshot pipeline (its
`docs/41` — `make screenshots` then `make screenshots-frames`) live in a
**shared, scene-keyed namespace**: `public/screenshots/<scene>/<code>.webp`. One
file per (scene, language) that *any* page can reference — `in-call` is one
asset, not a copy per post. **The guiding rule: a screenshot earns its place
only where it makes a page's point** — don't add one (or sync a scene) just
because the pipeline can make it.

Today the only consumer is the "place calls from the command line" blog post,
which embeds three, each illustrating the section it sits under: `in-call`
("what it actually does"), `settings-automation` ("there's nothing to install" —
the enable toggle + Install-CLI button), and `settings-automation-agents`
("connect an AI assistant in one click" — the one-click Connect rows).
`settings-automation*` are scenes added to wavekat-voice for this; the agents one
is the same page scrolled to the assistants section (a scene `scroll` hint, since
it's below the 960×640 fold).

- **Framed, not bare.** These use the pipeline's **Ubuntu/GNOME-framed** output
(`screenshots/framed/ubuntu/…`), so the window chrome is real, not CSS — we're
a Mac + Linux product and the author runs Ubuntu. No site-drawn frame.
- **Single theme (light), per language.** A baked-in frame can't follow the
page's dark/light toggle, so we pick light and keep it consistent — but each
localized surface shows the app in *its* language (`/screenshots/<scene>/<code>.webp`).
- **Committed, not built.** Nothing here pulls from the private renderer at build
time, so the chosen shots are committed under `public/screenshots/` as WebP
(~1.1 MB) and referenced by plain markdown `![alt](/screenshots/…)` with
translated alt text. Refresh with `make screenshots` (`npm run sync:screenshots`)
against a local wavekat-voice checkout; `npm run check:screenshots` asserts the
set is complete. The scene list lives in `scripts/sync-screenshots.js` — keep it
in sync with the `![](/screenshots/<scene>/…)` refs across the site.

## Current state

- Working branch: `feat/astro-scaffold`
Expand Down
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
SHELL := /bin/bash
NVM := . ~/.nvm/nvm.sh && nvm use 22 &&
.PHONY: dev build preview sync install clean cf-build help
.PHONY: dev build preview sync screenshots install clean cf-build help
.DEFAULT_GOAL := help

# Copy needed assets from wavekat-brand submodule → public/
sync:
$(NVM) npm run sync

# Refresh the Ubuntu-framed app screenshots (public/screenshots/) from a local
# wavekat-voice checkout (run `make screenshots && make screenshots-frames`
# there first). Committed assets — not part of the regular build.
screenshots:
$(NVM) npm run sync:screenshots

# Start dev server
dev:
$(NVM) npm run dev
Expand Down Expand Up @@ -38,6 +44,7 @@ help:
@echo " build Build for production → dist/"
@echo " preview Build and preview locally"
@echo " sync Copy assets from wavekat-brand submodule"
@echo " screenshots Refresh framed app screenshots → public/screenshots/"
@echo " install Install dependencies"
@echo " cf-build Simulate Cloudflare Pages build (no nvm)"
@echo " clean Remove dist/, .astro/, public/logos/"
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"scripts": {
"sync": "node scripts/sync-brand.js",
"sync:docs": "node scripts/sync-docs.js",
"sync:screenshots": "node scripts/sync-screenshots.js",
"check:screenshots": "node scripts/sync-screenshots.js --check",
"check:links": "node scripts/check-links.js",
"dev": "npm run sync && astro dev",
"build": "npm run sync && SYNC_DOCS=1 npm run sync:docs && astro build",
"preview": "astro preview",
Expand Down
Binary file added public/screenshots/in-call/de.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/es.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/fr.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/it.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/ja.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/ko.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/zh-Hans.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/in-call/zh-Hant.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/settings-automation/de.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/settings-automation/en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/settings-automation/es.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/settings-automation/fr.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/settings-automation/it.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/settings-automation/ja.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/screenshots/settings-automation/ko.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions scripts/check-links.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node
// Validates every internal link in the built site (dist/) before we publish.
// Catches the two ways an internal link goes wrong here:
//
// 1. Broken — the target page/asset doesn't exist (a 404 waiting to happen).
// 2. Leak — a localized page (/zh/…, /ja/…) links to the *unprefixed*
// default-locale URL of a page that DOES have a localized
// version, bouncing the reader out of their language.
//
// Both are derived from dist/ itself, so there's no second source of truth to
// drift: a link is broken if its file isn't there, and a leak if the localized
// twin (/<slug> + target) is there but the link skipped it. Locale slugs are
// read from src/lib/i18n.ts so adding a language needs no change here.
//
// Run it against a *full* build (with the private docs synced):
// npm run build && npm run check:links
// Without SYNC_DOCS_TOKEN the voice docs (/docs/voice/*) aren't built; those
// targets are reported as a skipped warning, not a failure, so local runs are
// still useful.
//
// Exit code is non-zero if any broken link or leak is found — wire it into CI
// right after the build.

import { readFileSync, existsSync, readdirSync, statSync } from "fs";
import { join } from "path";
import { fileURLToPath } from "url";

const root = join(fileURLToPath(import.meta.url), "../..");
const dist = join(root, "dist");

if (!existsSync(dist)) {
console.error("✗ no dist/ — run `npm run build` first.");
process.exit(1);
}

// Locale slugs straight from the i18n registry (non-empty `slug:` values).
const i18n = readFileSync(join(root, "src/lib/i18n.ts"), "utf8");
const localeSlugs = [...i18n.matchAll(/slug:\s*'([^']+)'/g)].map((m) => m[1]);
const slugRe = new RegExp(`^/(${localeSlugs.join("|")})(/|$)`);

// Are the private voice docs in this build? If not, don't fail on their URLs.
const voiceDocsBuilt = existsSync(join(dist, "docs/voice"));

function walk(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (entry.endsWith(".html")) out.push(p);
}
return out;
}

// Does an internal URL path resolve to a built file?
const ASSET_RE = /\.[a-z0-9]+$/i;
function resolves(urlPath) {
if (ASSET_RE.test(urlPath) && !urlPath.endsWith("/")) {
return existsSync(join(dist, urlPath));
}
// A page: accept dir/index.html, with or without the trailing slash.
const candidates = [
join(dist, urlPath, "index.html"),
join(dist, urlPath.replace(/\/$/, "") + ".html"),
join(dist, urlPath), // exact file (rare)
];
return candidates.some(existsSync);
}

const pages = walk(dist);
const broken = new Map(); // target -> Set(source page)
const leaks = new Map(); // "page → target" detail
const skipped = new Set(); // synced-doc targets we couldn't check locally
const linkRe = /(?:href|src)="(\/[^":]*?)"/g;

for (const file of pages) {
const sourceUrl = "/" + file.slice(dist.length + 1).replace(/index\.html$/, "");
const localeMatch = sourceUrl.match(slugRe);
const localeSlug = localeMatch ? localeMatch[1] : null;
// This page's path with the locale prefix stripped — i.e. its default-locale
// twin. The language switcher on every localized page links to exactly this
// (its "English" entry), which is correct, not a leak; we exclude it below.
const ownBasePath = localeSlug ? sourceUrl.replace(slugRe, "/") : sourceUrl;
const html = readFileSync(file, "utf8");

let m;
while ((m = linkRe.exec(html))) {
const target = m[1].split("#")[0].split("?")[0];
if (!target || target === "/") continue;

// Broken-link check.
if (!resolves(target)) {
if (!voiceDocsBuilt && target.startsWith("/docs/voice")) {
skipped.add(target);
} else {
if (!broken.has(target)) broken.set(target, new Set());
broken.get(target).add(sourceUrl);
}
}

// Locale-leak check: a localized page linking to an unprefixed URL whose
// localized twin exists is a leak (the link should carry the locale). The
// switcher's own "English" link points at this page's twin (ownBasePath) —
// that's intentional, so skip it.
const targetSlash = target.endsWith("/") ? target : target + "/";
if (
localeSlug &&
!slugRe.test(target) &&
!ASSET_RE.test(target) &&
targetSlash !== (ownBasePath.endsWith("/") ? ownBasePath : ownBasePath + "/")
) {
const twin = `/${localeSlug}${target}`;
if (resolves(twin)) {
leaks.set(`${sourceUrl} → ${target}`, `should be /${localeSlug}${target}`);
}
}
}
}

let failed = false;

if (broken.size) {
failed = true;
console.error(`✗ ${broken.size} broken internal link target(s):`);
for (const [target, srcs] of [...broken].sort()) {
console.error(` ${target}`);
for (const s of [...srcs].sort().slice(0, 5)) console.error(` ← ${s}`);
if (srcs.size > 5) console.error(` … and ${srcs.size - 5} more`);
}
console.error("");
}

if (leaks.size) {
failed = true;
console.error(`✗ ${leaks.size} locale-leaking link(s) (localized page → default-locale URL):`);
for (const [where, fix] of [...leaks].sort()) {
console.error(` ${where} (${fix})`);
}
console.error("");
}

if (skipped.size) {
console.warn(
`⚠ skipped ${skipped.size} /docs/voice/* link(s) — voice docs not synced in ` +
`this build. Run with SYNC_DOCS_TOKEN to verify them.\n`,
);
}

if (failed) {
console.error("Link check failed.");
process.exit(1);
}
console.log(`✓ links OK — ${pages.length} pages scanned, no broken or leaking internal links.`);
Loading
Loading