feat(site): 🚀 add Astro marketing/content site

Standalone Astro 6 site in /site (decoupled from the /web app monorepo):
landing page, /blog with one post (Content Layer), and terms/privacy/cookie
legal pages. Pure Astro, server-rendered to static HTML.

- Static by default via @astrojs/vercel adapter; any route can opt into SSR
  with `export const prerender = false` (deploys as a Vercel Function).
- SPA-style navigation with <ClientRouter />, transition:persist on the header
  (no icon flash), and viewport prefetch — ~6 KB gzip JS, no React.
- Deploy on Vercel by setting Root Directory to 'site' (no vercel.json needed).

See site/README.md for dev and deploy instructions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-06-07 08:57:37 +02:00
commit d55a8a0afa
20 changed files with 6154 additions and 0 deletions

16
site/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# build output
dist/
.vercel/
# generated content collection types & cache
.astro/
# dependencies
node_modules/
# environment
.env
.env.production
# macOS
.DS_Store

75
site/README.md Normal file
View file

@ -0,0 +1,75 @@
# site
Public-facing marketing/content site for the KiCad WebAssembly project: landing
page, blog, and legal pages. Built with **Astro 6**.
This is a **standalone** project — it is intentionally decoupled from the `/web`
app monorepo (which is the product itself: React frontend + Fastify backend).
It uses **npm** (not the monorepo's pnpm) and has its own `package-lock.json`.
## What it ships
- Static by default: every page is prerendered to HTML and ships **zero client
JavaScript**. A visitor downloads HTML + CSS only — no React/JS bundle.
- SSR-capable: the Vercel adapter is wired in, so any individual route can be
switched to per-request server rendering without ripping anything out (see
below).
## Routes
| Route | Source |
| --------------------- | --------------------------------------- |
| `/` | `src/pages/index.astro` |
| `/blog` | `src/pages/blog/index.astro` |
| `/blog/<id>` | `src/pages/blog/[slug].astro` |
| `/terms` | `src/pages/terms.astro` |
| `/privacy` | `src/pages/privacy.astro` |
| `/cookies` | `src/pages/cookies.astro` |
Blog posts are Markdown files in `src/content/blog/`, validated by the schema in
`src/content.config.ts`. Add a post by dropping a new `.md` file there with
`title`, `description`, and `pubDate` frontmatter.
## Local development
Requires **Node ≥ 22.12** (Astro 6 requirement).
```bash
cd site
npm install
npm run dev # http://localhost:4321
npm run build # outputs to dist/ (+ .vercel/output for the adapter)
npm run preview # serve the production build locally
```
## SSR per route
Pages are static by default. To render a specific page or endpoint on demand
(per request, as a Vercel Function), add this to its frontmatter:
```astro
---
export const prerender = false;
---
```
That's the only change needed — the `@astrojs/vercel` adapter in
`astro.config.mjs` already provides the server runtime. The rest of the site
stays static.
## Deploying to Vercel
The `@astrojs/vercel` adapter emits the Vercel Build Output API format, so **no
`vercel.json` is required**.
1. Push this repo to GitHub.
2. Vercel dashboard → **New Project** → import this repo.
3. Click **Edit** next to **Root Directory** and set it to **`site`**. This is
the standard way to deploy a project that lives in a subdirectory.
4. Vercel auto-detects the **Astro** framework preset and the package manager
from the lockfile. Leave the build/install commands at their defaults.
5. Deploy. Static pages are served from the CDN; any route with
`prerender = false` is deployed as a Vercel Function automatically.
Note: do **not** use `vercel.json` for URL rewrites with Astro — use Astro's
`redirects` option in `astro.config.mjs` instead.

14
site/astro.config.mjs Normal file
View file

@ -0,0 +1,14 @@
// @ts-check
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
// Static by default (every page prerenders to HTML, zero client JS).
// The Vercel adapter is wired in so any single route can opt into
// per-request SSR later with `export const prerender = false;`.
// See README.md ("SSR per route") for how.
export default defineConfig({
output: 'static',
adapter: vercel(),
// Prefetch linked pages so SPA-style navigation feels instant.
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
});

5526
site/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
site/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "site",
"type": "module",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^6.4.4",
"@astrojs/vercel": "^10.0.8"
}
}

4
site/public/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1d4ed8" />
<path d="M9 23V9h2.6v6.2L17 9h3.2l-5.6 6.4L20.6 23h-3.3l-4.3-5.8-1.4 1.6V23H9z" fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 221 B

