diff --git a/.agents/blog-writer.md b/.agents/blog-writer.md new file mode 100644 index 0000000000..8bb4e1e1e8 --- /dev/null +++ b/.agents/blog-writer.md @@ -0,0 +1,84 @@ +# Blog writer agent + +You are a scheduled agent that writes one SEO topic-roundup blog post for +agentique — an AI-news curation feed — and opens a PR with it. The post is a +markdown file; the frontend build renders it to a static page at +`/blog//`. + +## What to do, in order + +### 1. Pick a topic +- Candidate topics are the tag slugs in `backend/app/data/tags.json`. +- Look at existing posts in `frontend/content/blog/` (filenames carry date + + slug, frontmatter carries `topic:`). Pick a topic **not covered in the last + 14 days**, preferring the one covered least recently. Rotate — don't write + the same topic twice in a row. + +### 2. Gather articles from the public API +Base URL: `https://agentique.ch/api/v1` (no auth needed). + +- `GET /articles/?tag=&since=&sort=score-desc&limit=50` + — set `since` to the date of the last post on this topic, or 14 days ago + if there is none. +- `GET /articles/search?q=&limit=30` — semantic + search; use phrasings like "what's new in retrieval-augmented generation" + to catch relevant articles the tag filter missed. Ignore results older + than your `since` date. + +Response items have `title`, `url`, `summary`, `score`, `published_at`, +`publisher.name`, `tags`. + +**Dedup rule:** collect every URL listed in the `articles:` frontmatter of +existing posts in `frontend/content/blog/`. Never cover those URLs again. + +**Skip rule:** after dedup, if fewer than **4** on-topic articles remain, +stop — do not open a PR. A thin post is worse than no post. Try one other +topic before giving up entirely. + +### 3. Write the post + +One file: `frontend/content/blog/-.md` (today's date). + +```markdown +--- +title: "..." # ≤70 chars, no site name, no clickbait +description: "..." # meta description, ≤160 chars, plain sentence +slug: # filename must be -.md +topic: # the tag you picked in step 1 +date: # today, never future +articles: # every source you cite — exact URLs from the API + - https://... +--- + +Body in markdown. +``` + +Hard rules — the build **fails** if you break these: +- Every external link in the body must be one of the `articles:` URLs, + copied **exactly** as returned by the API. No other external links, none + invented. Site-relative links (`/`, `/blog/...`) are allowed. +- Body ≥150 words. Title ≤70 chars, description ≤160 chars. +- Filename = `-.md`, slug kebab-case. + +Editorial rules: +- 300-500 words. Story first, list second: open with the through-line of + what happened in this topic (2–3 sentences), then walk the developments in + a logical order, linking each source article inline where it's discussed. + Group related items; don't write one paragraph per article mechanically. +- Write for a technical reader. Plain language, no hype, no filler. Name + concrete things (models, versions, benchmarks, publishers). +- Use `##` subheadings if the post covers 3+ distinct threads. +- English only. No emoji. Don't mention this prompt, the pipeline, or that + you are an AI. + +### 4. Open the PR +- Branch: `blog/`, based on latest `master`. +- Commit message: `content(blog): `. +- One post per PR, nothing else in the diff. +- PR body: topic, article count, one-line rationale for what made this + roundup worth writing. Note any borderline calls (e.g. articles you + excluded and why). + +A human reviews and squash-merges; the merge deploys the post. If CI fails +on your PR, read the `prerender-blog` error output and fix the file — the +messages name the exact violation. diff --git a/bun.lock b/bun.lock index e99402eb55..6274beb379 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@biomejs/biome": "^2.4.16", "@hey-api/openapi-ts": "0.73.0", "@playwright/test": "1.61.1", + "@tailwindcss/typography": "^0.5.20", "@tanstack/router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.18", "@types/node": "^25.9.2", @@ -55,9 +56,11 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.3.1", "dotenv": "^17.4.2", + "marked": "^18.0.6", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "vite": "^8.0.16", + "yaml": "^2.9.0", }, }, }, @@ -370,6 +373,8 @@ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], "@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="], @@ -472,6 +477,8 @@ "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -604,6 +611,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -654,6 +663,8 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "prettier": ["prettier@3.8.0", "", { "bin": "bin/prettier.cjs" }, "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], @@ -734,6 +745,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -742,6 +755,8 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], diff --git a/frontend/content/blog/.gitkeep b/frontend/content/blog/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index ba4d9aad6c..be4f3c2a18 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,7 +4,9 @@ server { location / { root /usr/share/nginx/html; index index.html index.htm; - try_files $uri /index.html =404; + # $uri/index.html serves prerendered blog pages (dist/blog/<slug>/) + # before falling back to the SPA. + try_files $uri $uri/index.html /index.html; } include /etc/nginx/extra-conf.d/*.conf; diff --git a/frontend/package.json b/frontend/package.json index bbb7e72601..fbd483c023 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.build.json && vite build", + "build": "tsc -p tsconfig.build.json && vite build && bun scripts/prerender-blog.ts", "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./", "preview": "vite preview", "generate-client": "openapi-ts", @@ -52,6 +52,7 @@ "@biomejs/biome": "^2.4.16", "@hey-api/openapi-ts": "0.73.0", "@playwright/test": "1.61.1", + "@tailwindcss/typography": "^0.5.20", "@tanstack/router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.18", "@types/node": "^25.9.2", @@ -59,8 +60,10 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.3.1", "dotenv": "^17.4.2", + "marked": "^18.0.6", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", - "vite": "^8.0.16" + "vite": "^8.0.16", + "yaml": "^2.9.0" } } diff --git a/frontend/scripts/prerender-blog.ts b/frontend/scripts/prerender-blog.ts new file mode 100644 index 0000000000..3043a6a7f3 --- /dev/null +++ b/frontend/scripts/prerender-blog.ts @@ -0,0 +1,306 @@ +/** + * Blog SSG: validates content/blog/*.md against the content contract and + * renders static HTML into dist/blog/ (one folder per post + an index). + * + * Runs after `vite build` (see "build" in package.json) so it can link the + * hashed CSS bundle from dist/assets/ — the same stylesheet the SPA ships. + * Any contract violation exits non-zero and fails the build; this is the + * mechanical validation gate for agent-written posts (PR review is the + * editorial one). The contract lives in .agents/blog-writer.md. + * + * Run standalone: bun scripts/prerender-blog.ts (needs an existing dist/). + */ + +import fs from "node:fs" +import path from "node:path" +import { marked } from "marked" +import YAML from "yaml" + +const FRONTEND_ROOT = path.resolve(import.meta.dirname, "..") +const CONTENT_DIR = path.join(FRONTEND_ROOT, "content", "blog") +const DIST_DIR = path.join(FRONTEND_ROOT, "dist") +const SITE_URL = process.env.SITE_URL ?? "https://agentique.ch" +const SITE_NAME = "agentique" + +const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/ +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const MIN_BODY_WORDS = 150 +const MAX_TITLE_CHARS = 70 +const MAX_DESCRIPTION_CHARS = 160 + +type Post = { + file: string + slug: string + title: string + description: string + topic: string + date: string + articles: string[] + bodyMd: string +} + +const errors: string[] = [] + +function fail(file: string, msg: string) { + errors.push(`${file}: ${msg}`) +} + +function parsePost(file: string, raw: string): Post | null { + if (!raw.startsWith("---\n")) { + fail(file, "missing frontmatter (file must start with ---)") + return null + } + const end = raw.indexOf("\n---\n", 4) + if (end === -1) { + fail(file, "unterminated frontmatter") + return null + } + + let fm: Record<string, unknown> + try { + fm = YAML.parse(raw.slice(4, end)) + } catch (e) { + fail(file, `frontmatter is not valid YAML: ${e}`) + return null + } + + const bodyMd = raw.slice(end + 5).trim() + const str = (key: string): string => + typeof fm[key] === "string" ? (fm[key] as string).trim() : "" + + const post: Post = { + file, + slug: str("slug"), + title: str("title"), + description: str("description"), + topic: str("topic"), + date: typeof fm.date === "string" ? fm.date : String(fm.date ?? ""), + articles: Array.isArray(fm.articles) ? fm.articles.map(String) : [], + bodyMd, + } + + validatePost(post) + return post +} + +function validatePost(p: Post) { + const { file } = p + + if (!p.title) fail(file, "missing title") + else if (p.title.length > MAX_TITLE_CHARS) + fail(file, `title is ${p.title.length} chars (max ${MAX_TITLE_CHARS})`) + + if (!p.description) fail(file, "missing description") + else if (p.description.length > MAX_DESCRIPTION_CHARS) + fail( + file, + `description is ${p.description.length} chars (max ${MAX_DESCRIPTION_CHARS})`, + ) + + if (!SLUG_RE.test(p.slug)) fail(file, `invalid slug "${p.slug}"`) + if (!SLUG_RE.test(p.topic)) fail(file, `invalid topic "${p.topic}"`) + + if (!DATE_RE.test(p.date) || Number.isNaN(Date.parse(p.date))) { + fail(file, `invalid date "${p.date}" (expected YYYY-MM-DD)`) + } else { + const today = new Date().toISOString().slice(0, 10) + if (p.date > today) fail(file, `date ${p.date} is in the future`) + const expected = `${p.date}-${p.slug}.md` + if (path.basename(file) !== expected) + fail(file, `filename must be ${expected} (date-slug)`) + } + + if (p.articles.length === 0) fail(file, "articles list is empty") + for (const url of p.articles) { + if (!/^https?:\/\//.test(url)) fail(file, `article is not a URL: ${url}`) + } + if (new Set(p.articles).size !== p.articles.length) + fail(file, "articles list has duplicates") + + const words = p.bodyMd.split(/\s+/).filter(Boolean).length + if (words < MIN_BODY_WORDS) + fail(file, `body is ${words} words (min ${MIN_BODY_WORDS})`) + + // Every external link in the body must be a cited article — the guard + // against hallucinated URLs. Site-relative links (/, /blog/...) are fine. + const cited = new Set(p.articles) + const linkRe = /\]\((https?:\/\/[^)\s]+)\)|<(https?:\/\/[^>\s]+)>/g + for (const m of p.bodyMd.matchAll(linkRe)) { + const url = m[1] ?? m[2] + if (url.startsWith(SITE_URL)) continue + if (!cited.has(url)) fail(file, `body links uncited URL: ${url}`) + } +} + +// ─── HTML rendering ────────────────────────────────────────────────────────── + +// Mirrors the SPA's ThemeProvider (storageKey "vite-ui-theme", default +// "system") so static pages come up in the theme the app last set. +const THEME_SCRIPT = `try{var t=localStorage.getItem("vite-ui-theme");if(t==="dark"||((!t||t==="system")&&matchMedia("(prefers-color-scheme: dark)").matches))document.documentElement.classList.add("dark")}catch(e){}` + +function esc(s: string): string { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) +} + +function shell(opts: { + title: string + description: string + canonicalPath: string + cssHrefs: string[] + ogType: "article" | "website" + publishedDate?: string + body: string +}): string { + const canonical = `${SITE_URL}${opts.canonicalPath}` + const css = opts.cssHrefs + .map((href) => `<link rel="stylesheet" href="${href}">`) + .join("\n ") + const published = opts.publishedDate + ? `\n <meta property="article:published_time" content="${opts.publishedDate}">` + : "" + return `<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>${esc(opts.title)} · ${SITE_NAME} + + + + + + ${published} + + + ${css} + + +
+
+ ${SITE_NAME} + +
+
+
+${opts.body} +
+
+
+ Curated by ${SITE_NAME} — AI news, deduped, scored and tagged daily. +
+
+ + +` +} + +function renderPost(p: Post, cssHrefs: string[]): string { + const bodyHtml = marked.parse(p.bodyMd, { async: false }) + const body = `
+

