@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.
- package/README.md +15 -0
- package/bin/cli.mjs +28 -2
- package/package.json +1 -1
- package/scripts/verify-profile.sh +46 -0
- package/src/brief/profile-router.mjs +63 -28
- package/src/doctor-privacy.mjs +103 -0
- package/src/doctor.mjs +7 -1
- package/src/mvp-flow.mjs +10 -0
- package/src/private-cmd.mjs +35 -0
- package/src/private.mjs +215 -0
- package/templates/.claude/commands/add-storage.md +31 -1
- package/templates/START_HERE.md +1 -1
- package/templates/private/CLAUDE_PART_20.md +30 -0
- package/templates/private/api-audit-health.ts +9 -0
- package/templates/private/api-blob-proxy.ts +40 -0
- package/templates/private/lib-audit.ts +18 -0
- package/templates/private/lib-rate-limit-kv.ts +21 -0
- package/templates/private/lib-rate-limit-memory.ts +25 -0
- package/templates/private/middleware.ts +60 -0
- package/templates/private/next.config.ts +14 -0
- package/templates/profiles/blog/README.md +45 -40
- package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
- package/templates/profiles/blog/app/page.tsx +30 -0
- package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
- package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
- package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
- package/templates/profiles/blog/content/posts/intro.mdx +14 -0
- package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
- package/templates/profiles/blog/lib/posts.ts +43 -0
- package/templates/profiles/blog/next.config.mjs.additions +9 -0
- package/templates/profiles/blog/package.json.additions +6 -0
- package/templates/profiles/booking/.env.example.additions +3 -11
- package/templates/profiles/booking/README.md +26 -21
- package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
- package/templates/profiles/booking/app/page.tsx +38 -0
- package/templates/profiles/booking/components/BookingForm.tsx +130 -0
- package/templates/profiles/booking/components/Hero.tsx +20 -0
- package/templates/profiles/booking/components/ServiceList.tsx +19 -0
- package/templates/profiles/booking/lib/resend.ts +33 -0
- package/templates/profiles/booking/lib/storage.ts +44 -0
- package/templates/profiles/booking/package.json.additions +5 -0
- package/templates/profiles/booking/section-prompts.md +10 -8
- package/templates/profiles/lead-gen/.env.example.additions +2 -2
- package/templates/profiles/lead-gen/README.md +35 -27
- package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
- package/templates/profiles/lead-gen/app/layout.tsx +29 -0
- package/templates/profiles/lead-gen/app/page.tsx +79 -0
- package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
- package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
- package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
- package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
- package/templates/profiles/lead-gen/lib/resend.ts +33 -0
- package/templates/profiles/lead-gen/package.json.additions +5 -0
- package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
- package/templates/profiles/saas-dashboard/README.md +43 -36
- package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
- package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
- package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
- package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
- package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
- package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
- package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
- package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
- package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
- package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
- package/templates/profiles/saas-dashboard/middleware.ts +19 -0
- 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
|
-
|
|
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
|
package/templates/START_HERE.md
CHANGED
|
@@ -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
|
-
| `
|
|
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,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
|
|
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
|
|
27
|
+
| Homepage | Latest posts sorted desc by date | yes |
|
|
10
28
|
| Post page | Single post by slug, MDX-rendered | yes |
|
|
11
|
-
|
|
|
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
|
-
|
|
|
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
|
|
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
|
-
|
|
40
|
-
author: "
|
|
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
|
-
|
|
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
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
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
|
|
66
|
-
- Individual post page renders MDX
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
|
|
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, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
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.
|