2
site/public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

View file

@ -0,0 +1,14 @@
---
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="container">
<span>&copy; {year} KiCad WebAssembly — placeholder.</span>
<nav aria-label="Legal">
<a href="/terms">Terms &amp; Conditions</a>
<a href="/privacy">Privacy Policy</a>
<a href="/cookies">Cookie Policy</a>
</nav>
</div>
</footer>

View file

@ -0,0 +1,18 @@
---
const path = Astro.url.pathname;
const isActive = (href: string) =>
href === '/' ? path === '/' : path.startsWith(href);
---
<header class="site-header" transition:persist>
<div class="container">
<a class="brand" href="/">
<img src="/favicon.svg" alt="" width="24" height="24" />
<span>KiCad in the browser</span>
</a>
<nav class="nav">
<a href="/" aria-current={isActive('/') ? 'page' : undefined}>Home</a>
<a href="/blog" aria-current={isActive('/blog') ? 'page' : undefined}>Blog</a>
</nav>
</div>
</header>

View file

@ -0,0 +1,15 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
// Content Layer API (Astro 5+). Markdown files live in src/content/blog/.
// NOTE: this config file is src/content.config.ts — NOT src/content/config.ts.
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
}),
});
export const collections = { blog };

View file

@ -0,0 +1,19 @@
---
title: "Hello World"
description: "First placeholder post for the KiCad WebAssembly blog."
pubDate: 2026-06-07
---
This is a placeholder blog post. Replace this body with real content.
## A heading
Markdown content is rendered to static HTML at build time, so this page ships
**zero JavaScript** to the browser.
- Bullet one
- Bullet two
- Bullet three
To add another post, drop a new `.md` file in `src/content/blog/` with the same
frontmatter fields (`title`, `description`, `pubDate`).

View file

@ -0,0 +1,40 @@
---
import { ClientRouter } from 'astro:transitions';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import '../styles/global.css';
interface Props {
title: string;
description?: string;
}
const { title, description = 'KiCad in the browser — WebAssembly port.' } =
Astro.props;
const fullTitle =
title === 'KiCad in the browser'
? title
: `${title} — KiCad in the browser`;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="description" content={description} />
<title>{fullTitle}</title>
{/* SPA-style navigation: swaps the page without a full reload. */}
<ClientRouter />
</head>
<body>
<Header />
<main>
<div class="container">
<slot />
</div>
</main>
<Footer />
</body>
</html>

View file

@ -0,0 +1,39 @@
---
import { getCollection, render } from 'astro:content';
import type { GetStaticPaths } from 'astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog');
// post.id is the URL-safe identifier for glob-loaded collections
// (entry.slug was removed in Astro 5).
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}) satisfies GetStaticPaths;
const { post } = Astro.props;
const { Content } = await render(post);
const dateFmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article class="prose">
<p class="muted">
<a href="/blog">&larr; Back to blog</a>
</p>
<h1>{post.data.title}</h1>
<p class="muted">
<time datetime={post.data.pubDate.toISOString()}>
{dateFmt.format(post.data.pubDate)}
</time>
</p>
<Content />
</article>
</BaseLayout>

View file

@ -0,0 +1,35 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const dateFmt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
---
<BaseLayout title="Blog" description="Placeholder blog index.">
<h1>Blog</h1>
<p class="muted">Placeholder blog index. Posts live in src/content/blog/.</p>
<ul class="post-list">
{
posts.map((post) => (
<li class="card">
<time datetime={post.data.pubDate.toISOString()}>
{dateFmt.format(post.data.pubDate)}
</time>
<h3>
<a href={`/blog/${post.id}`}>{post.data.title}</a>
</h3>
<p class="muted">{post.data.description}</p>
</li>
))
}
</ul>
</BaseLayout>

View file

@ -0,0 +1,25 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Cookie Policy" description="Placeholder cookie policy.">
<article class="prose">
<h1>Cookie Policy</h1>
<p class="muted">Last updated: placeholder. This text is a placeholder.</p>
<h2>1. What are cookies</h2>
<p>
Placeholder content. Replace with your actual cookie policy before going
live.
</p>
<h2>2. Cookies we use</h2>
<p>Placeholder content.</p>
<h2>3. Managing cookies</h2>
<p>Placeholder content.</p>
<h2>4. Contact</h2>
<p>Placeholder content.</p>
</article>
</BaseLayout>

View file

