@oaklandzoo/ostup 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +15 -0
  2. package/bin/cli.mjs +28 -2
  3. package/package.json +1 -1
  4. package/scripts/verify-profile.sh +46 -0
  5. package/src/brief/profile-router.mjs +63 -28
  6. package/src/doctor-privacy.mjs +103 -0
  7. package/src/doctor.mjs +7 -1
  8. package/src/mvp-flow.mjs +10 -0
  9. package/src/private-cmd.mjs +35 -0
  10. package/src/private.mjs +215 -0
  11. package/templates/.claude/commands/add-storage.md +31 -1
  12. package/templates/START_HERE.md +1 -1
  13. package/templates/private/CLAUDE_PART_20.md +30 -0
  14. package/templates/private/api-audit-health.ts +9 -0
  15. package/templates/private/api-blob-proxy.ts +40 -0
  16. package/templates/private/lib-audit.ts +18 -0
  17. package/templates/private/lib-rate-limit-kv.ts +21 -0
  18. package/templates/private/lib-rate-limit-memory.ts +25 -0
  19. package/templates/private/middleware.ts +60 -0
  20. package/templates/private/next.config.ts +14 -0
  21. package/templates/profiles/blog/README.md +45 -40
  22. package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
  23. package/templates/profiles/blog/app/page.tsx +30 -0
  24. package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
  25. package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
  26. package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
  27. package/templates/profiles/blog/content/posts/intro.mdx +14 -0
  28. package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
  29. package/templates/profiles/blog/lib/posts.ts +43 -0
  30. package/templates/profiles/blog/next.config.mjs.additions +9 -0
  31. package/templates/profiles/blog/package.json.additions +6 -0
  32. package/templates/profiles/booking/.env.example.additions +3 -11
  33. package/templates/profiles/booking/README.md +26 -21
  34. package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
  35. package/templates/profiles/booking/app/page.tsx +38 -0
  36. package/templates/profiles/booking/components/BookingForm.tsx +130 -0
  37. package/templates/profiles/booking/components/Hero.tsx +20 -0
  38. package/templates/profiles/booking/components/ServiceList.tsx +19 -0
  39. package/templates/profiles/booking/lib/resend.ts +33 -0
  40. package/templates/profiles/booking/lib/storage.ts +44 -0
  41. package/templates/profiles/booking/package.json.additions +5 -0
  42. package/templates/profiles/booking/section-prompts.md +10 -8
  43. package/templates/profiles/lead-gen/.env.example.additions +2 -2
  44. package/templates/profiles/lead-gen/README.md +35 -27
  45. package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
  46. package/templates/profiles/lead-gen/app/layout.tsx +29 -0
  47. package/templates/profiles/lead-gen/app/page.tsx +79 -0
  48. package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
  49. package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
  50. package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
  51. package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
  52. package/templates/profiles/lead-gen/lib/resend.ts +33 -0
  53. package/templates/profiles/lead-gen/package.json.additions +5 -0
  54. package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
  55. package/templates/profiles/saas-dashboard/README.md +43 -36
  56. package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
  57. package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
  58. package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
  59. package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
  60. package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
  61. package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
  62. package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
  63. package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
  64. package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
  65. package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
  66. package/templates/profiles/saas-dashboard/middleware.ts +19 -0
  67. package/templates/profiles/saas-dashboard/package.json.additions +9 -0
@@ -62,7 +62,17 @@ Confirm `.env.local` got the new keys. Common ones:
62
62
  npm install @vercel/<type>
63
63
  ```
64
64
 
65
- Create `src/lib/<type>.ts` with typed helpers. Example for blob:
65
+ ### Privacy mode check (before writing blob helpers)
66
+
67
+ Before writing `src/lib/blob.ts`, check whether this project is in privacy mode by looking for `.ostup/private.json` at the project root OR a `# PART 20: PRIVACY DEFAULT-DENY` section in `CLAUDE.md`. If either is present:
68
+
69
+ - Default the helper to `access: 'private'` (not `'public'`).
70
+ - Also scaffold `app/api/blob/[...path]/route.ts` as the auth-checked proxy if it does not already exist (the `--private` applier may have already created it). Do NOT serve blob URLs directly to the client.
71
+ - Skip the `vercel blob create-store --access public` flag from Step 3. Re-create the store without `--access public` if it was already created public.
72
+
73
+ ### Blob helper template
74
+
75
+ Default (no privacy mode):
66
76
 
