diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 705b366..8ce7c49 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,8 +1,13 @@ name: Preview Deploy on: + pull_request: + branches: [main] workflow_dispatch: +permissions: + pull-requests: write + jobs: deploy: runs-on: ubuntu-latest @@ -20,7 +25,21 @@ jobs: - run: npm run cf:build - - run: npm run cf:deploy + - name: Deploy preview + id: deploy + run: | + OUTPUT=$(npx wrangler pages deploy ./dist --project-name=wavekat-com --branch="${{ github.head_ref || github.ref_name }}" 2>&1) + echo "$OUTPUT" + ALIAS=$(echo "$OUTPUT" | grep -oP '(?<=Deployment alias URL: )https://\S+') + echo "url=${ALIAS}" >> "$GITHUB_OUTPUT" env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + + - name: Comment preview URL + if: github.event_name == 'pull_request' && steps.deploy.outputs.url + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: preview + message: | + ๐Ÿ”— Preview: ${{ steps.deploy.outputs.url }} diff --git a/CLAUDE.md b/CLAUDE.md index 7934385..c1c0a81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ make sync ## What to do next -Phase 3 in `docs/dev-plan.md`: +Phase 3 in `docs/01-dev-plan.md`: - Pull remaining banners/assets from `wavekat-brand` as needed - Optimise any additional SVGs for web diff --git a/astro.config.mjs b/astro.config.mjs index b5bc8e6..70fd03f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,5 +1,6 @@ // @ts-check import { defineConfig } from 'astro/config'; +import sitemap from '@astrojs/sitemap'; import tailwindcss from '@tailwindcss/vite'; @@ -7,6 +8,7 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ site: 'https://wavekat.com', output: 'static', + integrations: [sitemap()], vite: { plugins: [tailwindcss()], server: { diff --git a/docs/dev-plan.md b/docs/01-dev-plan.md similarity index 100% rename from docs/dev-plan.md rename to docs/01-dev-plan.md diff --git a/docs/02-blog-plan.md b/docs/02-blog-plan.md new file mode 100644 index 0000000..6865eec --- /dev/null +++ b/docs/02-blog-plan.md @@ -0,0 +1,180 @@ +# Blog & SEO Plan + +## Goal + +Add a blog to `wavekat.com` for publishing articles about WaveKat's work โ€” voice AI, +open-source libraries, engineering deep-dives, and company updates. The blog is the +primary driver for organic search traffic and establishing domain authority. + +--- + +## Phase B1 โ€” Content Collections & Blog Infrastructure + +Use Astro's built-in **Content Collections** (the `src/content/` convention) with +Markdown (`.md`) files. No MDX needed unless we later want interactive components +inside posts. + +### Tasks + +- [ ] Define a `blog` content collection in `src/content.config.ts` + - Schema (Zod): `title`, `description`, `date`, `updated?`, `author?`, + `tags?`, `draft?`, `ogImage?` +- [ ] Create `src/content/blog/` directory for Markdown posts +- [ ] Add a seed post (e.g. "Hello World" or "Why We Built WaveKat") so we can + develop against real content + +### Routing + +Astro generates routes from `src/pages/`. We need two new page files: + +| Route | File | Purpose | +|-------|------|---------| +| `/blog` | `src/pages/blog/index.astro` | Post listing (newest first) | +| `/blog/` | `src/pages/blog/[slug].astro` | Individual post page | + +`[slug].astro` uses `getStaticPaths()` to generate one page per post at build time. +Drafts (`draft: true`) are excluded from production builds. + +### Layout + +- [ ] Create `src/layouts/Post.astro` โ€” wraps `Base.astro`, adds: + - Post title as `

