Skip to content

Commit c214e98

Browse files
feat(use-cases): clickable cards + per-case detail pages (#14)
Every use case on /use-cases is now a link to /use-cases/<slug> with an auto-generated detail page. 104 cases, 104 pre-rendered HTML files, no hand-authored content required. Each detail page renders: - Title + category + scenario (from frontmatter) - "How to set it up" — one step per service in the case, with the exact curl call and a description of what that call gets you - A final "wire it up" step pointing to the /claim flow - "Why this is useful" — generic value prop bullets (zero ceremony, anonymous-first, real infrastructure not a sandbox, designed for agents) - "Detail" — renders the case's optional body if present in the .md frontmatter file. Empty for all 104 cases today; will fill in via content-repo PRs over time. - Footer with the primary curl + links to /use-cases, /docs, and openapi.json Content shape upstream: use-cases.json (single file) → use-cases/<slug>.md (one per) The dashboard's useCases.ts loader switched accordingly, with the slug derived from filename (kebab-case of title, & → and). All 104 slugs unique. Total static HTML files: 11 → 115 (104 new detail pages + 11 existing pages). Cards on /use-cases use react-router Link so client-side navigation is instant after the first page load; SSG output is fully indexable. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dcfdf2c commit c214e98

5 files changed

Lines changed: 412 additions & 35 deletions

File tree

scripts/prerender.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@ async function loadRoutes() {
5151
// content repo automatically expands the prerender route list — no
5252
// change needed here.
5353
const blogDir = resolve(ROOT, '.content/blog')
54-
const slugs = existsSync(blogDir)
54+
const blogSlugs = existsSync(blogDir)
5555
? readdirSync(blogDir).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''))
5656
: []
57+
58+
const useCaseDir = resolve(ROOT, '.content/use-cases')
59+
const useCaseSlugs = existsSync(useCaseDir)
60+
? readdirSync(useCaseDir).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''))
61+
: []
62+
5763
return [
5864
'/',
5965
'/pricing',
@@ -62,7 +68,8 @@ async function loadRoutes() {
6268
'/docs',
6369
'/blog',
6470
'/use-cases',
65-
...slugs.map((s) => `/blog/${s}`),
71+
...blogSlugs.map((s) => `/blog/${s}`),
72+
...useCaseSlugs.map((s) => `/use-cases/${s}`),
6673
]
6774
}
6875

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { BlogPage } from './pages/BlogPage'
1010
import { BlogPostPage } from './pages/BlogPostPage'
1111
import { DocsPage } from './pages/DocsPage'
1212
import { UseCasesPage } from './pages/UseCasesPage'
13+
import { UseCaseDetailPage } from './pages/UseCaseDetailPage'
1314

