@oaklandzoo/ostup 0.9.0 → 0.10.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 (53) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/scripts/verify-profile.sh +46 -0
  4. package/src/bootstrap.mjs +146 -0
  5. package/src/brief/profile-router.mjs +63 -28
  6. package/templates/START_HERE.md +1 -1
  7. package/templates/profiles/blog/README.md +45 -40
  8. package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
  9. package/templates/profiles/blog/app/page.tsx +30 -0
  10. package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
  11. package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
  12. package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
  13. package/templates/profiles/blog/content/posts/intro.mdx +14 -0
  14. package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
  15. package/templates/profiles/blog/lib/posts.ts +43 -0
  16. package/templates/profiles/blog/next.config.mjs.additions +9 -0
  17. package/templates/profiles/blog/package.json.additions +6 -0
  18. package/templates/profiles/booking/.env.example.additions +3 -11
  19. package/templates/profiles/booking/README.md +26 -21
  20. package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
  21. package/templates/profiles/booking/app/page.tsx +38 -0
  22. package/templates/profiles/booking/components/BookingForm.tsx +130 -0
  23. package/templates/profiles/booking/components/Hero.tsx +20 -0
  24. package/templates/profiles/booking/components/ServiceList.tsx +19 -0
  25. package/templates/profiles/booking/lib/resend.ts +33 -0
  26. package/templates/profiles/booking/lib/storage.ts +44 -0
  27. package/templates/profiles/booking/package.json.additions +5 -0
  28. package/templates/profiles/booking/section-prompts.md +10 -8
  29. package/templates/profiles/lead-gen/.env.example.additions +2 -2
  30. package/templates/profiles/lead-gen/README.md +35 -27
  31. package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
  32. package/templates/profiles/lead-gen/app/layout.tsx +29 -0
  33. package/templates/profiles/lead-gen/app/page.tsx +79 -0
  34. package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
  35. package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
  36. package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
  37. package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
  38. package/templates/profiles/lead-gen/lib/resend.ts +33 -0
  39. package/templates/profiles/lead-gen/package.json.additions +5 -0
  40. package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
  41. package/templates/profiles/saas-dashboard/README.md +43 -36
  42. package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
  43. package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
  44. package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
  45. package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
  46. package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
  47. package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
  48. package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
  49. package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
  50. package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
  51. package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
  52. package/templates/profiles/saas-dashboard/middleware.ts +19 -0
  53. package/templates/profiles/saas-dashboard/package.json.additions +9 -0
package/README.md CHANGED
@@ -17,6 +17,13 @@ When you run this tool, it will:
17
17
  6. Create an `inputs/` folder inside the project where you can drop any
18
18
  prior materials (research, reference repos, screenshots, brand
19
19
  assets) you want the agent to have on hand
20
+ 7. If you ran `ostup brief` first (recommended), drop in **working code
21
+ for your profile**. The four industry profiles ship real Next.js
22
+ overlays: lead-gen (hero + services + contact form + Resend wiring),
23
+ booking (booking form + dates + email confirmation), saas-dashboard
24
+ (Better Auth + guarded dashboard + settings), blog (MDX posts + RSS
25
+ + sitemap). Your first deploy shows a working homepage and day-one
26
+ flow, not a blank Next.js welcome.
20
27
 
21
28
  ## Quick Start
22
29
 
@@ -199,6 +206,7 @@ you do not have to repeat them every session.
199
206
  | Beginner installer failed | The bash and PowerShell installers log to `~/.ostup/logs/install-*.log`. The ostup CLI logs to `~/.ostup/logs/init-*.log`. Open the most recent log for the failing command's stderr. |
200
207
  | "BOOTSTRAP_FAILED: Homebrew missing" | You ran `npx ostup init` on a Mac without Homebrew. Run the Mac beginner installer at the top of this README instead. |
201
208
  | "BOOTSTRAP_FAILED: WinGet missing" | Update App Installer from the Microsoft Store: `ms-windows-store://pdp/?productid=9NBLGGH4NNS1`. |