` + - Date, author, tags + - `
` with Tailwind typography (`@tailwindcss/typography`) + - Back-to-blog link + - Open Graph meta overrides (title, description, og:image per post) +- [ ] Install `@tailwindcss/typography` for Markdown prose styling + +### Components + +- [ ] `src/components/PostCard.astro` โ€” used on the listing page; shows title, + date, description, tags +- [ ] `src/components/TagList.astro` (optional, can defer) โ€” renders tag links + +--- + +## Phase B2 โ€” SEO Foundations + +### Sitemap + +- [ ] Install `@astrojs/sitemap` +- [ ] Add to `astro.config.mjs` integrations +- [ ] Verify `sitemap-index.xml` is generated at build time + - Relies on `site: 'https://wavekat.com'` already set in config + +### RSS Feed + +- [ ] Install `@astrojs/rss` +- [ ] Create `src/pages/rss.xml.ts` โ€” generates an RSS 2.0 feed from the blog + collection +- [ ] Add `` to `Base.astro` + `` + +### robots.txt + +- [ ] Add `public/robots.txt` + ``` + User-agent: * + Allow: / + + Sitemap: https://wavekat.com/sitemap-index.xml + ``` + +### Meta & Structured Data + +- [ ] Ensure every blog post sets unique ``, `<meta description>`, + `og:title`, `og:description`, `og:image` + - `Base.astro` already supports these via props โ€” `Post.astro` forwards them +- [ ] Add JSON-LD structured data (`Article` schema) to `Post.astro` + - Includes: headline, datePublished, dateModified, author, publisher, image +- [ ] Add `<link rel="canonical">` to each post (already in `Base.astro` for + the homepage โ€” extend to work per-page) + +--- + +## Phase B3 โ€” Blog Polish + +### Tag Pages (optional, can defer) + +- [ ] `src/pages/blog/tag/[tag].astro` โ€” lists posts filtered by tag +- [ ] `getStaticPaths()` generates one page per unique tag + +### Pagination (when needed) + +- [ ] If post count exceeds ~15, add pagination to the blog index + - Astro has built-in `paginate()` for this + +### Navigation + +- [ ] Add a "Blog" link to the homepage header/nav +- [ ] Add a "Blog" link in the footer + +### Reading Time + +- [ ] Calculate estimated reading time from word count, display on post card and + post page + +### OG Images + +- [ ] Auto-generate per-post OG images (title overlaid on a branded template) + - Can use `@resvg/resvg-js` (already a dependency) or Satori + - Or: just use the site-wide `og.png` as default and allow manual override + via frontmatter `ogImage` + +--- + +## Phase B4 โ€” Advanced SEO (Future) + +- [ ] Google Search Console verification & monitoring +- [ ] Internal linking strategy (link between related posts) +- [ ] Performance audit (Core Web Vitals โ€” Astro static is already fast) +- [ ] Schema.org `Organization` and `WebSite` structured data on homepage + +--- + +## Dependencies to Add + +| Package | Purpose | +|---------|---------| +| `@tailwindcss/typography` | Prose styling for Markdown content | +| `@astrojs/sitemap` | Auto-generated sitemap.xml | +| `@astrojs/rss` | RSS feed generation | + +All are official Astro/Tailwind packages with minimal footprint. + +--- + +## File Tree (After B1 + B2) + +``` +src/ +โ”œโ”€โ”€ content/ +โ”‚ โ”œโ”€โ”€ content.config.ts # Collection schemas +โ”‚ โ””โ”€โ”€ blog/ +โ”‚ โ””โ”€โ”€ hello-world.md # Seed post +โ”œโ”€โ”€ layouts/ +โ”‚ โ”œโ”€โ”€ Base.astro # (existing) +โ”‚ โ””โ”€โ”€ Post.astro # Blog post layout +โ”œโ”€โ”€ components/ +โ”‚ โ””โ”€โ”€ PostCard.astro # Blog listing card +โ”œโ”€โ”€ pages/ +โ”‚ โ”œโ”€โ”€ index.astro # (existing) +โ”‚ โ”œโ”€โ”€ rss.xml.ts # RSS feed +โ”‚ โ””โ”€โ”€ blog/ +โ”‚ โ”œโ”€โ”€ index.astro # Blog listing +โ”‚ โ””โ”€โ”€ [slug].astro # Individual post +public/ +โ””โ”€โ”€ robots.txt # Crawl directives + sitemap pointer +``` + +--- + +## Notes + +- **Static output**: Blog posts are built at deploy time. No server needed. + Publishing a new post = merge to main โ†’ release-please โ†’ Cloudflare deploys. +- **Markdown-first**: Write posts in `src/content/blog/*.md` with frontmatter. + Can upgrade to MDX later if interactive components are needed. +- **SEO is cumulative**: The biggest wins come from consistently publishing + quality content. The technical SEO (sitemap, structured data, meta tags) just + makes sure search engines can find and understand it. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 0000000..dd2cc87 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1,13 @@ +# Docs Conventions + +## File Naming + +Files in this folder use a **numbered prefix** for reading order: + +``` +NN-short-name.md +``` + +- `NN` is a two-digit number (01, 02, 03, ...). +- Duplicate numbers are allowed โ€” different branches may independently pick the same number. The suffix keeps filenames unique and avoids merge conflicts. +- When adding a new doc, use the next available number on your branch. Don't renumber existing files. diff --git a/package-lock.json b/package-lock.json index 4da75ae..dd76893 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "name": "wavekat-com", "version": "0.0.7", "dependencies": { + "@astrojs/rss": "^4.0.18", + "@astrojs/sitemap": "^3.7.2", "@lucide/astro": "^1.7.0", "@resvg/resvg-js": "^2.6.2", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.2", "astro": "^6.1.1", "tailwindcss": "^4.2.2" @@ -77,6 +80,28 @@ "node": ">=22.12.0" } }, + "node_modules/@astrojs/rss": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.18.tgz", + "integrity": "sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.7", + "piccolore": "^0.1.3", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.2.tgz", + "integrity": "sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "stream-replace-string": "^2.0.0", + "zod": "^4.3.6" + } + }, "node_modules/@astrojs/telemetry": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", @@ -2216,6 +2241,18 @@ "node": ">= 20" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", @@ -2278,6 +2315,24 @@ "@types/unist": "*" } }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2338,6 +2393,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2694,6 +2755,18 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -3022,6 +3095,41 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5198,6 +5306,21 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -5264,6 +5387,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -5674,6 +5810,25 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, "node_modules/smol-toml": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", @@ -5716,6 +5871,12 @@ "npm": ">=6" } }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5730,6 +5891,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -5902,6 +6075,12 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -6161,6 +6340,12 @@ } } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index a8810ac..ba67cd2 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,11 @@ "astro": "astro" }, "dependencies": { + "@astrojs/rss": "^4.0.18", + "@astrojs/sitemap": "^3.7.2", "@lucide/astro": "^1.7.0", "@resvg/resvg-js": "^2.6.2", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.2", "astro": "^6.1.1", "tailwindcss": "^4.2.2" diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9d8b15 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://wavekat.com/sitemap-index.xml diff --git a/src/components/PostCard.astro b/src/components/PostCard.astro new file mode 100644 index 0000000..2c862b5 --- /dev/null +++ b/src/components/PostCard.astro @@ -0,0 +1,38 @@ +--- +interface Props { + title: string; + description: string; + date: Date; + tags?: string[]; + slug: string; +} + +const { title, description, date, tags = [], slug } = Astro.props; + +const formatDate = (d: Date) => + d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); +--- + +<a + href={`/blog/${slug}`} + class="group block rounded border border-gray-200 dark:border-white/10 p-5 dark:bg-wk-surface bg-gray-50 hover:bg-gray-100 dark:hover:bg-[#0d1520] transition-colors" +> + <time datetime={date.toISOString()} class="text-[10px] text-gray-400 dark:text-gray-600 tracking-wide uppercase"> + {formatDate(date)} + </time> + <h3 class="text-sm font-bold text-gray-900 dark:text-white mt-1.5 mb-1.5 group-hover:underline underline-offset-2"> + {title} + </h3> + <p class="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mb-3"> + {description} + </p> + {tags.length > 0 && ( + <div class="flex flex-wrap gap-1.5"> + {tags.map((tag) => ( + <span class="text-[10px] tracking-wider uppercase text-gray-400 dark:text-gray-600"> + #{tag} + </span> + ))} + </div> + )} +</a> diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..44b18c1 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,18 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const blog = defineCollection({ + loader: glob({ pattern: '**/*.md', base: './src/content/blog' }), + schema: z.object({ + title: z.string(), + description: z.string(), + date: z.coerce.date(), + updated: z.coerce.date().optional(), + author: z.string().default('WaveKat'), + tags: z.array(z.string()).default([]), + draft: z.boolean().default(false), + ogImage: z.string().optional(), + }), +}); + +export const collections = { blog }; diff --git a/src/content/blog/hello-world.md b/src/content/blog/hello-world.md new file mode 100644 index 0000000..3a757d3 --- /dev/null +++ b/src/content/blog/hello-world.md @@ -0,0 +1,32 @@ +--- +title: "Hello, World โ€” WaveKat Is Here" +description: "Introducing WaveKat: open-source, AI-powered voice tools built for small businesses. Here's what we're building and why." +date: 2026-04-01 +author: Eason Guo +tags: [announcement, open-source, voice-ai] +--- + +We started WaveKat with a simple belief: + +> Every small business deserves the voice of a big one. + +Small businesses miss calls. They can't afford a front desk or a 24/7 answering service. Meanwhile, enterprise companies deploy sophisticated voice AI that handles thousands of calls a day. That gap shouldn't exist. + +## What we're building + +WaveKat is building tools for real-time voice AI. We're starting with a set of open-source libraries: + +- **wavekat-core** โ€” shared audio primitives like `AudioFrame` and sample format conversion +- **wavekat-vad** โ€” voice activity detection with multiple backends (WebRTC, Silero, and more) +- **wavekat-turn** โ€” turn detection that knows when a speaker is done talking +- **wavekat-lab** โ€” an interactive dashboard for testing and comparing audio backends + +On top of these libraries, we're building **wavekat-voice** โ€” an AI phone answering system that plugs into standard SIP/RTP infrastructure. It picks up the phone, has a real conversation, and handles the call โ€” so the business owner doesn't have to. + +## Why start with open source? + +We believe the foundational technology โ€” VAD, turn detection, audio processing โ€” should be open, auditable, and free to build on. These building blocks shouldn't be locked behind enterprise contracts. + +## What's next + +We're heads-down building. Follow along on [GitHub](https://github.com/wavekat) or check back here โ€” we'll be writing about the engineering behind real-time voice, the tradeoffs we're making, and what we learn along the way. diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 0d38f8b..1a8b701 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -27,6 +27,7 @@ const ogImageURL = new URL(ogImage, Astro.site); <link rel="icon" type="image/svg+xml" href="/logos/wavekat-icon-light.svg" media="(prefers-color-scheme: light)" /> <link rel="icon" type="image/svg+xml" href="/logos/wavekat-icon-dark.svg" media="(prefers-color-scheme: dark)" /> <title>{title} + diff --git a/src/layouts/Post.astro b/src/layouts/Post.astro new file mode 100644 index 0000000..d1c5e55 --- /dev/null +++ b/src/layouts/Post.astro @@ -0,0 +1,125 @@ +--- +import Base from './Base.astro'; +import { Sun, Moon } from '@lucide/astro'; + +interface Props { + title: string; + description: string; + date: Date; + updated?: Date; + author?: string; + tags?: string[]; + ogImage?: string; +} + +const { title, description, date, updated, author, tags = [], ogImage } = Astro.props; + +const formatDate = (d: Date) => + d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); +--- + + +
+ + +
+ + wavekat + + +
+ + blog + + +
+
+ + + + +

