@areca-cms/skills 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +2 -1
- package/dist/cli.js +3 -1
- package/dist/skills.d.ts +8 -3
- package/dist/skills.js +19 -12
- package/package.json +1 -1
- package/skills/areca-mcp/SKILL.md +23 -8
- package/skills/areca-mcp/references/content.md +8 -1
- package/skills/areca-mcp/references/languages.md +86 -0
- package/skills/areca-mcp/references/tools.md +32 -9
- package/skills/areca-quickstart/SKILL.md +245 -0
- package/skills/areca-quickstart/references/connect.md +123 -0
- package/skills/areca-quickstart/references/first-release.md +181 -0
- package/skills/areca-quickstart/references/frameworks.md +533 -0
- package/skills/areca-quickstart/references/interview.md +169 -0
- package/skills/areca-quickstart/references/recipes.md +243 -0
- package/skills/areca-sdk/SKILL.md +81 -34
- package/skills/areca-sdk/references/api.md +78 -15
- package/skills/areca-sdk/references/languages.md +177 -0
- package/skills/areca-sdk/references/production.md +8 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# Framework wiring
|
|
2
|
+
|
|
3
|
+
Use the section for the framework detected in step 0. Each section gives the pattern and a small example. The API is in the `areca-sdk` skill. Do not add other helpers.
|
|
4
|
+
|
|
5
|
+
The reference implementation is the demo site: https://github.com/ekozf/areca-cms/tree/master/examples/demo-site (TanStack Start with Paraglide, two languages, drafts, health route). Copy its patterns, not its copy.
|
|
6
|
+
|
|
7
|
+
Rules for every framework:
|
|
8
|
+
|
|
9
|
+
- Read content only on the server or at build time. Import `content` in server code only: a server function, a server component, a build script or a server route.
|
|
10
|
+
- Never import the generated `content` into a client component or a browser bundle. It reads files with `node:fs`, so the client build breaks or leaks server code.
|
|
11
|
+
- Use the same code path for every page. Keep list and detail pages on the same functions.
|
|
12
|
+
- Typecheck only after the first release pull (`areca.gen.ts` is written by the pull). Before that, check the files by reading them.
|
|
13
|
+
|
|
14
|
+
Common pieces, in every framework:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"scripts": {
|
|
19
|
+
"content:sync": "areca manifest pull && areca pull --out src/generated/content",
|
|
20
|
+
"content:pull": "areca pull --out src/generated/content",
|
|
21
|
+
"prebuild": "areca pull"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Keep the script names the ones your project uses. `content:sync` is for MCP-first. Code-first uses `content:pull` and `sync-manifest`. How `content:sync` behaves (it asks only before a removal) is in [SKILL.md](../SKILL.md), step 4b.
|
|
27
|
+
|
|
28
|
+
`areca pull` reads `areca.config.ts` and `.env.local` itself, so the hook needs no extra flags.
|
|
29
|
+
|
|
30
|
+
## Paraglide with TanStack Start (verified)
|
|
31
|
+
|
|
32
|
+
Paraglide is the only i18n library the SDK wires for you. With `i18n: { paraglide: './src/paraglide/runtime.js' }` in `areca.config.ts`, the generated content module reads the language from Paraglide's `getLocale()`. Use Paraglide when the project has no i18n library yet. If the project already uses another library, use the explicit locale (the end of this file).
|
|
33
|
+
|
|
34
|
+
This recipe was run end to end on a TanStack Start app with two languages (`en` main, `nl`). The Dutch blog is `/nl/blog/<slug>`, and the Dutch vacancies are `/nl/vacatures/<slug>`. Versions: `@inlang/paraglide-js` 2.29.1, `@tanstack/react-start` 1.169.4, Node 24.
|
|
35
|
+
|
|
36
|
+
Install only after the user says yes (step 1). The other steps only change files the plan names.
|
|
37
|
+
|
|
38
|
+
1. Install: `<pm> add @inlang/paraglide-js`. The compile reads plugins from a CDN (see the settings file), so it needs internet access.
|
|
39
|
+
2. Create `project.inlang/settings.json`. Keep `baseLocale` and `locales` equal to `languages` in `areca.config.ts`:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"$schema": "https://inlang.com/schema/project-settings",
|
|
44
|
+
"baseLocale": "en",
|
|
45
|
+
"locales": ["en", "nl"],
|
|
46
|
+
"modules": [
|
|
47
|
+
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
|
48
|
+
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
|
|
49
|
+
],
|
|
50
|
+
"plugin.inlang.messageFormat": {
|
|
51
|
+
"pathPattern": "./messages/{locale}.json"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
3. Create `messages/en.json` and `messages/nl.json`. Each holds only `{ "$schema": "https://inlang.com/schema/inlang-message-format" }`. Add UI strings later. Until then, the UI text stays in one language.
|
|
57
|
+
4. `tsconfig.json`: add `"allowJs": true` to `compilerOptions`. The compiled Paraglide files are JavaScript. The `#/*` alias must exist in `tsconfig.json` (`paths`) and `package.json` (`imports`). The scaffold has both. Add them if they are missing.
|
|
58
|
+
5. `areca.config.ts`: add `i18n: { paraglide: './src/paraglide/runtime.js' },` below `languages`.
|
|
59
|
+
6. `package.json` scripts. Keep `dev` as it is. Add `paraglide`, add `predev`, and change `prebuild` so the compile runs before the pull:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
"paraglide": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide",
|
|
63
|
+
"predev": "pnpm run paraglide",
|
|
64
|
+
"prebuild": "pnpm run paraglide && areca pull"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The pull writes `areca.gen.ts`, which imports `src/paraglide/runtime.js`. The compile must exist before the typecheck.
|
|
68
|
+
7. `vite.config.ts`: add the Paraglide plugin, and point the TanStack Start server at `src/server.ts`:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { paraglideVitePlugin } from '@inlang/paraglide-js'
|
|
72
|
+
// ...
|
|
73
|
+
plugins: [
|
|
74
|
+
paraglideVitePlugin({
|
|
75
|
+
project: './project.inlang',
|
|
76
|
+
outdir: './src/paraglide',
|
|
77
|
+
strategy: ['url', 'baseLocale'],
|
|
78
|
+
}),
|
|
79
|
+
tanstackStart({ server: { entry: 'server.ts' } }),
|
|
80
|
+
viteReact(),
|
|
81
|
+
],
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`strategy: ['url', 'baseLocale']` takes the language from the URL prefix only. Pages never depend on a cookie or the browser language.
|
|
85
|
+
8. `src/server.ts` (new). It sets the language for each server request from the URL:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server'
|
|
89
|
+
import type { Register } from '@tanstack/react-router'
|
|
90
|
+
import type { RequestHandler } from '@tanstack/react-start/server'
|
|
91
|
+
import { paraglideMiddleware } from '#/paraglide/server.js'
|
|
92
|
+
|
|
93
|
+
const startFetch = createStartHandler(defaultStreamHandler)
|
|
94
|
+
|
|
95
|
+
const fetch: RequestHandler<Register> = (req, ...args) =>
|
|
96
|
+
paraglideMiddleware(req, () => startFetch(req, ...args))
|
|
97
|
+
|
|
98
|
+
export default { fetch }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
9. `src/lib/locale-routes.ts` (new). Routes are written once in the base language. This file translates the page segment for each other language:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
export const BASE_LOCALE = 'en'
|
|
105
|
+
export const LOCALES = ['en', 'nl'] as const
|
|
106
|
+
export type Locale = (typeof LOCALES)[number]
|
|
107
|
+
|
|
108
|
+
// Base segment -> segment per language. Keep in step with `routes` in areca.config.ts.
|
|
109
|
+
export const SEGMENTS: Record<string, Partial<Record<Locale, string>>> = {
|
|
110
|
+
vacancies: { nl: 'vacatures' },
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function mapFirstSegment(pathname: string, map: (segment: string) => string): string {
|
|
114
|
+
const parts = pathname.split('/')
|
|
115
|
+
if (parts.length < 2 || parts[1] === '') return pathname
|
|
116
|
+
parts[1] = map(parts[1]!)
|
|
117
|
+
return parts.join('/')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** /vacancies/x -> /vacatures/x for nl. */
|
|
121
|
+
export function toLocalePath(pathname: string, locale: Locale): string {
|
|
122
|
+
if (locale === BASE_LOCALE) return pathname
|
|
123
|
+
return mapFirstSegment(pathname, (segment) => SEGMENTS[segment]?.[locale] ?? segment)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The inverse: /vacatures/x -> /vacancies/x. */
|
|
127
|
+
export function toBasePath(pathname: string, locale: Locale): string {
|
|
128
|
+
if (locale === BASE_LOCALE) return pathname
|
|
129
|
+
return mapFirstSegment(pathname, (segment) => {
|
|
130
|
+
const base = Object.keys(SEGMENTS).find((key) => SEGMENTS[key]?.[locale] === segment)
|
|
131
|
+
return base ?? segment
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
10. `src/router.tsx`: add the URL rewrite. Paraglide adds and strips `/nl`, and this code translates the page segment:
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
|
140
|
+
import { deLocalizeUrl, getLocale, localizeUrl } from '#/paraglide/runtime'
|
|
141
|
+
import { routeTree } from './routeTree.gen'
|
|
142
|
+
import { toBasePath, toLocalePath } from './lib/locale-routes'
|
|
143
|
+
|
|
144
|
+
function withPathname(url: URL, pathname: string): URL {
|
|
145
|
+
const next = new URL(url.href)
|
|
146
|
+
next.pathname = pathname
|
|
147
|
+
return next
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getRouter() {
|
|
151
|
+
const router = createTanStackRouter({
|
|
152
|
+
routeTree,
|
|
153
|
+
scrollRestoration: true,
|
|
154
|
+
defaultPreload: 'intent',
|
|
155
|
+
defaultPreloadStaleTime: 0,
|
|
156
|
+
rewrite: {
|
|
157
|
+
input: ({ url }) => {
|
|
158
|
+
const base = deLocalizeUrl(url)
|
|
159
|
+
return withPathname(base, toBasePath(base.pathname, getLocale()))
|
|
160
|
+
},
|
|
161
|
+
output: ({ url }) => {
|
|
162
|
+
const locale = getLocale()
|
|
163
|
+
return localizeUrl(withPathname(url, toLocalePath(url.pathname, locale)), { locale })
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
return router
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
declare module '@tanstack/react-router' {
|
|
171
|
+
interface Register {
|
|
172
|
+
router: ReturnType<typeof getRouter>
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
11. Routes: write the base-language routes only (`src/routes/blog/$slug.tsx`, `src/routes/vacancies/index.tsx`, and so on). Do not make `routes/nl/` copies. Every loader passes the language from `getLocale()`:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
import { getLocale } from '#/paraglide/runtime'
|
|
181
|
+
// ...
|
|
182
|
+
loader: async ({ params }) => {
|
|
183
|
+
const post = await loadPost({ data: { locale: getLocale(), slug: params.slug } })
|
|
184
|
+
if (!post) throw notFound()
|
|
185
|
+
return post
|
|
186
|
+
},
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The list rows use TanStack `Link` with the base route. The router rewrite adds `/nl` for Dutch:
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
<Link to="/blog/$slug" params={{ slug: row.slug }}>{row.title}</Link>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
12. `src/routes/__root.tsx`: set the page language from the URL: `<html lang={getLocale()}>` (import `getLocale` from `#/paraglide/runtime`).
|
|
196
|
+
13. Check, in this order:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
<pm> run generate-routes # regenerates routeTree.gen.ts (or: <pm> exec tsr generate)
|
|
200
|
+
<pm> exec tsc --noEmit
|
|
201
|
+
<pm> run build
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Then start the dev server (`<pm> run dev`) and check the pages with the HTTP checks in [first-release.md](first-release.md).
|
|
205
|
+
|
|
206
|
+
Notes:
|
|
207
|
+
|
|
208
|
+
- The `routes` values in `areca.config.ts` (for example `routes: { nl: '/vacatures/$slug' }`) have no `/nl` prefix. Keep `SEGMENTS` in step with them. See [recipes.md](recipes.md#routes-and-languages).
|
|
209
|
+
- The check was done with `curl` on server-rendered pages. Client-side navigation was not tested in a browser.
|
|
210
|
+
- The recipe does not turn on prerendering. Add `prerender` in the Vite config only when the site needs static HTML, and exclude `/__preview` and `/api` (as in the demo).
|
|
211
|
+
|
|
212
|
+
## TanStack Start
|
|
213
|
+
|
|
214
|
+
Detected by `@tanstack/react-start` in `package.json`. This section is for a site without Paraglide. For Paraglide, follow the recipe above first, then use the same pattern. The loaders pass `getLocale()` instead of a fixed locale.
|
|
215
|
+
|
|
216
|
+
No framework yet? Use the TanStack CLI: `<pm> dlx @tanstack/cli@latest create`. Do not use `create-tsrouter-app`. The rest of this skill assumes the site already exists.
|
|
217
|
+
|
|
218
|
+
Dependency to add (ask first): `@tanstack/markdown@1.0.0`, the same version the SDK pins. Install it with `<pm> add @tanstack/markdown@1.0.0`.
|
|
219
|
+
|
|
220
|
+
Rules for this framework:
|
|
221
|
+
|
|
222
|
+
- Server functions return plain data only: strings, numbers, arrays and objects. Do not return the entry, `seo` or a rich text object. `seo.jsonLd` and `richText.json` do not serialize, so the typecheck fails. Build the head on the server with `seoHead(...)`, and the body as a Markdown string with `richTextMarkdown(...)`.
|
|
223
|
+
- Keep every content call inside a `createServerFn`. Route files import the server function, never the content object.
|
|
224
|
+
- `.validator(...)` takes a plain function. No `zod` is needed.
|
|
225
|
+
- Run the typecheck only after the first pull (`areca.gen.ts` does not exist before it). Generate the route tree first (`<pm> run generate-routes`, or `<pm> exec tsr generate`), then `<pm> exec tsc --noEmit`.
|
|
226
|
+
|
|
227
|
+
### Server functions
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
// src/lib/content.functions.ts (server only)
|
|
231
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
232
|
+
import { richTextMarkdown, seoHead, type SeoHeadResult } from '@areca-cms/site-sdk'
|
|
233
|
+
import { content } from '../generated/content/areca.gen'
|
|
234
|
+
|
|
235
|
+
type Locale = 'en' | 'nl' // the languages in areca.config.ts
|
|
236
|
+
|
|
237
|
+
function parseLocale(value: unknown): Locale {
|
|
238
|
+
if (value === 'en' || value === 'nl') return value
|
|
239
|
+
throw new Error(`Unknown locale: ${String(value)}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The SDK's route has no language prefix. Add it for every language but the main one.
|
|
243
|
+
const localePath = (locale: Locale, path: string) => (locale === 'en' ? path : `/${locale}${path}`)
|
|
244
|
+
|
|
245
|
+
export type ListRow = { slug: string; title: string; summary: string | null; href: string }
|
|
246
|
+
export type PostPage = { title: string; body: string; head: SeoHeadResult }
|
|
247
|
+
|
|
248
|
+
export const loadPosts = createServerFn({ method: 'GET' })
|
|
249
|
+
.validator((input: { locale: unknown }) => ({ locale: parseLocale(input.locale) }))
|
|
250
|
+
.handler(({ data }): ListRow[] =>
|
|
251
|
+
content
|
|
252
|
+
.in(data.locale)
|
|
253
|
+
.posts.all({ orderBy: { publishedAt: 'desc' } })
|
|
254
|
+
.map((post) => ({
|
|
255
|
+
slug: post.slug,
|
|
256
|
+
title: post.data.title,
|
|
257
|
+
summary: post.data.excerpt ?? null,
|
|
258
|
+
href: localePath(data.locale, content.in(data.locale).posts.href(post.slug)),
|
|
259
|
+
})),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
export const loadPost = createServerFn({ method: 'GET' })
|
|
263
|
+
.validator((input: { locale: unknown; slug: unknown }) => ({
|
|
264
|
+
locale: parseLocale(input.locale),
|
|
265
|
+
slug: String(input.slug),
|
|
266
|
+
}))
|
|
267
|
+
.handler(({ data }): PostPage | null => {
|
|
268
|
+
const post = content.in(data.locale).posts.get(data.slug)
|
|
269
|
+
if (!post) return null
|
|
270
|
+
return {
|
|
271
|
+
title: post.data.title,
|
|
272
|
+
body: richTextMarkdown(post.data.body),
|
|
273
|
+
head: seoHead(post.seo, { siteName: 'Acme' }),
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Replace `Acme` with the client's name from `companyInfo`. For a single-language site, drop `locale`, `parseLocale` and `localePath`, and call `content.posts.all()`.
|
|
279
|
+
|
|
280
|
+
### Detail and list routes
|
|
281
|
+
|
|
282
|
+
```tsx
|
|
283
|
+
// src/routes/blog/$slug.tsx
|
|
284
|
+
import { createFileRoute, notFound } from '@tanstack/react-router'
|
|
285
|
+
import { loadPost } from '../../lib/content.functions'
|
|
286
|
+
import { PostDetail } from '../../components/pages'
|
|
287
|
+
|
|
288
|
+
export const Route = createFileRoute('/blog/$slug')({
|
|
289
|
+
loader: async ({ params }) => {
|
|
290
|
+
const post = await loadPost({ data: { locale: 'en', slug: params.slug } })
|
|
291
|
+
if (!post) throw notFound()
|
|
292
|
+
return post
|
|
293
|
+
},
|
|
294
|
+
head: ({ loaderData }) => loaderData?.head ?? {},
|
|
295
|
+
component: PostPage,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
function PostPage() {
|
|
299
|
+
return <PostDetail post={Route.useLoaderData()} />
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Each language has its own route file that calls the same loader with its own locale. For Dutch, `src/routes/nl/blog/$slug.tsx` uses `locale: 'nl'` and renders the same component.
|
|
304
|
+
|
|
305
|
+
The list route (`src/routes/blog/index.tsx`) uses `loader: () => loadPosts({ data: { locale: 'en' } })`. Its rows already carry `href`.
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
// src/components/pages.tsx
|
|
309
|
+
import { Markdown } from '@tanstack/markdown/react'
|
|
310
|
+
import { richTextComponents } from '@areca-cms/site-sdk/rich-text-react'
|
|
311
|
+
import type { ListRow, PostPage } from '../lib/content.functions'
|
|
312
|
+
|
|
313
|
+
export function ListPage({ heading, rows }: { heading: string; rows: ListRow[] }) {
|
|
314
|
+
return (
|
|
315
|
+
<main>
|
|
316
|
+
<h1>{heading}</h1>
|
|
317
|
+
<ul>
|
|
318
|
+
{rows.map((row) => (
|
|
319
|
+
<li key={row.slug}>
|
|
320
|
+
<a href={row.href}>{row.title}</a>
|
|
321
|
+
{row.summary ? <p>{row.summary}</p> : null}
|
|
322
|
+
</li>
|
|
323
|
+
))}
|
|
324
|
+
</ul>
|
|
325
|
+
</main>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function PostDetail({ post }: { post: PostPage }) {
|
|
330
|
+
return (
|
|
331
|
+
<article>
|
|
332
|
+
<h1>{post.title}</h1>
|
|
333
|
+
<Markdown components={richTextComponents()}>{post.body}</Markdown>
|
|
334
|
+
</article>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`richTextComponents()` keeps the image sizes from the CMS. Use it with every `Markdown` element.
|
|
340
|
+
|
|
341
|
+
### Preview route (optional)
|
|
342
|
+
|
|
343
|
+
Only when the user wants drafts. The CMS opens `<productionUrl>/__preview/<collection>/<slug>?token=…`.
|
|
344
|
+
|
|
345
|
+
- Name the folder `[__preview]` so the router keeps the `__preview` path (the demo does this).
|
|
346
|
+
- Read the token from the search params. Call `content.preview(token)` in a server function. Check `result.complete` before you render the entry. When it is false, show `result.issues`.
|
|
347
|
+
- Set the preview headers with `previewHeaders()` and add `<meta name="robots" content="noindex, nofollow">` in the head.
|
|
348
|
+
- Exclude `/__preview` from prerendering and caching. In the Vite config, use a `filter` on the prerender (the demo does this).
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
tanstackStart({
|
|
352
|
+
prerender: {
|
|
353
|
+
enabled: true,
|
|
354
|
+
crawlLinks: true,
|
|
355
|
+
filter: (page) => !page.path.startsWith('/__preview') && !page.path.startsWith('/api'),
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
An expired or wrong preview link makes the fetch throw. Catch the error and show a short message, not a stack trace.
|
|
361
|
+
|
|
362
|
+
### Health route (only for go-live builds)
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
// src/routes/api/health.ts
|
|
366
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
367
|
+
import { readContentManifest } from '@areca-cms/site-sdk/pull'
|
|
368
|
+
|
|
369
|
+
export const Route = createFileRoute('/api/health')({
|
|
370
|
+
server: {
|
|
371
|
+
handlers: {
|
|
372
|
+
GET: () => {
|
|
373
|
+
try {
|
|
374
|
+
const { publicationId } = readContentManifest('src/generated/content')
|
|
375
|
+
return Response.json({ ok: true, publicationId })
|
|
376
|
+
} catch {
|
|
377
|
+
return Response.json({ ok: false }, { status: 503 })
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
})
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Next.js (App Router)
|
|
386
|
+
|
|
387
|
+
Detected by `next` in `package.json`, and an `app/` or `src/app/` folder. Needs Next 15 or newer, where `params` is a promise.
|
|
388
|
+
|
|
389
|
+
- Read content in server components (the default). Do not add `'use client'` to a file that imports the content.
|
|
390
|
+
- Use `generateStaticParams` for static detail pages. It runs at build time, after the pull.
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
// app/blog/[slug]/page.tsx
|
|
394
|
+
import { notFound } from 'next/navigation'
|
|
395
|
+
import { richTextHtml, seoJsonLd } from '@areca-cms/site-sdk'
|
|
396
|
+
import { content } from '../../../src/generated/content/areca.gen'
|
|
397
|
+
|
|
398
|
+
type Props = { params: Promise<{ slug: string }> }
|
|
399
|
+
|
|
400
|
+
export function generateStaticParams() {
|
|
401
|
+
return content.posts.all().map((post) => ({ slug: post.slug }))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function generateMetadata({ params }: Props) {
|
|
405
|
+
const { slug } = await params
|
|
406
|
+
const post = content.posts.get(slug)
|
|
407
|
+
if (!post) return {}
|
|
408
|
+
const seo = post.seo
|
|
409
|
+
return {
|
|
410
|
+
title: seo?.title ?? post.data.title,
|
|
411
|
+
description: seo?.description ?? post.data.excerpt ?? undefined,
|
|
412
|
+
alternates: { canonical: seo?.canonicalUrl ?? undefined },
|
|
413
|
+
robots: seo ? { index: seo.robots.index, follow: seo.robots.follow } : undefined,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export default async function PostPage({ params }: Props) {
|
|
418
|
+
const { slug } = await params
|
|
419
|
+
const post = content.posts.get(slug)
|
|
420
|
+
if (!post) notFound()
|
|
421
|
+
return (
|
|
422
|
+
<>
|
|
423
|
+
{seoJsonLd(post.seo).map((json, index) => (
|
|
424
|
+
<script key={index} type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />
|
|
425
|
+
))}
|
|
426
|
+
<article>
|
|
427
|
+
<h1>{post.data.title}</h1>
|
|
428
|
+
{/* CMS HTML from the snapshot. Trusted output, so it is safe to render as HTML. */}
|
|
429
|
+
<div dangerouslySetInnerHTML={{ __html: richTextHtml(post.data.body) }} />
|
|
430
|
+
</article>
|
|
431
|
+
</>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Notes:
|
|
437
|
+
|
|
438
|
+
- `generateMetadata` covers the title, description, canonical and robots. Open Graph, hreflang and JSON-LD are the parts the SDK builds. Add the JSON-LD scripts (above) and the alternates on multilingual sites.
|
|
439
|
+
- Use the `alt` text and the sizes from `ImageValue` (`width`, `height`, `alt`) with `next/image`. The width and height can be `null`, so fall back to a plain `<img>`.
|
|
440
|
+
- Health route for go-live builds: `app/api/health/route.ts`, with `export const dynamic = 'force-dynamic'` and the same body as the TanStack health route (`readContentManifest` in a `try`, 503 on error).
|
|
441
|
+
- Preview: a route under a non-public path, marked `dynamic`, that calls `content.preview(token)` and sets `previewHeaders()`. Keep it out of `generateStaticParams`.
|
|
442
|
+
|
|
443
|
+
## Astro
|
|
444
|
+
|
|
445
|
+
Detected by `astro` in `package.json`. Astro builds static pages by default. The content is read in the frontmatter.
|
|
446
|
+
|
|
447
|
+
```astro
|
|
448
|
+
---
|
|
449
|
+
// src/pages/blog/[slug].astro
|
|
450
|
+
import { richTextHtml, seoHeadHtml, seoJsonLd } from '@areca-cms/site-sdk'
|
|
451
|
+
import { content } from '../../generated/content/areca.gen'
|
|
452
|
+
|
|
453
|
+
export function getStaticPaths() {
|
|
454
|
+
return content.posts.all().map((post) => ({ params: { slug: post.slug }, props: { post } }))
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const { post } = Astro.props
|
|
458
|
+
---
|
|
459
|
+
<html lang="en">
|
|
460
|
+
<head>
|
|
461
|
+
<Fragment set:html={seoHeadHtml(post.seo, { siteName: 'Acme' })} />
|
|
462
|
+
{seoJsonLd(post.seo).map((json) => (
|
|
463
|
+
<script type="application/ld+json" set:html={json} />
|
|
464
|
+
))}
|
|
465
|
+
</head>
|
|
466
|
+
<body>
|
|
467
|
+
<article>
|
|
468
|
+
<h1>{post.data.title}</h1>
|
|
469
|
+
<Fragment set:html={richTextHtml(post.data.body)} />
|
|
470
|
+
</article>
|
|
471
|
+
</body>
|
|
472
|
+
</html>
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Notes:
|
|
476
|
+
|
|
477
|
+
- `seoHeadHtml` returns the title, meta, link and script tags as one escaped string. Put it in `<head>` with `Fragment set:html`.
|
|
478
|
+
- Set `lang` on `<html>` to the page language.
|
|
479
|
+
- `getStaticPaths` runs at build time, after the pull. A detail page for every entry is generated.
|
|
480
|
+
- Preview on Astro: an SSR route with `output: 'server'` or an adapter. Ask before you change the output mode. It is a larger change. If the user wants it, call `content.preview(token)` there and set `previewHeaders()`.
|
|
481
|
+
|
|
482
|
+
## Vite or plain Node
|
|
483
|
+
|
|
484
|
+
Use this for any other setup, such as a Vite SPA with an API, Express, Hono, SvelteKit or a static generator.
|
|
485
|
+
|
|
486
|
+
- The generated module is plain TypeScript. Import it from server code, from a build script, or from a server route.
|
|
487
|
+
- The content is read from `src/generated/content` relative to the working directory. Run the commands from the project root.
|
|
488
|
+
- Add the pull to the prebuild script, as in the common pieces above.
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
// scripts/list-posts.ts (build time)
|
|
492
|
+
import { content } from '../src/generated/content/areca.gen.ts'
|
|
493
|
+
|
|
494
|
+
for (const post of content.posts.all()) {
|
|
495
|
+
console.log(post.slug, post.data.title)
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Use the SDK helpers in your renderer:
|
|
500
|
+
|
|
501
|
+
- `seoHeadHtml(entry.seo, options)` for `<head>` HTML (from `@areca-cms/site-sdk`).
|
|
502
|
+
- `richTextHtml(entry.data.body)` for body HTML. It is trusted CMS output.
|
|
503
|
+
- `richTextMarkdown(value)` when your renderer takes Markdown.
|
|
504
|
+
|
|
505
|
+
## Explicit locale
|
|
506
|
+
|
|
507
|
+
Use this when the project has an i18n library other than Paraglide, or when the server handles one request in several languages (for example next-intl).
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
content.in('nl').posts.all() // every read in Dutch
|
|
511
|
+
content.posts.all({ locale: 'nl' }) // one call in Dutch
|
|
512
|
+
content.posts.get(slug, { locale: 'nl', fallback: false }) // only if a Dutch translation exists
|
|
513
|
+
content.matchLocale(request.headers.get('accept-language')) // pick a language for a redirect
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
- Pass the locale from the route or the request. Do not rely on a global language setting in server code.
|
|
517
|
+
- `entry.isFallback` is `true` when the entry is shown in another language. Show a note or link to the main language.
|
|
518
|
+
- `entry.alternates` holds the route of each language **without** the `/nl` prefix (see [recipes.md](recipes.md#routes-and-languages)). Add the prefix yourself before you use it for hreflang. The main language has no prefix:
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
const alternates = Object.fromEntries(
|
|
522
|
+
Object.entries(entry.alternates ?? {}).map(([tag, path]) => [
|
|
523
|
+
tag,
|
|
524
|
+
tag === content.languages?.main ? path : `/${tag}${path}`,
|
|
525
|
+
]),
|
|
526
|
+
)
|
|
527
|
+
seoHead(entry.seo, { locale: entry.locale, alternates, main: content.languages?.main })
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
- Set the HTML `lang` attribute to the page language.
|
|
531
|
+
- `content.in(locale).<collection>.href(slug)` returns the route without the prefix too. Add the same prefix for links.
|
|
532
|
+
|
|
533
|
+
For the full language API, read the `areca-sdk` skill, `references/languages.md`.
|