@oaklandzoo/ostup 0.9.1 → 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 (52) hide show
  1. package/README.md +7 -0
  2. package/package.json +1 -1
  3. package/scripts/verify-profile.sh +46 -0
  4. package/src/brief/profile-router.mjs +63 -28
  5. package/templates/START_HERE.md +1 -1
  6. package/templates/profiles/blog/README.md +45 -40
  7. package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
  8. package/templates/profiles/blog/app/page.tsx +30 -0
  9. package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
  10. package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
  11. package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
  12. package/templates/profiles/blog/content/posts/intro.mdx +14 -0
  13. package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
  14. package/templates/profiles/blog/lib/posts.ts +43 -0
  15. package/templates/profiles/blog/next.config.mjs.additions +9 -0
  16. package/templates/profiles/blog/package.json.additions +6 -0
  17. package/templates/profiles/booking/.env.example.additions +3 -11
  18. package/templates/profiles/booking/README.md +26 -21
  19. package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
  20. package/templates/profiles/booking/app/page.tsx +38 -0
  21. package/templates/profiles/booking/components/BookingForm.tsx +130 -0
  22. package/templates/profiles/booking/components/Hero.tsx +20 -0
  23. package/templates/profiles/booking/components/ServiceList.tsx +19 -0
  24. package/templates/profiles/booking/lib/resend.ts +33 -0
  25. package/templates/profiles/booking/lib/storage.ts +44 -0
  26. package/templates/profiles/booking/package.json.additions +5 -0
  27. package/templates/profiles/booking/section-prompts.md +10 -8
  28. package/templates/profiles/lead-gen/.env.example.additions +2 -2
  29. package/templates/profiles/lead-gen/README.md +35 -27
  30. package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
  31. package/templates/profiles/lead-gen/app/layout.tsx +29 -0
  32. package/templates/profiles/lead-gen/app/page.tsx +79 -0
  33. package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
  34. package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
  35. package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
  36. package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
  37. package/templates/profiles/lead-gen/lib/resend.ts +33 -0
  38. package/templates/profiles/lead-gen/package.json.additions +5 -0
  39. package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
  40. package/templates/profiles/saas-dashboard/README.md +43 -36
  41. package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
  42. package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
  43. package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
  44. package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
  45. package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
  46. package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
  47. package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
  48. package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
  49. package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
  50. package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
  51. package/templates/profiles/saas-dashboard/middleware.ts +19 -0
  52. 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oaklandzoo/ostup",
3
- "version": "0.9.1",
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"
@@ -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.
@@ -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
+ }
@@ -1,16 +1,8 @@
1
1
  # --- booking profile additions ---
2
- # Database for Booking entity (Neon recommended; any Postgres works)
3
- DATABASE_URL=
4
-
5
- # Email confirmation
2
+ # Email confirmation (guest + admin) via Resend
6
3
  RESEND_API_KEY=
7
- BOOKING_TO_EMAIL=
8
- BOOKING_FROM_EMAIL=noreply@example.com
9
-
10
- # Optional: Stripe deposit checkout (enable only if `payments` addon is on)
11
- STRIPE_SECRET_KEY=
12
- STRIPE_PUBLISHABLE_KEY=
13
- STRIPE_WEBHOOK_SECRET=
4
+ RESEND_FROM_EMAIL=noreply@example.com
5
+ ADMIN_EMAIL=
14
6
 
15
7
  # Public app URL (Vercel sets this for production)
16
8
  NEXT_PUBLIC_APP_URL=http://localhost:3000