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,814 @@
1
+ /** True when the project opted into the marketing-custom starter. */
2
+ export function isMarketingCustom(options) {
3
+ return options.template === 'marketing-custom';
4
+ }
5
+ /** Extra package.json scripts for the component-blocks workflow. */
6
+ export const MARKETING_CUSTOM_SCRIPTS = {
7
+ 'blocks:extract': 'prenta-component-blocks extract --root ./src/blocks --out ./prenta-blocks.json',
8
+ 'blocks:diff': 'prenta-component-blocks extract --root ./src/blocks --out ./.prenta-blocks.next.json && prenta-component-blocks diff ./prenta-blocks.json ./.prenta-blocks.next.json',
9
+ 'verify:sections': 'node scripts/verify-sections.mjs',
10
+ };
11
+ /** Extra `.gitignore` entries (the throwaway manifest `blocks:diff` writes). */
12
+ export const MARKETING_CUSTOM_GITIGNORE = ['.prenta-blocks.next.json'];
13
+ /**
14
+ * Files emitted for the marketing-custom template, keyed by project-relative
15
+ * POSIX path. The scaffolder creates parent dirs and writes each entry.
16
+ */
17
+ export function marketingCustomFiles() {
18
+ return {
19
+ 'src/blocks/Pricing.tsx': pricingComponent(),
20
+ 'src/sections/types.ts': sectionTypesModule('flat'),
21
+ 'src/sections/register.tsx': sectionRegisterModule('flat'),
22
+ 'prenta.seed.json': marketingSeedJson(),
23
+ 'scripts/verify-sections.mjs': verifySectionsScript('flat'),
24
+ 'SECTIONS.md': sectionsDoc('flat'),
25
+ '.cursor/skills/prenta-add-section/SKILL.md': addSectionSkill('flat'),
26
+ };
27
+ }
28
+ /**
29
+ * Files for the shared section kit in an astro-split **pnpm workspace**
30
+ * scaffold, keyed by project-root-relative POSIX path.
31
+ *
32
+ * The kit is the single source of truth for the three-layer section contract:
33
+ * 1. schema (`packages/sections/src/types.ts`) → `apps/cms/prenta.config.ts`
34
+ * 2. ONE renderer map (`packages/sections/src/register.tsx`) → admin canvas
35
+ * (`<AdminRoot sectionRenderers>`) **and** the Astro public site
36
+ * (side-effect import in `PageSections.tsx` → `<Sections>`)
37
+ * 3. `verify:sections` keeps schema ids and renderer keys in lockstep.
38
+ *
39
+ * The package ships raw TypeScript source: Next compiles it via
40
+ * `transpilePackages`, and Vite/Astro processes linked workspace deps natively.
41
+ */
42
+ export function workspaceSectionKitFiles(opts) {
43
+ const v = (pkg) => opts.versions[pkg] ?? '^0.1.0';
44
+ const packageJson = {
45
+ name: opts.packageName,
46
+ version: '0.1.0',
47
+ private: true,
48
+ type: 'module',
49
+ // Raw-TS exports: consumers compile this package themselves (Next via
50
+ // transpilePackages, Astro/Vite via linked-dependency processing).
51
+ exports: {
52
+ '.': './src/index.ts',
53
+ './types': './src/types.ts',
54
+ './register': './src/register.tsx',
55
+ },
56
+ scripts: {
57
+ 'blocks:extract': 'prenta-component-blocks extract --root ./src/blocks --out ./prenta-blocks.json',
58
+ 'blocks:diff': 'prenta-component-blocks extract --root ./src/blocks --out ./.prenta-blocks.next.json && prenta-component-blocks diff ./prenta-blocks.json ./.prenta-blocks.next.json',
59
+ 'verify:sections': 'node scripts/verify-sections.mjs',
60
+ // Canvas (AdminRoot) ↔ public (Astro <Sections>) fidelity for this kit.
61
+ 'test:parity': 'vitest run',
62
+ },
63
+ dependencies: {
64
+ '@prenta/sections-react': v('@prenta/sections-react'),
65
+ },
66
+ peerDependencies: {
67
+ react: '^19.0.0',
68
+ },
69
+ devDependencies: {
70
+ // Type-only imports (AdminSectionRendererMap / CustomSectionTypeInput)
71
+ // plus the component-blocks CLI used by blocks:extract / blocks:diff.
72
+ '@prenta/admin': v('@prenta/admin'),
73
+ '@prenta/core': v('@prenta/core'),
74
+ '@prenta/component-blocks': v('@prenta/component-blocks'),
75
+ '@types/react': '^19.0.0',
76
+ '@types/react-dom': '^19.0.0',
77
+ react: '^19.0.0',
78
+ 'react-dom': '^19.0.0',
79
+ typescript: '^5.7.0',
80
+ vitest: '^3.0.0',
81
+ },
82
+ };
83
+ const tsconfig = {
84
+ compilerOptions: {
85
+ target: 'ES2022',
86
+ lib: ['dom', 'dom.iterable', 'esnext'],
87
+ module: 'esnext',
88
+ moduleResolution: 'bundler',
89
+ jsx: 'react-jsx',
90
+ strict: true,
91
+ noEmit: true,
92
+ skipLibCheck: true,
93
+ isolatedModules: true,
94
+ esModuleInterop: true,
95
+ },
96
+ include: ['src'],
97
+ };
98
+ const vitestConfig = `import { defineConfig } from 'vitest/config';
99
+
100
+ export default defineConfig({
101
+ test: { environment: 'node' },
102
+ esbuild: { jsx: 'automatic' },
103
+ });
104
+ `;
105
+ return {
106
+ 'packages/sections/package.json': `${JSON.stringify(packageJson, null, 2)}\n`,
107
+ 'packages/sections/tsconfig.json': `${JSON.stringify(tsconfig, null, 2)}\n`,
108
+ 'packages/sections/vitest.config.ts': vitestConfig,
109
+ 'packages/sections/src/index.ts': `export { marketingSectionTypes } from './types';
110
+ export { sectionRenderers } from './register';
111
+ `,
112
+ 'packages/sections/src/types.ts': sectionTypesModule('workspace'),
113
+ 'packages/sections/src/register.tsx': sectionRegisterModule('workspace'),
114
+ 'packages/sections/src/blocks/Pricing.tsx': pricingComponent(),
115
+ 'packages/sections/src/__tests__/section-parity.test.tsx': sectionParityTest('workspace'),
116
+ 'packages/sections/scripts/verify-sections.mjs': verifySectionsScript('workspace'),
117
+ 'apps/cms/prenta.seed.json': marketingSeedJson(),
118
+ 'SECTIONS.md': sectionsDoc('workspace', opts.packageName),
119
+ '.cursor/skills/prenta-add-section/SKILL.md': addSectionSkill('workspace', opts.packageName),
120
+ };
121
+ }
122
+ function pricingComponent() {
123
+ return `/**
124
+ * Presentational pricing grid. Props are deliberately explicit and typed so
125
+ * \`npm run blocks:extract\` can derive a component-blocks manifest from them.
126
+ * The section renderer in ../sections/register.tsx adapts a section's stored
127
+ * content to these props.
128
+ */
129
+ export interface PricingPlan {
130
+ /** Plan name, e.g. "Starter". */
131
+ name: string;
132
+ /** Display price, e.g. "$29/mo". */
133
+ price: string;
134
+ /** Optional short description shown under the price. */
135
+ description?: string;
136
+ }
137
+
138
+ export interface PricingProps {
139
+ /** Section heading. */
140
+ heading: string;
141
+ /** Plans to display, left to right. */
142
+ plans: PricingPlan[];
143
+ }
144
+
145
+ export function Pricing({ heading, plans }: PricingProps) {
146
+ return (
147
+ <section className="mx-auto max-w-5xl px-4 py-16">
148
+ <h2 className="mb-8 text-center text-3xl font-semibold text-zinc-900">{heading}</h2>
149
+ <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
150
+ {plans.map((plan, i) => (
151
+ <div key={i} className="rounded-xl border border-zinc-200 p-6">
152
+ <h3 className="text-lg font-medium text-zinc-900">{plan.name}</h3>
153
+ <p className="mt-2 text-2xl font-semibold text-violet-600">{plan.price}</p>
154
+ {plan.description ? (
155
+ <p className="mt-2 text-sm text-zinc-600">{plan.description}</p>
156
+ ) : null}
157
+ </div>
158
+ ))}
159
+ </div>
160
+ </section>
161
+ );
162
+ }
163
+ `;
164
+ }
165
+ function sectionTypesModule(layout) {
166
+ const wiring = layout === 'workspace'
167
+ ? ` * Wired into apps/cms/prenta.config.ts as \`sections: { types: marketingSectionTypes }\`
168
+ * via a relative import, so Next's config loader can compile it.`
169
+ : ` * Wired into prenta.config.ts as \`sections: { types: marketingSectionTypes }\`.`;
170
+ const verifyCmd = layout === 'workspace' ? 'pnpm verify:sections' : 'npm run verify:sections';
171
+ return `import type { CustomSectionTypeInput } from '@prenta/core/page-sections';
172
+
173
+ /**
174
+ * Custom section types for this site. Each entry teaches the admin editor how
175
+ * to author the section (Add Section gallery + field-driven inspector). The
176
+ * matching public renderer lives in ./register.tsx — keep the two in sync
177
+ * (\`${verifyCmd}\` checks this).
178
+ *
179
+ ${wiring}
180
+ */
181
+ export const marketingSectionTypes: CustomSectionTypeInput[] = [
182
+ {
183
+ id: 'pricing',
184
+ name: 'Pricing Table',
185
+ scope: ['page'],
186
+ icon: 'badge-dollar-sign',
187
+ description: 'A grid of pricing plans.',
188
+ category: 'conversion',
189
+ fields: [
190
+ { key: 'heading', label: 'Heading', type: 'text', required: true },
191
+ {
192
+ key: 'plans',
193
+ label: 'Plans',
194
+ type: 'repeater',
195
+ rowLabel: 'Plan',
196
+ fields: [
197
+ { key: 'name', label: 'Plan name', type: 'text', required: true },
198
+ { key: 'price', label: 'Price', type: 'text', required: true },
199
+ { key: 'description', label: 'Description', type: 'textarea' },
200
+ ],
201
+ },
202
+ ],
203
+ defaultContent: { heading: 'Pricing', plans: [] },
204
+ },
205
+ ];
206
+ `;
207
+ }
208
+ function sectionRegisterModule(layout) {
209
+ const pricingImport = layout === 'workspace' ? './blocks/Pricing' : '../blocks/Pricing';
210
+ const verifyCmd = layout === 'workspace' ? 'pnpm verify:sections' : 'npm run verify:sections';
211
+ const registrationNote = layout === 'workspace'
212
+ ? `// Register for the public site. apps/site imports this module as a
213
+ // side-effect (see src/components/PageSections.tsx — the import runs the
214
+ // registration); the apps/cms admin page imports \`sectionRenderers\` and
215
+ // passes it to <AdminRoot sectionRenderers={...}>.`
216
+ : `// Register for the public site. app/(site)/layout.tsx imports this module (the
217
+ // import runs the registration); the admin page imports \`sectionRenderers\` and
218
+ // passes it to <AdminRoot sectionRenderers={...}>.`;
219
+ return `import { registerSectionRenderers } from '@prenta/sections-react';
220
+ import type { AdminSectionRendererMap } from '@prenta/admin';
221
+ import { Pricing, type PricingPlan } from '${pricingImport}';
222
+
223
+ /**
224
+ * Renderers for this site's custom section types — ONE map, used for BOTH the
225
+ * public site (via <Sections>) and the admin editor canvas
226
+ * (<AdminRoot sectionRenderers={...}>). Both surfaces pass the same
227
+ * \`{ section }\`, so a single component serves both.
228
+ *
229
+ * Typed as \`AdminSectionRendererMap\` (the stricter of the two shapes — it is
230
+ * assignable to the public \`SectionRendererMap\` for the registration below).
231
+ * Read section data from \`section.content\`. Every id here must match a
232
+ * section-type id in ./types.ts (\`${verifyCmd}\` keeps them in sync).
233
+ */
234
+ export const sectionRenderers: AdminSectionRendererMap = {
235
+ pricing: ({ section }) => {
236
+ const content = section.content ?? {};
237
+ const heading = typeof content.heading === 'string' ? content.heading : '';
238
+ const plans = Array.isArray(content.plans) ? (content.plans as PricingPlan[]) : [];
239
+ return <Pricing heading={heading} plans={plans} />;
240
+ },
241
+ };
242
+
243
+ ${registrationNote}
244
+ registerSectionRenderers(sectionRenderers);
245
+ `;
246
+ }
247
+ /**
248
+ * `prenta.seed.json` — a "Marketing homepage" starter the project owner can
249
+ * load with `npm run populate`. Pre-fills hero, stats, pricing (the custom
250
+ * type), and CTA sections so the first visit to the Page editor shows a real,
251
+ * fully-authored page instead of an empty canvas.
252
+ */
253
+ function marketingSeedJson() {
254
+ const seed = {
255
+ globals: {
256
+ 'site-settings': {
257
+ siteName: 'My Site',
258
+ description: 'A marketing site powered by Prenta CMS.',
259
+ },
260
+ },
261
+ collections: {
262
+ forms: [
263
+ {
264
+ status: 'PUBLISHED',
265
+ data: {
266
+ name: 'Contact',
267
+ title: 'Contact',
268
+ slug: 'contact',
269
+ status: 'active',
270
+ fields: [
271
+ {
272
+ id: 'contact-name',
273
+ key: 'name',
274
+ label: 'Name',
275
+ type: 'text',
276
+ required: true,
277
+ sortOrder: 0,
278
+ },
279
+ {
280
+ id: 'contact-email',
281
+ key: 'email',
282
+ label: 'Email',
283
+ type: 'email',
284
+ required: true,
285
+ sortOrder: 1,
286
+ },
287
+ {
288
+ id: 'contact-message',
289
+ key: 'message',
290
+ label: 'Message',
291
+ type: 'textarea',
292
+ required: true,
293
+ sortOrder: 2,
294
+ },
295
+ ],
296
+ successMessage: "Thanks — we'll be in touch soon.",
297
+ },
298
+ },
299
+ ],
300
+ pages: [
301
+ {
302
+ status: 'PUBLISHED',
303
+ data: {
304
+ title: 'Home',
305
+ slug: 'home',
306
+ path: '/',
307
+ sections: [
308
+ {
309
+ id: 'sec-hero',
310
+ sectionType: 'hero',
311
+ name: 'Hero Banner',
312
+ visible: true,
313
+ content: {
314
+ eyebrow: 'Launching soon',
315
+ heading: 'Marketing sites your editors can actually edit',
316
+ body: 'Author every section of this page in the admin — copy, pricing, stats, and calls to action — and publish without a deploy.',
317
+ primaryButtonLabel: 'Get Started',
318
+ primaryButtonUrl: '/contact',
319
+ secondaryButtonLabel: 'See pricing',
320
+ secondaryButtonUrl: '#pricing',
321
+ },
322
+ settings: { backgroundType: 'solid', backgroundColor: 'purple', paddingY: 'xl' },
323
+ },
324
+ {
325
+ id: 'sec-stats',
326
+ sectionType: 'stats',
327
+ name: 'By the Numbers',
328
+ visible: true,
329
+ content: {
330
+ heading: 'By the numbers',
331
+ body: '',
332
+ stats: [
333
+ { value: '99.99%', label: 'Uptime', description: '' },
334
+ { value: '124ms', label: 'Avg response time', description: '' },
335
+ { value: '3M+', label: 'Requests served', description: '' },
336
+ ],
337
+ },
338
+ settings: { backgroundType: 'none', paddingY: 'lg' },
339
+ },
340
+ {
341
+ id: 'sec-pricing',
342
+ sectionType: 'pricing',
343
+ name: 'Pricing Table',
344
+ visible: true,
345
+ content: {
346
+ heading: 'Simple, transparent pricing',
347
+ plans: [
348
+ {
349
+ name: 'Starter',
350
+ price: '$29/mo',
351
+ description: 'For new projects finding their footing.',
352
+ },
353
+ {
354
+ name: 'Pro',
355
+ price: '$79/mo',
356
+ description: 'For growing teams that ship every week.',
357
+ },
358
+ {
359
+ name: 'Enterprise',
360
+ price: 'Custom',
361
+ description: 'Security review, SSO, and a dedicated SLA.',
362
+ },
363
+ ],
364
+ },
365
+ settings: { backgroundType: 'none', paddingY: 'xl' },
366
+ },
367
+ {
368
+ id: 'sec-cta',
369
+ sectionType: 'cta',
370
+ name: 'CTA Banner',
371
+ visible: true,
372
+ content: {
373
+ heading: 'Ready to get started?',
374
+ body: 'Spin up your site and hand the keys to your editors.',
375
+ buttonLabel: 'Start Building',
376
+ buttonUrl: '/contact',
377
+ },
378
+ settings: { backgroundType: 'solid', backgroundColor: 'purple', paddingY: 'xl' },
379
+ },
380
+ ],
381
+ },
382
+ },
383
+ {
384
+ status: 'PUBLISHED',
385
+ data: {
386
+ title: 'Contact',
387
+ slug: 'contact',
388
+ path: '/contact',
389
+ sections: [
390
+ {
391
+ id: 'sec-contact-form',
392
+ sectionType: 'form',
393
+ name: 'Contact Form',
394
+ visible: true,
395
+ content: {
396
+ heading: 'Get in touch',
397
+ description: 'Send us a message — submissions land in Admin → Forms and can forward to your CRM via Zapier.',
398
+ formSlug: 'contact',
399
+ },
400
+ settings: { backgroundType: 'none', paddingY: 'lg' },
401
+ },
402
+ ],
403
+ },
404
+ },
405
+ ],
406
+ },
407
+ };
408
+ return `${JSON.stringify(seed, null, 2)}\n`;
409
+ }
410
+ function verifySectionsScript(layout) {
411
+ const typesRel = layout === 'workspace' ? 'src/types.ts' : 'src/sections/types.ts';
412
+ const registerRel = layout === 'workspace' ? 'src/register.tsx' : 'src/sections/register.tsx';
413
+ return `#!/usr/bin/env node
414
+ /**
415
+ * verify:sections — fast static check that every custom section type declared
416
+ * in ${typesRel} has a registered public renderer in
417
+ * ${registerRel} (and vice versa), so a page can never reference a
418
+ * section type with no renderer ("no dead sections").
419
+ *
420
+ * Intentionally dependency-free and build-free: it scans the two source files
421
+ * as text rather than executing TypeScript, so it runs the moment the project
422
+ * is scaffolded.
423
+ */
424
+ import { readFileSync } from 'node:fs';
425
+ import { fileURLToPath } from 'node:url';
426
+ import { dirname, join } from 'node:path';
427
+
428
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
429
+
430
+ function read(rel) {
431
+ try {
432
+ return readFileSync(join(root, rel), 'utf-8');
433
+ } catch {
434
+ return '';
435
+ }
436
+ }
437
+
438
+ const typesSrc = read('${typesRel}');
439
+ const registerSrc = read('${registerRel}');
440
+
441
+ // Top-level section types declare \`id: '...'\`; sub-fields use \`key:\`, so this
442
+ // only matches section-type ids.
443
+ const declared = new Set([...typesSrc.matchAll(/\\bid:\\s*['"]([\\w-]+)['"]/g)].map((m) => m[1]));
444
+ // register.tsx declares each renderer as a map entry (\`id: ({ section }) => …\`),
445
+ // so match an indented identifier key immediately followed by a function.
446
+ const registered = new Set(
447
+ [...registerSrc.matchAll(/^\\s+([\\w-]+):\\s*(?:async\\s+)?\\(/gm)].map((m) => m[1]),
448
+ );
449
+
450
+ let ok = true;
451
+ if (declared.size === 0) {
452
+ console.error('verify:sections — no custom section types found in ${typesRel}');
453
+ ok = false;
454
+ }
455
+ for (const id of declared) {
456
+ if (!registered.has(id)) {
457
+ console.error(\`verify:sections — "\${id}" is declared in types.ts but has no renderer in register.tsx\`);
458
+ ok = false;
459
+ }
460
+ }
461
+ for (const id of registered) {
462
+ if (!declared.has(id)) {
463
+ console.error(\`verify:sections — "\${id}" has a renderer in register.tsx but is not declared in types.ts\`);
464
+ ok = false;
465
+ }
466
+ }
467
+
468
+ if (!ok) process.exit(1);
469
+ console.log(\`verify:sections — OK (\${declared.size} section type(s) declared and registered)\`);
470
+ `;
471
+ }
472
+ function sectionsDoc(layout, packageName) {
473
+ const ws = layout === 'workspace';
474
+ const typesPath = ws ? 'packages/sections/src/types.ts' : 'src/sections/types.ts';
475
+ const registerPath = ws ? 'packages/sections/src/register.tsx' : 'src/sections/register.tsx';
476
+ const blocksDir = ws ? 'packages/sections/src/blocks/' : 'src/blocks/';
477
+ const run = ws ? 'pnpm' : 'npm run';
478
+ const configWiring = ws
479
+ ? `Wired
480
+ into \`apps/cms/prenta.config.ts\` as \`sections: { types: marketingSectionTypes }\`.`
481
+ : `Wired
482
+ into \`prenta.config.ts\` as \`sections: { types: marketingSectionTypes }\`.`;
483
+ const registrationText = ws
484
+ ? `The Astro app
485
+ (\`apps/site/src/components/PageSections.tsx\`) imports the module as a
486
+ side effect (registering the public renderers for \`<Sections>\`), and the
487
+ \`apps/cms\` admin page passes the same \`sectionRenderers\` (imported as
488
+ \`${packageName}/register\`) to \`<AdminRoot sectionRenderers={...}>\` so
489
+ editors see the real component while they edit — no separate
490
+ admin-renderer file to keep in sync.`
491
+ : `\`app/(site)/layout.tsx\` imports the module (registering the
492
+ public renderers via \`<Sections>\`), and the admin page passes the same
493
+ \`sectionRenderers\` to \`<AdminRoot sectionRenderers={...}>\` so editors see the
494
+ real component while they edit — no separate admin-renderer file to keep in
495
+ sync.`;
496
+ const seedIntro = ws ? '`apps/cms/prenta.seed.json`' : '`prenta.seed.json`';
497
+ const kitIntro = ws
498
+ ? `This project uses the **marketing-custom** template. The section kit is a
499
+ shared workspace package — **\`packages/sections\`** (\`${packageName}\`) —
500
+ consumed by both \`apps/cms\` (admin authoring + editor canvas) and \`apps/site\`
501
+ (Astro public rendering). A custom section has just two files:`
502
+ : `This project uses the **marketing-custom** template, which wires up custom
503
+ section types end to end. A custom section has just two files:`;
504
+ return `# Custom Sections
505
+
506
+ ${kitIntro}
507
+
508
+ 1. **Schema** (\`${typesPath}\`) — declares the type so the admin Page
509
+ editor can author it (Add Section gallery + field-driven inspector). ${configWiring}
510
+ 2. **Renderer** (\`${registerPath}\`) — ONE map (\`sectionRenderers\`)
511
+ that renders each type, used for **both** the public site and the admin
512
+ editor canvas. ${registrationText} Read section data from \`section.content\`.
513
+
514
+ Renderers must be client-safe (the admin canvas runs them in the browser) and
515
+ SSR-safe (the public site renders them on the server) — presentational
516
+ components under \`${blocksDir}\` satisfy both. A renderer that throws falls back
517
+ to a structural placeholder in the canvas automatically.
518
+
519
+ ## Adding sections from a design (agent)
520
+
521
+ This project ships an agent skill at
522
+ \`.cursor/skills/prenta-add-section/SKILL.md\`. Ask your agent (Cursor, Claude,
523
+ etc.) to make a designed section editable and it follows that contract — emitting
524
+ the schema + renderer (+ optional block + seed) in sync — so a design becomes an
525
+ editable CMS section without hand-wiring. The manual steps below are the same
526
+ thing, done by hand.
527
+
528
+ ## Add a section type
529
+
530
+ 1. Add an entry to \`marketingSectionTypes\` in \`${typesPath}\`.
531
+ 2. (Optional) build a presentational component under \`${blocksDir}\`.
532
+ 3. Add a renderer for the new \`id\` to the \`sectionRenderers\` map in
533
+ \`${registerPath}\` — it drives both the public site and the admin
534
+ canvas.
535
+ 4. Run \`${run} verify:sections\` to confirm the schema and renderers match.
536
+ 5. (Astro-split) Add a fixture in \`packages/sections/src/__tests__/section-parity.test.tsx\`
537
+ and run \`${run} test:parity\`.
538
+
539
+ ## Starter homepage seed
540
+
541
+ ${seedIntro} contains a pre-filled "Marketing homepage" (hero, stats,
542
+ pricing, CTA) plus a **Contact** page with an embedded form. Load it after your first deploy / local setup:
543
+
544
+ \`\`\`bash
545
+ ${run} populate
546
+ \`\`\`
547
+
548
+ Then open the admin Page editor — every section is real, editable content.
549
+
550
+ ## Repeater fields
551
+
552
+ Use \`type: 'repeater'\` with a \`fields\` sub-schema for structured arrays (the
553
+ included Pricing section uses one for its plans). Each row is an object keyed by
554
+ the sub-field \`key\`s.
555
+
556
+ ## Component blocks
557
+
558
+ \`${blocksDir}\` holds typed presentational components. Generate a manifest of
559
+ their props for tooling/migrations:
560
+
561
+ \`\`\`bash
562
+ ${run} blocks:extract # writes prenta-blocks.json
563
+ ${run} blocks:diff # compares the committed manifest to the current source
564
+ \`\`\`
565
+
566
+ Commit \`prenta-blocks.json\` so \`blocks:diff\` can flag breaking prop changes in
567
+ review.
568
+ ${ws
569
+ ? `
570
+ ## Canvas ↔ public parity
571
+
572
+ In an Astro-split site the editing canvas (Next admin) and the public page
573
+ (Astro) are two orchestrators of the **same** \`sectionRenderers\` map. Keep
574
+ them honest with the parity harness from
575
+ \`@prenta/sections-react/testing\`:
576
+
577
+ \`\`\`bash
578
+ ${run} test:parity # or: pnpm --filter ${packageName} test:parity
579
+ \`\`\`
580
+
581
+ The starter test in \`packages/sections/src/__tests__/section-parity.test.tsx\`
582
+ renders seed-like fixtures through public defaults and canvas-equivalent
583
+ options and asserts the normalized trees match. Add a fixture when you add a
584
+ section type — if a renderer starts branching on \`options\` (or the maps
585
+ drift), CI fails before editors see a WYSIWYG lie.
586
+ `
587
+ : ''}`;
588
+ }
589
+ /** Starter contract test: same sectionRenderers → same tree on both surfaces. */
590
+ function sectionParityTest(layout) {
591
+ const registerImport = layout === 'workspace' ? '../register' : '../../sections/register';
592
+ return `import { describe, it, expect } from 'vitest';
593
+ import { assertSectionRenderParity } from '@prenta/sections-react/testing';
594
+ import type { PublicSection } from '@prenta/sections-react';
595
+ import { sectionRenderers } from '${registerImport}';
596
+
597
+ function section(
598
+ overrides: Partial<PublicSection> & Pick<PublicSection, 'sectionType'>,
599
+ ): PublicSection {
600
+ return {
601
+ id: overrides.id ?? \`s-\${overrides.sectionType}\`,
602
+ sectionType: overrides.sectionType,
603
+ visible: overrides.visible ?? true,
604
+ name: overrides.name,
605
+ content: overrides.content ?? {},
606
+ settings: overrides.settings ?? {},
607
+ };
608
+ }
609
+
610
+ /**
611
+ * Canvas (AdminRoot sectionRenderers) ↔ public (Astro <Sections>) fidelity.
612
+ * Both surfaces must keep using this package's \`sectionRenderers\` map — see
613
+ * SECTIONS.md. Extend the fixtures when you add a custom section type.
614
+ */
615
+ describe('section render parity (canvas ↔ public)', () => {
616
+ it('custom pricing + built-in marketing sections match on both orchestrators', () => {
617
+ expect(() =>
618
+ assertSectionRenderParity(
619
+ [
620
+ section({
621
+ sectionType: 'hero',
622
+ content: {
623
+ heading: 'Welcome',
624
+ body: 'Subtitle',
625
+ primaryButtonLabel: 'Start',
626
+ primaryButtonUrl: '/go',
627
+ },
628
+ }),
629
+ section({
630
+ sectionType: 'pricing',
631
+ content: {
632
+ heading: 'Simple, transparent pricing',
633
+ plans: [
634
+ { name: 'Starter', price: '$29/mo', description: 'For small teams' },
635
+ { name: 'Pro', price: '$79/mo', description: 'For growing teams' },
636
+ ],
637
+ },
638
+ }),
639
+ section({
640
+ sectionType: 'cta',
641
+ content: {
642
+ heading: 'Ready?',
643
+ primaryButtonLabel: 'Contact',
644
+ primaryButtonUrl: '/contact',
645
+ },
646
+ }),
647
+ ],
648
+ { renderers: sectionRenderers },
649
+ ),
650
+ ).not.toThrow();
651
+ });
652
+ });
653
+ `;
654
+ }
655
+ /**
656
+ * \`.cursor/skills/prenta-add-section/SKILL.md\` — an agent contract that turns a
657
+ * designed section into an editable Prenta section (schema + renderer + seed),
658
+ * kept in sync. Shipping it in the kit makes the "design → editable CMS pages"
659
+ * workflow repeatable: the agent follows this instead of re-deriving it.
660
+ */
661
+ function addSectionSkill(layout, packageName) {
662
+ const ws = layout === 'workspace';
663
+ const typesPath = ws ? 'packages/sections/src/types.ts' : 'src/sections/types.ts';
664
+ const registerPath = ws ? 'packages/sections/src/register.tsx' : 'src/sections/register.tsx';
665
+ const blocksDir = ws ? 'packages/sections/src/blocks/' : 'src/blocks/';
666
+ const seedPath = ws ? 'apps/cms/prenta.seed.json' : 'prenta.seed.json';
667
+ const run = ws ? 'pnpm' : 'npm run';
668
+ const configNote = ws
669
+ ? `**Pure data, no React** (this file is
670
+ imported by \`apps/cms/prenta.config.ts\`, which the admin client bundle loads).`
671
+ : `**Pure data, no React** (this file is
672
+ imported by \`prenta.config.ts\`, which the admin client bundle loads).`;
673
+ const rendererSurfaces = ws
674
+ ? `One React function per type, used for **both**
675
+ the Astro public site (\`apps/site\`) and the admin editor canvas
676
+ (\`apps/cms\`, imported as \`${packageName}/register\`). Reads \`section.content\`.`
677
+ : `One React function per type, used for **both**
678
+ the public site and the admin editor canvas. Reads \`section.content\`.`;
679
+ return `---
680
+ name: prenta-add-section
681
+ description: "Turn a designed section into an editable Prenta CMS page-builder section — schema + renderer + optional block + seed, kept in sync. Invoke when the user wants a designed section (hero, pricing, stats, features, CTA, testimonials, gallery, FAQ, logos, etc.) editable in the admin Page editor, or asks to 'add a section', 'make this design editable in the CMS', or 'build this page in the CMS'."
682
+ ---
683
+
684
+ # Add an Prenta custom section
685
+
686
+ Turn a designed section into an **editable** Prenta page-builder section so
687
+ editors author it in the admin Page editor (Add Section → field inspector) and
688
+ it renders identically on the public site. Follow this contract exactly — it
689
+ keeps the pieces in sync so a section never renders in one place but breaks in
690
+ another.
691
+
692
+ ## The model — two files (+ optional block + seed)
693
+
694
+ 1. **Schema** — an entry in \`marketingSectionTypes\` in \`${typesPath}\`.
695
+ Declares the type + its editable fields. ${configNote}
696
+ 2. **Renderer** — an entry in the \`sectionRenderers\` map in
697
+ \`${registerPath}\`. ${rendererSurfaces}
698
+ 3. *(optional)* **Block** — a typed presentational component in \`${blocksDir}\`
699
+ the renderer adapts to (keeps the renderer thin).
700
+ 4. *(recommended)* **Seed** — an entry in \`${seedPath}\` so the section
701
+ ships with real content (\`${run} populate\`).
702
+
703
+ The schema \`id\` and the renderer map key **must match** —
704
+ \`${run} verify:sections\` enforces it.
705
+
706
+ ## Procedure (per section)
707
+
708
+ ### 1. Choose the editable fields
709
+ From the design, list what an editor should change (headings, body, CTA
710
+ label+url, images, repeating rows like plans/stats/cards). Everything purely
711
+ decorative or structural stays in the component's markup — do NOT make it a field.
712
+
713
+ ### 2. Add the schema — \`${typesPath}\`
714
+ Append to the \`marketingSectionTypes\` array:
715
+
716
+ \`\`\`ts
717
+ {
718
+ id: 'pricing', // kebab-case, unique, === renderer map key
719
+ name: 'Pricing Table', // shown in the Add Section gallery
720
+ scope: ['page'], // 'page' | 'post'
721
+ icon: 'badge-dollar-sign', // any lucide-react icon name
722
+ description: 'A grid of pricing plans.',
723
+ category: 'conversion', // layout | content | media | conversion
724
+ fields: [
725
+ { key: 'heading', label: 'Heading', type: 'text', required: true },
726
+ {
727
+ key: 'plans', label: 'Plans', type: 'repeater', rowLabel: 'Plan',
728
+ fields: [
729
+ { key: 'name', label: 'Plan name', type: 'text', required: true },
730
+ { key: 'price', label: 'Price', type: 'text', required: true },
731
+ { key: 'description', label: 'Description', type: 'textarea' },
732
+ ],
733
+ },
734
+ ],
735
+ defaultContent: { heading: 'Pricing', plans: [] },
736
+ }
737
+ \`\`\`
738
+
739
+ ### 3. Add the renderer — \`${registerPath}\`
740
+ Add a key to the \`sectionRenderers\` map. Read data from \`section.content\`
741
+ (guard every value — content is untyped JSON):
742
+
743
+ \`\`\`tsx
744
+ pricing: ({ section }) => {
745
+ const c = section.content ?? {};
746
+ const heading = typeof c.heading === 'string' ? c.heading : '';
747
+ const plans = Array.isArray(c.plans) ? c.plans : [];
748
+ return <PricingGrid heading={heading} plans={plans} />;
749
+ },
750
+ \`\`\`
751
+
752
+ Renderers must be **client-safe** (the admin canvas runs them in the browser)
753
+ **and SSR-safe** (the public site renders them on the server): plain
754
+ presentational components only — no \`useEffect\` data fetching, no server-only
755
+ imports. A renderer that throws falls back to a structural placeholder.
756
+
757
+ ### 4. (optional) Extract a block to \`${blocksDir}\` and import it in the renderer.
758
+
759
+ ### 5. Seed — \`${seedPath}\`
760
+ Add a section to the target page's \`sections\` array with realistic content:
761
+
762
+ \`\`\`json
763
+ { "id": "sec-pricing", "sectionType": "pricing", "name": "Pricing Table",
764
+ "visible": true,
765
+ "content": { "heading": "Simple pricing", "plans": [ { "name": "Pro", "price": "$79/mo" } ] },
766
+ "settings": { "paddingY": "xl" } }
767
+ \`\`\`
768
+
769
+ ### 6. Verify
770
+ \`\`\`bash
771
+ ${run} verify:sections # schema ids <-> renderer keys match
772
+ ${run} dev # open the admin Page editor; add + edit the section
773
+ \`\`\`
774
+
775
+ ## Field types (\`type:\` values)
776
+
777
+ | type | use for |
778
+ |------|---------|
779
+ | \`text\` | short single-line copy |
780
+ | \`textarea\` | multi-line plain copy |
781
+ | \`richText\` | formatted prose |
782
+ | \`url\` | a link (pair with a \`text\` label field for buttons) |
783
+ | \`media\` | image / file picker |
784
+ | \`select\` | fixed options — provide \`options: [{ label, value }]\` |
785
+ | \`boolean\` | on/off toggle |
786
+ | \`color\` | a color value |
787
+ | \`relationship\` | reference other documents — \`relationTo: 'slug'\` |
788
+ | \`repeater\` | repeatable rows — provide \`fields: [...]\`, \`rowLabel\`, optional \`minRows\`/\`maxRows\` |
789
+ | \`stats\` | preset repeater of { value, label, description } rows |
790
+ | \`gallery\` | preset repeater of { url, alt, caption } image rows |
791
+
792
+ Each repeater row is stored as an object keyed by its sub-field \`key\`s.
793
+
794
+ ## Design → schema mapping
795
+
796
+ - Editable copy → \`text\` / \`textarea\` / \`richText\`.
797
+ - A button → two fields: a \`text\` label + a \`url\`.
798
+ - An image → \`media\`.
799
+ - A repeating group (plans, stats, features, logos, testimonials, FAQ) →
800
+ \`repeater\` (or the \`stats\` / \`gallery\` presets).
801
+ - A fixed choice (alignment, variant) → \`select\`.
802
+ - Decorative / structural markup → NOT a field; keep it in the component.
803
+
804
+ ## Checklist before you finish
805
+
806
+ - [ ] Schema \`id\` === renderer map key (\`${run} verify:sections\` passes).
807
+ - [ ] Renderer reads \`section.content\` and guards every value.
808
+ - [ ] Renderer is client- and SSR-safe (no browser- or server-only APIs).
809
+ - [ ] No React / components in \`${typesPath}\`.
810
+ - [ ] Seed entry (if added) uses the same \`sectionType\` id + realistic content.
811
+ - [ ] The section appears in Add Section and edits live in \`${run} dev\`.
812
+ `;
813
+ }
814
+ //# sourceMappingURL=marketing-custom.js.map