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.
Files changed (49) hide show
  1. package/CHANGELOG.md +964 -0
  2. package/LICENSE +21 -0
  3. package/assets/prisma/migrations/0001_init/migration.sql +941 -0
  4. package/assets/prisma/migrations/0002_media_usage_field_path/migration.sql +11 -0
  5. package/assets/prisma/migrations/0003_ai_crawler_hits/migration.sql +21 -0
  6. package/assets/prisma/migrations/0004_drop_search_vector/migration.sql +4 -0
  7. package/assets/prisma/migrations/0005_script_tag_delivery_controls/migration.sql +8 -0
  8. package/assets/prisma/migrations/0006_totp_last_step/migration.sql +4 -0
  9. package/assets/prisma/migrations/0007_workflow_fk_drop_dead_enums/migration.sql +27 -0
  10. package/assets/prisma/migrations/0008_form_delivery_attempts/migration.sql +29 -0
  11. package/assets/prisma/migrations/0009_webhook_secret_rotation/migration.sql +6 -0
  12. package/assets/prisma/migrations/0010_media_is_decorative/migration.sql +3 -0
  13. package/assets/prisma/migrations/0011_media_content_hash/migration.sql +4 -0
  14. package/assets/prisma/migrations/0012_rename_to_prenta/migration.sql +89 -0
  15. package/assets/prisma/migrations/migration_lock.toml +3 -0
  16. package/assets/prisma/schema.prisma +989 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +182 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/options.d.ts +160 -0
  22. package/dist/options.d.ts.map +1 -0
  23. package/dist/options.js +206 -0
  24. package/dist/options.js.map +1 -0
  25. package/dist/scaffold.d.ts +45 -0
  26. package/dist/scaffold.d.ts.map +1 -0
  27. package/dist/scaffold.js +2163 -0
  28. package/dist/scaffold.js.map +1 -0
  29. package/dist/templates/admin-page.ts.template.d.ts +24 -0
  30. package/dist/templates/admin-page.ts.template.d.ts.map +1 -0
  31. package/dist/templates/admin-page.ts.template.js +145 -0
  32. package/dist/templates/admin-page.ts.template.js.map +1 -0
  33. package/dist/templates/astro-site.d.ts +36 -0
  34. package/dist/templates/astro-site.d.ts.map +1 -0
  35. package/dist/templates/astro-site.js +918 -0
  36. package/dist/templates/astro-site.js.map +1 -0
  37. package/dist/templates/login-page.ts.template.d.ts +14 -0
  38. package/dist/templates/login-page.ts.template.d.ts.map +1 -0
  39. package/dist/templates/login-page.ts.template.js +143 -0
  40. package/dist/templates/login-page.ts.template.js.map +1 -0
  41. package/dist/templates/marketing-custom.d.ts +56 -0
  42. package/dist/templates/marketing-custom.d.ts.map +1 -0
  43. package/dist/templates/marketing-custom.js +814 -0
  44. package/dist/templates/marketing-custom.js.map +1 -0
  45. package/dist/templates/prenta.config.ts.template.d.ts +3 -0
  46. package/dist/templates/prenta.config.ts.template.d.ts.map +1 -0
  47. package/dist/templates/prenta.config.ts.template.js +253 -0
  48. package/dist/templates/prenta.config.ts.template.js.map +1 -0
  49. 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