+ {title} +

+ +
+ + {updated && ( + (updated {formatDate(updated)}) + )} + {author && ยท {author}} +
+ + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + +
+ +
+ + + + +
+ + + + diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro new file mode 100644 index 0000000..c589557 --- /dev/null +++ b/src/pages/blog/[slug].astro @@ -0,0 +1,27 @@ +--- +import { getCollection, render } from 'astro:content'; +import Post from '../../layouts/Post.astro'; + +export async function getStaticPaths() { + const posts = await getCollection('blog', ({ data }) => !data.draft); + return posts.map((post) => ({ + params: { slug: post.id }, + props: { post }, + })); +} + +const { post } = Astro.props; +const { Content } = await render(post); +--- + + + + diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro new file mode 100644 index 0000000..8440744 --- /dev/null +++ b/src/pages/blog/index.astro @@ -0,0 +1,78 @@ +--- +import { getCollection } from 'astro:content'; +import Base from '../../layouts/Base.astro'; +import PostCard from '../../components/PostCard.astro'; +import { Sun, Moon } from '@lucide/astro'; + +const posts = (await getCollection('blog', ({ data }) => !data.draft)) + .sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); +--- + + +
+ + +
+ + wavekat + + +
+ + blog + + +
+
+ + +
+

Blog

