@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.
- package/README.md +7 -0
- package/package.json +1 -1
- package/scripts/verify-profile.sh +46 -0
- package/src/brief/profile-router.mjs +63 -28
- package/templates/START_HERE.md +1 -1
- 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
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
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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 {
|
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
|
|
@@ -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.
|
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
# --- booking profile additions ---
|
|
2
|
-
#
|
|
3
|
-
DATABASE_URL=
|
|
4
|
-
|
|
5
|
-
# Email confirmation
|
|
2
|
+
# Email confirmation (guest + admin) via Resend
|
|
6
3
|
RESEND_API_KEY=
|
|
7
|
-
|
|
8
|
-
|
|
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
|