@ -0,0 +1,36 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="KiCad in the browser">
<section class="hero">
<h1>KiCad in the browser</h1>
<p class="lead">
Placeholder landing page for the KiCad WebAssembly port — run PCB and
schematic tools in a browser, no installation required. Copy here is a
placeholder and will be replaced.
</p>
<a class="cta" href="/blog">Read the blog</a>
</section>
<section class="grid" aria-label="Highlights">
<div class="card">
<h3>Placeholder feature</h3>
<p class="muted">
Short description of something the project does. Replace with real copy.
</p>
</div>
<div class="card">
<h3>Placeholder feature</h3>
<p class="muted">
Short description of something the project does. Replace with real copy.
</p>
</div>
<div class="card">
<h3>Placeholder feature</h3>
<p class="muted">
Short description of something the project does. Replace with real copy.
</p>
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,28 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Privacy Policy" description="Placeholder privacy policy.">
<article class="prose">
<h1>Privacy Policy</h1>
<p class="muted">Last updated: placeholder. This text is a placeholder.</p>
<h2>1. Information we collect</h2>
<p>
Placeholder content. Replace with your actual privacy policy before going
live.
</p>
<h2>2. How we use information</h2>
<p>Placeholder content.</p>
<h2>3. Data retention</h2>
<p>Placeholder content.</p>
<h2>4. Your rights</h2>
<p>Placeholder content.</p>
<h2>5. Contact</h2>
<p>Placeholder content.</p>
</article>
</BaseLayout>

View file

@ -0,0 +1,25 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Terms & Conditions" description="Placeholder terms and conditions.">
<article class="prose">
<h1>Terms &amp; Conditions</h1>
<p class="muted">Last updated: placeholder. This text is a placeholder.</p>
<h2>1. Introduction</h2>
<p>
Placeholder content. Replace with your actual terms and conditions before
going live.
</p>
<h2>2. Use of the service</h2>
<p>Placeholder content.</p>
<h2>3. Limitation of liability</h2>
<p>Placeholder content.</p>
<h2>4. Contact</h2>
<p>Placeholder content.</p>
</article>
</BaseLayout>

199
site/src/styles/global.css Normal file
View file

@ -0,0 +1,199 @@
:root {
--bg: #0b1020;
--bg-soft: #11182e;
--fg: #e7ecf5;
--fg-muted: #9aa6c0;
--accent: #4f7cff;
--border: #233052;
--max-width: 64rem;
--radius: 10px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
}
* {
box-sizing: border-box;
}
html {
background: var(--bg);
color: var(--fg);
line-height: 1.6;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1,
h2,
h3 {
line-height: 1.2;
margin: 0 0 0.5em;
}
.container {
width: 100%;
max-width: var(--max-width);
margin-inline: auto;
padding-inline: 1.25rem;
}
main {
flex: 1;
padding-block: 3rem;
}
.muted {
color: var(--fg-muted);
}
/* Header */
.site-header {
border-bottom: 1px solid var(--border);
background: var(--bg-soft);
}
.site-header .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-block: 1rem;
}
.brand {
font-weight: 700;
color: var(--fg);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.brand img {
width: 1.5rem;
height: 1.5rem;
}
.nav {
display: flex;
gap: 1.25rem;
}
.nav a {
color: var(--fg-muted);
}
.nav a:hover {
color: var(--fg);
text-decoration: none;
}
/* Hero / landing */
.hero {
padding-block: 2rem 1rem;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3rem);
}
.hero p.lead {
font-size: 1.2rem;
color: var(--fg-muted);
max-width: 42rem;
}
.cta {
display: inline-block;
margin-top: 1.25rem;
padding: 0.7rem 1.25rem;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-weight: 600;
}
.cta:hover {
text-decoration: none;
filter: brightness(1.08);
}
/* Feature grid */
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
margin-top: 2.5rem;
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
background: var(--bg-soft);
}
.card h3 {
margin-bottom: 0.35rem;
}
/* Blog list */
.post-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 1rem;
}
.post-list time {
display: block;
font-size: 0.85rem;
color: var(--fg-muted);
}
/* Prose (legal pages, blog post body) */
.prose {
max-width: 44rem;
}
.prose h2 {
margin-top: 2rem;
}
/* Footer */
.site-footer {
border-top: 1px solid var(--border);
background: var(--bg-soft);
color: var(--fg-muted);
font-size: 0.9rem;
}
.site-footer .container {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
align-items: center;
justify-content: space-between;
padding-block: 1.5rem;
}
.site-footer nav {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}

5
site/tsconfig.json Normal file
View file

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}