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,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
|