1415
// Auth surfaces
1516
import { LoginPage } from './pages/LoginPage'
@@ -61,6 +62,7 @@ export function AppRoutes() {
6162
<Route path="/blog/:slug" element={<BlogPostPage />} />
6263
<Route path="/docs" element={<DocsPage />} />
6364
<Route path="/use-cases" element={<UseCasesPage />} />
65+
<Route path="/use-cases/:slug" element={<UseCaseDetailPage />} />
6466

6567
{/* ─── auth surfaces (no chrome, dedicated layout) ───────── */}
6668
<Route path="/login" element={<LoginPage />} />

src/content/useCases.ts

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,93 @@
11
/* useCases.ts — loader for the /use-cases catalogue.
22
*
3-
* Source of truth: InstaNode-dev/content/use-cases.json, fetched
4-
* into .content/ at build time by scripts/fetch-content.mjs.
3+
* Source of truth: InstaNode-dev/content/use-cases/<slug>.md, cloned
4+
* into .content/ at build time. Each file is a markdown document with
5+
* YAML frontmatter:
56
*
6-
* Vite handles JSON imports natively (no plugin, no loader); the
7-
* import.meta.glob form here keeps the load-time pattern identical
8-
* to posts.ts so a future move to many files (one per case) is a
9-
* one-line change. Adding a use case = one entry in the JSON file
10-
* in the content repo. */
7+
* ---
8+
* title: ...
9+
* category: ...
10+
* services: ["pg", "redis"] # JSON-array syntax for arrays
11+
* scenario: ...
12+
* ---
13+
*
14+
* (optional body: hand-authored "How to do it" / "Why this is useful")
15+
*
16+
* The slug is the filename without `.md`. Adding a use case = one
17+
* markdown file in the content repo; no dashboard PR.
18+
*
19+
* Service is a closed union (platform-defined). Category is a plain
20+
* string (content-defined). See PR #13 for the category type history. */
1121

1222
export type Service = 'pg' | 'redis' | 'mongo' | 'nats' | 'minio' | 'webhook' | 'deploy'
13-
14-
/* Category is intentionally a plain string. The content repo is the source
15-
* of truth for what categories exist, so the dashboard should accept
16-
* anything that lands there. The UseCasesPage renders categories in
17-
* alphabetical order and uses them as filter-chip labels — no code path
18-
* needs a closed union. Adding a new category = one entry in
19-
* use-cases.json; no dashboard PR. */
2023
export type Category = string
2124

2225
export type UseCase = {
26+
slug: string
2327
title: string
2428
category: Category
2529
scenario: string
2630
services: Service[]
31+
body: string // "" if no hand-authored detail content yet
2732
}
2833

29-
/* Vite inlines the JSON at build time. The glob form returns a map of
30-
* { path: parsedJson }; we have exactly one file, so flatten. */
31-
const RAW = import.meta.glob('../../.content/use-cases.json', {
34+
const RAW = import.meta.glob('../../.content/use-cases/*.md', {
3235
eager: true,
36+
query: '?raw',
3337
import: 'default',
34-
}) as Record<string, unknown[]>
38+
}) as Record<string, string>
39+
40+
export const USE_CASES: UseCase[] = Object.entries(RAW)
41+
.map(([path, src]) => buildCase(path, src))
42+
.filter((c): c is UseCase => c !== null)
43+
.sort((a, b) => a.title.localeCompare(b.title))
44+
45+
export function getUseCaseBySlug(slug: string): UseCase | undefined {
46+
return USE_CASES.find((c) => c.slug === slug)
47+
}
3548

36-
const ENTRIES = (Object.values(RAW)[0] ?? []) as Array<UseCase | { _comment: string }>
49+
function buildCase(path: string, src: string): UseCase | null {
50+
const filename = path.split('/').pop()
51+
if (!filename) return null
52+
const slug = filename.replace(/\.md$/, '')
3753

38-
export const USE_CASES: UseCase[] = ENTRIES.filter(
39-
(e): e is UseCase => !('_comment' in e) && typeof (e as UseCase).title === 'string',
40-
)
54+
const { meta, body } = parseFrontmatter(src)
55+
if (!meta.title || !meta.category || !meta.scenario) return null
56+
57+
return {
58+
slug,
59+
title: meta.title,
60+
category: meta.category,
61+
scenario: meta.scenario,
62+
services: parseServices(meta.services),
63+
body: body.trim(),
64+
}
65+
}
66+
67+
/* parseServices — accepts JSON-array syntax in frontmatter, e.g.
68+
* services: ["pg", "redis"]
69+
* Anything that doesn't parse as an array of strings becomes []. */
70+
function parseServices(raw: string | undefined): Service[] {
71+
if (!raw) return []
72+
try {
73+
const parsed = JSON.parse(raw) as unknown
74+
if (!Array.isArray(parsed)) return []
75+
return parsed.filter((s): s is Service => typeof s === 'string') as Service[]
76+
} catch {
77+
return []
78+
}
79+
}
80+
81+
function parseFrontmatter(src: string): { meta: Record<string, string>; body: string } {
82+
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
83+
if (!m) return { meta: {}, body: src }
84+
const meta: Record<string, string> = {}
85+
for (const line of m[1].split(/\r?\n/)) {
86+
const sep = line.indexOf(':')
87+
if (sep < 0) continue
88+
const key = line.slice(0, sep).trim()
89+
const value = line.slice(sep + 1).trim()
90+
if (key) meta[key] = value
91+
}
92+
return { meta, body: m[2] }
93+
}

0 commit comments

Comments
 (0)