67
77
  ```typescript
68
78
  // src/lib/blob.ts
@@ -81,6 +91,26 @@ export async function deleteFile(url: string) {
81
91
  }
82
92
  ```
83
93
 
94
+ Privacy mode:
95
+
96
+ ```typescript
97
+ // src/lib/blob.ts
98
+ import { put, list, del } from '@vercel/blob';
99
+
100
+ export async function uploadFile(path: string, body: Blob | ArrayBuffer | string) {
101
+ // access: 'private' is mandatory in privacy mode. Serve via /api/blob/[...path] proxy.
102
+ return put(path, body, { access: 'private' as 'private' });
103
+ }
104
+
105
+ export async function listFiles(prefix?: string) {
106
+ return list({ prefix });
107
+ }
108
+
109
+ export async function deleteFile(url: string) {
110
+ return del(url);
111
+ }
112
+ ```
113
+
84
114
  Match the project's existing TypeScript conventions (named exports, no defaults, etc.).
85
115
 
86
116
  ## Step 6: update AGENTS.md
@@ -41,7 +41,7 @@ That is the whole jump-in. The agent reads, asks, proposes, you approve, it buil
41
41
  | `docs/brief.md` | Operator brief (only if you scaffolded with `ostup brief`). Source of truth for scope, brand, constraints. | Both |
42
42
  | `docs/brief.json` | Machine-readable brief (same trigger). Used by downstream commands. | Agent |
43
43
  | `tasks/prd-initial-build.md` | Auto-seeded first PRD from the brief (same trigger). | Both |
44
- | `templates/profiles/<profile>/` | (if applicable) Profile-specific guidance the agent reads on day one. | Agent |
44
+ | Profile overlay files at the project root (`app/page.tsx`, `app/api/*`, `components/*`, `lib/*`, plus `README.md` + `section-prompts.md`) | (if you ran `ostup brief`) Working code for your profile: hero, day-one flow, API routes. Agent extends from here instead of building from scratch. | Both |
45
45
  | `.claude/commands/` | Slash command definitions. | Agent |
46
46
 
47
47
  ## Slash commands
