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
30 changes: 29 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,34 @@ function rehypeRewriteDocLinks() {
};
}

// Give version-number headings (the changelog's `0.0.41`, …) a stable, lossless
// id equal to the literal version, so pages can deep-link to a release with
// `…/voice/changelog/#0.0.41`. github-slugger (Astro's default) strips the dots
// to `0041`, which is both ugly and collision-prone — `0.1.41` and `0.14.1`
// both reduce to `0141`. Scoped to headings whose text is *exactly* a
// `X.Y.Z` version, so every other heading keeps its normal slug; this runs
// after the default slug plugin and overrides the id only for those.
function rehypeVersionAnchors() {
const VERSION = /^\d+\.\d+\.\d+$/;
const textOf = (node) =>
(node.children || [])
.map((c) => (c.type === 'text' ? c.value : c.tagName ? textOf(c) : ''))
.join('');
return () => (tree) => {
const visit = (node) => {
if (typeof node.tagName === 'string' && /^h[1-6]$/.test(node.tagName)) {
const text = textOf(node).trim();
if (VERSION.test(text)) {
node.properties = node.properties || {};
node.properties.id = text;
}
}
if (Array.isArray(node.children)) node.children.forEach(visit);
};
visit(tree);
};
}

// https://astro.build/config
export default defineConfig({
site: 'https://wavekat.com',
Expand Down Expand Up @@ -83,7 +111,7 @@ export default defineConfig({
}),
],
markdown: {
rehypePlugins: [rehypeRewriteDocLinks()],
rehypePlugins: [rehypeRewriteDocLinks(), rehypeVersionAnchors()],
},
vite: {
plugins: [tailwindcss()],
Expand Down
Binary file added public/screenshots/share-sheet/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/share-sheet/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/share-sheet/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/share-sheet/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/share-sheet/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/share-sheet/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/share-sheet/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/share-sheet/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/share-sheet/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.
Binary file added public/screenshots/share-viewer/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/share-viewer/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/share-viewer/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/share-viewer/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/share-viewer/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/share-viewer/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/share-viewer/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/share-viewer/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/share-viewer/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.
180 changes: 116 additions & 64 deletions scripts/sync-screenshots.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,71 @@
#!/usr/bin/env node
// Pulls the Ubuntu-framed marketing screenshots generated by wavekat-voice's
// screenshot pipeline (its docs/41 — `make screenshots` + `make
// screenshots-frames`) into public/screenshots/, a shared, reusable namespace
// keyed by scene (so `in-call` is one file every page can reference, not a
// copy per post).
// Pulls marketing screenshots from the WaveKat product repos into
// public/screenshots/, a shared, reusable namespace keyed by scene (so
// `in-call` is one file every page can reference, not a copy per post).
//
// Two sources, because a screenshot's home is its product:
// - voice — the desktop app (wavekat-voice). Ubuntu/GNOME-*framed* PNGs
// from its screenshot pipeline (its docs/41 — `make screenshots`
// + `make screenshots-frames`), named `<code>-<theme>.png`.
// - platform — the web app (wavekat-platform). Plain browser-page PNGs from
// its Playwright pipeline, named `<code>.png` (no frame, no
// theme suffix — a web page, not a desktop window).
//
// Why committed, not built on the fly
// -----------------------------------
// The PNGs are git-ignored build artifacts in wavekat-voice, and the site
// builds on Cloudflare Pages without that private renderer. So the chosen,
// localized, already-framed shots are committed here as WebP and referenced by
// a plain public path (/screenshots/<scene>/<code>.webp) — from the blog today,
// from anywhere later.
// The PNGs are git-ignored build artifacts in their source repos, and the site
// builds on Cloudflare Pages without those private renderers. So the chosen,
// localized shots are committed here as WebP and referenced by a plain public
// path (/screenshots/<scene>/<code>.webp) — from the blog today, anywhere later.
//
// Single theme on purpose: these go in a single page read in one language, and
// a baked-in window frame can't follow the page's light/dark toggle — so we
// pick the light frame and keep it consistent. Per *language*, though — each
// localized surface shows the app in its own language.
//
// Source (first that exists), pointing at the *framed/ubuntu* output:
// WAVEKAT_FRAMES_DIR=<dir> — a framed/ubuntu dir directly
// WAVEKAT_LOCAL_REPOS=<base> — <base>/wavekat-voice/apps/desktop/screenshots/framed/ubuntu
// ../wavekat-voice/apps/desktop/screenshots/framed/ubuntu (default sibling)
// Sources (first that exists):
// voice:
// WAVEKAT_FRAMES_DIR=<dir> — a framed/ubuntu dir directly
// WAVEKAT_LOCAL_REPOS=<base> — <base>/wavekat-voice/apps/desktop/screenshots/framed/ubuntu
// ../wavekat-voice/apps/desktop/screenshots/framed/ubuntu (default sibling)
// platform:
// WAVEKAT_PLATFORM_SHOTS_DIR=<dir> — a screenshots dir directly
// WAVEKAT_LOCAL_REPOS=<base> — <base>/wavekat-platform/apps/web/screenshots
// ../wavekat-platform/apps/web/screenshots (default sibling)
//
// To regenerate the source first, in the wavekat-voice repo:
// make screenshots && make screenshots-frames
// To regenerate the sources first:
// wavekat-voice: make screenshots && make screenshots-frames
// wavekat-platform: pnpm --filter web screenshots:share
// then here:
// npm run sync:screenshots
//
// Usage:
// node scripts/sync-screenshots.js # refresh committed WebPs
// node scripts/sync-screenshots.js --check # verify all present, no write

import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "fs";
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
import { join } from "path";
import { fileURLToPath } from "url";
import sharp from "sharp";

const root = join(fileURLToPath(import.meta.url), "../..");
const destRoot = join(root, "public/screenshots");

// The scenes we actually surface. Only commit what's referenced — each shot
// should earn its place on a page, not exist because the pipeline can make it.
// Keep in sync with the `![](/screenshots/<scene>/…)` refs across the site
// (today: src/content/blog/**/place-calls-from-the-command-line.md).
// The scenes we actually surface, each tagged with its source repo. Only commit
// what's referenced — each shot should earn its place on a page, not exist
// because the pipeline can make it. Keep in sync with the `![](/screenshots/…)`
// refs across the site (today: the CLI and share-recording blog posts).
//
// `frame` (voice scenes only): the desktop pipeline composites both macOS and
// Ubuntu window chrome; each scene picks which one it ships. The CLI post shots
// use the Ubuntu frame (its author runs Ubuntu); the share-recording post shows
// the Mac build. Defaults to "ubuntu" when omitted.
const SCENES = [
"in-call", // a placed call, live transcript — reusable beyond the blog
"settings-automation", // enable command-line access + install the CLI
"settings-automation-agents", // one-click "connect an AI assistant" rows
{ name: "in-call", source: "voice", frame: "ubuntu" }, // a placed call
{ name: "settings-automation", source: "voice", frame: "ubuntu" }, // enable CLI
{ name: "settings-automation-agents", source: "voice", frame: "ubuntu" }, // connect
{ name: "share-sheet", source: "voice", frame: "macos" }, // the Share panel
{ name: "share-viewer", source: "platform" }, // what the recipient opens (web)
];

// The nine shipped interface languages, by wavekat.com locale `code` — exactly
Expand All @@ -71,22 +89,47 @@ const THEME = "light"; // single, consistent theme for the blog
const MAX_WIDTH = 1200;
const WEBP_QUALITY = 86;

function resolveSourceDir() {
const direct = process.env.WAVEKAT_FRAMES_DIR;
if (direct) return direct;
const base = process.env.WAVEKAT_LOCAL_REPOS;
if (base)
return join(base, "wavekat-voice/apps/desktop/screenshots/framed/ubuntu");
return join(root, "../wavekat-voice/apps/desktop/screenshots/framed/ubuntu");
}
// Each source repo: where its PNGs live, and the source filename for a given
// (scene, code). Voice frames carry a theme suffix; platform web shots don't.
const SOURCES = {
voice: {
label: "wavekat-voice (screenshots/framed)",
regen: "make screenshots && make screenshots-frames",
// The `framed` root, which holds both `macos/` and `ubuntu/`. The per-scene
// `frame` selects the subdir (see sceneDir below).
dir: () => {
const direct = process.env.WAVEKAT_FRAMES_DIR;
if (direct) return direct;
const base = process.env.WAVEKAT_LOCAL_REPOS;
if (base) return join(base, "wavekat-voice/apps/desktop/screenshots/framed");
return join(root, "../wavekat-voice/apps/desktop/screenshots/framed");
},
// <framed>/<frame>/<scene>
sceneDir: (rootDir, scene) => join(rootDir, scene.frame ?? "ubuntu", scene.name),
file: (code) => `${code}-${THEME}.png`,
},
platform: {
label: "wavekat-platform (apps/web/screenshots)",
regen: "pnpm --filter web screenshots:share",
dir: () => {
const direct = process.env.WAVEKAT_PLATFORM_SHOTS_DIR;
if (direct) return direct;
const base = process.env.WAVEKAT_LOCAL_REPOS;
if (base) return join(base, "wavekat-platform/apps/web/screenshots");
return join(root, "../wavekat-platform/apps/web/screenshots");
},
// <screenshots>/<scene>
sceneDir: (rootDir, scene) => join(rootDir, scene.name),
file: (code) => `${code}.png`,
},
};

const check = process.argv.includes("--check");

function expected() {
const out = [];
for (const scene of SCENES)
for (const code of CODES)
out.push({ scene, name: `${code}.webp` });
for (const { name } of SCENES)
for (const code of CODES) out.push({ scene: name, name: `${code}.webp` });
return out;
}

Expand All @@ -98,61 +141,70 @@ if (check) {
console.error(
`✗ ${missing.length} committed screenshot(s) missing:\n` +
missing.map((m) => ` ${m.scene}/${m.name}`).join("\n") +
`\n\nRegenerate with \`make screenshots && make screenshots-frames\` in ` +
`wavekat-voice, then \`npm run sync:screenshots\` here.`,
`\n\nRegenerate the sources, then \`npm run sync:screenshots\` here.`,
);
process.exit(1);
}
console.log(`✓ all ${expected().length} screenshots present`);
process.exit(0);
}

const srcRoot = resolveSourceDir();
if (!existsSync(srcRoot)) {
console.error(
`✗ no framed screenshots at ${srcRoot}\n\n` +
`Generate them first — in your wavekat-voice checkout:\n` +
` make screenshots && make screenshots-frames\n\n` +
`then point this script at them with WAVEKAT_FRAMES_DIR=<dir>, ` +
`WAVEKAT_LOCAL_REPOS=<base>, or a sibling ../wavekat-voice checkout.`,
// Resolve each source dir. A missing source is NOT fatal and does NOT wipe:
// these WebPs are committed artifacts, and the two source repos render
// independently (you usually have only one checked out at a time). So we
// regenerate what a present source provides and keep the committed shots for any
// source that's absent — only a scene with neither a source nor a committed copy
// is a real miss. (This is why there's no wholesale destRoot wipe.)
const srcRoots = {};
for (const key of new Set(SCENES.map((s) => s.source))) {
const dir = SOURCES[key].dir();
srcRoots[key] = existsSync(dir) ? dir : null;
console.log(
srcRoots[key]
? `▶ ${key}: ${dir}`
: `▷ ${key}: not present — keeping committed shots (${dir})`,
);
process.exit(1);
}

console.log(`▶ syncing screenshots from ${srcRoot}`);
rmSync(destRoot, { recursive: true, force: true });

let written = 0;
let kept = 0;
const missing = [];
for (const scene of SCENES) {
const srcDir = join(srcRoot, scene);
const destDir = join(destRoot, scene);
const src = SOURCES[scene.source];
const root = srcRoots[scene.source];
const srcDir = root ? src.sceneDir(root, scene) : null;
const destDir = join(destRoot, scene.name);
mkdirSync(destDir, { recursive: true });
for (const code of CODES) {
const src = join(srcDir, `${code}-${THEME}.png`);
if (!existsSync(src)) {
missing.push(`${scene}/${code}-${THEME}.png`);
continue;
const destFile = join(destDir, `${code}.webp`);
const srcFile = srcDir ? join(srcDir, src.file(code)) : null;
if (srcFile && existsSync(srcFile)) {
await sharp(srcFile)
.resize({ width: MAX_WIDTH, withoutEnlargement: true })
.webp({ quality: WEBP_QUALITY, effort: 6 })
.toFile(destFile);
written++;
} else if (existsSync(destFile)) {
kept++; // source absent, but the committed WebP is already here
} else {
missing.push(`${scene.name}/${code}.webp (no ${scene.source} source, no committed copy)`);
}
await sharp(src)
.resize({ width: MAX_WIDTH, withoutEnlargement: true })
.webp({ quality: WEBP_QUALITY, effort: 6 })
.toFile(join(destDir, `${code}.webp`));
written++;
}
}

console.log(`✓ wrote ${written} screenshots → public/screenshots/`);
console.log(
`✓ wrote ${written}, kept ${kept} committed → public/screenshots/`,
);
if (missing.length) {
console.warn(
`\n⚠ ${missing.length} expected file(s) not found in source:\n` +
`\n⚠ ${missing.length} screenshot(s) have neither a source nor a committed copy:\n` +
missing.map((m) => ` ${m}`).join("\n"),
);
process.exitCode = 1;
}

const totalKb = SCENES.reduce((sum, scene) => {
const d = join(destRoot, scene);
const totalKb = SCENES.reduce((sum, { name }) => {
const d = join(destRoot, name);
if (!existsSync(d)) return sum;
for (const f of readdirSync(d)) sum += statSync(join(d, f)).size / 1024;
return sum;
Expand Down
Loading
Loading