create-prenta 1.3.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 +964 -0
- package/LICENSE +21 -0
- package/assets/prisma/migrations/0001_init/migration.sql +941 -0
- package/assets/prisma/migrations/0002_media_usage_field_path/migration.sql +11 -0
- package/assets/prisma/migrations/0003_ai_crawler_hits/migration.sql +21 -0
- package/assets/prisma/migrations/0004_drop_search_vector/migration.sql +4 -0
- package/assets/prisma/migrations/0005_script_tag_delivery_controls/migration.sql +8 -0
- package/assets/prisma/migrations/0006_totp_last_step/migration.sql +4 -0
- package/assets/prisma/migrations/0007_workflow_fk_drop_dead_enums/migration.sql +27 -0
- package/assets/prisma/migrations/0008_form_delivery_attempts/migration.sql +29 -0
- package/assets/prisma/migrations/0009_webhook_secret_rotation/migration.sql +6 -0
- package/assets/prisma/migrations/0010_media_is_decorative/migration.sql +3 -0
- package/assets/prisma/migrations/0011_media_content_hash/migration.sql +4 -0
- package/assets/prisma/migrations/0012_rename_to_prenta/migration.sql +89 -0
- package/assets/prisma/migrations/migration_lock.toml +3 -0
- package/assets/prisma/schema.prisma +989 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +160 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +206 -0
- package/dist/options.js.map +1 -0
- package/dist/scaffold.d.ts +45 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +2163 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/templates/admin-page.ts.template.d.ts +24 -0
- package/dist/templates/admin-page.ts.template.d.ts.map +1 -0
- package/dist/templates/admin-page.ts.template.js +145 -0
- package/dist/templates/admin-page.ts.template.js.map +1 -0
- package/dist/templates/astro-site.d.ts +36 -0
- package/dist/templates/astro-site.d.ts.map +1 -0
- package/dist/templates/astro-site.js +918 -0
- package/dist/templates/astro-site.js.map +1 -0
- package/dist/templates/login-page.ts.template.d.ts +14 -0
- package/dist/templates/login-page.ts.template.d.ts.map +1 -0
- package/dist/templates/login-page.ts.template.js +143 -0
- package/dist/templates/login-page.ts.template.js.map +1 -0
- package/dist/templates/marketing-custom.d.ts +56 -0
- package/dist/templates/marketing-custom.d.ts.map +1 -0
- package/dist/templates/marketing-custom.js +814 -0
- package/dist/templates/marketing-custom.js.map +1 -0
- package/dist/templates/prenta.config.ts.template.d.ts +3 -0
- package/dist/templates/prenta.config.ts.template.d.ts.map +1 -0
- package/dist/templates/prenta.config.ts.template.js +253 -0
- package/dist/templates/prenta.config.ts.template.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Astro public-site files for `create-prenta --framework astro-split`
|
|
3
|
+
* and the `examples/astro-site` reference.
|
|
4
|
+
*
|
|
5
|
+
* Keys are POSIX paths relative to the Astro app root (`site/` in a split
|
|
6
|
+
* scaffold, or the example package root).
|
|
7
|
+
*/
|
|
8
|
+
export function astroSiteFiles(opts) {
|
|
9
|
+
const cmsExample = opts.cmsUrlExample ?? 'http://localhost:3000';
|
|
10
|
+
const sectionsPackage = opts.sectionsPackage;
|
|
11
|
+
const adminPath = opts.adminPath ?? '/admin';
|
|
12
|
+
const loginPath = opts.loginPath ?? '/securelogin';
|
|
13
|
+
return {
|
|
14
|
+
'package.json': JSON.stringify({
|
|
15
|
+
name: opts.packageName ?? 'prenta-astro-site',
|
|
16
|
+
private: true,
|
|
17
|
+
type: 'module',
|
|
18
|
+
scripts: {
|
|
19
|
+
dev: 'astro dev',
|
|
20
|
+
build: 'astro build',
|
|
21
|
+
preview: 'astro preview',
|
|
22
|
+
...(sectionsPackage
|
|
23
|
+
? { 'verify:sections': 'node ../../packages/sections/scripts/verify-sections.mjs' }
|
|
24
|
+
: {}),
|
|
25
|
+
},
|
|
26
|
+
dependencies: {
|
|
27
|
+
'@prenta/core': 'latest',
|
|
28
|
+
'@prenta/sections-react': 'latest',
|
|
29
|
+
...(sectionsPackage ? { [sectionsPackage]: 'workspace:*' } : {}),
|
|
30
|
+
'@astrojs/react': '^6.0.0',
|
|
31
|
+
'@astrojs/vercel': '^11.0.0',
|
|
32
|
+
astro: '^7.1.0',
|
|
33
|
+
react: '^19.0.0',
|
|
34
|
+
'react-dom': '^19.0.0',
|
|
35
|
+
},
|
|
36
|
+
devDependencies: {
|
|
37
|
+
'@types/react': '^19.0.0',
|
|
38
|
+
'@types/react-dom': '^19.0.0',
|
|
39
|
+
typescript: '^5.7.0',
|
|
40
|
+
},
|
|
41
|
+
engines: {
|
|
42
|
+
node: '>=22.12.0',
|
|
43
|
+
},
|
|
44
|
+
}, null, 2),
|
|
45
|
+
'astro.config.mjs': `import { defineConfig } from 'astro/config';
|
|
46
|
+
import react from '@astrojs/react';
|
|
47
|
+
import vercel from '@astrojs/vercel';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prenta Astro public site — talks to a separate CMS host via PRENTA_CMS_URL.
|
|
51
|
+
* Requires Node.js 22.12+ (Astro 7).
|
|
52
|
+
*
|
|
53
|
+
* Hybrid rendering: \`output: 'server'\` keeps CMS catch-alls on-demand.
|
|
54
|
+
* Code-owned/static pages should set \`export const prerender = true\`
|
|
55
|
+
* (see \`src/pages/404.astro\`).
|
|
56
|
+
*
|
|
57
|
+
* @see https://docs.prentacms.com/astro-public-site
|
|
58
|
+
*/
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
output: 'server',
|
|
61
|
+
adapter: vercel(),
|
|
62
|
+
integrations: [react()],
|
|
63
|
+
});
|
|
64
|
+
`,
|
|
65
|
+
'tsconfig.json': JSON.stringify({
|
|
66
|
+
extends: 'astro/tsconfigs/strict',
|
|
67
|
+
compilerOptions: {
|
|
68
|
+
jsx: 'react-jsx',
|
|
69
|
+
baseUrl: '.',
|
|
70
|
+
paths: { '@/*': ['src/*'] },
|
|
71
|
+
},
|
|
72
|
+
}, null, 2),
|
|
73
|
+
'.env.example': `# Public site origin (Astro) — must match CMS NEXT_PUBLIC_SITE_URL
|
|
74
|
+
SITE=http://localhost:4321
|
|
75
|
+
|
|
76
|
+
# Optional brand signals for site-identity JSON-LD (Organization / WebSite)
|
|
77
|
+
# PUBLIC_SITE_NAME=Example
|
|
78
|
+
# PUBLIC_SITE_LOGO=https://cdn.example.com/logo.webp
|
|
79
|
+
|
|
80
|
+
# CMS host origin (Next/Node Prenta API + admin) — no trailing slash
|
|
81
|
+
PRENTA_CMS_URL=${cmsExample}
|
|
82
|
+
|
|
83
|
+
# Optional: API key for public reads if the CMS requires auth on /resolve
|
|
84
|
+
# PRENTA_API_KEY=act_sk_...
|
|
85
|
+
|
|
86
|
+
# Purge-on-publish (recommended): CMS Settings → Webhooks → add endpoint
|
|
87
|
+
# https://<this site>/api/prenta/purge and paste its signing secret here.
|
|
88
|
+
# PRENTA_WEBHOOK_SECRET=whsec_...
|
|
89
|
+
|
|
90
|
+
# Optional CDN purge credentials (pick the one matching your deploy):
|
|
91
|
+
# Cloudflare zone purge (targeted, instant)
|
|
92
|
+
# CLOUDFLARE_ZONE_ID=...
|
|
93
|
+
# CLOUDFLARE_API_TOKEN=...
|
|
94
|
+
# Vercel deploy hook (full purge via redeploy)
|
|
95
|
+
# VERCEL_DEPLOY_HOOK_URL=https://api.vercel.com/v1/integrations/deploy/...
|
|
96
|
+
|
|
97
|
+
# Optional layer-2 runtime cache (Upstash Redis) — drained on purge
|
|
98
|
+
# UPSTASH_REDIS_REST_URL=https://...upstash.io
|
|
99
|
+
# UPSTASH_REDIS_REST_TOKEN=...
|
|
100
|
+
`,
|
|
101
|
+
'src/env.d.ts': `/// <reference path="../.astro/types.d.ts" />
|
|
102
|
+
/// <reference types="astro/client" />
|
|
103
|
+
|
|
104
|
+
interface ImportMetaEnv {
|
|
105
|
+
readonly PRENTA_CMS_URL: string
|
|
106
|
+
readonly PRENTA_API_KEY?: string
|
|
107
|
+
readonly SITE?: string
|
|
108
|
+
/** Brand name for site-identity JSON-LD (Organization / WebSite). */
|
|
109
|
+
readonly PUBLIC_SITE_NAME?: string
|
|
110
|
+
/** Absolute logo URL for Organization JSON-LD (Article rich results). */
|
|
111
|
+
readonly PUBLIC_SITE_LOGO?: string
|
|
112
|
+
/** Signing secret from CMS Settings → Webhooks (purge-on-publish). */
|
|
113
|
+
readonly PRENTA_WEBHOOK_SECRET?: string
|
|
114
|
+
readonly CLOUDFLARE_ZONE_ID?: string
|
|
115
|
+
readonly CLOUDFLARE_API_TOKEN?: string
|
|
116
|
+
readonly VERCEL_DEPLOY_HOOK_URL?: string
|
|
117
|
+
readonly UPSTASH_REDIS_REST_URL?: string
|
|
118
|
+
readonly UPSTASH_REDIS_REST_TOKEN?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ImportMeta {
|
|
122
|
+
readonly env: ImportMetaEnv
|
|
123
|
+
}
|
|
124
|
+
`,
|
|
125
|
+
'src/lib/cms.ts': `import { createAstroSiteHelpers } from '@prenta/core/astro/site';
|
|
126
|
+
import {
|
|
127
|
+
createUpstashSiteCacheAdapter,
|
|
128
|
+
publicPageCacheControl,
|
|
129
|
+
type UpstashSiteCacheAdapter,
|
|
130
|
+
} from '@prenta/core/astro/cache';
|
|
131
|
+
|
|
132
|
+
/** ADR 0011 cache-static header helper — re-exported for page routes. */
|
|
133
|
+
export { publicPageCacheControl };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Optional layer-2 runtime cache (Upstash Redis) — active when the env
|
|
137
|
+
* credentials exist. The purge endpoint drains it on publish, so entries can
|
|
138
|
+
* outlive their TTL without going stale.
|
|
139
|
+
*/
|
|
140
|
+
export const siteCache: UpstashSiteCacheAdapter | undefined =
|
|
141
|
+
import.meta.env.UPSTASH_REDIS_REST_URL && import.meta.env.UPSTASH_REDIS_REST_TOKEN
|
|
142
|
+
? createUpstashSiteCacheAdapter({
|
|
143
|
+
url: import.meta.env.UPSTASH_REDIS_REST_URL,
|
|
144
|
+
token: import.meta.env.UPSTASH_REDIS_REST_TOKEN,
|
|
145
|
+
})
|
|
146
|
+
: undefined;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Framework-neutral site client. Points at the CMS host via PRENTA_CMS_URL
|
|
150
|
+
* (set in .env). Do not use createNextSiteHelpers here — that is Next-only.
|
|
151
|
+
*/
|
|
152
|
+
export const {
|
|
153
|
+
client,
|
|
154
|
+
getGlobal,
|
|
155
|
+
resolveDocument,
|
|
156
|
+
getPublishedDocument,
|
|
157
|
+
getNavigation,
|
|
158
|
+
getPublicForm,
|
|
159
|
+
getPageMeta,
|
|
160
|
+
toPageMeta,
|
|
161
|
+
} = createAstroSiteHelpers({
|
|
162
|
+
baseUrl: import.meta.env.PRENTA_CMS_URL,
|
|
163
|
+
apiKey: import.meta.env.PRENTA_API_KEY,
|
|
164
|
+
cache: siteCache,
|
|
165
|
+
});
|
|
166
|
+
`,
|
|
167
|
+
'src/lib/cms-proxy.ts': `import { rewritePublicOrigin } from '@prenta/core';
|
|
168
|
+
|
|
169
|
+
/** CMS host origin (no trailing slash). */
|
|
170
|
+
export function cmsOrigin(): string {
|
|
171
|
+
return (import.meta.env.PRENTA_CMS_URL as string).replace(/\\/$/, '');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Public serving origin — SITE from env, else Astro site config. */
|
|
175
|
+
export function publicOrigin(siteFromAstro?: string | URL): string {
|
|
176
|
+
const fromEnv = (import.meta.env.SITE as string | undefined)?.replace(/\\/$/, '');
|
|
177
|
+
if (fromEnv) return fromEnv;
|
|
178
|
+
if (siteFromAstro) return String(siteFromAstro).replace(/\\/$/, '');
|
|
179
|
+
return cmsOrigin();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Proxy a CMS SEO artifact to the public host, rewriting absolute URLs from
|
|
184
|
+
* the CMS origin to the public apex (Yoast-parity sitemaps, robots Sitemap:
|
|
185
|
+
* line, feeds, llms).
|
|
186
|
+
*/
|
|
187
|
+
export async function proxyCmsSeoArtifact(
|
|
188
|
+
upstreamPath: string,
|
|
189
|
+
opts: { accept?: string; publicOrigin: string },
|
|
190
|
+
): Promise<Response> {
|
|
191
|
+
const from = cmsOrigin();
|
|
192
|
+
const res = await fetch(\`\${from}\${upstreamPath}\`, {
|
|
193
|
+
headers: { accept: opts.accept ?? '*/*' },
|
|
194
|
+
});
|
|
195
|
+
const raw = await res.text();
|
|
196
|
+
const body = rewritePublicOrigin(raw, {
|
|
197
|
+
publicOrigin: opts.publicOrigin,
|
|
198
|
+
fromOrigin: from,
|
|
199
|
+
});
|
|
200
|
+
return new Response(body, {
|
|
201
|
+
status: res.status,
|
|
202
|
+
headers: {
|
|
203
|
+
'Content-Type':
|
|
204
|
+
res.headers.get('Content-Type') ??
|
|
205
|
+
(upstreamPath.endsWith('.xml') || upstreamPath.endsWith('.xsl')
|
|
206
|
+
? 'application/xml'
|
|
207
|
+
: 'text/plain; charset=utf-8'),
|
|
208
|
+
'Cache-Control': res.headers.get('Cache-Control') ?? 'public, max-age=60',
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
`,
|
|
213
|
+
'src/middleware.ts': `import { sequence } from 'astro:middleware';
|
|
214
|
+
import type { MiddlewareHandler } from 'astro';
|
|
215
|
+
import { createAstroRedirectMiddleware } from '@prenta/core/astro/redirects';
|
|
216
|
+
import { createAstroSecurityHeadersMiddleware } from '@prenta/core/astro/security';
|
|
217
|
+
import { assertServingOriginConfigured } from '@prenta/core/astro/site';
|
|
218
|
+
import { publicOrigin } from './lib/cms-proxy';
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Split-deploy public host middleware:
|
|
222
|
+
* 1. Launch Gate E — fail loud in production when SITE / public apex is unset
|
|
223
|
+
* 2. Admin-managed SEO redirects — Next \`proxy.ts\` only covers the CMS host
|
|
224
|
+
* 3. CMS baseline security headers — Next \`withPrentaCMS()\` only covers the CMS host
|
|
225
|
+
*
|
|
226
|
+
* @see https://docs.prentacms.com/astro-public-site
|
|
227
|
+
*/
|
|
228
|
+
let servingOriginChecked = false;
|
|
229
|
+
const assertServingOrigin: MiddlewareHandler = async (context, next) => {
|
|
230
|
+
if (!servingOriginChecked) {
|
|
231
|
+
servingOriginChecked = true;
|
|
232
|
+
assertServingOriginConfigured({
|
|
233
|
+
servingOrigin: publicOrigin(context.site),
|
|
234
|
+
siteUrl: import.meta.env.SITE,
|
|
235
|
+
strict: import.meta.env.PROD,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return next();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export const onRequest = sequence(
|
|
242
|
+
assertServingOrigin,
|
|
243
|
+
createAstroRedirectMiddleware({
|
|
244
|
+
baseUrl: import.meta.env.PRENTA_CMS_URL,
|
|
245
|
+
}),
|
|
246
|
+
createAstroSecurityHeadersMiddleware(),
|
|
247
|
+
);
|
|
248
|
+
`,
|
|
249
|
+
'src/components/PageSections.tsx': `import { Sections, type PublicSection, type PostContext } from '@prenta/sections-react'
|
|
250
|
+
${sectionsPackage
|
|
251
|
+
? `// Side-effect import: registers this site's custom section renderers (the
|
|
252
|
+
// shared section kit) before <Sections> renders. The same map drives the
|
|
253
|
+
// admin editor canvas in apps/cms — see SECTIONS.md.
|
|
254
|
+
import '${sectionsPackage}/register'
|
|
255
|
+
`
|
|
256
|
+
: ''}
|
|
257
|
+
/**
|
|
258
|
+
* React SSR for Prenta sections (Astro islands pattern).
|
|
259
|
+
*
|
|
260
|
+
* Default: import from .astro with no client:* directive — Astro SSR-renders
|
|
261
|
+
* the React tree to HTML with zero client JS (best for CMS page content).
|
|
262
|
+
*
|
|
263
|
+
* Add interactivity only where needed:
|
|
264
|
+
* <PageSections client:visible sections={...} /> // hydrate when scrolled into view
|
|
265
|
+
* <PageSections client:load sections={...} /> // hydrate immediately
|
|
266
|
+
*
|
|
267
|
+
* Prefer zero client JS for marketing pages; hydrate carousels, forms widgets,
|
|
268
|
+
* or custom interactive sections only.
|
|
269
|
+
*/
|
|
270
|
+
export function PageSections({
|
|
271
|
+
sections,
|
|
272
|
+
ctx,
|
|
273
|
+
}: {
|
|
274
|
+
sections: PublicSection[];
|
|
275
|
+
ctx?: PostContext;
|
|
276
|
+
}) {
|
|
277
|
+
return <Sections sections={sections} ctx={ctx} />;
|
|
278
|
+
}
|
|
279
|
+
`,
|
|
280
|
+
'src/live.config.ts': `/**
|
|
281
|
+
* Astro Live Content Collections (stable since Astro 6) — optional blog/index listing from Prenta.
|
|
282
|
+
*
|
|
283
|
+
* Posts still render via [...path].astro (sections + SEO). Use this collection
|
|
284
|
+
* for index pages, related lists, and typed getLiveEntry lookups.
|
|
285
|
+
*
|
|
286
|
+
* @see https://docs.astro.build/en/guides/content-collections/#live-content-collections
|
|
287
|
+
*/
|
|
288
|
+
import { defineLiveCollection } from 'astro:content';
|
|
289
|
+
import { z } from 'astro/zod';
|
|
290
|
+
import { createPrentaLiveLoader } from '@prenta/core/astro/content';
|
|
291
|
+
|
|
292
|
+
const posts = defineLiveCollection({
|
|
293
|
+
loader: createPrentaLiveLoader({
|
|
294
|
+
baseUrl: import.meta.env.PRENTA_CMS_URL,
|
|
295
|
+
apiKey: import.meta.env.PRENTA_API_KEY,
|
|
296
|
+
}),
|
|
297
|
+
schema: z.object({
|
|
298
|
+
title: z.string(),
|
|
299
|
+
url: z.string(),
|
|
300
|
+
summary: z.string().optional(),
|
|
301
|
+
datePublished: z.string().optional(),
|
|
302
|
+
dateModified: z.string().optional(),
|
|
303
|
+
}),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
export const collections = { posts };
|
|
307
|
+
`,
|
|
308
|
+
'src/pages/blog/index.astro': `---
|
|
309
|
+
/**
|
|
310
|
+
* Example blog index via Astro Live Content Collections + Prenta feed.json.
|
|
311
|
+
* Individual posts still use [...path].astro → /resolve → sections.
|
|
312
|
+
*
|
|
313
|
+
* On-demand: this page reads the live CMS feed at request time.
|
|
314
|
+
*/
|
|
315
|
+
export const prerender = false;
|
|
316
|
+
|
|
317
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
318
|
+
import { getLiveCollection } from 'astro:content';
|
|
319
|
+
|
|
320
|
+
const { entries, error } = await getLiveCollection('posts');
|
|
321
|
+
const posts = error ? [] : entries;
|
|
322
|
+
---
|
|
323
|
+
<BaseLayout title="Blog" description="Latest posts from Prenta CMS">
|
|
324
|
+
<main style="max-width:42rem;margin:2rem auto;padding:0 1rem;font:16px/1.5 system-ui">
|
|
325
|
+
<h1>Blog</h1>
|
|
326
|
+
{error && (
|
|
327
|
+
<p role="alert">Could not load posts from the CMS feed. Check PRENTA_CMS_URL and that SEO feeds are enabled.</p>
|
|
328
|
+
)}
|
|
329
|
+
{!error && posts.length === 0 && <p>No published posts yet.</p>}
|
|
330
|
+
<ul>
|
|
331
|
+
{posts.map((post) => (
|
|
332
|
+
<li>
|
|
333
|
+
<a href={post.id}>{post.data.title}</a>
|
|
334
|
+
{post.data.summary && <p>{post.data.summary}</p>}
|
|
335
|
+
{post.data.datePublished && (
|
|
336
|
+
<time datetime={post.data.datePublished}>{post.data.datePublished.slice(0, 10)}</time>
|
|
337
|
+
)}
|
|
338
|
+
</li>
|
|
339
|
+
))}
|
|
340
|
+
</ul>
|
|
341
|
+
</main>
|
|
342
|
+
</BaseLayout>
|
|
343
|
+
`,
|
|
344
|
+
'src/pages/404.astro': `---
|
|
345
|
+
/**
|
|
346
|
+
* Code-owned static route — built once per deploy (no CMS round-trip).
|
|
347
|
+
* Add other registry/marketing pages the same way: \`export const prerender = true\`.
|
|
348
|
+
* Keep CMS-backed routes (\`[...path].astro\`, live feeds) on-demand.
|
|
349
|
+
*/
|
|
350
|
+
export const prerender = true;
|
|
351
|
+
|
|
352
|
+
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
353
|
+
---
|
|
354
|
+
<BaseLayout title="Page not found" description="This page does not exist." robots="noindex, follow">
|
|
355
|
+
<main style="max-width:42rem;margin:4rem auto;padding:0 1rem;font:16px/1.5 system-ui;text-align:center">
|
|
356
|
+
<h1>Page not found</h1>
|
|
357
|
+
<p><a href="/">Back to home</a></p>
|
|
358
|
+
</main>
|
|
359
|
+
</BaseLayout>
|
|
360
|
+
`,
|
|
361
|
+
'src/layouts/BaseLayout.astro': `---
|
|
362
|
+
import type { AstroPageMeta } from '@prenta/core/astro/site';
|
|
363
|
+
import {
|
|
364
|
+
jsonLdHasOrganization,
|
|
365
|
+
siteIdentityJsonLdHtml,
|
|
366
|
+
} from '@prenta/core/astro/site';
|
|
367
|
+
import { publicOrigin } from '../lib/cms-proxy';
|
|
368
|
+
|
|
369
|
+
interface Props {
|
|
370
|
+
/** Full composed head from toPageMeta() — use this for CMS-resolved pages. */
|
|
371
|
+
meta?: AstroPageMeta;
|
|
372
|
+
/** Simple overrides for static pages (blog index, 404, ...). */
|
|
373
|
+
title?: string;
|
|
374
|
+
description?: string;
|
|
375
|
+
canonical?: string;
|
|
376
|
+
/** Optional robots override; defaults to meta.robots / metadata.robots. */
|
|
377
|
+
robots?: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const { meta, ...overrides } = Astro.props;
|
|
381
|
+
|
|
382
|
+
const title = overrides.title ?? meta?.title ?? 'Site';
|
|
383
|
+
const description = overrides.description ?? meta?.description;
|
|
384
|
+
const canonical = overrides.canonical ?? meta?.canonical;
|
|
385
|
+
const origin = publicOrigin(Astro.site);
|
|
386
|
+
const brandName =
|
|
387
|
+
(import.meta.env.PUBLIC_SITE_NAME as string | undefined)?.trim() || 'Site';
|
|
388
|
+
const brandLogo = (import.meta.env.PUBLIC_SITE_LOGO as string | undefined)?.trim();
|
|
389
|
+
|
|
390
|
+
// Site-identity JSON-LD so publisher/Organization @ids resolve on every page
|
|
391
|
+
// (including static routes that never call composePageMeta). Skip when the
|
|
392
|
+
// page graph already includes Organization (CMS /resolve path).
|
|
393
|
+
const identityHtml =
|
|
394
|
+
!jsonLdHasOrganization(meta?.jsonLd) && origin
|
|
395
|
+
? siteIdentityJsonLdHtml({
|
|
396
|
+
siteUrl: origin,
|
|
397
|
+
siteName: brandName,
|
|
398
|
+
...(brandLogo ? { organizationLogo: brandLogo } : {}),
|
|
399
|
+
})
|
|
400
|
+
: '';
|
|
401
|
+
|
|
402
|
+
// Prefer first-class AstroPageMeta.robots; fall back to the metadata bag.
|
|
403
|
+
const bagRobots = (meta?.metadata.robots ?? {}) as { index?: boolean; follow?: boolean };
|
|
404
|
+
const robotsContent =
|
|
405
|
+
overrides.robots ??
|
|
406
|
+
meta?.robots ??
|
|
407
|
+
[
|
|
408
|
+
bagRobots.index === false ? 'noindex' : 'index',
|
|
409
|
+
bagRobots.follow === false ? 'nofollow' : 'follow',
|
|
410
|
+
].join(', ');
|
|
411
|
+
|
|
412
|
+
const og = (meta?.metadata.openGraph ?? {}) as Record<string, unknown>;
|
|
413
|
+
const ogImage = ((og.images ?? []) as { url?: string; alt?: string }[])[0];
|
|
414
|
+
const ogImageUrl = ogImage?.url
|
|
415
|
+
? ogImage.url.startsWith('http')
|
|
416
|
+
? ogImage.url
|
|
417
|
+
: \`\${origin}\${ogImage.url.startsWith('/') ? '' : '/'}\${ogImage.url}\`
|
|
418
|
+
: undefined;
|
|
419
|
+
const twitter = (meta?.metadata.twitter ?? {}) as Record<string, unknown>;
|
|
420
|
+
const twitterImageRaw = Array.isArray(twitter.images) ? twitter.images[0] : twitter.images;
|
|
421
|
+
const twitterImage =
|
|
422
|
+
typeof twitterImageRaw === 'string'
|
|
423
|
+
? twitterImageRaw.startsWith('http')
|
|
424
|
+
? twitterImageRaw
|
|
425
|
+
: \`\${origin}\${twitterImageRaw.startsWith('/') ? '' : '/'}\${twitterImageRaw}\`
|
|
426
|
+
: ogImageUrl;
|
|
427
|
+
const alternates = (meta?.metadata.alternates ?? {}) as { languages?: Record<string, string> };
|
|
428
|
+
const languages = alternates.languages ?? {};
|
|
429
|
+
---
|
|
430
|
+
<!doctype html>
|
|
431
|
+
<html lang="en">
|
|
432
|
+
<head>
|
|
433
|
+
<meta charset="utf-8" />
|
|
434
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
435
|
+
<title>{title}</title>
|
|
436
|
+
{description && <meta name="description" content={description} />}
|
|
437
|
+
{canonical && <link rel="canonical" href={canonical} />}
|
|
438
|
+
<meta name="robots" content={robotsContent} />
|
|
439
|
+
{Object.entries(languages).map(([hreflang, href]) => (
|
|
440
|
+
<link rel="alternate" hreflang={hreflang} href={href} />
|
|
441
|
+
))}
|
|
442
|
+
|
|
443
|
+
<meta property="og:title" content={String(og.title ?? title)} />
|
|
444
|
+
{(og.description ?? description) && (
|
|
445
|
+
<meta property="og:description" content={String(og.description ?? description)} />
|
|
446
|
+
)}
|
|
447
|
+
{og.url && <meta property="og:url" content={String(og.url)} />}
|
|
448
|
+
{og.siteName && <meta property="og:site_name" content={String(og.siteName)} />}
|
|
449
|
+
<meta property="og:type" content={String(og.type ?? 'website')} />
|
|
450
|
+
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
|
451
|
+
{ogImage?.alt && <meta property="og:image:alt" content={ogImage.alt} />}
|
|
452
|
+
|
|
453
|
+
<meta name="twitter:card" content={String(twitter.card ?? (ogImageUrl ? 'summary_large_image' : 'summary'))} />
|
|
454
|
+
<meta name="twitter:title" content={String(twitter.title ?? og.title ?? title)} />
|
|
455
|
+
{(twitter.description ?? og.description ?? description) && (
|
|
456
|
+
<meta name="twitter:description" content={String(twitter.description ?? og.description ?? description)} />
|
|
457
|
+
)}
|
|
458
|
+
{twitterImage && <meta name="twitter:image" content={twitterImage} />}
|
|
459
|
+
{twitter.site && <meta name="twitter:site" content={String(twitter.site)} />}
|
|
460
|
+
|
|
461
|
+
{identityHtml && <Fragment set:html={identityHtml} />}
|
|
462
|
+
{meta?.jsonLdHtml && <Fragment set:html={meta.jsonLdHtml} />}
|
|
463
|
+
</head>
|
|
464
|
+
<body>
|
|
465
|
+
<slot />
|
|
466
|
+
</body>
|
|
467
|
+
</html>
|
|
468
|
+
`,
|
|
469
|
+
'src/pages/[...path].astro': `---
|
|
470
|
+
/**
|
|
471
|
+
* CMS catch-all — always on-demand so publishes + preview tokens work.
|
|
472
|
+
* Code-owned pages (no CMS fetch) should use \`export const prerender = true\`.
|
|
473
|
+
*/
|
|
474
|
+
export const prerender = false;
|
|
475
|
+
|
|
476
|
+
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
477
|
+
import { PageSections } from '../components/PageSections';
|
|
478
|
+
import {
|
|
479
|
+
getPublishedDocument,
|
|
480
|
+
resolveDocument,
|
|
481
|
+
toPageMeta,
|
|
482
|
+
publicPageCacheControl,
|
|
483
|
+
} from '../lib/cms';
|
|
484
|
+
import type { PublicSection, PostContext } from '@prenta/sections-react';
|
|
485
|
+
|
|
486
|
+
const segments = Astro.params.path;
|
|
487
|
+
const urlPath =
|
|
488
|
+
!segments || (Array.isArray(segments) && segments.length === 0)
|
|
489
|
+
? '/'
|
|
490
|
+
: '/' + (Array.isArray(segments) ? segments.join('/') : segments);
|
|
491
|
+
|
|
492
|
+
const previewToken =
|
|
493
|
+
Astro.url.searchParams.get('preview') ??
|
|
494
|
+
Astro.cookies.get('prenta_preview')?.value ??
|
|
495
|
+
undefined;
|
|
496
|
+
|
|
497
|
+
const result = previewToken
|
|
498
|
+
? await resolveDocument(urlPath, previewToken)
|
|
499
|
+
: await getPublishedDocument(urlPath);
|
|
500
|
+
|
|
501
|
+
if (!result) {
|
|
502
|
+
return new Response(null, { status: 404, statusText: 'Not Found' });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Serving-contract: /home → / when /resolve hints a canonical redirect.
|
|
506
|
+
if (result.redirect?.to) {
|
|
507
|
+
return Astro.redirect(result.redirect.to, result.redirect.status ?? 301);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Cache-static delivery via publicPageCacheControl (ADR 0011 defaults).
|
|
511
|
+
// /api/prenta/purge evicts by path/URL the moment an editor publishes.
|
|
512
|
+
// @see https://docs.prentacms.com/astro-public-site#caching
|
|
513
|
+
Astro.response.headers.set(
|
|
514
|
+
'Cache-Control',
|
|
515
|
+
publicPageCacheControl({ preview: Boolean(previewToken) }),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const meta = toPageMeta(result);
|
|
519
|
+
const data = result.data.data as Record<string, unknown>;
|
|
520
|
+
const sections = (Array.isArray(data.sections) ? data.sections : []) as PublicSection[];
|
|
521
|
+
|
|
522
|
+
const postCtx: PostContext | undefined = result.post
|
|
523
|
+
? {
|
|
524
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
525
|
+
excerpt: typeof data.excerpt === 'string' ? data.excerpt : undefined,
|
|
526
|
+
featuredImageUrl: result.post.featuredImage?.url,
|
|
527
|
+
featuredImageWidth: result.post.featuredImage?.width,
|
|
528
|
+
featuredImageHeight: result.post.featuredImage?.height,
|
|
529
|
+
publishDate: result.data.publishedAt ?? null,
|
|
530
|
+
author: result.post.author
|
|
531
|
+
? { name: result.post.author.name, avatarUrl: result.post.author.avatarUrl }
|
|
532
|
+
: undefined,
|
|
533
|
+
relatedPosts: result.post.relatedPosts,
|
|
534
|
+
}
|
|
535
|
+
: undefined;
|
|
536
|
+
---
|
|
537
|
+
<BaseLayout meta={meta}>
|
|
538
|
+
{previewToken && (
|
|
539
|
+
<div style="background:#fef3c7;padding:0.5rem 1rem;font:14px system-ui">
|
|
540
|
+
Preview mode — unpublished content may be shown.
|
|
541
|
+
</div>
|
|
542
|
+
)}
|
|
543
|
+
<main>
|
|
544
|
+
{/* Zero client JS by default — SSR only. Add client:visible only for interactive sections. */}
|
|
545
|
+
<PageSections sections={sections} ctx={postCtx} />
|
|
546
|
+
</main>
|
|
547
|
+
</BaseLayout>
|
|
548
|
+
`,
|
|
549
|
+
'src/pages/index.astro': `---
|
|
550
|
+
/**
|
|
551
|
+
* CMS home — on-demand (same contract as [...path].astro).
|
|
552
|
+
* Home is handled by [...path] with path undefined → "/".
|
|
553
|
+
* Keep a thin index that mirrors "/" resolve for clearer routing.
|
|
554
|
+
*/
|
|
555
|
+
export const prerender = false;
|
|
556
|
+
|
|
557
|
+
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
558
|
+
import { PageSections } from '../components/PageSections';
|
|
559
|
+
import {
|
|
560
|
+
getPublishedDocument,
|
|
561
|
+
resolveDocument,
|
|
562
|
+
toPageMeta,
|
|
563
|
+
publicPageCacheControl,
|
|
564
|
+
} from '../lib/cms';
|
|
565
|
+
import type { PublicSection } from '@prenta/sections-react';
|
|
566
|
+
|
|
567
|
+
const previewToken =
|
|
568
|
+
Astro.url.searchParams.get('preview') ??
|
|
569
|
+
Astro.cookies.get('prenta_preview')?.value ??
|
|
570
|
+
undefined;
|
|
571
|
+
|
|
572
|
+
const result = previewToken
|
|
573
|
+
? await resolveDocument('/', previewToken)
|
|
574
|
+
: await getPublishedDocument('/');
|
|
575
|
+
|
|
576
|
+
if (!result) {
|
|
577
|
+
return new Response(null, { status: 404, statusText: 'Not Found' });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
Astro.response.headers.set(
|
|
581
|
+
'Cache-Control',
|
|
582
|
+
publicPageCacheControl({ preview: Boolean(previewToken) }),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const meta = toPageMeta(result);
|
|
586
|
+
const data = result.data.data as Record<string, unknown>;
|
|
587
|
+
const sections = (Array.isArray(data.sections) ? data.sections : []) as PublicSection[];
|
|
588
|
+
---
|
|
589
|
+
<BaseLayout meta={meta}>
|
|
590
|
+
<main>
|
|
591
|
+
<PageSections sections={sections} />
|
|
592
|
+
</main>
|
|
593
|
+
</BaseLayout>
|
|
594
|
+
`,
|
|
595
|
+
'src/pages/api/prenta/purge.ts': `import type { APIRoute } from 'astro';
|
|
596
|
+
import {
|
|
597
|
+
createPublishPurgeReceiver,
|
|
598
|
+
createCloudflarePurgeAdapter,
|
|
599
|
+
createVercelDeployHookPurgeAdapter,
|
|
600
|
+
type CachePurgeAdapter,
|
|
601
|
+
} from '@prenta/core/purge';
|
|
602
|
+
import { siteCache } from '../../../lib/cms';
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Purge-on-publish receiver — makes cache-static delivery safe by evicting
|
|
606
|
+
* edge-cached pages the moment content changes in the CMS.
|
|
607
|
+
*
|
|
608
|
+
* Setup (CMS admin → Settings → Webhooks):
|
|
609
|
+
* 1. Add an endpoint: https://<this site>/api/prenta/purge
|
|
610
|
+
* (subscribes to the publish lifecycle by default).
|
|
611
|
+
* 2. Copy the signing secret into PRENTA_WEBHOOK_SECRET on this deploy.
|
|
612
|
+
* 3. Add CDN purge credentials (.env.example). In production a missing
|
|
613
|
+
* adapter returns 503 so "webhook delivered" never means "edge purged"
|
|
614
|
+
* when the CDN was never contacted.
|
|
615
|
+
*/
|
|
616
|
+
function buildPurgeAdapter(): CachePurgeAdapter | null {
|
|
617
|
+
const env = import.meta.env;
|
|
618
|
+
if (env.CLOUDFLARE_ZONE_ID && env.CLOUDFLARE_API_TOKEN) {
|
|
619
|
+
return createCloudflarePurgeAdapter({
|
|
620
|
+
zoneId: env.CLOUDFLARE_ZONE_ID,
|
|
621
|
+
apiToken: env.CLOUDFLARE_API_TOKEN,
|
|
622
|
+
siteUrl: env.SITE ?? 'http://localhost:4321',
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (env.VERCEL_DEPLOY_HOOK_URL) {
|
|
626
|
+
return createVercelDeployHookPurgeAdapter({ deployHookUrl: env.VERCEL_DEPLOY_HOOK_URL });
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const secret = import.meta.env.PRENTA_WEBHOOK_SECRET;
|
|
632
|
+
const cdnAdapter = buildPurgeAdapter();
|
|
633
|
+
const isProd = import.meta.env.PROD;
|
|
634
|
+
|
|
635
|
+
const receiver =
|
|
636
|
+
secret && (cdnAdapter || !isProd)
|
|
637
|
+
? createPublishPurgeReceiver({
|
|
638
|
+
secret,
|
|
639
|
+
purge: cdnAdapter ?? {
|
|
640
|
+
purgeAll: async () => {
|
|
641
|
+
console.error(
|
|
642
|
+
'[prenta] CDN purge not configured — draining site cache only. ' +
|
|
643
|
+
'Set CLOUDFLARE_* or VERCEL_DEPLOY_HOOK_URL before production launch.',
|
|
644
|
+
);
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
onPurged: async () => {
|
|
648
|
+
await siteCache?.clear();
|
|
649
|
+
},
|
|
650
|
+
})
|
|
651
|
+
: null;
|
|
652
|
+
|
|
653
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
654
|
+
if (!secret) {
|
|
655
|
+
return new Response(
|
|
656
|
+
JSON.stringify({ error: 'PRENTA_WEBHOOK_SECRET is not configured on this deploy' }),
|
|
657
|
+
{ status: 503, headers: { 'content-type': 'application/json' } },
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (!cdnAdapter && isProd) {
|
|
661
|
+
console.error(
|
|
662
|
+
'[prenta] Rejecting purge webhook: no CDN adapter in production (silent no-op forbidden)',
|
|
663
|
+
);
|
|
664
|
+
return new Response(
|
|
665
|
+
JSON.stringify({
|
|
666
|
+
error: 'cdn_purge_not_configured',
|
|
667
|
+
message:
|
|
668
|
+
'Set CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN or VERCEL_DEPLOY_HOOK_URL. ' +
|
|
669
|
+
'A 200 with a no-op CDN adapter is not allowed in production.',
|
|
670
|
+
}),
|
|
671
|
+
{ status: 503, headers: { 'content-type': 'application/json' } },
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (!receiver) {
|
|
675
|
+
return new Response(JSON.stringify({ error: 'Purge receiver unavailable' }), {
|
|
676
|
+
status: 503,
|
|
677
|
+
headers: { 'content-type': 'application/json' },
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return receiver(request);
|
|
681
|
+
};
|
|
682
|
+
`,
|
|
683
|
+
'src/pages/sitemap.xml.ts': `import type { APIRoute } from 'astro';
|
|
684
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
685
|
+
|
|
686
|
+
/** Yoast-style sitemap index (children + XSL proxied separately). */
|
|
687
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
688
|
+
proxyCmsSeoArtifact('/api/cms/sitemap.xml', {
|
|
689
|
+
accept: 'application/xml',
|
|
690
|
+
publicOrigin: publicOrigin(site),
|
|
691
|
+
});
|
|
692
|
+
`,
|
|
693
|
+
'src/pages/[name].xml.ts': `import type { APIRoute } from 'astro';
|
|
694
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Yoast-style child sitemaps at the public root:
|
|
698
|
+
* /page-sitemap.xml, /post-sitemap2.xml, /case-studies-sitemap.xml, …
|
|
699
|
+
* Upstream: /api/cms/sitemaps/{name}.xml
|
|
700
|
+
*/
|
|
701
|
+
const CHILD_SITEMAP = /^[a-z0-9-]+-sitemap\\d*$/i;
|
|
702
|
+
|
|
703
|
+
export const GET: APIRoute = async ({ params, site }) => {
|
|
704
|
+
const name = params.name ?? '';
|
|
705
|
+
if (!CHILD_SITEMAP.test(name)) {
|
|
706
|
+
return new Response('Not Found', { status: 404 });
|
|
707
|
+
}
|
|
708
|
+
return proxyCmsSeoArtifact(\`/api/cms/sitemaps/\${name}.xml\`, {
|
|
709
|
+
accept: 'application/xml',
|
|
710
|
+
publicOrigin: publicOrigin(site),
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
`,
|
|
714
|
+
'src/pages/sitemap.xsl.ts': `import type { APIRoute } from 'astro';
|
|
715
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
716
|
+
|
|
717
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
718
|
+
proxyCmsSeoArtifact('/api/cms/sitemap.xsl', {
|
|
719
|
+
accept: 'application/xml',
|
|
720
|
+
publicOrigin: publicOrigin(site),
|
|
721
|
+
});
|
|
722
|
+
`,
|
|
723
|
+
'src/pages/sitemap-index.xsl.ts': `import type { APIRoute } from 'astro';
|
|
724
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
725
|
+
|
|
726
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
727
|
+
proxyCmsSeoArtifact('/api/cms/sitemap-index.xsl', {
|
|
728
|
+
accept: 'application/xml',
|
|
729
|
+
publicOrigin: publicOrigin(site),
|
|
730
|
+
});
|
|
731
|
+
`,
|
|
732
|
+
'src/pages/robots.txt.ts': `import type { APIRoute } from 'astro';
|
|
733
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
734
|
+
|
|
735
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
736
|
+
proxyCmsSeoArtifact('/api/cms/robots.txt', {
|
|
737
|
+
accept: 'text/plain',
|
|
738
|
+
publicOrigin: publicOrigin(site),
|
|
739
|
+
});
|
|
740
|
+
`,
|
|
741
|
+
'src/pages/llms.txt.ts': `import type { APIRoute } from 'astro';
|
|
742
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
743
|
+
|
|
744
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
745
|
+
proxyCmsSeoArtifact('/api/cms/llms.txt', {
|
|
746
|
+
accept: 'text/plain',
|
|
747
|
+
publicOrigin: publicOrigin(site),
|
|
748
|
+
});
|
|
749
|
+
`,
|
|
750
|
+
'src/pages/llms-full.txt.ts': `import type { APIRoute } from 'astro';
|
|
751
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
752
|
+
|
|
753
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
754
|
+
proxyCmsSeoArtifact('/api/cms/llms-full.txt', {
|
|
755
|
+
accept: 'text/plain',
|
|
756
|
+
publicOrigin: publicOrigin(site),
|
|
757
|
+
});
|
|
758
|
+
`,
|
|
759
|
+
'src/pages/feed.xml.ts': `import type { APIRoute } from 'astro';
|
|
760
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
761
|
+
|
|
762
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
763
|
+
proxyCmsSeoArtifact('/api/cms/feed.xml', {
|
|
764
|
+
accept: 'application/xml',
|
|
765
|
+
publicOrigin: publicOrigin(site),
|
|
766
|
+
});
|
|
767
|
+
`,
|
|
768
|
+
'src/pages/feed.json.ts': `import type { APIRoute } from 'astro';
|
|
769
|
+
import { proxyCmsSeoArtifact, publicOrigin } from '../lib/cms-proxy';
|
|
770
|
+
|
|
771
|
+
export const GET: APIRoute = async ({ site }) =>
|
|
772
|
+
proxyCmsSeoArtifact('/api/cms/feed.json', {
|
|
773
|
+
accept: 'application/feed+json, application/json',
|
|
774
|
+
publicOrigin: publicOrigin(site),
|
|
775
|
+
});
|
|
776
|
+
`,
|
|
777
|
+
'README.md': `# Prenta Astro public site
|
|
778
|
+
|
|
779
|
+
Headless public frontend for an Prenta CMS host (**Astro 7**, Node.js 22.12+).
|
|
780
|
+
|
|
781
|
+
## Setup
|
|
782
|
+
|
|
783
|
+
\`\`\`bash
|
|
784
|
+
# Preferred (existing CMS host — UpChat / fleet migrations)
|
|
785
|
+
npm create prenta-astro@latest my-site
|
|
786
|
+
# or: npx create-prenta@latest my-site -- --yes --framework astro-site
|
|
787
|
+
|
|
788
|
+
cp .env.example .env
|
|
789
|
+
# Set PRENTA_CMS_URL to your CMS host (admin + /api/cms)
|
|
790
|
+
npm install
|
|
791
|
+
npm run dev
|
|
792
|
+
\`\`\`
|
|
793
|
+
|
|
794
|
+
## Islands
|
|
795
|
+
|
|
796
|
+
Page sections SSR via \`@astrojs/react\` with **no** \`client:*\` by default (zero client JS).
|
|
797
|
+
Hydrate only interactive widgets (\`client:visible\` / \`client:load\`). See \`PageSections.tsx\`.
|
|
798
|
+
|
|
799
|
+
## SEO redirects + security headers
|
|
800
|
+
|
|
801
|
+
\`src/middleware.ts\` sequences \`createAstroRedirectMiddleware\` (\`PRENTA_CMS_URL\`)
|
|
802
|
+
and \`createAstroSecurityHeadersMiddleware\` (same baseline as Next
|
|
803
|
+
\`withPrentaCMS()\`). Next only covers the CMS host — keep this middleware on
|
|
804
|
+
every Astro public deploy.
|
|
805
|
+
|
|
806
|
+
## Site identity JSON-LD
|
|
807
|
+
|
|
808
|
+
\`BaseLayout\` emits WebSite + Organization JSON-LD (stable \`#website\` /
|
|
809
|
+
\`#organization\` @ids) on every page that does not already include Organization
|
|
810
|
+
via CMS \`composePageMeta\`. Set \`PUBLIC_SITE_NAME\` / \`PUBLIC_SITE_LOGO\` for brand
|
|
811
|
+
signals — this unblocks Article rich results that reference publisher by \`@id\`.
|
|
812
|
+
|
|
813
|
+
Production middleware also asserts \`SITE\` is set (Launch Gate E). On the CMS
|
|
814
|
+
host, \`NEXT_PUBLIC_SITE_URL\` must equal that same public apex.
|
|
815
|
+
|
|
816
|
+
## Rendering: prerender vs on-demand
|
|
817
|
+
|
|
818
|
+
Hybrid \`output: 'server'\`:
|
|
819
|
+
|
|
820
|
+
| Route | \`prerender\` | Why |
|
|
821
|
+
| --- | --- | --- |
|
|
822
|
+
| \`404.astro\` (and other code-owned pages) | \`true\` | Built once per deploy; no CMS dependency |
|
|
823
|
+
| \`[...path].astro\` / \`index.astro\` / \`blog/index.astro\` | \`false\` | CMS-backed; needs preview + publish freshness |
|
|
824
|
+
| SEO proxies (\`sitemap.xml\`, \`robots.txt\`, …) | on-demand | Proxied from the CMS host |
|
|
825
|
+
|
|
826
|
+
When you add a static marketing/registry page, start with
|
|
827
|
+
\`export const prerender = true\`. Only leave a page on-demand when it calls
|
|
828
|
+
\`/resolve\`, a live feed, or other request-time CMS data.
|
|
829
|
+
|
|
830
|
+
## Freshness: cache-static + purge-on-publish
|
|
831
|
+
|
|
832
|
+
Published pages use \`publicPageCacheControl()\` from
|
|
833
|
+
\`@prenta/core/astro/cache\` (**cache-static** defaults:
|
|
834
|
+
\`s-maxage=3600, stale-while-revalidate=86400\`) and are evicted the moment an
|
|
835
|
+
editor publishes:
|
|
836
|
+
|
|
837
|
+
1. In the CMS admin, go to **Settings → Webhooks** and add an endpoint for
|
|
838
|
+
\`https://<this site>/api/prenta/purge\` (it subscribes to the publish
|
|
839
|
+
lifecycle by default).
|
|
840
|
+
2. Copy the signing secret into \`PRENTA_WEBHOOK_SECRET\` on this deploy.
|
|
841
|
+
3. **Required for production:** CDN purge credentials in \`.env\` (Cloudflare
|
|
842
|
+
zone purge or a Vercel deploy hook — see \`.env.example\`). Without them the
|
|
843
|
+
production purge endpoint returns **503** (silent no-op is forbidden).
|
|
844
|
+
4. Optional: set \`UPSTASH_REDIS_REST_URL\` / \`UPSTASH_REDIS_REST_TOKEN\` for
|
|
845
|
+
a layer-2 runtime cache drained on publish.
|
|
846
|
+
|
|
847
|
+
If you cannot run the purge webhook, pass a lower \`maxAge\` to
|
|
848
|
+
\`publicPageCacheControl({ maxAge })\` — staleness is then bounded by the TTL alone.
|
|
849
|
+
|
|
850
|
+
## Blog / content collections
|
|
851
|
+
|
|
852
|
+
\`src/live.config.ts\` wires Astro **Live Content Collections** to Prenta \`feed.json\`.
|
|
853
|
+
\`/blog\` lists posts; individual URLs still resolve through \`[...path].astro\`.
|
|
854
|
+
|
|
855
|
+
## Preview (same-origin, default)
|
|
856
|
+
|
|
857
|
+
Draft preview cookies only work when the browser treats \`/api/cms\` as **same-origin**
|
|
858
|
+
as the public site. The shipped \`vercel.json\` routes the apex accordingly:
|
|
859
|
+
|
|
860
|
+
| Path | Target |
|
|
861
|
+
|------|--------|
|
|
862
|
+
| \`/api/cms/*\` | CMS host |
|
|
863
|
+
| \`/admin\`, login path | CMS host |
|
|
864
|
+
| Everything else | This Astro app |
|
|
865
|
+
|
|
866
|
+
**Before going live:** replace \`CMS_HOST_ORIGIN\` in \`vercel.json\` with your
|
|
867
|
+
deployed CMS host origin (or configure the same path rules at your CDN), and set
|
|
868
|
+
\`PRENTA_CMS_URL\` to the **public apex** (not a separate CMS subdomain).
|
|
869
|
+
|
|
870
|
+
## Deploy
|
|
871
|
+
|
|
872
|
+
1. Deploy the **CMS host** (Next) with admin, API, cron, database.
|
|
873
|
+
2. Deploy this Astro app with \`PRENTA_CMS_URL\` pointing at the CMS origin (or apex if proxied).
|
|
874
|
+
3. At the CDN: route \`/admin\`, \`/api/cms\`, \`/securelogin\` → CMS; everything else → Astro.
|
|
875
|
+
4. In the CMS: **Settings → Webhooks** → add \`https://<this site>/api/prenta/purge\`
|
|
876
|
+
and set \`PRENTA_WEBHOOK_SECRET\` here (see Freshness above).
|
|
877
|
+
|
|
878
|
+
Docs: [Astro public site](https://github.com/actuate-media/prenta/blob/main/apps/docs/content/astro-public-site.md)
|
|
879
|
+
`,
|
|
880
|
+
// Same-apex routing is the DEFAULT: the public apex serves Astro, while
|
|
881
|
+
// /api/cms, /admin, and the login mount rewrite to the CMS host. This is
|
|
882
|
+
// what makes draft-preview cookies same-origin out of the box. Replace
|
|
883
|
+
// CMS_HOST_ORIGIN with the deployed CMS host before going live (see README).
|
|
884
|
+
'vercel.json': JSON.stringify({
|
|
885
|
+
framework: 'astro',
|
|
886
|
+
rewrites: [
|
|
887
|
+
{
|
|
888
|
+
source: '/api/cms/:path*',
|
|
889
|
+
destination: 'https://CMS_HOST_ORIGIN/api/cms/:path*',
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
source: `${adminPath}/:path*`,
|
|
893
|
+
destination: `https://CMS_HOST_ORIGIN${adminPath}/:path*`,
|
|
894
|
+
},
|
|
895
|
+
...(loginPath !== adminPath
|
|
896
|
+
? [
|
|
897
|
+
{
|
|
898
|
+
source: `${loginPath}/:path*`,
|
|
899
|
+
destination: `https://CMS_HOST_ORIGIN${loginPath}/:path*`,
|
|
900
|
+
},
|
|
901
|
+
]
|
|
902
|
+
: []),
|
|
903
|
+
],
|
|
904
|
+
}, null, 2),
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/** True when the scaffold should emit a separate Astro public site under `site/`. */
|
|
908
|
+
export function isAstroSplit(options) {
|
|
909
|
+
return options.framework === 'astro-split';
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* True when scaffolding an Astro-only public site (no CMS host).
|
|
913
|
+
* Used by `create-prenta-astro` / `--framework astro-site` against an existing Prenta CMS.
|
|
914
|
+
*/
|
|
915
|
+
export function isAstroSiteOnly(options) {
|
|
916
|
+
return options.framework === 'astro-site';
|
|
917
|
+
}
|
|
918
|
+
//# sourceMappingURL=astro-site.js.map
|