create-headroom-site 0.1.3 → 0.1.4
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/package.json +1 -1
- package/templates/astro/CLAUDE.md +364 -0
- package/templates/astro/package.json +4 -2
- package/templates/astro/scripts/generate-schemas.sh +16 -0
- package/templates/astro/scripts/generate-schemas.ts +30 -0
- package/templates/astro/src/components/Footer.astro +8 -0
- package/templates/astro/src/components/Header.astro +8 -4
- package/templates/astro/src/content.config.ts +10 -3
- package/templates/astro/src/pages/[...slug].astro +6 -5
- package/templates/astro/src/pages/index.astro +16 -0
- package/templates/astro/sst-env.d.ts +10 -0
- package/templates/nextjs/CLAUDE.md +394 -0
- package/templates/nextjs/package.json +4 -2
- package/templates/nextjs/scripts/generate-schemas.sh +16 -0
- package/templates/nextjs/scripts/generate-schemas.ts +30 -0
- package/templates/nextjs/src/app/[slug]/page.tsx +3 -4
- package/templates/nextjs/src/app/page.tsx +20 -0
- package/templates/nextjs/src/components/Footer.tsx +8 -1
- package/templates/nextjs/src/components/Header.tsx +8 -4
- package/templates/nextjs/sst-env.d.ts +10 -0
package/package.json
CHANGED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# Headroom CMS - Astro Site
|
|
2
|
+
|
|
3
|
+
This is an Astro site powered by [Headroom CMS](https://github.com/headroom-cms), a serverless headless CMS. Content is managed in the Headroom admin UI and fetched at build time via the `@headroom-cms/api` SDK.
|
|
4
|
+
|
|
5
|
+
## Tech Stack
|
|
6
|
+
|
|
7
|
+
- **Astro** (static output) with content collections
|
|
8
|
+
- **@headroom-cms/api** SDK for data fetching and block rendering
|
|
9
|
+
- **Tailwind CSS v4** for styling
|
|
10
|
+
|
|
11
|
+
## Project Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
src/
|
|
15
|
+
├── content.config.ts # Astro content collections backed by Headroom loader
|
|
16
|
+
├── lib/
|
|
17
|
+
│ ├── client.ts # Singleton HeadroomClient instance
|
|
18
|
+
│ ├── links.ts # Content link resolver (maps refs to URL paths)
|
|
19
|
+
│ └── schemas.ts # Auto-generated Zod schemas (run generate:schemas)
|
|
20
|
+
├── layouts/
|
|
21
|
+
│ └── BaseLayout.astro # Shell with Header, Footer, <slot />
|
|
22
|
+
├── components/ # Reusable Astro components
|
|
23
|
+
├── pages/
|
|
24
|
+
│ ├── index.astro # Homepage
|
|
25
|
+
│ ├── blog/
|
|
26
|
+
│ │ ├── index.astro # Blog listing
|
|
27
|
+
│ │ └── [slug].astro # Blog post detail
|
|
28
|
+
│ ├── [...slug].astro # Dynamic pages from "pages" collection
|
|
29
|
+
│ └── 404.astro
|
|
30
|
+
└── styles/
|
|
31
|
+
└── global.css # Tailwind imports
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Environment Variables
|
|
35
|
+
|
|
36
|
+
Copy `_env.example` to `.env` and fill in your Headroom credentials:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
HEADROOM_URL=https://your-headroom-url.cloudfront.net
|
|
40
|
+
HEADROOM_SITE=your-site.com
|
|
41
|
+
HEADROOM_API_KEY=headroom_xxxxx
|
|
42
|
+
HEADROOM_IMAGE_SIGNING_SECRET=your-secret # optional, for image transforms
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm run dev # Start Astro dev server
|
|
49
|
+
npm run build # Build static site
|
|
50
|
+
npm run preview # Preview built site
|
|
51
|
+
npm run generate:schemas # Generate Zod schemas from Headroom collections
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Default Collections
|
|
55
|
+
|
|
56
|
+
New Headroom sites are scaffolded with three collections:
|
|
57
|
+
|
|
58
|
+
| Collection | Type | Description |
|
|
59
|
+
|------------|------|-------------|
|
|
60
|
+
| `posts` | Standard | Blog posts with `author` (text) and `content` (blocks) fields; use the `featured` tag to mark featured posts |
|
|
61
|
+
| `pages` | Standard | Generic pages with a `content` (blocks) field |
|
|
62
|
+
| `site-settings` | Singleton | Site name, tagline, footer text, and menu items |
|
|
63
|
+
|
|
64
|
+
## How Data Fetching Works
|
|
65
|
+
|
|
66
|
+
This site uses **two approaches** to fetch content:
|
|
67
|
+
|
|
68
|
+
### 1. Astro Content Collections (for listings and metadata)
|
|
69
|
+
|
|
70
|
+
Content collections are defined in `src/content.config.ts` using `headroomLoader()`. This loads content metadata into Astro's content layer at build time, enabling `getCollection()` and `getEntry()`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// src/content.config.ts
|
|
74
|
+
import { defineCollection } from "astro:content";
|
|
75
|
+
import { headroomLoader } from "@headroom-cms/api/astro";
|
|
76
|
+
|
|
77
|
+
export const collections = {
|
|
78
|
+
posts: defineCollection({
|
|
79
|
+
loader: headroomLoader({ collection: "posts" }),
|
|
80
|
+
}),
|
|
81
|
+
pages: defineCollection({
|
|
82
|
+
loader: headroomLoader({ collection: "pages", bodies: true }),
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Use `bodies: true` when you need the full block content in the content layer (e.g., for pages). Omit it for listings where you only need metadata.
|
|
88
|
+
|
|
89
|
+
```astro
|
|
90
|
+
---
|
|
91
|
+
import { getCollection, getEntry } from "astro:content";
|
|
92
|
+
|
|
93
|
+
// List all posts
|
|
94
|
+
const posts = await getCollection("posts");
|
|
95
|
+
|
|
96
|
+
// Get a singleton
|
|
97
|
+
const settings = await getEntry("site-settings", "site-settings");
|
|
98
|
+
const siteName = settings?.data.body?.siteName as string;
|
|
99
|
+
---
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 2. Direct SDK Client (for full content with body and refs)
|
|
103
|
+
|
|
104
|
+
For detail pages that need the full block body and content references (`_refs`), use the SDK client directly:
|
|
105
|
+
|
|
106
|
+
```astro
|
|
107
|
+
---
|
|
108
|
+
import { getClient } from "../lib/client";
|
|
109
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
110
|
+
|
|
111
|
+
const client = getClient();
|
|
112
|
+
const post = await client.getContentBySlug("posts", "my-post");
|
|
113
|
+
|
|
114
|
+
const blocks = (post.body?.content || []) as Block[];
|
|
115
|
+
const refs = (post._refs || {}) as RefsMap;
|
|
116
|
+
---
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### SDK Client Methods
|
|
120
|
+
|
|
121
|
+
| Method | Returns | Description |
|
|
122
|
+
|--------|---------|-------------|
|
|
123
|
+
| `listContent(collection, opts?)` | `{ items, cursor, hasMore }` | List published content with pagination/filtering |
|
|
124
|
+
| `getContent(contentId)` | `ContentItem` | Get single item by ID (includes body + refs) |
|
|
125
|
+
| `getContentBySlug(collection, slug)` | `ContentItem \| undefined` | Look up by slug |
|
|
126
|
+
| `getSingleton(collection)` | `ContentItem` | Get singleton content (e.g. site settings) |
|
|
127
|
+
| `listCollections()` | `{ items }` | List all collections |
|
|
128
|
+
| `getCollection(name)` | `Collection` | Get collection schema |
|
|
129
|
+
| `mediaUrl(path)` | `string` | Full URL for a media path |
|
|
130
|
+
| `transformUrl(path, opts?)` | `string` | Signed image transform URL |
|
|
131
|
+
|
|
132
|
+
### listContent Options
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const { items, cursor, hasMore } = await client.listContent("posts", {
|
|
136
|
+
limit: 10,
|
|
137
|
+
cursor: "...", // pagination
|
|
138
|
+
sort: "published_desc", // published_desc | published_asc | title_asc | title_desc
|
|
139
|
+
before: 1700000000, // unix timestamp filter
|
|
140
|
+
after: 1690000000,
|
|
141
|
+
relatedTo: "01ABC", // reverse relationship query
|
|
142
|
+
relField: "author", // filter reverse query to specific field
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Rendering Blocks
|
|
147
|
+
|
|
148
|
+
Content bodies are arrays of [BlockNote](https://www.blocknotejs.org/) blocks. Use the Astro `BlockRenderer` component to render them as semantic HTML with zero client-side JavaScript:
|
|
149
|
+
|
|
150
|
+
```astro
|
|
151
|
+
---
|
|
152
|
+
import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
|
|
153
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
154
|
+
|
|
155
|
+
const blocks = (post.body?.content || []) as Block[];
|
|
156
|
+
const refs = (post._refs || {}) as RefsMap;
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
<div class="prose">
|
|
160
|
+
<BlockRenderer
|
|
161
|
+
blocks={blocks}
|
|
162
|
+
refs={refs}
|
|
163
|
+
resolveContentLink={(ref) => `/${ref.collection}/${ref.slug}`}
|
|
164
|
+
transformImage={(path) => client.transformUrl(path, { width: 1200, format: "webp" })}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### BlockRenderer Props
|
|
170
|
+
|
|
171
|
+
| Prop | Type | Description |
|
|
172
|
+
|------|------|-------------|
|
|
173
|
+
| `blocks` | `Block[]` | Block content array from `post.body.content` |
|
|
174
|
+
| `refs` | `RefsMap?` | Content reference map from `post._refs` (resolves `headroom://` links) |
|
|
175
|
+
| `resolveContentLink` | `(ref: PublicContentRef) => string` | Maps content references to URL paths |
|
|
176
|
+
| `transformImage` | `(path: string) => string` | Transforms image paths (e.g., for responsive images) |
|
|
177
|
+
| `baseUrl` | `string?` | Base URL for media (defaults to `HEADROOM_URL` env var) |
|
|
178
|
+
| `class` | `string?` | CSS class for the wrapper `<div>` |
|
|
179
|
+
|
|
180
|
+
### Block Types
|
|
181
|
+
|
|
182
|
+
Built-in block types: `paragraph`, `heading`, `image`, `codeBlock`, `bulletListItem`, `numberedListItem`, `checkListItem`, `table`
|
|
183
|
+
|
|
184
|
+
Astro block components render semantic HTML (`<p>`, `<h1>`-`<h6>`, `<ul>`, `<ol>`, `<figure>`, `<table>`) that work naturally with Tailwind's `prose` class.
|
|
185
|
+
|
|
186
|
+
### Custom Block Rendering in Astro
|
|
187
|
+
|
|
188
|
+
To add custom block types or override built-in rendering, create your own renderer that wraps the individual block components:
|
|
189
|
+
|
|
190
|
+
```astro
|
|
191
|
+
---
|
|
192
|
+
// src/components/CustomBlockRenderer.astro
|
|
193
|
+
import Paragraph from "@headroom-cms/api/blocks/Paragraph.astro";
|
|
194
|
+
import Heading from "@headroom-cms/api/blocks/Heading.astro";
|
|
195
|
+
import Image from "@headroom-cms/api/blocks/Image.astro";
|
|
196
|
+
import CodeBlock from "@headroom-cms/api/blocks/CodeBlock.astro";
|
|
197
|
+
import BulletList from "@headroom-cms/api/blocks/BulletList.astro";
|
|
198
|
+
import NumberedList from "@headroom-cms/api/blocks/NumberedList.astro";
|
|
199
|
+
import CheckList from "@headroom-cms/api/blocks/CheckList.astro";
|
|
200
|
+
import Table from "@headroom-cms/api/blocks/Table.astro";
|
|
201
|
+
import Fallback from "@headroom-cms/api/blocks/Fallback.astro";
|
|
202
|
+
import MyCallout from "./MyCallout.astro";
|
|
203
|
+
|
|
204
|
+
const { blocks, refs, resolveContentLink, transformImage } = Astro.props;
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
{blocks.map((block) => {
|
|
208
|
+
if (block.type === "callout") return <MyCallout block={block} />;
|
|
209
|
+
if (block.type === "paragraph") return <Paragraph block={block} refs={refs} resolveContentLink={resolveContentLink} />;
|
|
210
|
+
if (block.type === "heading") return <Heading block={block} refs={refs} resolveContentLink={resolveContentLink} />;
|
|
211
|
+
if (block.type === "image") return <Image block={block} transformImage={transformImage} />;
|
|
212
|
+
if (block.type === "codeBlock") return <CodeBlock block={block} />;
|
|
213
|
+
if (block.type === "bulletListItem") return <BulletList block={block} refs={refs} resolveContentLink={resolveContentLink} />;
|
|
214
|
+
if (block.type === "numberedListItem") return <NumberedList block={block} refs={refs} resolveContentLink={resolveContentLink} />;
|
|
215
|
+
if (block.type === "checkListItem") return <CheckList block={block} refs={refs} resolveContentLink={resolveContentLink} />;
|
|
216
|
+
if (block.type === "table") return <Table block={block} refs={refs} resolveContentLink={resolveContentLink} />;
|
|
217
|
+
return <Fallback block={block} />;
|
|
218
|
+
})}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Content Links
|
|
222
|
+
|
|
223
|
+
Rich text can contain links to other Headroom content using `headroom://content/{collection}/{contentId}` URLs. The `_refs` map on each content item resolves these to metadata (slug, title, collection, published status).
|
|
224
|
+
|
|
225
|
+
The `BlockRenderer` resolves these automatically using the `resolveContentLink` prop. Define your URL mapping in `src/lib/links.ts`:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import type { PublicContentRef } from "@headroom-cms/api";
|
|
229
|
+
|
|
230
|
+
export function resolveContentLink(ref: PublicContentRef): string {
|
|
231
|
+
switch (ref.collection) {
|
|
232
|
+
case "posts":
|
|
233
|
+
return `/blog/${ref.slug}`;
|
|
234
|
+
default:
|
|
235
|
+
return `/${ref.slug}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Relationships
|
|
241
|
+
|
|
242
|
+
Collections can define relationships to other collections. Access them on content items:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// Forward: get a project's related artists
|
|
246
|
+
const project = await client.getContent("01ABC");
|
|
247
|
+
const artists = project.relationships?.artists; // ContentRef[]
|
|
248
|
+
|
|
249
|
+
// Reverse: find all projects referencing an artist
|
|
250
|
+
const { items } = await client.listContent("projects", {
|
|
251
|
+
relatedTo: "01ARTIST",
|
|
252
|
+
relField: "artists",
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Images and Media
|
|
257
|
+
|
|
258
|
+
Media paths in content are relative (e.g., `/media/site/id/original.jpg`). Use the client to build full URLs:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
const client = getClient();
|
|
262
|
+
|
|
263
|
+
// Full URL for original image
|
|
264
|
+
client.mediaUrl(post.data.coverUrl);
|
|
265
|
+
|
|
266
|
+
// Signed transform URL (resized + webp)
|
|
267
|
+
client.transformUrl(post.data.coverUrl, { width: 800, format: "webp" });
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Transform Options
|
|
271
|
+
|
|
272
|
+
| Option | Type | Description |
|
|
273
|
+
|--------|------|-------------|
|
|
274
|
+
| `width` | `number` | Target width in pixels |
|
|
275
|
+
| `height` | `number` | Target height in pixels |
|
|
276
|
+
| `fit` | `"cover" \| "contain" \| "fill" \| "inside" \| "outside"` | Resize fit mode |
|
|
277
|
+
| `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | Output format |
|
|
278
|
+
| `quality` | `number` | 1-100 quality |
|
|
279
|
+
|
|
280
|
+
Transforms require `HEADROOM_IMAGE_SIGNING_SECRET` in `.env`. Without it, `transformUrl()` falls back to `mediaUrl()`.
|
|
281
|
+
|
|
282
|
+
## Dev Refresh
|
|
283
|
+
|
|
284
|
+
The `headroomDevRefresh()` integration in `astro.config.mjs` polls Headroom for content version changes and triggers automatic reloads during `astro dev`.
|
|
285
|
+
|
|
286
|
+
## Schema Generation
|
|
287
|
+
|
|
288
|
+
Run `npm run generate:schemas` to generate Zod schemas from your Headroom collections into `src/lib/schemas.ts`. This provides type-safe validation in content collections. Regenerate after changing collection schemas in the admin UI.
|
|
289
|
+
|
|
290
|
+
## Adding a New Collection
|
|
291
|
+
|
|
292
|
+
1. Create the collection in the Headroom admin UI
|
|
293
|
+
2. Add it to `src/content.config.ts`:
|
|
294
|
+
```ts
|
|
295
|
+
projects: defineCollection({
|
|
296
|
+
loader: headroomLoader({ collection: "projects" }),
|
|
297
|
+
}),
|
|
298
|
+
```
|
|
299
|
+
3. Create pages (listing + detail) under `src/pages/`
|
|
300
|
+
4. Update `src/lib/links.ts` to map the new collection to URL paths
|
|
301
|
+
5. Run `npm run generate:schemas` to update type definitions
|
|
302
|
+
|
|
303
|
+
## Adding a New Page for Existing Content
|
|
304
|
+
|
|
305
|
+
Example: adding a page that lists posts filtered by a relationship.
|
|
306
|
+
|
|
307
|
+
```astro
|
|
308
|
+
---
|
|
309
|
+
// src/pages/author/[id].astro
|
|
310
|
+
import { getClient } from "../../lib/client";
|
|
311
|
+
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
312
|
+
import PostCard from "../../components/PostCard.astro";
|
|
313
|
+
|
|
314
|
+
const { id } = Astro.params;
|
|
315
|
+
const client = getClient();
|
|
316
|
+
const { items: posts } = await client.listContent("posts", {
|
|
317
|
+
relatedTo: id,
|
|
318
|
+
relField: "author",
|
|
319
|
+
});
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
<BaseLayout title="Author Posts">
|
|
323
|
+
<div class="max-w-5xl mx-auto py-12 px-6">
|
|
324
|
+
<div class="grid md:grid-cols-3 gap-8">
|
|
325
|
+
{posts.map((post) => (
|
|
326
|
+
<PostCard title={post.title} snippet={post.snippet} slug={post.slug} />
|
|
327
|
+
))}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</BaseLayout>
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Error Handling
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
import { HeadroomClient, HeadroomError } from "@headroom-cms/api";
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const post = await client.getContent("nonexistent");
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (e instanceof HeadroomError) {
|
|
342
|
+
console.log(e.status); // 404
|
|
343
|
+
console.log(e.code); // "CONTENT_NOT_FOUND"
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Key Types
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import type {
|
|
352
|
+
ContentItem, // Full content item with body, refs, relationships
|
|
353
|
+
ContentMetadata, // Content without body (listings)
|
|
354
|
+
Block, // A BlockNote block
|
|
355
|
+
RefsMap, // Map of content ID -> PublicContentRef
|
|
356
|
+
PublicContentRef, // { contentId, collection, slug, title, published }
|
|
357
|
+
ContentRef, // Relationship reference
|
|
358
|
+
Collection, // Collection schema
|
|
359
|
+
FieldDef, // Field definition in a collection
|
|
360
|
+
RelationshipDef, // Relationship definition
|
|
361
|
+
HeadroomConfig, // Client configuration
|
|
362
|
+
TransformOptions, // Image transform options
|
|
363
|
+
} from "@headroom-cms/api";
|
|
364
|
+
```
|
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "astro dev",
|
|
7
7
|
"build": "astro build",
|
|
8
|
-
"preview": "astro preview"
|
|
8
|
+
"preview": "astro preview",
|
|
9
|
+
"generate:schemas": "bash scripts/generate-schemas.sh"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
12
|
"astro": "^5.0.0",
|
|
12
|
-
"@headroom-cms/api": "^0.1.
|
|
13
|
+
"@headroom-cms/api": "^0.1.4"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
15
16
|
"@tailwindcss/vite": "^4.0.0",
|
|
16
17
|
"tailwindcss": "^4.0.0",
|
|
18
|
+
"tsx": "^4.0.0",
|
|
17
19
|
"typescript": "^5.9.3"
|
|
18
20
|
}
|
|
19
21
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Generates Zod schemas from Headroom collection definitions.
|
|
5
|
+
# Requires: .env with HEADROOM_* vars, API accessible.
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
SITE_DIR="$(dirname "$SCRIPT_DIR")"
|
|
9
|
+
|
|
10
|
+
if [ ! -f "$SITE_DIR/.env" ]; then
|
|
11
|
+
echo "Error: .env not found. Create a .env file with HEADROOM_URL, HEADROOM_SITE, and HEADROOM_API_KEY." >&2
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
cd "$SITE_DIR"
|
|
16
|
+
npx tsx scripts/generate-schemas.ts
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Generates Zod schemas from Headroom collection definitions.
|
|
4
|
+
* Reads connection details from .env and writes src/lib/schemas.ts.
|
|
5
|
+
*
|
|
6
|
+
* Usage: npx tsx scripts/generate-schemas.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { HeadroomClient } from "@headroom-cms/api";
|
|
13
|
+
import { generateZodSchemas } from "@headroom-cms/api/codegen";
|
|
14
|
+
import { configFromEnv } from "@headroom-cms/api/astro";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const siteDir = resolve(__dirname, "..");
|
|
18
|
+
|
|
19
|
+
const config = configFromEnv();
|
|
20
|
+
if (!config.site || !config.apiKey) {
|
|
21
|
+
console.error("Error: HEADROOM_SITE and HEADROOM_API_KEY must be set in .env");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const client = new HeadroomClient(config);
|
|
26
|
+
const output = await generateZodSchemas(client);
|
|
27
|
+
|
|
28
|
+
const outPath = resolve(siteDir, "src/lib/schemas.ts");
|
|
29
|
+
writeFileSync(outPath, output, "utf-8");
|
|
30
|
+
console.log(`Schemas generated at src/lib/schemas.ts`);
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getEntry } from "astro:content";
|
|
3
|
+
|
|
4
|
+
const settingsEntry = await getEntry("site-settings", "site-settings");
|
|
5
|
+
const footerText = (settingsEntry?.data.body?.footerText as string) || "";
|
|
6
|
+
---
|
|
7
|
+
|
|
1
8
|
<footer class="border-t border-gray-100 py-8 mt-16">
|
|
2
9
|
<div class="max-w-5xl mx-auto px-6 text-center text-sm text-gray-500">
|
|
10
|
+
{footerText && <p class="mb-2">{footerText}</p>}
|
|
3
11
|
<p>
|
|
4
12
|
Powered by <a href="https://headroom.dev" class="hover:underline">Headroom CMS</a>
|
|
5
13
|
· Built with <a href="https://astro.build" class="hover:underline">Astro</a>
|
|
@@ -3,6 +3,7 @@ import { getEntry } from "astro:content";
|
|
|
3
3
|
|
|
4
4
|
const settingsEntry = await getEntry("site-settings", "site-settings");
|
|
5
5
|
const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
|
|
6
|
+
const menuItems = (settingsEntry?.data.body?.menuItems as Array<{ label: string; href: string }>) || [];
|
|
6
7
|
---
|
|
7
8
|
|
|
8
9
|
<header class="border-b border-gray-100 bg-white sticky top-0 z-50">
|
|
@@ -10,9 +11,12 @@ const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
|
|
|
10
11
|
<a href="/" class="text-xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
|
11
12
|
{siteName}
|
|
12
13
|
</a>
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
{menuItems.length > 0 && (
|
|
15
|
+
<ul class="flex items-center gap-6">
|
|
16
|
+
{menuItems.map((item) => (
|
|
17
|
+
<li><a href={item.href} class="text-sm font-medium text-gray-600 hover:text-gray-900">{item.label}</a></li>
|
|
18
|
+
))}
|
|
19
|
+
</ul>
|
|
20
|
+
)}
|
|
17
21
|
</nav>
|
|
18
22
|
</header>
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { defineCollection } from "astro:content";
|
|
2
2
|
import { headroomLoader } from "@headroom-cms/api/astro";
|
|
3
3
|
|
|
4
|
+
let schemas: Record<string, import("zod").ZodTypeAny> = {};
|
|
5
|
+
try {
|
|
6
|
+
schemas = await import("./lib/schemas");
|
|
7
|
+
} catch {
|
|
8
|
+
// schemas not yet generated — collections still work, just without validation
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
export const collections = {
|
|
5
12
|
posts: defineCollection({
|
|
6
|
-
loader: headroomLoader({ collection: "posts" }),
|
|
13
|
+
loader: headroomLoader({ collection: "posts", schema: schemas.postsSchema }),
|
|
7
14
|
}),
|
|
8
15
|
pages: defineCollection({
|
|
9
|
-
loader: headroomLoader({ collection: "pages", bodies: true }),
|
|
16
|
+
loader: headroomLoader({ collection: "pages", bodies: true, schema: schemas.pagesSchema }),
|
|
10
17
|
}),
|
|
11
18
|
"site-settings": defineCollection({
|
|
12
|
-
loader: headroomLoader({ collection: "site-settings", bodies: true }),
|
|
19
|
+
loader: headroomLoader({ collection: "site-settings", bodies: true, schema: schemas.siteSettingsSchema }),
|
|
13
20
|
}),
|
|
14
21
|
};
|
|
@@ -8,10 +8,12 @@ import { resolveContentLink } from "../lib/links";
|
|
|
8
8
|
|
|
9
9
|
export async function getStaticPaths() {
|
|
10
10
|
const pages = await getCollection("pages");
|
|
11
|
-
return pages
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
return pages
|
|
12
|
+
.filter((page) => page.id !== "home")
|
|
13
|
+
.map((page) => ({
|
|
14
|
+
params: { slug: page.id },
|
|
15
|
+
props: { page },
|
|
16
|
+
}));
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
const { page } = Astro.props;
|
|
@@ -23,7 +25,6 @@ const refs = (page.data._refs || {}) as RefsMap;
|
|
|
23
25
|
<BaseLayout title={page.data.title}>
|
|
24
26
|
<article class="py-12 px-6">
|
|
25
27
|
<div class="max-w-3xl mx-auto">
|
|
26
|
-
<h1 class="text-4xl font-extrabold tracking-tight mb-8">{page.data.title}</h1>
|
|
27
28
|
<div class="prose">
|
|
28
29
|
<BlockRenderer blocks={blocks} refs={refs} resolveContentLink={resolveContentLink} />
|
|
29
30
|
</div>
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { getCollection, getEntry } from "astro:content";
|
|
3
3
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
4
4
|
import PostCard from "../components/PostCard.astro";
|
|
5
|
+
import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
|
|
6
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
7
|
+
import { resolveContentLink } from "../lib/links";
|
|
5
8
|
|
|
6
9
|
const settingsEntry = await getEntry("site-settings", "site-settings");
|
|
7
10
|
const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
|
|
@@ -10,6 +13,11 @@ const tagline = (settingsEntry?.data.body?.tagline as string) || "";
|
|
|
10
13
|
const posts = (await getCollection("posts"))
|
|
11
14
|
.sort((a, b) => new Date(b.data.publishedAt).getTime() - new Date(a.data.publishedAt).getTime())
|
|
12
15
|
.slice(0, 3);
|
|
16
|
+
|
|
17
|
+
const pages = await getCollection("pages");
|
|
18
|
+
const homePage = pages.find((page) => page.id === "home");
|
|
19
|
+
const homeBlocks = (homePage?.data.body?.content || []) as Block[];
|
|
20
|
+
const homeRefs = (homePage?.data._refs || {}) as RefsMap;
|
|
13
21
|
---
|
|
14
22
|
|
|
15
23
|
<BaseLayout>
|
|
@@ -34,4 +42,12 @@ const posts = (await getCollection("posts"))
|
|
|
34
42
|
</div>
|
|
35
43
|
</section>
|
|
36
44
|
)}
|
|
45
|
+
|
|
46
|
+
{homeBlocks.length > 0 && (
|
|
47
|
+
<section class="py-12 px-6">
|
|
48
|
+
<div class="max-w-3xl mx-auto prose">
|
|
49
|
+
<BlockRenderer blocks={homeBlocks} refs={homeRefs} resolveContentLink={resolveContentLink} />
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
)}
|
|
37
53
|
</BaseLayout>
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Headroom CMS - Next.js Site
|
|
2
|
+
|
|
3
|
+
This is a Next.js site powered by [Headroom CMS](https://github.com/headroom-cms), a serverless headless CMS. Content is managed in the Headroom admin UI and fetched via the `@headroom-cms/api` SDK.
|
|
4
|
+
|
|
5
|
+
## Tech Stack
|
|
6
|
+
|
|
7
|
+
- **Next.js 15** (App Router, React Server Components)
|
|
8
|
+
- **@headroom-cms/api** SDK for data fetching and block rendering
|
|
9
|
+
- **Tailwind CSS v4** for styling
|
|
10
|
+
|
|
11
|
+
## Project Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
src/
|
|
15
|
+
├── app/
|
|
16
|
+
│ ├── layout.tsx # Root layout with Header, Footer, metadata from site-settings
|
|
17
|
+
│ ├── page.tsx # Homepage
|
|
18
|
+
│ ├── globals.css # Tailwind imports
|
|
19
|
+
│ ├── not-found.tsx # 404 page
|
|
20
|
+
│ ├── blog/
|
|
21
|
+
│ │ ├── page.tsx # Blog listing
|
|
22
|
+
│ │ └── [slug]/page.tsx # Blog post detail
|
|
23
|
+
│ └── [slug]/page.tsx # Dynamic pages from "pages" collection
|
|
24
|
+
├── components/ # Reusable React components
|
|
25
|
+
└── lib/
|
|
26
|
+
├── client.ts # Singleton HeadroomClient instance
|
|
27
|
+
└── links.ts # Content link resolver (maps refs to URL paths)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Environment Variables
|
|
31
|
+
|
|
32
|
+
Copy `_env.example` to `.env.local` and fill in your Headroom credentials:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
HEADROOM_URL=https://your-headroom-url.cloudfront.net
|
|
36
|
+
HEADROOM_SITE=your-site.com
|
|
37
|
+
HEADROOM_API_KEY=headroom_xxxxx
|
|
38
|
+
HEADROOM_IMAGE_SIGNING_SECRET=your-secret # optional, for image transforms
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm run dev # Start Next.js dev server
|
|
45
|
+
npm run build # Build for production
|
|
46
|
+
npm run start # Start production server
|
|
47
|
+
npm run generate:schemas # Generate Zod schemas from Headroom collections
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Default Collections
|
|
51
|
+
|
|
52
|
+
New Headroom sites are scaffolded with three collections:
|
|
53
|
+
|
|
54
|
+
| Collection | Type | Description |
|
|
55
|
+
|------------|------|-------------|
|
|
56
|
+
| `posts` | Standard | Blog posts with `author` (text) and `content` (blocks) fields; use the `featured` tag to mark featured posts |
|
|
57
|
+
| `pages` | Standard | Generic pages with a `content` (blocks) field |
|
|
58
|
+
| `site-settings` | Singleton | Site name, tagline, footer text, and menu items |
|
|
59
|
+
|
|
60
|
+
## Fetching Data
|
|
61
|
+
|
|
62
|
+
All data fetching uses the `HeadroomClient` from `src/lib/client.ts`. Since this is a Next.js App Router project, data fetching happens in **Server Components** (the default).
|
|
63
|
+
|
|
64
|
+
### Getting the Client
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { getClient } from "@/lib/client";
|
|
68
|
+
|
|
69
|
+
export default async function MyPage() {
|
|
70
|
+
const client = getClient();
|
|
71
|
+
const { items: posts } = await client.listContent("posts", {
|
|
72
|
+
limit: 10,
|
|
73
|
+
sort: "published_desc",
|
|
74
|
+
});
|
|
75
|
+
// ...
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### SDK Client Methods
|
|
80
|
+
|
|
81
|
+
| Method | Returns | Description |
|
|
82
|
+
|--------|---------|-------------|
|
|
83
|
+
| `listContent(collection, opts?)` | `{ items, cursor, hasMore }` | List published content with pagination/filtering |
|
|
84
|
+
| `getContent(contentId)` | `ContentItem` | Get single item by ID (includes body + refs) |
|
|
85
|
+
| `getContentBySlug(collection, slug)` | `ContentItem \| undefined` | Look up by slug |
|
|
86
|
+
| `getSingleton(collection)` | `ContentItem` | Get singleton content (e.g. site settings) |
|
|
87
|
+
| `listCollections()` | `{ items }` | List all collections |
|
|
88
|
+
| `getCollection(name)` | `Collection` | Get collection schema |
|
|
89
|
+
| `mediaUrl(path)` | `string` | Full URL for a media path |
|
|
90
|
+
| `transformUrl(path, opts?)` | `string` | Signed image transform URL |
|
|
91
|
+
|
|
92
|
+
### listContent Options
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const { items, cursor, hasMore } = await client.listContent("posts", {
|
|
96
|
+
limit: 10,
|
|
97
|
+
cursor: "...", // pagination
|
|
98
|
+
sort: "published_desc", // published_desc | published_asc | title_asc | title_desc
|
|
99
|
+
before: 1700000000, // unix timestamp filter
|
|
100
|
+
after: 1690000000,
|
|
101
|
+
relatedTo: "01ABC", // reverse relationship query
|
|
102
|
+
relField: "author", // filter reverse query to specific field
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Static Generation
|
|
107
|
+
|
|
108
|
+
Use `generateStaticParams` to pre-render content pages:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
export async function generateStaticParams() {
|
|
112
|
+
const client = getClient();
|
|
113
|
+
const { items } = await client.listContent("posts", { limit: 100 });
|
|
114
|
+
return items.map((post) => ({ slug: post.slug }));
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Fetching Singleton Content
|
|
119
|
+
|
|
120
|
+
Site-wide settings are stored in the `site-settings` singleton collection:
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
const client = getClient();
|
|
124
|
+
const settings = await client.getSingleton("site-settings").catch(() => null);
|
|
125
|
+
const siteName = (settings?.body?.siteName as string) || "My Site";
|
|
126
|
+
const menuItems = (settings?.body?.menuItems as Array<{ label: string; href: string }>) || [];
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Rendering Blocks
|
|
130
|
+
|
|
131
|
+
Content bodies are arrays of [BlockNote](https://www.blocknotejs.org/) blocks. Use the React `BlockRenderer` component:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { BlockRenderer } from "@headroom-cms/api/react";
|
|
135
|
+
import "@headroom-cms/api/react/headroom-blocks.css";
|
|
136
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
137
|
+
import { resolveContentLink } from "@/lib/links";
|
|
138
|
+
|
|
139
|
+
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
140
|
+
const { slug } = await params;
|
|
141
|
+
const client = getClient();
|
|
142
|
+
const post = await client.getContentBySlug("posts", slug);
|
|
143
|
+
if (!post) notFound();
|
|
144
|
+
|
|
145
|
+
const blocks = (post.body?.content || []) as Block[];
|
|
146
|
+
const refs = (post._refs || {}) as RefsMap;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="prose">
|
|
150
|
+
<BlockRenderer
|
|
151
|
+
blocks={blocks}
|
|
152
|
+
baseUrl={process.env.HEADROOM_URL}
|
|
153
|
+
refs={refs}
|
|
154
|
+
resolveContentLink={resolveContentLink}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### BlockRenderer Props
|
|
162
|
+
|
|
163
|
+
| Prop | Type | Description |
|
|
164
|
+
|------|------|-------------|
|
|
165
|
+
| `blocks` | `Block[]` | Block content array from `post.body.content` |
|
|
166
|
+
| `baseUrl` | `string?` | Base URL for media |
|
|
167
|
+
| `refs` | `RefsMap?` | Content reference map from `post._refs` (resolves `headroom://` links) |
|
|
168
|
+
| `resolveContentLink` | `(ref: PublicContentRef) => string` | Maps content references to URL paths |
|
|
169
|
+
| `components` | `BlockComponentMap?` | Override or extend block component rendering |
|
|
170
|
+
| `fallback` | `ComponentType \| null` | Custom fallback for unknown blocks (`null` to suppress) |
|
|
171
|
+
| `className` | `string?` | CSS class for the wrapper `<div>` |
|
|
172
|
+
|
|
173
|
+
### Block Types
|
|
174
|
+
|
|
175
|
+
Built-in block types: `paragraph`, `heading`, `image`, `codeBlock`, `bulletListItem`, `numberedListItem`, `checkListItem`, `table`
|
|
176
|
+
|
|
177
|
+
### Styling Blocks
|
|
178
|
+
|
|
179
|
+
Import the default stylesheet:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
import "@headroom-cms/api/react/headroom-blocks.css";
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Styles use low-specificity `:where()` selectors, easy to override. Customize via CSS custom properties:
|
|
186
|
+
|
|
187
|
+
| Property | Default | Used by |
|
|
188
|
+
|----------|---------|---------|
|
|
189
|
+
| `--hr-code-bg` | `#f3f4f6` | Inline code background |
|
|
190
|
+
| `--hr-link-color` | `#2563eb` | Link color |
|
|
191
|
+
| `--hr-image-radius` | `0.5rem` | Image border radius |
|
|
192
|
+
| `--hr-caption-color` | `#6b7280` | Image caption color |
|
|
193
|
+
| `--hr-code-block-bg` | `#1e1e1e` | Code block background |
|
|
194
|
+
| `--hr-code-block-color` | `#d4d4d4` | Code block text color |
|
|
195
|
+
| `--hr-accent` | `#2563eb` | Checkbox accent color |
|
|
196
|
+
| `--hr-table-header-bg` | `#f9fafb` | Table header background |
|
|
197
|
+
|
|
198
|
+
### Custom Block Components
|
|
199
|
+
|
|
200
|
+
Pass a `components` map to override built-in blocks or render custom block types:
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
import { BlockRenderer } from "@headroom-cms/api/react";
|
|
204
|
+
import type { BlockComponentProps } from "@headroom-cms/api/react";
|
|
205
|
+
|
|
206
|
+
function Callout({ block }: BlockComponentProps) {
|
|
207
|
+
return (
|
|
208
|
+
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 my-4">
|
|
209
|
+
<p>{block.props?.text as string}</p>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function CustomImage({ block }: BlockComponentProps) {
|
|
215
|
+
const src = block.props?.url as string;
|
|
216
|
+
return (
|
|
217
|
+
<figure className="my-8">
|
|
218
|
+
<img src={src} alt={block.props?.caption as string} className="rounded-xl shadow-lg" />
|
|
219
|
+
</figure>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
<BlockRenderer
|
|
224
|
+
blocks={blocks}
|
|
225
|
+
baseUrl={process.env.HEADROOM_URL}
|
|
226
|
+
refs={refs}
|
|
227
|
+
resolveContentLink={resolveContentLink}
|
|
228
|
+
components={{
|
|
229
|
+
callout: Callout,
|
|
230
|
+
image: CustomImage, // override built-in image rendering
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Content Links
|
|
236
|
+
|
|
237
|
+
Rich text can contain links to other Headroom content using `headroom://content/{collection}/{contentId}` URLs. The `_refs` map on each content item resolves these to metadata (slug, title, collection, published status).
|
|
238
|
+
|
|
239
|
+
The `BlockRenderer` resolves these automatically using the `resolveContentLink` prop. Define your URL mapping in `src/lib/links.ts`:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import type { PublicContentRef } from "@headroom-cms/api";
|
|
243
|
+
|
|
244
|
+
export function resolveContentLink(ref: PublicContentRef): string {
|
|
245
|
+
switch (ref.collection) {
|
|
246
|
+
case "posts":
|
|
247
|
+
return `/blog/${ref.slug}`;
|
|
248
|
+
default:
|
|
249
|
+
return `/${ref.slug}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Relationships
|
|
255
|
+
|
|
256
|
+
Collections can define relationships to other collections. Access them on content items:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// Forward: get a project's related artists
|
|
260
|
+
const project = await client.getContent("01ABC");
|
|
261
|
+
const artists = project.relationships?.artists; // ContentRef[]
|
|
262
|
+
|
|
263
|
+
// Reverse: find all projects referencing an artist
|
|
264
|
+
const { items } = await client.listContent("projects", {
|
|
265
|
+
relatedTo: "01ARTIST",
|
|
266
|
+
relField: "artists",
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Images and Media
|
|
271
|
+
|
|
272
|
+
Media paths in content are relative (e.g., `/media/site/id/original.jpg`). Use the client to build full URLs:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
const client = getClient();
|
|
276
|
+
|
|
277
|
+
// Full URL for original image
|
|
278
|
+
client.mediaUrl(post.coverUrl);
|
|
279
|
+
|
|
280
|
+
// Signed transform URL (resized + webp)
|
|
281
|
+
client.transformUrl(post.coverUrl, { width: 800, format: "webp" });
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Transform Options
|
|
285
|
+
|
|
286
|
+
| Option | Type | Description |
|
|
287
|
+
|--------|------|-------------|
|
|
288
|
+
| `width` | `number` | Target width in pixels |
|
|
289
|
+
| `height` | `number` | Target height in pixels |
|
|
290
|
+
| `fit` | `"cover" \| "contain" \| "fill" \| "inside" \| "outside"` | Resize fit mode |
|
|
291
|
+
| `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | Output format |
|
|
292
|
+
| `quality` | `number` | 1-100 quality |
|
|
293
|
+
|
|
294
|
+
Transforms require `HEADROOM_IMAGE_SIGNING_SECRET` in `.env.local`. Without it, `transformUrl()` falls back to `mediaUrl()`.
|
|
295
|
+
|
|
296
|
+
## Adding a New Collection
|
|
297
|
+
|
|
298
|
+
1. Create the collection in the Headroom admin UI
|
|
299
|
+
2. Create pages under `src/app/`:
|
|
300
|
+
```tsx
|
|
301
|
+
// src/app/projects/page.tsx — listing
|
|
302
|
+
import { getClient } from "@/lib/client";
|
|
303
|
+
|
|
304
|
+
export default async function ProjectsPage() {
|
|
305
|
+
const client = getClient();
|
|
306
|
+
const { items } = await client.listContent("projects", { sort: "published_desc" });
|
|
307
|
+
return (
|
|
308
|
+
<div className="max-w-5xl mx-auto py-12 px-6">
|
|
309
|
+
{items.map((project) => (
|
|
310
|
+
<a key={project.contentId} href={`/projects/${project.slug}`}>
|
|
311
|
+
<h3>{project.title}</h3>
|
|
312
|
+
</a>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
```tsx
|
|
319
|
+
// src/app/projects/[slug]/page.tsx — detail
|
|
320
|
+
import { getClient } from "@/lib/client";
|
|
321
|
+
import { BlockRenderer } from "@headroom-cms/api/react";
|
|
322
|
+
import "@headroom-cms/api/react/headroom-blocks.css";
|
|
323
|
+
import { resolveContentLink } from "@/lib/links";
|
|
324
|
+
import { notFound } from "next/navigation";
|
|
325
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
326
|
+
|
|
327
|
+
export async function generateStaticParams() {
|
|
328
|
+
const client = getClient();
|
|
329
|
+
const { items } = await client.listContent("projects", { limit: 100 });
|
|
330
|
+
return items.map((p) => ({ slug: p.slug }));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export default async function ProjectPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
334
|
+
const { slug } = await params;
|
|
335
|
+
const client = getClient();
|
|
336
|
+
const project = await client.getContentBySlug("projects", slug);
|
|
337
|
+
if (!project) notFound();
|
|
338
|
+
|
|
339
|
+
const blocks = (project.body?.content || []) as Block[];
|
|
340
|
+
const refs = (project._refs || {}) as RefsMap;
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<article className="py-12 px-6">
|
|
344
|
+
<div className="max-w-3xl mx-auto">
|
|
345
|
+
<h1 className="text-4xl font-extrabold tracking-tight mb-4">{project.title}</h1>
|
|
346
|
+
<div className="prose">
|
|
347
|
+
<BlockRenderer blocks={blocks} baseUrl={process.env.HEADROOM_URL} refs={refs} resolveContentLink={resolveContentLink} />
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</article>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
3. Update `src/lib/links.ts` to map the new collection to URL paths
|
|
355
|
+
|
|
356
|
+
## Error Handling
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import { HeadroomClient, HeadroomError } from "@headroom-cms/api";
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const post = await client.getContent("nonexistent");
|
|
363
|
+
} catch (e) {
|
|
364
|
+
if (e instanceof HeadroomError) {
|
|
365
|
+
console.log(e.status); // 404
|
|
366
|
+
console.log(e.code); // "CONTENT_NOT_FOUND"
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Key Types
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
import type {
|
|
375
|
+
ContentItem, // Full content item with body, refs, relationships
|
|
376
|
+
ContentMetadata, // Content without body (listings)
|
|
377
|
+
Block, // A BlockNote block
|
|
378
|
+
RefsMap, // Map of content ID -> PublicContentRef
|
|
379
|
+
PublicContentRef, // { contentId, collection, slug, title, published }
|
|
380
|
+
ContentRef, // Relationship reference
|
|
381
|
+
Collection, // Collection schema
|
|
382
|
+
FieldDef, // Field definition in a collection
|
|
383
|
+
RelationshipDef, // Relationship definition
|
|
384
|
+
HeadroomConfig, // Client configuration
|
|
385
|
+
TransformOptions, // Image transform options
|
|
386
|
+
} from "@headroom-cms/api";
|
|
387
|
+
|
|
388
|
+
// React-specific types
|
|
389
|
+
import type {
|
|
390
|
+
BlockRendererProps,
|
|
391
|
+
BlockComponentProps,
|
|
392
|
+
BlockComponentMap,
|
|
393
|
+
} from "@headroom-cms/api/react";
|
|
394
|
+
```
|
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
|
-
"start": "next start"
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"generate:schemas": "bash scripts/generate-schemas.sh"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
12
|
"next": "^15.0.0",
|
|
12
13
|
"react": "^19.0.0",
|
|
13
14
|
"react-dom": "^19.0.0",
|
|
14
|
-
"@headroom-cms/api": "^0.1.
|
|
15
|
+
"@headroom-cms/api": "^0.1.4"
|
|
15
16
|
},
|
|
16
17
|
"devDependencies": {
|
|
17
18
|
"@tailwindcss/postcss": "^4.0.0",
|
|
18
19
|
"tailwindcss": "^4.0.0",
|
|
20
|
+
"tsx": "^4.0.0",
|
|
19
21
|
"typescript": "^5.9.3",
|
|
20
22
|
"@types/react": "^19.0.0",
|
|
21
23
|
"@types/node": "^22.0.0"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Generates Zod schemas from Headroom collection definitions.
|
|
5
|
+
# Requires: .env with HEADROOM_* vars, API accessible.
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
SITE_DIR="$(dirname "$SCRIPT_DIR")"
|
|
9
|
+
|
|
10
|
+
if [ ! -f "$SITE_DIR/.env" ]; then
|
|
11
|
+
echo "Error: .env not found. Create a .env file with HEADROOM_URL, HEADROOM_SITE, and HEADROOM_API_KEY." >&2
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
cd "$SITE_DIR"
|
|
16
|
+
npx tsx scripts/generate-schemas.ts
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Generates Zod schemas from Headroom collection definitions.
|
|
4
|
+
* Reads connection details from .env and writes src/lib/schemas.ts.
|
|
5
|
+
*
|
|
6
|
+
* Usage: npx tsx scripts/generate-schemas.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { HeadroomClient } from "@headroom-cms/api";
|
|
13
|
+
import { generateZodSchemas } from "@headroom-cms/api/codegen";
|
|
14
|
+
import { configFromEnv } from "@headroom-cms/api/astro";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const siteDir = resolve(__dirname, "..");
|
|
18
|
+
|
|
19
|
+
const config = configFromEnv();
|
|
20
|
+
if (!config.site || !config.apiKey) {
|
|
21
|
+
console.error("Error: HEADROOM_SITE and HEADROOM_API_KEY must be set in .env");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const client = new HeadroomClient(config);
|
|
26
|
+
const output = await generateZodSchemas(client);
|
|
27
|
+
|
|
28
|
+
const outPath = resolve(siteDir, "src/lib/schemas.ts");
|
|
29
|
+
writeFileSync(outPath, output, "utf-8");
|
|
30
|
+
console.log(`Schemas generated at src/lib/schemas.ts`);
|
|
@@ -12,7 +12,9 @@ interface Props {
|
|
|
12
12
|
export async function generateStaticParams() {
|
|
13
13
|
const client = getClient();
|
|
14
14
|
const { items } = await client.listContent("pages", { limit: 100 });
|
|
15
|
-
return items
|
|
15
|
+
return items
|
|
16
|
+
.filter((page) => page.slug !== "home")
|
|
17
|
+
.map((page) => ({ slug: page.slug }));
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export default async function Page({ params }: Props) {
|
|
@@ -27,9 +29,6 @@ export default async function Page({ params }: Props) {
|
|
|
27
29
|
return (
|
|
28
30
|
<article className="py-12 px-6">
|
|
29
31
|
<div className="max-w-3xl mx-auto">
|
|
30
|
-
<h1 className="text-4xl font-extrabold tracking-tight mb-8">
|
|
31
|
-
{page.title}
|
|
32
|
-
</h1>
|
|
33
32
|
<div className="prose">
|
|
34
33
|
<BlockRenderer
|
|
35
34
|
blocks={blocks}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { getClient } from "@/lib/client";
|
|
2
2
|
import { PostCard } from "@/components/PostCard";
|
|
3
|
+
import { BlockRenderer } from "@headroom-cms/api/react";
|
|
4
|
+
import "@headroom-cms/api/react/headroom-blocks.css";
|
|
5
|
+
import { resolveContentLink } from "@/lib/links";
|
|
6
|
+
import type { Block, RefsMap } from "@headroom-cms/api";
|
|
3
7
|
|
|
4
8
|
export default async function Home() {
|
|
5
9
|
const client = getClient();
|
|
@@ -8,6 +12,9 @@ export default async function Home() {
|
|
|
8
12
|
limit: 3,
|
|
9
13
|
sort: "published_desc",
|
|
10
14
|
});
|
|
15
|
+
const homePage = await client.getContentBySlug("pages", "home").catch(() => null);
|
|
16
|
+
const homeBlocks = (homePage?.body?.content || []) as Block[];
|
|
17
|
+
const homeRefs = (homePage?._refs || {}) as RefsMap;
|
|
11
18
|
|
|
12
19
|
return (
|
|
13
20
|
<>
|
|
@@ -36,6 +43,19 @@ export default async function Home() {
|
|
|
36
43
|
</div>
|
|
37
44
|
</section>
|
|
38
45
|
)}
|
|
46
|
+
|
|
47
|
+
{homeBlocks.length > 0 && (
|
|
48
|
+
<section className="py-12 px-6">
|
|
49
|
+
<div className="max-w-3xl mx-auto prose">
|
|
50
|
+
<BlockRenderer
|
|
51
|
+
blocks={homeBlocks}
|
|
52
|
+
baseUrl={process.env.HEADROOM_URL}
|
|
53
|
+
refs={homeRefs}
|
|
54
|
+
resolveContentLink={resolveContentLink}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
)}
|
|
39
59
|
</>
|
|
40
60
|
);
|
|
41
61
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { getClient } from "@/lib/client";
|
|
2
|
+
|
|
3
|
+
export async function Footer() {
|
|
4
|
+
const client = getClient();
|
|
5
|
+
const settings = await client.getSingleton("site-settings").catch(() => null);
|
|
6
|
+
const footerText = (settings?.body?.footerText as string) || "";
|
|
7
|
+
|
|
2
8
|
return (
|
|
3
9
|
<footer className="border-t border-gray-100 py-8 mt-16">
|
|
4
10
|
<div className="max-w-5xl mx-auto px-6 text-center text-sm text-gray-500">
|
|
11
|
+
{footerText && <p className="mb-2">{footerText}</p>}
|
|
5
12
|
<p>
|
|
6
13
|
Powered by{" "}
|
|
7
14
|
<a href="https://headroom.dev" className="hover:underline">Headroom CMS</a>
|
|
@@ -5,6 +5,7 @@ export async function Header() {
|
|
|
5
5
|
const client = getClient();
|
|
6
6
|
const settings = await client.getSingleton("site-settings").catch(() => null);
|
|
7
7
|
const siteName = (settings?.body?.siteName as string) || "My Site";
|
|
8
|
+
const menuItems = (settings?.body?.menuItems as Array<{ label: string; href: string }>) || [];
|
|
8
9
|
|
|
9
10
|
return (
|
|
10
11
|
<header className="border-b border-gray-100 bg-white sticky top-0 z-50">
|
|
@@ -12,10 +13,13 @@ export async function Header() {
|
|
|
12
13
|
<Link href="/" className="text-xl font-bold tracking-tight hover:opacity-80 transition-opacity">
|
|
13
14
|
{siteName}
|
|
14
15
|
</Link>
|
|
15
|
-
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
{menuItems.length > 0 && (
|
|
17
|
+
<ul className="flex items-center gap-6">
|
|
18
|
+
{menuItems.map((item) => (
|
|
19
|
+
<li key={item.href}><Link href={item.href} className="text-sm font-medium text-gray-600 hover:text-gray-900">{item.label}</Link></li>
|
|
20
|
+
))}
|
|
21
|
+
</ul>
|
|
22
|
+
)}
|
|
19
23
|
</nav>
|
|
20
24
|
</header>
|
|
21
25
|
);
|