209
+ | "/opt/homebrew/Cellar is not writable" | You are on a shared Mac where Homebrew was installed by another user. 0.9.1+ handles this automatically by installing `gh` to `~/.local/bin/` and routing `vercel` through `~/.npm-global/`. If you saw this error, you are on 0.9.0; upgrade with `npx @oaklandzoo/ostup@latest`. |
202
210
 
203
211
  ## Advanced: API tokens instead of interactive login
204
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Scaffolds a new repo with the Ostup Agent Kit pre-installed: slash commands, doc templates, and a clean working state.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # verify-profile.sh: apply a profile overlay to a fresh temp dir and assert key files exist.
3
+ #
4
+ # Usage: bash scripts/verify-profile.sh <profile-name>
5
+ # profile-name: one of lead-gen, booking, saas-dashboard, blog
6
+ #
7
+ # This script does NOT run `next build` (that requires installing many deps and a
8
+ # full Next.js scaffold). It verifies that the overlay APPLIES correctly and the
9
+ # expected files land in the target directory. Full next-build verification is
10
+ # the operator's job after `ostup init --brief`.
11
+
12
+ set -euo pipefail
13
+
14
+ PROFILE="${1:-}"
15
+ if [[ -z "$PROFILE" ]]; then
16
+ echo "usage: $0 <profile-name>" >&2
17
+ echo " profile-name: lead-gen | booking | saas-dashboard | blog" >&2
18
+ exit 2
19
+ fi
20
+
21
+ case "$PROFILE" in
22
+ lead-gen|booking|saas-dashboard|blog)
23
+ ;;
24
+ *)
25
+ echo "error: unknown profile '$PROFILE'. expected one of: lead-gen, booking, saas-dashboard, blog" >&2
26
+ exit 2
27
+ ;;
28
+ esac
29
+
30
+ OSTUP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
31
+ TEST_FILE="$OSTUP_ROOT/test/profile-$(echo "$PROFILE" | tr -d -)-overlay.test.mjs"
32
+
33
+ # Map profile names to test file names: lead-gen -> profile-leadgen-overlay.test.mjs
34
+ case "$PROFILE" in
35
+ lead-gen) TEST_FILE="$OSTUP_ROOT/test/profile-leadgen-overlay.test.mjs" ;;
36
+ booking) TEST_FILE="$OSTUP_ROOT/test/profile-booking-overlay.test.mjs" ;;
37
+ saas-dashboard) TEST_FILE="$OSTUP_ROOT/test/profile-saas-overlay.test.mjs" ;;
38
+ blog) TEST_FILE="$OSTUP_ROOT/test/profile-blog-overlay.test.mjs" ;;
39
+ esac
40
+
41
+ echo ">> verifying profile: $PROFILE"
42
+ echo ">> running: node --test $TEST_FILE"
43
+ cd "$OSTUP_ROOT"
44
+ node --test "$TEST_FILE"
45
+
46
+ echo ">> ok: $PROFILE overlay applies and asserts pass"
package/src/bootstrap.mjs CHANGED
@@ -6,6 +6,9 @@
6
6
  import * as p from '@clack/prompts';
7
7
  import { execSync } from 'node:child_process';
8
8
  import { homedir } from 'node:os';