@@ -0,0 +1,30 @@
1
+
2
+ ---
3
+
4
+ # PART 20: PRIVACY DEFAULT-DENY (--private mode)
5
+
6
+ This project was scaffolded with `--private` (or `ostup private add` was later run on it). Default-deny is mandatory. Treat the rules below as hard rules per Part 3.
7
+
8
+ ## Hard rules
9
+
10
+ 1. **Do NOT widen the middleware matcher.** The `middleware.ts` matcher at the project root is intentionally strict. Adding paths to its exclusion list ungates them across the entire app. Any change MUST be reviewed for whether it accidentally exposes user content.
11
+
12
+ 2. **All `@vercel/blob` helpers default to `access: 'private'`.** Generated `src/lib/blob.ts` and any blob helper code uses `access: 'private'`. To serve a blob to authenticated users, route it through `app/api/blob/[...path]/route.ts` (the auth-checked proxy). Never set `access: 'public'` unless the file is truly safe to expose to the open internet.
13
+
14
+ 3. **Do NOT change `next.config.ts` -> `images.remotePatterns`.** It is intentionally empty (`[]`). Adding blob hosts would let `/_next/image?url=<blob-url>` proxy private blobs around auth.
15
+
16
+ 4. **`/_next/image` is auth-gated.** The matcher deliberately does NOT exclude `/_next/image`. This blocks public abuse of the image optimizer for any URL the matcher does not already allow.
17
+
18
+ 5. **Rate limiting is wired in middleware.** Default: `/api/*` 60 req/min/IP, everything else 600 req/min/IP. Adjust `src/lib/rate-limit.ts` constants. Removing the rate-limit call from middleware requires explicit operator review.
19
+
20
+ 6. **Audit log every blocked request.** Every 401/429 goes through `src/lib/audit.ts` -> `console.log` -> Vercel Logs. Do not silence the audit logger.
21
+
22
+ 7. **The audit-health endpoint at `/api/audit/health` is the only intentionally public route in privacy mode.** It confirms the audit pipeline is wired. Used by `ostup doctor --privacy` to verify the deploy.
23
+
24
+ ## Verification
25
+
26
+ Run `ostup doctor --privacy <deployed-url>` after every deploy. It probes a list of suspicious paths without cookies and asserts they return 401 or 404. Any 200 is a privacy regression.
27
+
28
+ ## To undo
29
+
30
+ If privacy mode is no longer needed, run `ostup private remove` from the project root. The applier captured original file contents in `.ostup/private.json`; remove will restore them.
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ ok: true,
6
+ pipeline: 'wired',
7
+ privacy: '--private',
8
+ });
9
+ }
@@ -0,0 +1,40 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { head } from '@vercel/blob';
3
+ import { audit } from '@/lib/audit';
4
+
5
+ const SESSION_COOKIE_NAMES = ['better-auth.session_token', 'authjs.session-token', 'session_token'];
6
+
7
+ export async function GET(req: Request, { params }: { params: Promise<{ path: string[] }> }) {
8
+ const cookieHeader = req.headers.get('cookie') || '';
9
+ const hasSession = SESSION_COOKIE_NAMES.some((name) => cookieHeader.includes(`${name}=`));
10
+ if (!hasSession) {
11
+ audit({
12
+ ts: new Date().toISOString(),
13
+ path: new URL(req.url).pathname,
14
+ ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown',
15
+ method: 'GET',
16
+ ua: req.headers.get('user-agent') || '',
17
+ reason: 'no_session',
18
+ status: 401,
19
+ });
20
+ return new NextResponse('Unauthorized', { status: 401 });
21
+ }
22
+
23
+ const { path } = await params;
24
+ const blobPath = path.join('/');
25
+ try {
26
+ const blob = await head(blobPath);
27
+ if (!blob) return new NextResponse('Not Found', { status: 404 });
28
+ const upstream = await fetch(blob.url);
29
+ if (!upstream.ok || !upstream.body) return new NextResponse('Not Found', { status: 404 });
30
+ return new NextResponse(upstream.body, {
31
+ headers: {
32
+ 'content-type': blob.contentType || 'application/octet-stream',
33
+ 'cache-control': 'private, no-store',
34
+ 'x-robots-tag': 'noindex, nofollow',
35
+ },
36
+ });
37
+ } catch {
38
+ return new NextResponse('Not Found', { status: 404 });
39
+ }
40
+ }
@@ -0,0 +1,18 @@
1
+ export type AuditEntry = {
2
+ ts: string;
3
+ path: string;
4
+ ip: string;
5
+ method: string;
6
+ ua: string;
7
+ reason: 'no_session' | 'rate_limit' | 'invalid_token';
8
+ status: number;
9
+ };
10
+
11
+ const IGNORE_PATHS: string[] = [
12
+ '/api/audit/health',
13
+ ];
14
+
15
+ export function audit(entry: AuditEntry): void {
16
+ if (IGNORE_PATHS.some((p) => entry.path.startsWith(p))) return;
17
+ console.log(`[audit] ${JSON.stringify(entry)}`);
18
+ }
@@ -0,0 +1,21 @@
1
+ import { kv } from '@vercel/kv';
2
+
3
+ const LIMITS: { test: (path: string) => boolean; perMin: number }[] = [
4
+ { test: (p) => p.startsWith('/api/'), perMin: 60 },
5
+ { test: () => true, perMin: 600 },
6
+ ];
7
+
8
+ export async function rateLimit({ ip, path }: { ip: string; path: string }): Promise<{ ok: boolean; retryAfterSeconds: number }> {
9
+ const limit = LIMITS.find((l) => l.test(path))!;
10
+ const minute = Math.floor(Date.now() / 60_000);
11
+ const key = `rl:${ip}:${minute}:${limit.perMin}`;
12
+ const count = await kv.incr(key);
13
+ if (count === 1) {
14
+ await kv.expire(key, 65);
15
+ }
16
+ if (count > limit.perMin) {
17
+ const retryAfterSeconds = 60 - (Math.floor(Date.now() / 1000) % 60);
18
+ return { ok: false, retryAfterSeconds };
19
+ }
20
+ return { ok: true, retryAfterSeconds: 0 };
21
+ }
@@ -0,0 +1,25 @@
1
+ type Bucket = { count: number; resetAt: number };
2
+
3
+ const buckets = new Map<string, Bucket>();
4
+
5
+ const LIMITS: { test: (path: string) => boolean; perMin: number }[] = [
6
+ { test: (p) => p.startsWith('/api/'), perMin: 60 },
7
+ { test: () => true, perMin: 600 },
8
+ ];
9
+
10
+ export async function rateLimit({ ip, path }: { ip: string; path: string }): Promise<{ ok: boolean; retryAfterSeconds: number }> {
11
+ const limit = LIMITS.find((l) => l.test(path))!;
12
+ const key = `${ip}::${limit.perMin}`;
13
+ const now = Date.now();
14
+ const bucket = buckets.get(key);
15
+
16
+ if (!bucket || bucket.resetAt < now) {
17
+ buckets.set(key, { count: 1, resetAt: now + 60_000 });
18
+ return { ok: true, retryAfterSeconds: 0 };
19
+ }
20
+ if (bucket.count >= limit.perMin) {
21
+ return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
22
+ }
23
+ bucket.count += 1;
24
+ return { ok: true, retryAfterSeconds: 0 };
25
+ }
@@ -0,0 +1,60 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+ import { rateLimit } from '@/lib/rate-limit';
4
+ import { audit } from '@/lib/audit';
5
+
6
+ const SESSION_COOKIE_NAMES = ['better-auth.session_token', 'authjs.session-token', 'session_token'];
7
+
8
+ function getIp(req: NextRequest): string {
9
+ const xff = req.headers.get('x-forwarded-for');
10
+ if (xff) return xff.split(',')[0].trim();
11
+ return req.headers.get('x-real-ip') || 'unknown';
12
+ }
13
+
14
+ export async function middleware(req: NextRequest) {
15
+ const ip = getIp(req);
16
+ const path = req.nextUrl.pathname;
17
+ const method = req.method;
18
+ const ua = req.headers.get('user-agent') || '';
19
+
20
+ const rl = await rateLimit({ ip, path });
21
+ if (!rl.ok) {
22
+ audit({ ts: new Date().toISOString(), path, ip, method, ua, reason: 'rate_limit', status: 429 });
23
+ return new NextResponse('Too Many Requests', {
24
+ status: 429,
25
+ headers: {
26
+ 'retry-after': String(rl.retryAfterSeconds),
27
+ 'x-robots-tag': 'noindex, nofollow',
28
+ },
29
+ });
30
+ }
31
+
32
+ const hasSession = SESSION_COOKIE_NAMES.some((name) => req.cookies.get(name)?.value);
33
+ if (!hasSession) {
34
+ audit({ ts: new Date().toISOString(), path, ip, method, ua, reason: 'no_session', status: 401 });
35
+ if (method === 'GET' && req.headers.get('accept')?.includes('text/html')) {
36
+ const url = req.nextUrl.clone();
37
+ url.pathname = '/sign-in';
38
+ url.searchParams.set('next', path);
39
+ return NextResponse.redirect(url);
40
+ }
41
+ return new NextResponse('Unauthorized', {
42
+ status: 401,
43
+ headers: { 'x-robots-tag': 'noindex, nofollow' },
44
+ });
45
+ }
46
+
47
+ const res = NextResponse.next();
48
+ res.headers.set('x-robots-tag', 'noindex, nofollow');
49
+ return res;
50
+ }
51
+
52
+ export const config = {
53
+ // Default-deny matcher.
54
+ // Bypassed: /login, /signup, /sign-in, /sign-up, /api/auth/*, /api/audit/health,
55
+ // /_next/static/*, /favicon.ico, /robots.txt.
56
+ // NOTE: /_next/image is deliberately NOT excluded. The image optimizer must be
57
+ // auth-gated too. Otherwise /_next/image?url=<blob-url> would proxy private blobs
58
+ // around auth.
59
+ matcher: ['/((?!login|signup|sign-in|sign-up|api/auth|api/audit/health|_next/static|favicon\\.ico|robots\\.txt).*)'],
60
+ };
@@ -0,0 +1,14 @@
1
+ import type { NextConfig } from 'next';
2
+
3
+ const nextConfig: NextConfig = {
4
+ images: {
5
+ // Intentionally empty. The Next.js image optimizer cannot proxy any external
6
+ // host. This is required for privacy mode: if a private blob URL were added
7
+ // here, /_next/image?url=<blob-url> would serve the blob around auth.
8
+ //
9
+ // To serve user uploads with auth, use the /api/blob/[...path] proxy route.
10
+ remotePatterns: [],
11
+ },
12
+ };
13
+
14
+ export default nextConfig;
@@ -1,32 +1,38 @@
1
1
  # Profile: blog