+

+ Engineering notes, announcements, and deep-dives from the WaveKat team. +

+ + {posts.length === 0 ? ( +

No posts yet. Check back soon.

+ ) : ( +
+ {posts.map((post) => ( + + ))} +
+ )} +
+ + + + +
+ + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 4750247..3b58c08 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,7 +1,7 @@ --- import { execSync } from 'child_process'; import Base from '../layouts/Base.astro'; -import { Mail } from '@lucide/astro'; +import { Mail, Sun, Moon } from '@lucide/astro'; import { version } from '../../package.json'; let siteVersion: string; @@ -46,14 +46,22 @@ const libraries = [
wavekat - +
+ + blog + + +
@@ -144,7 +152,7 @@ const libraries = [ > {siteVersion} - Apache 2.0 + ยฉ {new Date().getFullYear()} WaveKat diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts new file mode 100644 index 0000000..8701b0c --- /dev/null +++ b/src/pages/rss.xml.ts @@ -0,0 +1,20 @@ +import rss from '@astrojs/rss'; +import { getCollection } from 'astro:content'; +import type { APIContext } from 'astro'; + +export async function GET(context: APIContext) { + const posts = (await getCollection('blog', ({ data }) => !data.draft)) + .sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); + + return rss({ + title: 'WaveKat Blog', + description: 'Engineering notes, announcements, and deep-dives from the WaveKat team.', + site: context.site!, + items: posts.map((post) => ({ + title: post.data.title, + description: post.data.description, + pubDate: post.data.date, + link: `/blog/${post.id}`, + })), + }); +} diff --git a/src/styles/global.css b/src/styles/global.css index ad4c013..152d9c6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; /* Class-based dark mode โ€” toggle .dark on */ @variant dark (&:where(.dark, .dark *));