9
+ import { existsSync, constants as fsConstants } from 'node:fs';
10
+ import { access, readFile, writeFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
9
12
  import { detectAll, installCommandFor } from './tool-registry.mjs';
10
13
  import { run as exec } from './exec.mjs';
11
14
 
@@ -55,6 +58,114 @@ function patchPathFor(plan) {
55
58
  process.env.PATH = toAdd.join(sep) + sep + (process.env.PATH || '');
56
59
  }
57
60
 
61
+ // --- darwin multi-user helpers ----------------------------------------------
62
+ // On a Mac where Homebrew was installed by a different user (shared / refurbished /
63
+ // family Mac), the current user can READ /opt/homebrew/ (so detection passes) but
64
+ // cannot WRITE to it (so `brew install gh` fails). Same applies to npm's global
65
+ // prefix which lives under brew. These helpers detect that state and switch to
66
+ // user-local install strategies that don't need sudo or admin handoff.
67
+
68
+ async function isPathWritable(path) {
69
+ try {
70
+ await access(path, fsConstants.W_OK);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ async function checkBrewWritable() {
78
+ for (const cellar of ['/opt/homebrew/Cellar', '/usr/local/Cellar']) {
79
+ if (existsSync(cellar)) {
80
+ return { exists: true, writable: await isPathWritable(cellar), cellar };
81
+ }
82
+ }
83
+ return { exists: false, writable: false, cellar: null };
84
+ }
85
+
86
+ async function checkNpmGlobalWritable() {
87
+ let prefix = null;
88
+ try {
89
+ prefix = execSync('npm prefix -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
90
+ } catch {
91
+ return { prefix: null, writable: false };
92
+ }
93
+ if (!prefix) return { prefix: null, writable: false };
94
+ return { prefix, writable: await isPathWritable(prefix) };
95
+ }
96
+
97
+ async function appendToZshrcIfMissing(line) {
98
+ const zshrc = join(homedir(), '.zshrc');
99
+ try {
100
+ const body = existsSync(zshrc) ? await readFile(zshrc, 'utf8') : '';
101
+ if (body.includes(line)) return false;
102
+ const sep = body.length === 0 || body.endsWith('\n') ? '' : '\n';
103
+ await writeFile(zshrc, body + sep + line + '\n');
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function prependToProcessPath(dir) {
111
+ const sep = process.platform === 'win32' ? ';' : ':';
112
+ const parts = (process.env.PATH || '').split(sep);
113
+ if (parts.includes(dir)) return;
114
+ process.env.PATH = dir + sep + (process.env.PATH || '');
115
+ }
116
+
117
+ async function fetchLatestGhVersion() {
118
+ try {
119
+ const out = execSync(
120
+ 'curl -fsSL https://api.github.com/repos/cli/cli/releases/latest',
121
+ { stdio: ['ignore', 'pipe', 'ignore'] }
122
+ ).toString();
123
+ const m = out.match(/"tag_name"\s*:\s*"v([0-9]+\.[0-9]+\.[0-9]+)"/);
124
+ return m ? m[1] : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ async function installGhBinaryDirect() {
131
+ // Multi-user-Mac fallback: download gh from its GitHub release tarball into
132
+ // ~/.local/bin/gh. No brew, no sudo, no admin handoff. Patches PATH for the
133
+ // current process and appends an export line to ~/.zshrc for future shells.
134
+ const home = homedir();
135
+ const binDir = join(home, '.local', 'bin');
136
+ const arch = process.arch === 'arm64' ? 'macOS_arm64' : 'macOS_amd64';
137
+ const version = (await fetchLatestGhVersion()) || '2.82.1';
138
+ const url = `https://github.com/cli/cli/releases/download/v${version}/gh_${version}_${arch}.tar.gz`;
139
+
140
+ const installScript = [
141
+ 'set -euo pipefail',
142
+ `mkdir -p "${binDir}"`,
143
+ 'tmp=$(mktemp -d)',
144
+ 'cd "$tmp"',
145
+ `curl -fL -o gh.tar.gz "${url}"`,
146
+ 'tar -xzf gh.tar.gz',
147
+ `cp gh_${version}_${arch}/bin/gh "${binDir}/gh"`,
148
+ `chmod +x "${binDir}/gh"`,
149
+ 'rm -rf "$tmp"',
150
+ ].join('\n');
151
+
152
+ await exec('bootstrap-gh-direct', 'bash', ['-c', installScript], { stdio: 'inherit' });
153
+ prependToProcessPath(binDir);
154
+ await appendToZshrcIfMissing(`export PATH="${binDir}:$PATH"`);
155
+ }
156
+
157
+ async function setupUserNpmPrefix() {
158
+ // Multi-user-Mac fallback: route `npm install -g` into ~/.npm-global so the
159
+ // current user owns the directory and doesn't need write access to brew's
160
+ // npm prefix.
161
+ const home = homedir();
162
+ const prefix = join(home, '.npm-global');
163
+ const binDir = join(prefix, 'bin');
164
+ await exec('bootstrap-npm-prefix', 'npm', ['config', 'set', 'prefix', prefix], { stdio: 'inherit' });
165
+ prependToProcessPath(binDir);
166
+ await appendToZshrcIfMissing(`export PATH="${binDir}:$PATH"`);
167
+ }
168
+
58
169
  function checkInstallPrerequisites(missing, hasOnPath, platform) {
59
170
  const reasons = [];
60
171
  for (const m of missing) {
@@ -167,10 +278,45 @@ export async function runBootstrap({
167
278
  }
168
279
  }
169
280
 
281
+ // On darwin: detect multi-user-Mac scenario (brew owned by another user) and
282
+ // switch to user-local install strategies before running the loop. Skip when
283
+ // running under an injected hasOnPath (tests use platform-agnostic flows).
284
+ const useUserGhFallback = new Set();
285
+ let needsUserNpmPrefix = false;
286
+ if (platform === 'darwin' && hasOnPath === defaultHasOnPath) {
287
+ const brewMissing = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'brew');
288
+ const npmgMissing = missing.filter((m) => installCommandFor(m.tool, platform).kind === 'npm-g');
289
+ if (brewMissing.length > 0) {
290
+ const brewState = await checkBrewWritable();
291
+ if (brewState.exists && !brewState.writable) {
292
+ process.stdout.write('\n[bootstrap] Homebrew is installed but owned by another user.\n');
293
+ process.stdout.write('[bootstrap] Switching to user-local install for brew-managed tools (no sudo, no admin handoff).\n\n');
294
+ for (const m of brewMissing) {
295
+ if (m.tool.id === 'gh') useUserGhFallback.add(m.tool.id);
296
+ }
297
+ }
298
+ }
299
+ if (npmgMissing.length > 0) {
300
+ const npmState = await checkNpmGlobalWritable();
301
+ if (!npmState.writable) {
302
+ process.stdout.write('[bootstrap] npm global prefix is not writable by this user. Setting up ~/.npm-global as a per-user prefix.\n\n');
303
+ needsUserNpmPrefix = true;
304
+ }
305
+ }
306
+ }
307
+ if (needsUserNpmPrefix) {
308
+ await setupUserNpmPrefix();
309
+ }
310
+
170
311
  // Run installs sequentially. stdio: 'inherit' so sudo/UAC prompts, progress bars, and
171
312
  // any interactive winget/brew confirmations reach the user's terminal.
172
313
  const installed = [];
173
314
  for (const m of missing) {
315
+ if (useUserGhFallback.has(m.tool.id)) {
316
+ await installGhBinaryDirect();
317
+ installed.push(m.tool.id);
318
+ continue;
319
+ }
174
320
  const plan = installCommandFor(m.tool, platform);
175
321
  await exec(`bootstrap-${m.tool.id}`, plan.cmd, plan.args || [], {
176
322
  stdio: 'inherit',
@@ -9,18 +9,11 @@ import { substitute } from '../substitute.mjs';
9
9
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
10
10
  const TEMPLATES_ROOT = resolve(PKG_ROOT, 'templates');
11
11
 
12
- /**
13
- * Returns the absolute path to the profile's template overlay folder.
14
- * Returns null if the profile has no overlay (yet).
15
- */
16
12
  export function profileTemplatePath(profile) {
17
13
  const p = resolve(TEMPLATES_ROOT, 'profiles', profile);
18
14
  return existsSync(p) ? p : null;
19
15
  }
20
16
 
21
- /**
22
- * Recursively list every file in a directory, returning relative paths.
23
- */
24
17
  async function listFiles(dir, base = dir, out = []) {
25
18
  const entries = await readdir(dir, { withFileTypes: true });
26
19
  for (const entry of entries) {
@@ -34,19 +27,51 @@ async function listFiles(dir, base = dir, out = []) {
34
27
  return out;
35
28
  }
36
29
 
37
- /**
38
- * Apply a profile overlay to the target directory.
39
- * - Reads every file in templates/profiles/<profile>/
40
- * - Substitutes tokens
41
- * - Writes to <targetDir>/<relative-path>
42
- *
43
- * Skip rules:
44
- * - .DS_Store
45
- * - any path containing /node_modules/
46
- *
47
- * Files with name suffix `.additions` are appended to the existing file at the
48
- * destination (used for .env.example.additions). All others are written.
49
- */
30
+ function mergeJsonObjects(target, source) {
31
+ const out = { ...target };
32
+ for (const key of Object.keys(source)) {
33
+ const sv = source[key];
34
+ const tv = out[key];
35
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
36
+ out[key] = { ...tv, ...sv };
37
+ } else {
38
+ out[key] = sv;
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export async function mergePackageJsonAdditions(destPath, additionsText) {
45
+ let additions;
46
+ try {
47
+ additions = JSON.parse(additionsText);
48
+ } catch (err) {
49
+ throw new Error(`Invalid JSON in package.json.additions: ${err.message}`);
50
+ }
51
+ let existing = {};
52
+ if (existsSync(destPath)) {
53
+ const raw = await readFile(destPath, 'utf8');
54
+ try {
55
+ existing = JSON.parse(raw);
56
+ } catch (err) {
57
+ throw new Error(`Invalid JSON in target package.json at ${destPath}: ${err.message}`);
58
+ }
59
+ }
60
+ const merged = mergeJsonObjects(existing, additions);
61
+ await writeFile(destPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
62
+ }
63
+
64
+ export async function mergeNextConfigAdditions(destPath, additionsText) {
65
+ const marker = '--- added by ostup profile overlay ---';
66
+ if (existsSync(destPath)) {
67
+ const existing = await readFile(destPath, 'utf8');
68
+ if (existing.includes(marker)) return;
69
+ await writeFile(destPath, existing + `\n// ${marker}\n` + additionsText, 'utf8');
70
+ } else {
71
+ await writeFile(destPath, `// next.config.mjs (created by ostup profile overlay)\n` + additionsText, 'utf8');
72
+ }
73
+ }
74
+
50
75
  export async function applyProfileOverlay({ targetDir, profile, tokens, dryRun = false } = {}) {
51
76
  const overlayPath = profileTemplatePath(profile);
52
77
  if (!overlayPath) {
@@ -59,12 +84,19 @@ export async function applyProfileOverlay({ targetDir, profile, tokens, dryRun =
59
84
  const actions = [];
60
85
  for (const rel of filtered) {
61
86
  const src = join(overlayPath, rel);
62
- const isAddition = rel.endsWith('.additions');
63
- const dest = isAddition
64
- ? join(targetDir, rel.replace(/\.additions$/, ''))
65
- : join(targetDir, rel);
66
-
67
- actions.push({ rel, src, dest, isAddition });
87
+ let kind = 'copy';
88
+ let dest = join(targetDir, rel);
89
+ if (rel.endsWith('package.json.additions')) {
90
+ kind = 'package-merge';
91
+ dest = join(targetDir, rel.replace(/\.additions$/, ''));
92
+ } else if (rel.endsWith('next.config.mjs.additions')) {
93
+ kind = 'next-config-merge';
94
+ dest = join(targetDir, rel.replace(/\.additions$/, ''));
95
+ } else if (rel.endsWith('.additions')) {
96
+ kind = 'append';
97
+ dest = join(targetDir, rel.replace(/\.additions$/, ''));
98
+ }
99
+ actions.push({ rel, src, dest, kind });
68
100
  }
69
101
 
70
102
  if (dryRun) {
@@ -75,9 +107,12 @@ export async function applyProfileOverlay({ targetDir, profile, tokens, dryRun =
75
107
  const raw = await readFile(a.src, 'utf8');
76
108
  const out = substitute(raw, tokens);
77
109
  await mkdir(dirname(a.dest), { recursive: true });
78
- if (a.isAddition && existsSync(a.dest)) {
110
+ if (a.kind === 'package-merge') {
111
+ await mergePackageJsonAdditions(a.dest, out);
112
+ } else if (a.kind === 'next-config-merge') {
113
+ await mergeNextConfigAdditions(a.dest, out);
114
+ } else if (a.kind === 'append' && existsSync(a.dest)) {
79
115
  const existing = await readFile(a.dest, 'utf8');
80
- // Append with a separator if not already present
81
116
  const sep = existing.endsWith('\n') ? '' : '\n';
82
117
  await writeFile(a.dest, existing + sep + '\n# --- added by ostup profile overlay ---\n' + out, 'utf8');
83
118
  } else {
@@ -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
@@ -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.