2
2
 
3
- > Content engine. MDX posts, RSS, sitemap, tag/author pages. SEO-first. No CMS v1.
3
+ > Content engine. MDX posts, RSS, sitemap. SEO-first. No CMS v1.
4
+
5
+ ## What this profile ships (v1)
6
+
7
+ The overlay drops a working blog into your new project. After `vercel --prod` you have:
8
+
9
+ - A homepage at `/` listing all posts in `content/posts/`.
10
+ - Individual post pages at `/posts/[slug]` rendering MDX via `next-mdx-remote`.
11
+ - `/feed.xml` (RSS 2.0) and `/sitemap.xml`.
12
+ - Three starter posts in `content/posts/*.mdx` with frontmatter (title, date, summary, author).
13
+ - `Article` + `Organization` JSON-LD schema injected per post page.
14
+
15
+ ## Pro upgrades (not shipped in v1)
16
+
17
+ - Tag index / tag pages.
18
+ - Author pages (multi-author).
19
+ - Newsletter integration.
20
+ - Editorial dashboard.
21
+ - Image optimization for inline post images.
4
22
 
5
23
  ## Day-one scope
6
24
 
7
25
  | Section | Purpose | Required |
8
26
  |---|---|---|
9
- | Homepage | Latest 6-10 posts, intro paragraph | yes |
27
+ | Homepage | Latest posts sorted desc by date | yes |
10
28
  | Post page | Single post by slug, MDX-rendered | yes |
