create-headroom-site 0.1.3
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/dist/index.d.ts +16 -0
- package/dist/index.js +133 -0
- package/package.json +32 -0
- package/templates/astro/_env.example +4 -0
- package/templates/astro/_gitignore +4 -0
- package/templates/astro/astro.config.mjs +11 -0
- package/templates/astro/package.json +19 -0
- package/templates/astro/public/favicon.svg +4 -0
- package/templates/astro/src/components/Footer.astro +8 -0
- package/templates/astro/src/components/Header.astro +18 -0
- package/templates/astro/src/components/PostCard.astro +16 -0
- package/templates/astro/src/content.config.ts +14 -0
- package/templates/astro/src/env.d.ts +12 -0
- package/templates/astro/src/layouts/BaseLayout.astro +36 -0
- package/templates/astro/src/lib/client.ts +16 -0
- package/templates/astro/src/lib/links.ts +11 -0
- package/templates/astro/src/pages/404.astro +13 -0
- package/templates/astro/src/pages/[...slug].astro +32 -0
- package/templates/astro/src/pages/blog/[slug].astro +38 -0
- package/templates/astro/src/pages/blog/index.astro +25 -0
- package/templates/astro/src/pages/index.astro +37 -0
- package/templates/astro/src/styles/global.css +137 -0
- package/templates/astro/tsconfig.json +9 -0
- package/templates/nextjs/_env.example +4 -0
- package/templates/nextjs/_gitignore +4 -0
- package/templates/nextjs/next.config.ts +14 -0
- package/templates/nextjs/package.json +23 -0
- package/templates/nextjs/postcss.config.mjs +5 -0
- package/templates/nextjs/public/favicon.svg +4 -0
- package/templates/nextjs/src/app/[slug]/page.tsx +44 -0
- package/templates/nextjs/src/app/blog/[slug]/page.tsx +44 -0
- package/templates/nextjs/src/app/blog/page.tsx +27 -0
- package/templates/nextjs/src/app/globals.css +137 -0
- package/templates/nextjs/src/app/layout.tsx +41 -0
- package/templates/nextjs/src/app/not-found.tsx +15 -0
- package/templates/nextjs/src/app/page.tsx +41 -0
- package/templates/nextjs/src/components/Footer.tsx +15 -0
- package/templates/nextjs/src/components/Header.tsx +22 -0
- package/templates/nextjs/src/components/PostCard.tsx +18 -0
- package/templates/nextjs/src/lib/client.ts +15 -0
- package/templates/nextjs/src/lib/links.ts +11 -0
- package/templates/nextjs/tsconfig.json +23 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Dotfiles are stored with _ prefix to survive npm publish, renamed on copy */
|
|
3
|
+
declare const RENAME_MAP: Record<string, string>;
|
|
4
|
+
/** Recursively copy a directory, skipping node_modules/.git, renaming _ dotfiles */
|
|
5
|
+
declare function copyDir(src: string, dest: string): void;
|
|
6
|
+
/** Generate .env file content from answers */
|
|
7
|
+
declare function generateEnv(answers: {
|
|
8
|
+
url: string;
|
|
9
|
+
site: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
imageSigningSecret?: string;
|
|
12
|
+
}): string;
|
|
13
|
+
/** Patch package.json name to match chosen directory */
|
|
14
|
+
declare function patchPackageName(targetDir: string, directory: string): void;
|
|
15
|
+
|
|
16
|
+
export { RENAME_MAP, copyDir, generateEnv, patchPackageName };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
var __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
var TEMPLATES_DIR = path.resolve(__dirname, "..", "templates");
|
|
11
|
+
var RENAME_MAP = {
|
|
12
|
+
_gitignore: ".gitignore",
|
|
13
|
+
"_env.example": ".env.example"
|
|
14
|
+
};
|
|
15
|
+
async function main() {
|
|
16
|
+
const argDir = process.argv[2];
|
|
17
|
+
p.intro("create-headroom-site");
|
|
18
|
+
const answers = await p.group(
|
|
19
|
+
{
|
|
20
|
+
directory: () => argDir ? Promise.resolve(argDir) : p.text({
|
|
21
|
+
message: "Project directory",
|
|
22
|
+
initialValue: "my-headroom-site",
|
|
23
|
+
validate: (v) => v.trim().length === 0 ? "Directory name is required" : void 0
|
|
24
|
+
}),
|
|
25
|
+
framework: () => p.select({
|
|
26
|
+
message: "Framework",
|
|
27
|
+
options: [
|
|
28
|
+
{ label: "Astro", value: "astro" },
|
|
29
|
+
{ label: "Next.js", value: "nextjs" }
|
|
30
|
+
]
|
|
31
|
+
}),
|
|
32
|
+
url: () => p.text({
|
|
33
|
+
message: "Headroom URL (your CDN URL, e.g. https://headroom.example.com)",
|
|
34
|
+
validate: (v) => {
|
|
35
|
+
const trimmed = v.trim();
|
|
36
|
+
if (trimmed.length === 0) return "URL is required";
|
|
37
|
+
if (!/^https?:\/\/.+/.test(trimmed))
|
|
38
|
+
return "URL must start with https:// or http://";
|
|
39
|
+
if (trimmed.endsWith("/"))
|
|
40
|
+
return "URL must not end with a trailing slash";
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
site: () => p.text({
|
|
45
|
+
message: "Site identifier (e.g. mysite.com)",
|
|
46
|
+
validate: (v) => v.trim().length === 0 ? "Site identifier is required" : void 0
|
|
47
|
+
}),
|
|
48
|
+
apiKey: () => p.text({
|
|
49
|
+
message: "API key",
|
|
50
|
+
validate: (v) => v.trim().length === 0 ? "API key is required" : void 0
|
|
51
|
+
}),
|
|
52
|
+
imageSigningSecret: () => p.text({
|
|
53
|
+
message: "Image signing secret (optional, for transforms)",
|
|
54
|
+
defaultValue: ""
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
{ onCancel: () => process.exit(1) }
|
|
58
|
+
);
|
|
59
|
+
const targetDir = path.resolve(process.cwd(), answers.directory);
|
|
60
|
+
if (fs.existsSync(targetDir)) {
|
|
61
|
+
const files = fs.readdirSync(targetDir);
|
|
62
|
+
if (files.length > 0) {
|
|
63
|
+
p.cancel(`Directory ${answers.directory} is not empty`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const verifySpinner = p.spinner();
|
|
68
|
+
verifySpinner.start("Verifying API credentials...");
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`${answers.url.replace(/\/$/, "")}/health`);
|
|
71
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
72
|
+
verifySpinner.stop("Connected to " + answers.url);
|
|
73
|
+
} catch {
|
|
74
|
+
verifySpinner.stop(
|
|
75
|
+
pc.yellow("\u26A0 Could not reach API \u2014 continuing anyway (check .env later)")
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const templateDir = path.join(TEMPLATES_DIR, answers.framework);
|
|
79
|
+
copyDir(templateDir, targetDir);
|
|
80
|
+
p.log.success(`Created ${pc.bold(answers.directory)}/`);
|
|
81
|
+
const envContent = generateEnv(answers);
|
|
82
|
+
fs.writeFileSync(path.join(targetDir, ".env"), envContent);
|
|
83
|
+
p.log.success("Wrote .env");
|
|
84
|
+
patchPackageName(targetDir, answers.directory);
|
|
85
|
+
p.outro(`Done! Next steps:
|
|
86
|
+
${pc.cyan("cd")} ${answers.directory}
|
|
87
|
+
${pc.cyan("npm install")}
|
|
88
|
+
${pc.cyan("npm run dev")}`);
|
|
89
|
+
}
|
|
90
|
+
function copyDir(src, dest) {
|
|
91
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
92
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
93
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
94
|
+
const srcPath = path.join(src, entry.name);
|
|
95
|
+
const destName = RENAME_MAP[entry.name] || entry.name;
|
|
96
|
+
const destPath = path.join(dest, destName);
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
copyDir(srcPath, destPath);
|
|
99
|
+
} else {
|
|
100
|
+
fs.copyFileSync(srcPath, destPath);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function generateEnv(answers) {
|
|
105
|
+
const url = answers.url.replace(/\/+$/, "");
|
|
106
|
+
const lines = [
|
|
107
|
+
`HEADROOM_URL=${url}`,
|
|
108
|
+
`HEADROOM_SITE=${answers.site}`,
|
|
109
|
+
`HEADROOM_API_KEY=${answers.apiKey}`
|
|
110
|
+
];
|
|
111
|
+
if (answers.imageSigningSecret)
|
|
112
|
+
lines.push(
|
|
113
|
+
`HEADROOM_IMAGE_SIGNING_SECRET=${answers.imageSigningSecret}`
|
|
114
|
+
);
|
|
115
|
+
lines.push("");
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
function patchPackageName(targetDir, directory) {
|
|
119
|
+
const pkgPath = path.join(targetDir, "package.json");
|
|
120
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
121
|
+
pkg.name = path.basename(directory);
|
|
122
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
123
|
+
}
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
console.error(err);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|
|
128
|
+
export {
|
|
129
|
+
RENAME_MAP,
|
|
130
|
+
copyDir,
|
|
131
|
+
generateEnv,
|
|
132
|
+
patchPackageName
|
|
133
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-headroom-site",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Scaffold a new Headroom CMS frontend site (Astro or Next.js)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
7
|
+
"bin": {
|
|
8
|
+
"create-headroom-site": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"templates"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/index.ts --format esm --dts --outDir dist",
|
|
16
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@clack/prompts": "^0.9.1",
|
|
21
|
+
"picocolors": "^1.1.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsup": "~8.5.0",
|
|
25
|
+
"typescript": "~5.9.3",
|
|
26
|
+
"@types/node": "~22.0.0",
|
|
27
|
+
"vitest": "^3.2.1"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from "astro/config";
|
|
2
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
3
|
+
import { headroomDevRefresh } from "@headroom-cms/api/astro";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
output: "static",
|
|
7
|
+
integrations: [headroomDevRefresh()],
|
|
8
|
+
vite: {
|
|
9
|
+
plugins: [tailwindcss()],
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-headroom-site",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "astro dev",
|
|
7
|
+
"build": "astro build",
|
|
8
|
+
"preview": "astro preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"astro": "^5.0.0",
|
|
12
|
+
"@headroom-cms/api": "^0.1.3"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
16
|
+
"tailwindcss": "^4.0.0",
|
|
17
|
+
"typescript": "^5.9.3"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<footer class="border-t border-gray-100 py-8 mt-16">
|
|
2
|
+
<div class="max-w-5xl mx-auto px-6 text-center text-sm text-gray-500">
|
|
3
|
+
<p>
|
|
4
|
+
Powered by <a href="https://headroom.dev" class="hover:underline">Headroom CMS</a>
|
|
5
|
+
· Built with <a href="https://astro.build" class="hover:underline">Astro</a>
|
|
6
|
+
</p>
|
|
7
|
+
</div>
|
|
8
|
+
</footer>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getEntry } from "astro:content";
|
|
3
|
+
|
|
4
|
+
const settingsEntry = await getEntry("site-settings", "site-settings");
|
|
5
|
+
const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<header class="border-b border-gray-100 bg-white sticky top-0 z-50">
|
|
9
|
+
<nav class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
10
|
+
<a href="/" class="text-xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
|
11
|
+
{siteName}
|
|
12
|
+
</a>
|
|
13
|
+
<ul class="flex items-center gap-6">
|
|
14
|
+
<li><a href="/" class="text-sm font-medium text-gray-600 hover:text-gray-900">Home</a></li>
|
|
15
|
+
<li><a href="/blog" class="text-sm font-medium text-gray-600 hover:text-gray-900">Blog</a></li>
|
|
16
|
+
</ul>
|
|
17
|
+
</nav>
|
|
18
|
+
</header>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
title: string;
|
|
4
|
+
snippet?: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { title, snippet, slug } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<a href={`/blog/${slug}`} class="block group">
|
|
12
|
+
<article class="bg-white rounded-lg border border-gray-100 p-6 transition-shadow hover:shadow-md">
|
|
13
|
+
<h3 class="text-lg font-semibold group-hover:text-primary transition-colors">{title}</h3>
|
|
14
|
+
{snippet && <p class="mt-2 text-sm text-gray-500 line-clamp-2">{snippet}</p>}
|
|
15
|
+
</article>
|
|
16
|
+
</a>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineCollection } from "astro:content";
|
|
2
|
+
import { headroomLoader } from "@headroom-cms/api/astro";
|
|
3
|
+
|
|
4
|
+
export const collections = {
|
|
5
|
+
posts: defineCollection({
|
|
6
|
+
loader: headroomLoader({ collection: "posts" }),
|
|
7
|
+
}),
|
|
8
|
+
pages: defineCollection({
|
|
9
|
+
loader: headroomLoader({ collection: "pages", bodies: true }),
|
|
10
|
+
}),
|
|
11
|
+
"site-settings": defineCollection({
|
|
12
|
+
loader: headroomLoader({ collection: "site-settings", bodies: true }),
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/// <reference types="astro/client" />
|
|
2
|
+
|
|
3
|
+
interface ImportMetaEnv {
|
|
4
|
+
readonly HEADROOM_URL: string;
|
|
5
|
+
readonly HEADROOM_SITE: string;
|
|
6
|
+
readonly HEADROOM_API_KEY: string;
|
|
7
|
+
readonly HEADROOM_IMAGE_SIGNING_SECRET: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ImportMeta {
|
|
11
|
+
readonly env: ImportMetaEnv;
|
|
12
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getEntry } from "astro:content";
|
|
3
|
+
import Header from "../components/Header.astro";
|
|
4
|
+
import Footer from "../components/Footer.astro";
|
|
5
|
+
import "../styles/global.css";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const settingsEntry = await getEntry("site-settings", "site-settings");
|
|
13
|
+
const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
|
|
14
|
+
const defaultDescription = (settingsEntry?.data.body?.tagline as string) || "";
|
|
15
|
+
|
|
16
|
+
const { title, description = defaultDescription } = Astro.props;
|
|
17
|
+
const fullTitle = title ? `${title} | ${siteName}` : siteName;
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<!doctype html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="utf-8" />
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
25
|
+
<meta name="description" content={description} />
|
|
26
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
27
|
+
<title>{fullTitle}</title>
|
|
28
|
+
</head>
|
|
29
|
+
<body class="min-h-screen flex flex-col">
|
|
30
|
+
<Header />
|
|
31
|
+
<main class="flex-1">
|
|
32
|
+
<slot />
|
|
33
|
+
</main>
|
|
34
|
+
<Footer />
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HeadroomClient } from "@headroom-cms/api";
|
|
2
|
+
|
|
3
|
+
let _client: HeadroomClient | null = null;
|
|
4
|
+
|
|
5
|
+
export function getClient(): HeadroomClient {
|
|
6
|
+
if (!_client) {
|
|
7
|
+
_client = new HeadroomClient({
|
|
8
|
+
url: import.meta.env.HEADROOM_URL || "http://localhost:3000",
|
|
9
|
+
site: import.meta.env.HEADROOM_SITE || "my.local",
|
|
10
|
+
apiKey: import.meta.env.HEADROOM_API_KEY || "",
|
|
11
|
+
imageSigningSecret:
|
|
12
|
+
import.meta.env.HEADROOM_IMAGE_SIGNING_SECRET || undefined,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return _client;
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PublicContentRef } from "@headroom-cms/api";
|
|
2
|
+
|
|
3
|
+
/** Map a content reference to its URL path. */
|
|
4
|
+
export function resolveContentLink(ref: PublicContentRef): string {
|
|
5
|
+
switch (ref.collection) {
|
|
6
|
+
case "posts":
|
|
7
|
+
return `/blog/${ref.slug}`;
|
|
8
|
+
default:
|
|
9
|
+
return `/${ref.slug}`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<BaseLayout title="Page Not Found">
|
|
6
|
+
<section class="py-20 px-6 text-center">
|
|
7
|
+
<div class="max-w-3xl mx-auto">
|
|
8
|
+
<h1 class="text-6xl font-extrabold tracking-tight text-gray-300">404</h1>
|
|
9
|
+
<p class="mt-4 text-xl text-gray-500">Page not found</p>
|
|
10
|
+
<a href="/" class="mt-8 inline-block text-sm font-medium hover:underline">← Back to home</a>
|
|
11
|
+
</div>
|
|
12
|
+
</section>
|
|
13
|
+
</BaseLayout>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
4
|
+
import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
|
|
5
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
6
|
+
import { getClient } from "../lib/client";
|
|
7
|
+
import { resolveContentLink } from "../lib/links";
|
|
8
|
+
|
|
9
|
+
export async function getStaticPaths() {
|
|
10
|
+
const pages = await getCollection("pages");
|
|
11
|
+
return pages.map((page) => ({
|
|
12
|
+
params: { slug: page.id },
|
|
13
|
+
props: { page },
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { page } = Astro.props;
|
|
18
|
+
const client = getClient();
|
|
19
|
+
const blocks = (page.data.body?.content || []) as Block[];
|
|
20
|
+
const refs = (page.data._refs || {}) as RefsMap;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<BaseLayout title={page.data.title}>
|
|
24
|
+
<article class="py-12 px-6">
|
|
25
|
+
<div class="max-w-3xl mx-auto">
|
|
26
|
+
<h1 class="text-4xl font-extrabold tracking-tight mb-8">{page.data.title}</h1>
|
|
27
|
+
<div class="prose">
|
|
28
|
+
<BlockRenderer blocks={blocks} refs={refs} resolveContentLink={resolveContentLink} />
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</article>
|
|
32
|
+
</BaseLayout>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
4
|
+
import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
|
|
5
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
6
|
+
import { getClient } from "../../lib/client";
|
|
7
|
+
import { resolveContentLink } from "../../lib/links";
|
|
8
|
+
|
|
9
|
+
export async function getStaticPaths() {
|
|
10
|
+
const posts = await getCollection("posts");
|
|
11
|
+
return posts.map((post) => ({
|
|
12
|
+
params: { slug: post.id },
|
|
13
|
+
props: { post },
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { post } = Astro.props;
|
|
18
|
+
const client = getClient();
|
|
19
|
+
const fullPost = await client.getContentBySlug("posts", post.id);
|
|
20
|
+
if (!fullPost) return Astro.redirect("/404");
|
|
21
|
+
|
|
22
|
+
const blocks = (fullPost.body?.content || []) as Block[];
|
|
23
|
+
const refs = (fullPost._refs || {}) as RefsMap;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<BaseLayout title={post.data.title} description={post.data.snippet}>
|
|
27
|
+
<article class="py-12 px-6">
|
|
28
|
+
<div class="max-w-3xl mx-auto">
|
|
29
|
+
<h1 class="text-4xl font-extrabold tracking-tight mb-4">{post.data.title}</h1>
|
|
30
|
+
<div class="prose">
|
|
31
|
+
<BlockRenderer blocks={blocks} refs={refs} resolveContentLink={resolveContentLink} />
|
|
32
|
+
</div>
|
|
33
|
+
<footer class="mt-12 pt-8 border-t border-gray-100">
|
|
34
|
+
<a href="/blog" class="text-sm font-medium hover:underline">← Back to all posts</a>
|
|
35
|
+
</footer>
|
|
36
|
+
</div>
|
|
37
|
+
</article>
|
|
38
|
+
</BaseLayout>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
4
|
+
import PostCard from "../../components/PostCard.astro";
|
|
5
|
+
|
|
6
|
+
const posts = (await getCollection("posts"))
|
|
7
|
+
.sort((a, b) => new Date(b.data.publishedAt).getTime() - new Date(a.data.publishedAt).getTime());
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<BaseLayout title="Blog">
|
|
11
|
+
<section class="py-12 px-6">
|
|
12
|
+
<div class="max-w-5xl mx-auto">
|
|
13
|
+
<h1 class="text-4xl font-extrabold tracking-tight mb-8">Blog</h1>
|
|
14
|
+
{posts.length > 0 ? (
|
|
15
|
+
<div class="grid md:grid-cols-3 gap-8">
|
|
16
|
+
{posts.map((post) => (
|
|
17
|
+
<PostCard title={post.data.title} snippet={post.data.snippet} slug={post.id} />
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
) : (
|
|
21
|
+
<p class="text-gray-500">No posts yet. Create some content in the Headroom admin.</p>
|
|
22
|
+
)}
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
</BaseLayout>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, getEntry } from "astro:content";
|
|
3
|
+
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
4
|
+
import PostCard from "../components/PostCard.astro";
|
|
5
|
+
|
|
6
|
+
const settingsEntry = await getEntry("site-settings", "site-settings");
|
|
7
|
+
const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
|
|
8
|
+
const tagline = (settingsEntry?.data.body?.tagline as string) || "";
|
|
9
|
+
|
|
10
|
+
const posts = (await getCollection("posts"))
|
|
11
|
+
.sort((a, b) => new Date(b.data.publishedAt).getTime() - new Date(a.data.publishedAt).getTime())
|
|
12
|
+
.slice(0, 3);
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<BaseLayout>
|
|
16
|
+
<section class="py-20 px-6">
|
|
17
|
+
<div class="max-w-5xl mx-auto text-center">
|
|
18
|
+
<h1 class="text-5xl font-extrabold tracking-tight">{siteName}</h1>
|
|
19
|
+
{tagline && (
|
|
20
|
+
<p class="mt-4 text-xl text-gray-500 max-w-2xl mx-auto">{tagline}</p>
|
|
21
|
+
)}
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
|
24
|
+
|
|
25
|
+
{posts.length > 0 && (
|
|
26
|
+
<section class="py-12 px-6 bg-gray-50">
|
|
27
|
+
<div class="max-w-5xl mx-auto">
|
|
28
|
+
<h2 class="text-2xl font-bold mb-8">Recent Posts</h2>
|
|
29
|
+
<div class="grid md:grid-cols-3 gap-8">
|
|
30
|
+
{posts.map((post) => (
|
|
31
|
+
<PostCard title={post.data.title} snippet={post.data.snippet} slug={post.id} />
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</section>
|
|
36
|
+
)}
|
|
37
|
+
</BaseLayout>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--color-primary: oklch(0.45 0.2 260);
|
|
5
|
+
--color-primary-light: oklch(0.55 0.18 260);
|
|
6
|
+
--color-accent: oklch(0.7 0.18 50);
|
|
7
|
+
--color-accent-dark: oklch(0.6 0.18 50);
|
|
8
|
+
--color-surface: oklch(0.98 0.005 80);
|
|
9
|
+
--color-surface-alt: oklch(0.95 0.01 80);
|
|
10
|
+
--color-text: oklch(0.25 0.02 260);
|
|
11
|
+
--color-text-muted: oklch(0.5 0.02 260);
|
|
12
|
+
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
|
13
|
+
--font-display: "Inter", system-ui, -apple-system, sans-serif;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
font-family: var(--font-sans);
|
|
18
|
+
color: var(--color-text);
|
|
19
|
+
background: white;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Prose styles for block content */
|
|
23
|
+
.prose h1 {
|
|
24
|
+
font-size: 2.25rem;
|
|
25
|
+
font-weight: 800;
|
|
26
|
+
line-height: 1.2;
|
|
27
|
+
margin-top: 2rem;
|
|
28
|
+
margin-bottom: 1rem;
|
|
29
|
+
letter-spacing: -0.02em;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.prose h2 {
|
|
33
|
+
font-size: 1.5rem;
|
|
34
|
+
font-weight: 700;
|
|
35
|
+
line-height: 1.3;
|
|
36
|
+
margin-top: 1.75rem;
|
|
37
|
+
margin-bottom: 0.75rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.prose h3 {
|
|
41
|
+
font-size: 1.25rem;
|
|
42
|
+
font-weight: 600;
|
|
43
|
+
line-height: 1.4;
|
|
44
|
+
margin-top: 1.5rem;
|
|
45
|
+
margin-bottom: 0.5rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.prose p {
|
|
49
|
+
margin-bottom: 1rem;
|
|
50
|
+
line-height: 1.75;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.prose ul,
|
|
54
|
+
.prose ol {
|
|
55
|
+
margin-bottom: 1rem;
|
|
56
|
+
padding-left: 1.5rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.prose li {
|
|
60
|
+
margin-bottom: 0.25rem;
|
|
61
|
+
line-height: 1.75;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.prose ul li {
|
|
65
|
+
list-style-type: disc;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.prose ol li {
|
|
69
|
+
list-style-type: decimal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.prose img {
|
|
73
|
+
border-radius: 0.5rem;
|
|
74
|
+
margin: 1.5rem auto;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.prose pre {
|
|
78
|
+
background: oklch(0.2 0.02 260);
|
|
79
|
+
color: oklch(0.9 0.01 80);
|
|
80
|
+
padding: 1rem 1.25rem;
|
|
81
|
+
border-radius: 0.5rem;
|
|
82
|
+
overflow-x: auto;
|
|
83
|
+
margin-bottom: 1rem;
|
|
84
|
+
font-size: 0.875rem;
|
|
85
|
+
line-height: 1.6;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.prose code {
|
|
89
|
+
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.prose a {
|
|
93
|
+
color: var(--color-primary);
|
|
94
|
+
text-decoration: underline;
|
|
95
|
+
text-underline-offset: 2px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.prose a:hover {
|
|
99
|
+
color: var(--color-primary-light);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.prose blockquote {
|
|
103
|
+
border-left: 3px solid var(--color-primary);
|
|
104
|
+
padding-left: 1rem;
|
|
105
|
+
margin: 1rem 0;
|
|
106
|
+
color: var(--color-text-muted);
|
|
107
|
+
font-style: italic;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.prose figure {
|
|
111
|
+
margin: 1.5rem 0;
|
|
112
|
+
text-align: center;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.prose figcaption {
|
|
116
|
+
font-size: 0.875rem;
|
|
117
|
+
color: var(--color-text-muted);
|
|
118
|
+
margin-top: 0.5rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.prose table {
|
|
122
|
+
width: 100%;
|
|
123
|
+
border-collapse: collapse;
|
|
124
|
+
margin-bottom: 1rem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.prose th,
|
|
128
|
+
.prose td {
|
|
129
|
+
border: 1px solid oklch(0.85 0.01 80);
|
|
130
|
+
padding: 0.5rem 0.75rem;
|
|
131
|
+
text-align: left;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.prose th {
|
|
135
|
+
background: var(--color-surface-alt);
|
|
136
|
+
font-weight: 600;
|
|
137
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-headroom-site",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"next": "^15.0.0",
|
|
12
|
+
"react": "^19.0.0",
|
|
13
|
+
"react-dom": "^19.0.0",
|
|
14
|
+
"@headroom-cms/api": "^0.1.3"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@tailwindcss/postcss": "^4.0.0",
|
|
18
|
+
"tailwindcss": "^4.0.0",
|
|
19
|
+
"typescript": "^5.9.3",
|
|
20
|
+
"@types/react": "^19.0.0",
|
|
21
|
+
"@types/node": "^22.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getClient } from "@/lib/client";
|
|
2
|
+
import { BlockRenderer } from "@headroom-cms/api/react";
|
|
3
|
+
import "@headroom-cms/api/react/headroom-blocks.css";
|
|
4
|
+
import { resolveContentLink } from "@/lib/links";
|
|
5
|
+
import { notFound } from "next/navigation";
|
|
6
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
params: Promise<{ slug: string }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function generateStaticParams() {
|
|
13
|
+
const client = getClient();
|
|
14
|
+
const { items } = await client.listContent("pages", { limit: 100 });
|
|
15
|
+
return items.map((page) => ({ slug: page.slug }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function Page({ params }: Props) {
|
|
19
|
+
const { slug } = await params;
|
|
20
|
+
const client = getClient();
|
|
21
|
+
const page = await client.getContentBySlug("pages", slug);
|
|
22
|
+
if (!page) notFound();
|
|
23
|
+
|
|
24
|
+
const blocks = (page.body?.content || []) as Block[];
|
|
25
|
+
const refs = (page._refs || {}) as RefsMap;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<article className="py-12 px-6">
|
|
29
|
+
<div className="max-w-3xl mx-auto">
|
|
30
|
+
<h1 className="text-4xl font-extrabold tracking-tight mb-8">
|
|
31
|
+
{page.title}
|
|
32
|
+
</h1>
|
|
33
|
+
<div className="prose">
|
|
34
|
+
<BlockRenderer
|
|
35
|
+
blocks={blocks}
|
|
36
|
+
baseUrl={process.env.HEADROOM_URL}
|
|
37
|
+
refs={refs}
|
|
38
|
+
resolveContentLink={resolveContentLink}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</article>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getClient } from "@/lib/client";
|
|
2
|
+
import { BlockRenderer } from "@headroom-cms/api/react";
|
|
3
|
+
import "@headroom-cms/api/react/headroom-blocks.css";
|
|
4
|
+
import { resolveContentLink } from "@/lib/links";
|
|
5
|
+
import { notFound } from "next/navigation";
|
|
6
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
params: Promise<{ slug: string }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function generateStaticParams() {
|
|
13
|
+
const client = getClient();
|
|
14
|
+
const { items } = await client.listContent("posts", { limit: 100 });
|
|
15
|
+
return items.map((post) => ({ slug: post.slug }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function BlogPost({ params }: Props) {
|
|
19
|
+
const { slug } = await params;
|
|
20
|
+
const client = getClient();
|
|
21
|
+
const post = await client.getContentBySlug("posts", slug);
|
|
22
|
+
if (!post) notFound();
|
|
23
|
+
|
|
24
|
+
const blocks = (post.body?.content || []) as Block[];
|
|
25
|
+
const refs = (post._refs || {}) as RefsMap;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<article className="py-12 px-6">
|
|
29
|
+
<div className="max-w-3xl mx-auto">
|
|
30
|
+
<h1 className="text-4xl font-extrabold tracking-tight mb-4">
|
|
31
|
+
{post.title}
|
|
32
|
+
</h1>
|
|
33
|
+
<div className="prose">
|
|
34
|
+
<BlockRenderer
|
|
35
|
+
blocks={blocks}
|
|
36
|
+
baseUrl={process.env.HEADROOM_URL}
|
|
37
|
+
refs={refs}
|
|
38
|
+
resolveContentLink={resolveContentLink}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</article>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getClient } from "@/lib/client";
|
|
2
|
+
import { PostCard } from "@/components/PostCard";
|
|
3
|
+
|
|
4
|
+
export default async function BlogIndex() {
|
|
5
|
+
const client = getClient();
|
|
6
|
+
const { items: posts } = await client.listContent("posts", {
|
|
7
|
+
limit: 100,
|
|
8
|
+
sort: "published_desc",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<section className="py-12 px-6">
|
|
13
|
+
<div className="max-w-5xl mx-auto">
|
|
14
|
+
<h1 className="text-4xl font-extrabold tracking-tight mb-8">Blog</h1>
|
|
15
|
+
{posts.length > 0 ? (
|
|
16
|
+
<div className="grid md:grid-cols-3 gap-8">
|
|
17
|
+
{posts.map((post) => (
|
|
18
|
+
<PostCard key={post.contentId} title={post.title} snippet={post.snippet} slug={post.slug} />
|
|
19
|
+
))}
|
|
20
|
+
</div>
|
|
21
|
+
) : (
|
|
22
|
+
<p className="text-gray-500">No posts yet. Create some content in the Headroom admin.</p>
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--color-primary: oklch(0.45 0.2 260);
|
|
5
|
+
--color-primary-light: oklch(0.55 0.18 260);
|
|
6
|
+
--color-accent: oklch(0.7 0.18 50);
|
|
7
|
+
--color-accent-dark: oklch(0.6 0.18 50);
|
|
8
|
+
--color-surface: oklch(0.98 0.005 80);
|
|
9
|
+
--color-surface-alt: oklch(0.95 0.01 80);
|
|
10
|
+
--color-text: oklch(0.25 0.02 260);
|
|
11
|
+
--color-text-muted: oklch(0.5 0.02 260);
|
|
12
|
+
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
|
13
|
+
--font-display: "Inter", system-ui, -apple-system, sans-serif;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
font-family: var(--font-sans);
|
|
18
|
+
color: var(--color-text);
|
|
19
|
+
background: white;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Prose styles for block content */
|
|
23
|
+
.prose h1 {
|
|
24
|
+
font-size: 2.25rem;
|
|
25
|
+
font-weight: 800;
|
|
26
|
+
line-height: 1.2;
|
|
27
|
+
margin-top: 2rem;
|
|
28
|
+
margin-bottom: 1rem;
|
|
29
|
+
letter-spacing: -0.02em;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.prose h2 {
|
|
33
|
+
font-size: 1.5rem;
|
|
34
|
+
font-weight: 700;
|
|
35
|
+
line-height: 1.3;
|
|
36
|
+
margin-top: 1.75rem;
|
|
37
|
+
margin-bottom: 0.75rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.prose h3 {
|
|
41
|
+
font-size: 1.25rem;
|
|
42
|
+
font-weight: 600;
|
|
43
|
+
line-height: 1.4;
|
|
44
|
+
margin-top: 1.5rem;
|
|
45
|
+
margin-bottom: 0.5rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.prose p {
|
|
49
|
+
margin-bottom: 1rem;
|
|
50
|
+
line-height: 1.75;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.prose ul,
|
|
54
|
+
.prose ol {
|
|
55
|
+
margin-bottom: 1rem;
|
|
56
|
+
padding-left: 1.5rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.prose li {
|
|
60
|
+
margin-bottom: 0.25rem;
|
|
61
|
+
line-height: 1.75;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.prose ul li {
|
|
65
|
+
list-style-type: disc;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.prose ol li {
|
|
69
|
+
list-style-type: decimal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.prose img {
|
|
73
|
+
border-radius: 0.5rem;
|
|
74
|
+
margin: 1.5rem auto;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.prose pre {
|
|
78
|
+
background: oklch(0.2 0.02 260);
|
|
79
|
+
color: oklch(0.9 0.01 80);
|
|
80
|
+
padding: 1rem 1.25rem;
|
|
81
|
+
border-radius: 0.5rem;
|
|
82
|
+
overflow-x: auto;
|
|
83
|
+
margin-bottom: 1rem;
|
|
84
|
+
font-size: 0.875rem;
|
|
85
|
+
line-height: 1.6;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.prose code {
|
|
89
|
+
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.prose a {
|
|
93
|
+
color: var(--color-primary);
|
|
94
|
+
text-decoration: underline;
|
|
95
|
+
text-underline-offset: 2px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.prose a:hover {
|
|
99
|
+
color: var(--color-primary-light);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.prose blockquote {
|
|
103
|
+
border-left: 3px solid var(--color-primary);
|
|
104
|
+
padding-left: 1rem;
|
|
105
|
+
margin: 1rem 0;
|
|
106
|
+
color: var(--color-text-muted);
|
|
107
|
+
font-style: italic;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.prose figure {
|
|
111
|
+
margin: 1.5rem 0;
|
|
112
|
+
text-align: center;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.prose figcaption {
|
|
116
|
+
font-size: 0.875rem;
|
|
117
|
+
color: var(--color-text-muted);
|
|
118
|
+
margin-top: 0.5rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.prose table {
|
|
122
|
+
width: 100%;
|
|
123
|
+
border-collapse: collapse;
|
|
124
|
+
margin-bottom: 1rem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.prose th,
|
|
128
|
+
.prose td {
|
|
129
|
+
border: 1px solid oklch(0.85 0.01 80);
|
|
130
|
+
padding: 0.5rem 0.75rem;
|
|
131
|
+
text-align: left;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.prose th {
|
|
135
|
+
background: var(--color-surface-alt);
|
|
136
|
+
font-weight: 600;
|
|
137
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Header } from "@/components/Header";
|
|
3
|
+
import { Footer } from "@/components/Footer";
|
|
4
|
+
import { getClient } from "@/lib/client";
|
|
5
|
+
import { unstable_cache } from "next/cache";
|
|
6
|
+
import "./globals.css";
|
|
7
|
+
|
|
8
|
+
const getSettings = unstable_cache(
|
|
9
|
+
async () => {
|
|
10
|
+
const client = getClient();
|
|
11
|
+
return client.getSingleton("site-settings");
|
|
12
|
+
},
|
|
13
|
+
["site-settings"],
|
|
14
|
+
{ revalidate: 3600 }
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
18
|
+
try {
|
|
19
|
+
const settings = await getSettings();
|
|
20
|
+
const siteName = (settings.body?.siteName as string) || "My Site";
|
|
21
|
+
return {
|
|
22
|
+
title: { default: siteName, template: `%s | ${siteName}` },
|
|
23
|
+
description: (settings.body?.tagline as string) || "",
|
|
24
|
+
};
|
|
25
|
+
} catch {
|
|
26
|
+
console.warn("Could not fetch site settings for metadata");
|
|
27
|
+
return { title: "My Site" };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<body className="min-h-screen flex flex-col">
|
|
35
|
+
<Header />
|
|
36
|
+
<main className="flex-1">{children}</main>
|
|
37
|
+
<Footer />
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
export default function NotFound() {
|
|
4
|
+
return (
|
|
5
|
+
<section className="py-20 px-6 text-center">
|
|
6
|
+
<div className="max-w-3xl mx-auto">
|
|
7
|
+
<h1 className="text-6xl font-extrabold tracking-tight text-gray-300">404</h1>
|
|
8
|
+
<p className="mt-4 text-xl text-gray-500">Page not found</p>
|
|
9
|
+
<Link href="/" className="mt-8 inline-block text-sm font-medium hover:underline">
|
|
10
|
+
← Back to home
|
|
11
|
+
</Link>
|
|
12
|
+
</div>
|
|
13
|
+
</section>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getClient } from "@/lib/client";
|
|
2
|
+
import { PostCard } from "@/components/PostCard";
|
|
3
|
+
|
|
4
|
+
export default async function Home() {
|
|
5
|
+
const client = getClient();
|
|
6
|
+
const settings = await client.getSingleton("site-settings").catch(() => null);
|
|
7
|
+
const { items: posts } = await client.listContent("posts", {
|
|
8
|
+
limit: 3,
|
|
9
|
+
sort: "published_desc",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<section className="py-20 px-6">
|
|
15
|
+
<div className="max-w-5xl mx-auto text-center">
|
|
16
|
+
<h1 className="text-5xl font-extrabold tracking-tight">
|
|
17
|
+
{settings?.body?.siteName || "My Site"}
|
|
18
|
+
</h1>
|
|
19
|
+
{settings?.body?.tagline && (
|
|
20
|
+
<p className="mt-4 text-xl text-gray-500 max-w-2xl mx-auto">
|
|
21
|
+
{settings.body.tagline as string}
|
|
22
|
+
</p>
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
{posts.length > 0 && (
|
|
28
|
+
<section className="py-12 px-6 bg-gray-50">
|
|
29
|
+
<div className="max-w-5xl mx-auto">
|
|
30
|
+
<h2 className="text-2xl font-bold mb-8">Recent Posts</h2>
|
|
31
|
+
<div className="grid md:grid-cols-3 gap-8">
|
|
32
|
+
{posts.map((post) => (
|
|
33
|
+
<PostCard key={post.contentId} title={post.title} snippet={post.snippet} slug={post.slug} />
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
)}
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function Footer() {
|
|
2
|
+
return (
|
|
3
|
+
<footer className="border-t border-gray-100 py-8 mt-16">
|
|
4
|
+
<div className="max-w-5xl mx-auto px-6 text-center text-sm text-gray-500">
|
|
5
|
+
<p>
|
|
6
|
+
Powered by{" "}
|
|
7
|
+
<a href="https://headroom.dev" className="hover:underline">Headroom CMS</a>
|
|
8
|
+
{" "}·{" "}
|
|
9
|
+
Built with{" "}
|
|
10
|
+
<a href="https://nextjs.org" className="hover:underline">Next.js</a>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
</footer>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { getClient } from "@/lib/client";
|
|
3
|
+
|
|
4
|
+
export async function Header() {
|
|
5
|
+
const client = getClient();
|
|
6
|
+
const settings = await client.getSingleton("site-settings").catch(() => null);
|
|
7
|
+
const siteName = (settings?.body?.siteName as string) || "My Site";
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<header className="border-b border-gray-100 bg-white sticky top-0 z-50">
|
|
11
|
+
<nav className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
12
|
+
<Link href="/" className="text-xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
|
13
|
+
{siteName}
|
|
14
|
+
</Link>
|
|
15
|
+
<ul className="flex items-center gap-6">
|
|
16
|
+
<li><Link href="/" className="text-sm font-medium text-gray-600 hover:text-gray-900">Home</Link></li>
|
|
17
|
+
<li><Link href="/blog" className="text-sm font-medium text-gray-600 hover:text-gray-900">Blog</Link></li>
|
|
18
|
+
</ul>
|
|
19
|
+
</nav>
|
|
20
|
+
</header>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
interface PostCardProps {
|
|
4
|
+
title: string;
|
|
5
|
+
snippet?: string;
|
|
6
|
+
slug: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function PostCard({ title, snippet, slug }: PostCardProps) {
|
|
10
|
+
return (
|
|
11
|
+
<Link href={`/blog/${slug}`} className="block group">
|
|
12
|
+
<article className="bg-white rounded-lg border border-gray-100 p-6 transition-shadow hover:shadow-md">
|
|
13
|
+
<h3 className="text-lg font-semibold group-hover:text-indigo-700 transition-colors">{title}</h3>
|
|
14
|
+
{snippet && <p className="mt-2 text-sm text-gray-500 line-clamp-2">{snippet}</p>}
|
|
15
|
+
</article>
|
|
16
|
+
</Link>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { HeadroomClient } from "@headroom-cms/api";
|
|
2
|
+
|
|
3
|
+
let _client: HeadroomClient | null = null;
|
|
4
|
+
|
|
5
|
+
export function getClient(): HeadroomClient {
|
|
6
|
+
if (!_client) {
|
|
7
|
+
_client = new HeadroomClient({
|
|
8
|
+
url: process.env.HEADROOM_URL || "http://localhost:3000",
|
|
9
|
+
site: process.env.HEADROOM_SITE || "my.local",
|
|
10
|
+
apiKey: process.env.HEADROOM_API_KEY || "",
|
|
11
|
+
imageSigningSecret: process.env.HEADROOM_IMAGE_SIGNING_SECRET || undefined,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return _client;
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PublicContentRef } from "@headroom-cms/api";
|
|
2
|
+
|
|
3
|
+
/** Map a content reference to its URL path. */
|
|
4
|
+
export function resolveContentLink(ref: PublicContentRef): string {
|
|
5
|
+
switch (ref.collection) {
|
|
6
|
+
case "posts":
|
|
7
|
+
return `/blog/${ref.slug}`;
|
|
8
|
+
default:
|
|
9
|
+
return `/${ref.slug}`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": {
|
|
18
|
+
"@/*": ["./src/*"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|