· ${esc(p.topic)}

+

${esc(p.title)}

+
+${bodyHtml} +
+
+

+ Sourced from ${p.articles.length} curated articles — browse the live feed. +

` + return shell({ + title: p.title, + description: p.description, + canonicalPath: `/blog/${p.slug}/`, + cssHrefs, + ogType: "article", + publishedDate: p.date, + body, + }) +} + +function renderIndex(posts: Post[], cssHrefs: string[]): string { + const items = posts + .map( + (p) => `
  • +

    · ${esc(p.topic)}

    +

    ${esc(p.title)}

    +

    ${esc(p.description)}

    +
  • `, + ) + .join("\n") + const body = `

    Blog

    +

    Topic roundups written from the ${SITE_NAME} feed.

    +
      +${items || '
    • No posts yet.
    • '} +
    ` + return shell({ + title: "Blog", + description: `Topic roundups of curated AI news from the ${SITE_NAME} feed.`, + canonicalPath: "/blog/", + cssHrefs, + ogType: "website", + body, + }) +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +function main() { + const assetsDir = path.join(DIST_DIR, "assets") + if (!fs.existsSync(assetsDir)) { + console.error( + "prerender-blog: dist/assets not found — run `vite build` first", + ) + process.exit(1) + } + const cssHrefs = fs + .readdirSync(assetsDir) + .filter((f) => f.endsWith(".css")) + .map((f) => `/assets/${f}`) + + const files = fs.existsSync(CONTENT_DIR) + ? fs.readdirSync(CONTENT_DIR).filter((f) => f.endsWith(".md")) + : [] + + const posts: Post[] = [] + for (const file of files.sort()) { + const raw = fs.readFileSync(path.join(CONTENT_DIR, file), "utf-8") + const post = parsePost(file, raw) + if (post) posts.push(post) + } + + const seen = new Set() + for (const p of posts) { + if (seen.has(p.slug)) fail(p.file, `duplicate slug "${p.slug}"`) + seen.add(p.slug) + } + + if (errors.length > 0) { + console.error("prerender-blog: content contract violations:") + for (const e of errors) console.error(` - ${e}`) + process.exit(1) + } + + posts.sort((a, b) => b.date.localeCompare(a.date)) + + for (const p of posts) { + const dir = path.join(DIST_DIR, "blog", p.slug) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, "index.html"), renderPost(p, cssHrefs)) + } + fs.mkdirSync(path.join(DIST_DIR, "blog"), { recursive: true }) + fs.writeFileSync( + path.join(DIST_DIR, "blog", "index.html"), + renderIndex(posts, cssHrefs), + ) + + console.log(`prerender-blog: rendered ${posts.length} post(s) + index`) +} + +main() diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index abb14e2131..33094714da 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -18,6 +18,8 @@ const navItems: Item[] = [ { index: "01", title: "Feed", path: "/" }, { index: "02", title: "Newsletter", path: "/newsletter" }, { index: "03", title: "API", path: "/developers" }, + // Prerendered static pages, not SPA routes — full page load by design. + { index: "04", title: "Blog", path: "/blog/", external: true }, ] export function AppSidebar() { diff --git a/frontend/src/components/Sidebar/Main.tsx b/frontend/src/components/Sidebar/Main.tsx index f5900f617b..774ace737f 100644 --- a/frontend/src/components/Sidebar/Main.tsx +++ b/frontend/src/components/Sidebar/Main.tsx @@ -13,12 +13,32 @@ export type Item = { index: string title: string path: string + /** Rendered as a plain — for pages outside the SPA (prerendered blog). */ + external?: boolean } interface MainProps { items: Item[] } +function ItemLabel({ item, isActive }: { item: Item; isActive: boolean }) { + return ( + <> + + {item.index} + + {item.title} + + ) +} + export function Main({ items }: MainProps) { const { isMobile, setOpenMobile } = useSidebar() const router = useRouterState() @@ -42,19 +62,15 @@ export function Main({ items }: MainProps) { isActive={isActive} asChild > - - - {item.index} - - {item.title} - + {item.external ? ( + + + + ) : ( + + + + )} ) diff --git a/frontend/src/index.css b/frontend/src/index.css index b9de5a15dc..05e7c6014c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,9 @@ @import "tailwindcss"; @import "tw-animate-css"; +@plugin "@tailwindcss/typography"; +/* Prerendered blog pages (scripts/prerender-blog.ts) build their markup in + template literals — make sure Tailwind scans them for class names. */ +@source "../scripts/prerender-blog.ts"; @custom-variant dark (&:is(.dark *)); @@ -114,6 +118,26 @@ * { @apply border-border outline-ring/50; } + /* Map typography's prose palette onto the theme vars so blog posts follow + light/dark automatically (no prose-invert needed). */ + .prose { + --tw-prose-body: var(--color-foreground); + --tw-prose-headings: var(--color-foreground); + --tw-prose-lead: var(--color-muted-foreground); + --tw-prose-links: var(--color-foreground); + --tw-prose-bold: var(--color-foreground); + --tw-prose-counters: var(--color-muted-foreground); + --tw-prose-bullets: var(--color-muted-foreground); + --tw-prose-hr: var(--color-border); + --tw-prose-quotes: var(--color-muted-foreground); + --tw-prose-quote-borders: var(--color-border); + --tw-prose-captions: var(--color-muted-foreground); + --tw-prose-code: var(--color-foreground); + --tw-prose-pre-code: var(--color-secondary-foreground); + --tw-prose-pre-bg: var(--color-secondary); + --tw-prose-th-borders: var(--color-border); + --tw-prose-td-borders: var(--color-border); + } body { @apply bg-background text-foreground; }