11
- | Tag index | List of all tags with post counts | yes |
12
- | Tag page | All posts under a tag | yes |
13
- | Author page (if multi-author) | Author bio + their posts | conditional |
14
- | RSS feed | `/rss.xml` valid RSS 2.0 | yes |
29
+ | RSS feed | `/feed.xml` valid RSS 2.0 | yes |
15
30
  | Sitemap | `/sitemap.xml` valid sitemap | yes |
16
- | About | Static about page | yes |
17
- | Footer | Links, RSS, year | yes |
18
-
19
- ## Wired infrastructure
20
-
21
- - **MDX** for post bodies. Posts live as `.mdx` files in `content/posts/`.
22
- - Frontmatter schema: `title`, `slug`, `date`, `tags[]`, `author?`, `description?`, `draft?`.
23
- - `next-mdx-remote` or `@next/mdx` (agent picks; both work).
24
- - Open Graph + Twitter card metadata per post.
25
- - Article + Organization JSON-LD schema.
31
+ | Starter content | 3 MDX posts in `content/posts/` | yes |
26
32
 
27
33
  ## Env additions
28
34
 
29
- See `.env.example.additions` (mostly empty for blog v1).
35
+ See `.env.example.additions`. The blog needs `NEXT_PUBLIC_APP_URL` for absolute URLs in RSS + sitemap.
30
36
 
31
37
  ## File contracts
32
38
 
@@ -34,37 +40,36 @@ See `.env.example.additions` (mostly empty for blog v1).
34
40
  content/posts/<slug>.mdx
35
41
  ---
36
42
  title: "Post title"
37
- slug: "post-slug"
38
43
  date: "2026-05-21"
39
- tags: ["agents", "tooling"]
40
- author: "GG"
41
- description: "One-sentence summary for SEO."
44
+ summary: "One-sentence summary for SEO."
45
+ author: "Author name"
42
46
  ---
43
-
44
- Body in MDX.
45
47
 
46
- content/authors/<author>.mdx (optional)
47
- ---
48
- name: "GG"
49
- bio: "One-paragraph bio."
50
- social: { x: "@goodshin", github: "DubsFan" }
51
- ---
48
+ Body in MDX.
52
49
  ```
53
50
 
54
51
  ## Hard rules
55
52
 
56
53
  - Posts ship as committed MDX files. No DB v1.
57
- - `draft: true` excludes from production builds AND from RSS/sitemap.
58
- - RSS / sitemap regenerate on build.
59
- - Code blocks use one syntax highlighter (Shiki recommended). Pick at build time, not client-side.
60
- - Reading width capped at 720px or so for body comfort.
61
- - Light + dark mode via `prefers-color-scheme`.
54
+ - RSS + sitemap regenerate at build time (statically rendered).
55
+ - Reading width capped around 720px for body comfort.
56
+ - Light theme by default. Dark mode is operator-add.
62
57
 
63
58
  ## Acceptance
64
59
 
65
- - Homepage shows latest 6 posts sorted by date desc.
66
- - Individual post page renders MDX with syntax-highlighted code.
67
- - RSS feed validates at https://validator.w3.org/feed/.
68
- - Sitemap includes every published post (no drafts).
69
- - Tag pages reachable from each post's tag list.
70
- - Lighthouse Performance >= 95 (static content, should be fast).
60
+ - Homepage shows all posts sorted by date desc.
61
+ - Individual post page renders MDX.
62
+ - `/feed.xml` validates at https://validator.w3.org/feed/.
63
+ - `/sitemap.xml` includes every post.
64
+ - Lighthouse Performance >= 95 (static content).
65
+
66
+ ## Visual verification
67
+
68
+ After deploy, run:
69
+
70
+ ```bash
71
+ bash scripts/screenshot.sh <your-vercel-url>
72
+ bash scripts/screenshot.sh <your-vercel-url>/posts/welcome
73
+ ```
74
+
75
+ Read the resulting PNGs to confirm the post list and an individual post render as expected.
@@ -0,0 +1,41 @@
1
+ import { getAllPosts } from '@/lib/posts';
2
+
3
+ function escapeXml(s: string): string {
4
+ return s
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&apos;');
10
+ }
11
+
12
+ export async function GET() {
13
+ const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
14
+ const posts = await getAllPosts();
15
+ const items = posts
16
+ .map(
17
+ (p) => `
18
+ <item>
19
+ <title>${escapeXml(p.title)}</title>
20
+ <link>${siteUrl}/posts/${p.slug}</link>
21
+ <guid>${siteUrl}/posts/${p.slug}</guid>
22
+ <pubDate>${new Date(p.date).toUTCString()}</pubDate>
23
+ <description>${escapeXml(p.summary)}</description>
24
+ </item>`,
25
+ )
26
+ .join('');
27
+
28
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
29
+ <rss version="2.0">
30
+ <channel>
31
+ <title>{{DISPLAY_NAME}}</title>
32
+ <link>${siteUrl}</link>
33
+ <description>{{PROJECT_PURPOSE_ONE_SENTENCE}}</description>
34
+ <language>en</language>${items}
35
+ </channel>
36
+ </rss>`;
37
+
38
+ return new Response(xml, {
39
+ headers: { 'content-type': 'application/xml; charset=utf-8' },
40
+ });
41
+ }
@@ -0,0 +1,30 @@
1
+ import Link from 'next/link';
2
+ import { getAllPosts } from '@/lib/posts';
3
+
4
+ export default async function HomePage() {
5
+ const posts = await getAllPosts();
6
+
7
+ return (
8
+ <main className="mx-auto max-w-3xl px-6 py-16">
9
+ <header className="border-b border-slate-200 pb-8">
10
+ <h1 className="text-4xl font-bold tracking-tight">{`{{DISPLAY_NAME}}`}</h1>
11
+ <p className="mt-2 text-lg text-slate-600">{`{{PROJECT_PURPOSE_ONE_SENTENCE}}`}</p>
12
+ </header>
13
+ <ul className="mt-10 space-y-10">
14
+ {posts.map((post) => (
15
+ <li key={post.slug}>
16
+ <article>
17
+ <Link href={`/posts/${post.slug}`}>
18
+ <h2 className="text-2xl font-semibold tracking-tight hover:underline">{post.title}</h2>
19
+ </Link>
20
+ <p className="mt-1 text-sm text-slate-500">
21
+ {new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
22
+ </p>
23
+ <p className="mt-3 text-slate-700">{post.summary}</p>
24
+ </article>
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ </main>
29
+ );
30
+ }
@@ -0,0 +1,51 @@
1
+ import { notFound } from 'next/navigation';
2
+ import { MDXRemote } from 'next-mdx-remote/rsc';
3
+ import { getAllPosts, getPostBySlug } from '@/lib/posts';
4
+
5
+ export async function generateStaticParams() {
6
+ const posts = await getAllPosts();
7
+ return posts.map((p) => ({ slug: p.slug }));
8
+ }
9
+
10
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
11
+ const { slug } = await params;
12
+ const post = await getPostBySlug(slug);
13
+ if (!post) notFound();
14
+
15
+ const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
16
+
17
+ const articleSchema = {
18
+ '@context': 'https://schema.org',
19
+ '@type': 'Article',
20
+ headline: post.title,
21
+ description: post.summary,
22
+ datePublished: post.date,
23
+ author: { '@type': 'Person', name: post.author },
24
+ publisher: {
25
+ '@type': 'Organization',
26
+ name: '{{DISPLAY_NAME}}',
27
+ url: siteUrl,
28
+ },
29
+ };
30
+
31
+ return (
32
+ <main className="mx-auto max-w-3xl px-6 py-16">
33
+ <script
34
+ type="application/ld+json"
35
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
36
+ />
37
+ <article>
38
+ <header className="border-b border-slate-200 pb-6">
39
+ <h1 className="text-4xl font-bold tracking-tight">{post.title}</h1>
40
+ <p className="mt-2 text-sm text-slate-500">
41
+ {new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
42
+ {post.author ? ` - ${post.author}` : ''}
43
+ </p>
44
+ </header>
45
+ <div className="prose prose-slate mt-8 max-w-none">
46
+ <MDXRemote source={post.body} />
47
+ </div>
48
+ </article>
49
+ </main>
50
+ );
51
+ }
@@ -0,0 +1,21 @@
1
+ import { getAllPosts } from '@/lib/posts';
2
+
3
+ export async function GET() {
4
+ const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
5
+ const posts = await getAllPosts();
6
+
7
+ const urls = [
8
+ `<url><loc>${siteUrl}</loc><changefreq>weekly</changefreq></url>`,
9
+ ...posts.map(
10
+ (p) =>
11
+ `<url><loc>${siteUrl}/posts/${p.slug}</loc><lastmod>${new Date(p.date).toISOString()}</lastmod></url>`,
12
+ ),
13
+ ].join('');
14
+
15
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
16
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
17
+
18
+ return new Response(xml, {
19
+ headers: { 'content-type': 'application/xml; charset=utf-8' },
20
+ });
21
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: Getting started, the boring version
3
+ date: 2026-05-21
4
+ summary: A simple checklist anyone can use to start something new without overthinking it.
5
+ author: {{OWNER_OR_CLIENT}}
6
+ ---
7
+
8
+ Most "getting started" guides are aspirational. This one is boring on purpose.
9
+
10
+ 1. Decide one outcome that would count as done.
11
+ 2. Block thirty minutes on the calendar this week.
12
+ 3. Write the smallest thing that moves you toward the outcome.
13
+ 4. Tell one person what you did.
14
+ 5. Repeat next week.
15
+
16
+ That is the whole list. The trick is not finding a better list. The trick is doing the list when you do not feel like it.
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: What this blog covers
3
+ date: 2026-05-22
4
+ summary: The topics I plan to write about and why they matter.
5
+ author: {{OWNER_OR_CLIENT}}
6
+ ---
7
+
8
+ A few things I plan to write about here:
9
+
10
+ - The work behind the work. Not the highlight reel, the actual decisions.
11
+ - Small lessons that compound. The kind of thing you wish someone had told you a year ago.
12
+ - Reading notes, recommendations, and the occasional review.
13
+
14
+ I would rather publish ten honest posts than one perfect one, so expect mistakes. I will correct them in public when I find them.
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: Welcome to {{DISPLAY_NAME}}
3
+ date: 2026-05-23
4
+ summary: A short note on why this blog exists and what to expect.
5
+ author: {{OWNER_OR_CLIENT}}
6
+ ---
7
+
8
+ Welcome. This is the first post on {{DISPLAY_NAME}}.
9
+
10
+ I started this site to share what I learn and to give readers a single place to follow along. New posts will appear here regularly.
11
+
12
+ If you have a question or a suggestion, the contact link in the footer is the best way to reach me.
@@ -0,0 +1,43 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import matter from 'gray-matter';
5
+
6
+ const POSTS_DIR = join(process.cwd(), 'content', 'posts');
7
+
8
+ export type Post = {
9
+ slug: string;
10
+ title: string;
11
+ date: string;
12
+ summary: string;
13
+ author: string;
14
+ body: string;
15
+ };
16
+
17
+ async function readPostFile(filename: string): Promise<Post | null> {
18
+ if (!filename.endsWith('.mdx')) return null;
19
+ const slug = filename.replace(/\.mdx$/, '');
20
+ const raw = await readFile(join(POSTS_DIR, filename), 'utf8');
21
+ const { data, content } = matter(raw);
22
+ return {
23
+ slug,
24
+ title: String(data.title || slug),
25
+ date: String(data.date || new Date().toISOString()),
26
+ summary: String(data.summary || ''),
27
+ author: String(data.author || ''),
28
+ body: content,
29
+ };
30
+ }
31
+
32
+ export async function getAllPosts(): Promise<Post[]> {
33
+ if (!existsSync(POSTS_DIR)) return [];
34
+ const files = await readdir(POSTS_DIR);
35
+ const posts = (await Promise.all(files.map(readPostFile))).filter((p): p is Post => p !== null);
36
+ return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
37
+ }
38
+
39
+ export async function getPostBySlug(slug: string): Promise<Post | null> {
40
+ const filename = `${slug}.mdx`;
41
+ if (!existsSync(join(POSTS_DIR, filename))) return null;
42
+ return readPostFile(filename);
43
+ }
@@ -0,0 +1,9 @@
1
+ // MDX is handled at runtime via next-mdx-remote (see app/posts/[slug]/page.tsx).
2
+ // No next.config.mjs changes are required for the v1 blog overlay.
3
+ //
4
+ // If you later switch to compile-time MDX (@next/mdx) you will need to:
5
+ // 1. npm install @next/mdx @mdx-js/loader @mdx-js/react
6
+ // 2. Wrap your next.config.mjs export with createMDX({})
7
+ // 3. Add "mdx" to pageExtensions
8
+ //
9
+ // This file is shipped as a placeholder so the convention is visible.
@@ -0,0 +1,6 @@
1
+ {
2
+ "dependencies": {
3
+ "next-mdx-remote": "^5.0.0",
4
+ "gray-matter": "^4.0.3"
5
+ }
6
+ }