create-elytra 0.0.0 → 0.0.1
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/LICENSE +21 -0
- package/README.md +45 -4
- package/index.js +8 -5
- package/package.json +11 -4
- package/src/args.js +93 -0
- package/src/cli.js +113 -0
- package/src/prompts.js +40 -0
- package/src/scaffold.js +96 -0
- package/template/CONNECT.md +89 -0
- package/template/README.md +51 -0
- package/template/cms/blocks.ts +17 -0
- package/template/cms/collections/asset.ts +13 -0
- package/template/cms/collections/author.ts +12 -0
- package/template/cms/collections/index.ts +11 -0
- package/template/cms/collections/page.ts +45 -0
- package/template/cms/collections/post.ts +104 -0
- package/template/cms/collections/settings.ts +27 -0
- package/template/cms/index.ts +11 -0
- package/template/cms/redirects.ts +7 -0
- package/template/cms/routes.ts +34 -0
- package/template/components/index.ts +24 -0
- package/template/components/marketing/feature-card.tsx +77 -0
- package/template/components/marketing/hero.tsx +81 -0
- package/template/components/marketing/index.tsx +32 -0
- package/template/components/marketing/section.tsx +41 -0
- package/template/components/marketing/shared.ts +21 -0
- package/template/components/post-body.tsx +47 -0
- package/template/components/post-teaser.tsx +46 -0
- package/template/components/theme.css +31 -0
- package/template/dot-gitignore +34 -0
- package/template/elytra.config.ts +39 -0
- package/template/frontend/app/[[...slug]]/page.tsx +22 -0
- package/template/frontend/app/api/revalidate/route.ts +14 -0
- package/template/frontend/app/layout.tsx +14 -0
- package/template/frontend/app/not-found.tsx +8 -0
- package/template/frontend/app/sitemap.ts +22 -0
- package/template/frontend/dot-env +14 -0
- package/template/frontend/lib/content.ts +68 -0
- package/template/frontend/lib/host.ts +9 -0
- package/template/frontend/lib/live-content.ts +270 -0
- package/template/frontend/lib/project-config.ts +22 -0
- package/template/frontend/next.config.mjs +19 -0
- package/template/frontend/package.json +25 -0
- package/template/frontend/tsconfig.json +39 -0
- package/template/package.json +22 -0
- package/template/pnpm-workspace.yaml +3 -0
- package/template/studio/convex/assets.ts +1 -0
- package/template/studio/convex/auth.config.ts +3 -0
- package/template/studio/convex/auth.ts +6 -0
- package/template/studio/convex/cliTokens.ts +1 -0
- package/template/studio/convex/cms.ts +1 -0
- package/template/studio/convex/content.ts +1 -0
- package/template/studio/convex/delivery.ts +1 -0
- package/template/studio/convex/functions.ts +1 -0
- package/template/studio/convex/graphs.ts +1 -0
- package/template/studio/convex/guard.ts +1 -0
- package/template/studio/convex/http.ts +6 -0
- package/template/studio/convex/members.ts +1 -0
- package/template/studio/convex/publishing.ts +1 -0
- package/template/studio/convex/references.ts +1 -0
- package/template/studio/convex/schema.ts +1 -0
- package/template/studio/convex/sync.ts +4 -0
- package/template/studio/convex/tsconfig.json +17 -0
- package/template/studio/convex/users.ts +1 -0
- package/template/studio/convex/webhooks.ts +1 -0
- package/template/studio/dot-env +18 -0
- package/template/studio/package.json +34 -0
- package/template/studio/src/routeTree.gen.ts +104 -0
- package/template/studio/src/router.tsx +25 -0
- package/template/studio/src/routes/$projectId.$.tsx +14 -0
- package/template/studio/src/routes/__root.tsx +119 -0
- package/template/studio/src/routes/index.tsx +17 -0
- package/template/studio/src/routes/sign-in.tsx +159 -0
- package/template/studio/src/styles/app.css +11 -0
- package/template/studio/src/styles/canvas.css +23 -0
- package/template/studio/src/vite-env.d.ts +1 -0
- package/template/studio/tsconfig.json +20 -0
- package/template/studio/vite.config.ts +26 -0
- package/template/turbo.json +18 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CollectionDef } from '@elytracms/core/cms-core'
|
|
2
|
+
import { PROJECT_BLOCKS } from '../blocks'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `page` collection (EC-187): a document collection whose `body` IS a
|
|
6
|
+
* composition (`blocks`) field on its own "Canvas" tab (EC-188), plus structured
|
|
7
|
+
* SEO fields and a `layoutId` selecting which graph layout wraps the rendered
|
|
8
|
+
* composition at delivery.
|
|
9
|
+
*/
|
|
10
|
+
export const page: CollectionDef = {
|
|
11
|
+
id: 'page',
|
|
12
|
+
kind: 'document',
|
|
13
|
+
titleField: 'name',
|
|
14
|
+
form: { label: 'Pages', labelSingular: 'Page', tabs: ['main', 'canvas', 'seo'] },
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: 'name', type: 'text', validation: { required: true }, form: { tab: 'main' } },
|
|
17
|
+
// EC-218: page hierarchy. The local `slug` is one URL segment; the `parent`
|
|
18
|
+
// self-relation nests pages. The full nested URL is composed from the parent
|
|
19
|
+
// chain (never stored), so moving/renaming a parent re-derives descendants.
|
|
20
|
+
{ name: 'slug', type: 'text', filterable: true, form: { tab: 'main', label: 'Slug' } },
|
|
21
|
+
{
|
|
22
|
+
name: 'parent',
|
|
23
|
+
type: 'relation',
|
|
24
|
+
target: 'page',
|
|
25
|
+
cardinality: 'one',
|
|
26
|
+
form: { tab: 'main', label: 'Parent page' },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'body',
|
|
30
|
+
type: 'blocks',
|
|
31
|
+
allow: PROJECT_BLOCKS,
|
|
32
|
+
cardinality: 'many',
|
|
33
|
+
form: { label: 'Canvas', tab: 'canvas' },
|
|
34
|
+
},
|
|
35
|
+
{ name: 'layoutId', type: 'text', form: { tab: 'main' } },
|
|
36
|
+
{ name: 'seoTitle', type: 'text', form: { tab: 'seo' } },
|
|
37
|
+
{ name: 'seoDescription', type: 'text', form: { tab: 'seo' } },
|
|
38
|
+
// The page's canonical URL (absolute or root-relative), emitted as
|
|
39
|
+
// `metadata.alternates.canonical`.
|
|
40
|
+
{ name: 'seoCanonical', type: 'text', form: { tab: 'seo', label: 'Canonical URL' } },
|
|
41
|
+
{ name: 'seoOgTitle', type: 'text', form: { tab: 'seo' } },
|
|
42
|
+
{ name: 'seoOgImage', type: 'text', form: { tab: 'seo' } },
|
|
43
|
+
{ name: 'seoNoindex', type: 'boolean', form: { tab: 'seo' } },
|
|
44
|
+
],
|
|
45
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { CollectionDef } from '@elytracms/core/cms-core'
|
|
2
|
+
import { PROJECT_BLOCKS } from '../blocks'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A localized blog-post collection exercising every field type. Filter
|
|
6
|
+
* declarations (EC-143) are applied inline. The `content` field is a `blocks`
|
|
7
|
+
* composition on its own "Canvas" fieldset tab (EC-188), leashed to this repo's
|
|
8
|
+
* real component vocabulary.
|
|
9
|
+
*/
|
|
10
|
+
export const post: CollectionDef = {
|
|
11
|
+
id: 'post',
|
|
12
|
+
kind: 'document',
|
|
13
|
+
localized: true,
|
|
14
|
+
titleField: 'title',
|
|
15
|
+
form: {
|
|
16
|
+
label: 'Posts',
|
|
17
|
+
labelSingular: 'Post',
|
|
18
|
+
groups: ['content', 'meta'],
|
|
19
|
+
tabs: ['main', 'canvas', 'seo'],
|
|
20
|
+
},
|
|
21
|
+
fields: [
|
|
22
|
+
{
|
|
23
|
+
name: 'title',
|
|
24
|
+
type: 'text',
|
|
25
|
+
localized: true,
|
|
26
|
+
validation: { required: true, min: 1, max: 120 },
|
|
27
|
+
form: { label: 'Title', group: 'content', tab: 'main', order: 1 },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'slug',
|
|
31
|
+
type: 'text',
|
|
32
|
+
filterable: true,
|
|
33
|
+
validation: { required: true, pattern: '^[a-z0-9-]+$', unique: true },
|
|
34
|
+
form: { label: 'Slug', group: 'content', tab: 'main', order: 2 },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'body',
|
|
38
|
+
type: 'richText',
|
|
39
|
+
localized: true,
|
|
40
|
+
form: { label: 'Body', group: 'content', tab: 'main', order: 3 },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'views',
|
|
44
|
+
type: 'number',
|
|
45
|
+
filterable: true,
|
|
46
|
+
validation: { min: 0 },
|
|
47
|
+
form: { label: 'Views', group: 'meta', tab: 'main' },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'featured',
|
|
51
|
+
type: 'boolean',
|
|
52
|
+
filterable: true,
|
|
53
|
+
form: { label: 'Featured', group: 'meta', tab: 'main' },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'publishedAt',
|
|
57
|
+
type: 'date',
|
|
58
|
+
filterable: true,
|
|
59
|
+
form: { label: 'Published at', group: 'meta', tab: 'main' },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'status',
|
|
63
|
+
type: 'select',
|
|
64
|
+
filterable: true,
|
|
65
|
+
options: [
|
|
66
|
+
{ value: 'news', label: 'News' },
|
|
67
|
+
{ value: 'guide', label: 'Guide' },
|
|
68
|
+
],
|
|
69
|
+
form: { label: 'Status', group: 'meta', tab: 'main' },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'author',
|
|
73
|
+
type: 'relation',
|
|
74
|
+
filterable: true,
|
|
75
|
+
target: 'author',
|
|
76
|
+
cardinality: 'one',
|
|
77
|
+
validation: { required: true },
|
|
78
|
+
form: { label: 'Author', group: 'meta', tab: 'main' },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'related',
|
|
82
|
+
type: 'relation',
|
|
83
|
+
target: 'post',
|
|
84
|
+
cardinality: 'many',
|
|
85
|
+
form: { label: 'Related posts', group: 'meta', tab: 'main' },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'cover',
|
|
89
|
+
type: 'asset',
|
|
90
|
+
cardinality: 'one',
|
|
91
|
+
accept: ['image'],
|
|
92
|
+
form: { label: 'Cover image', group: 'content', tab: 'main' },
|
|
93
|
+
},
|
|
94
|
+
// EC-188: the composition (`blocks`) field on its own "Canvas" fieldset tab,
|
|
95
|
+
// leashed to this repo's real component vocabulary (PROJECT_BLOCKS).
|
|
96
|
+
{
|
|
97
|
+
name: 'content',
|
|
98
|
+
type: 'blocks',
|
|
99
|
+
allow: PROJECT_BLOCKS,
|
|
100
|
+
cardinality: 'many',
|
|
101
|
+
form: { label: 'Content', tab: 'canvas' },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CollectionDef } from '@elytracms/core/cms-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global site settings — a singleton collection (EC-217): exactly one fixed
|
|
5
|
+
* document, opened straight in the detail editor with no list view. Internally
|
|
6
|
+
* an ordinary `document` collection with `singleton: true`, so delivery,
|
|
7
|
+
* validation, and binding paths treat it like any other document.
|
|
8
|
+
*/
|
|
9
|
+
export const settings: CollectionDef = {
|
|
10
|
+
id: 'settings',
|
|
11
|
+
kind: 'document',
|
|
12
|
+
singleton: true,
|
|
13
|
+
titleField: 'siteName',
|
|
14
|
+
form: { label: 'Settings', labelSingular: 'Settings' },
|
|
15
|
+
fields: [
|
|
16
|
+
{
|
|
17
|
+
name: 'siteName',
|
|
18
|
+
type: 'text',
|
|
19
|
+
validation: { required: true },
|
|
20
|
+
form: { label: 'Site name' },
|
|
21
|
+
},
|
|
22
|
+
{ name: 'tagline', type: 'text', localized: true, form: { label: 'Tagline' } },
|
|
23
|
+
{ name: 'logo', type: 'asset', cardinality: 'one', accept: ['image'], form: { label: 'Logo' } },
|
|
24
|
+
{ name: 'primaryCtaLabel', type: 'text', form: { label: 'Primary CTA label' } },
|
|
25
|
+
{ name: 'primaryCtaHref', type: 'text', form: { label: 'Primary CTA link' } },
|
|
26
|
+
],
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This project's CMS structure, authored in code in its OWN repo (AD-11): the
|
|
3
|
+
* collection schema, routes, and redirects. The host (`apps/example-site`) reads
|
|
4
|
+
* these directly; the studio (`apps/builder`) reads the same files through its
|
|
5
|
+
* `@site/*` alias — neither borrows a vendor package for structure. A clone of
|
|
6
|
+
* this project edits `cms/collections/*.ts` / `cms/routes.ts` here, in-repo.
|
|
7
|
+
*/
|
|
8
|
+
export { collections, asset, author, post, page } from './collections'
|
|
9
|
+
export { routes } from './routes'
|
|
10
|
+
export { redirects } from './redirects'
|
|
11
|
+
export { PROJECT_BLOCKS } from './blocks'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RedirectRecord } from '@elytracms/core/cms-core'
|
|
2
|
+
|
|
3
|
+
/** Redirects as code (EC-207): one permanent (301-class), one temporary (302-class). */
|
|
4
|
+
export const redirects: RedirectRecord[] = [
|
|
5
|
+
{ id: 'rd-old-about', from: '/old-about', to: '/about', permanent: true },
|
|
6
|
+
{ id: 'rd-temp-home', from: '/temp', to: '/', permanent: false },
|
|
7
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CollectionDef, RouteRecord } from '@elytracms/core/cms-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Routes as code (AD-3/AD-11, EC-207): this project's URL→document map, declared
|
|
5
|
+
* in its own repo and read by BOTH the host (delivery) and the studio (read-only).
|
|
6
|
+
* A derive `(collections) => RouteRecord[]` (next.config `rewrites()`-style): the
|
|
7
|
+
* concrete page routes are static; the `post` collection, when present, is served
|
|
8
|
+
* at the template route `/blog/:slug` (`:slug` is substituted per document at
|
|
9
|
+
* resolve time). The engine (`createRouter`) consumes the result verbatim.
|
|
10
|
+
*/
|
|
11
|
+
export const routes = (collections: CollectionDef[]): RouteRecord[] => {
|
|
12
|
+
const records: RouteRecord[] = [
|
|
13
|
+
{ id: 'r-home', pattern: '/', document: { collection: 'page', id: 'home' } },
|
|
14
|
+
]
|
|
15
|
+
if (collections.some((collection) => collection.id === 'post')) {
|
|
16
|
+
records.push({
|
|
17
|
+
id: 'r-post',
|
|
18
|
+
pattern: '/blog/:slug',
|
|
19
|
+
document: { collection: 'post', id: ':slug' },
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
// EC-218: a hierarchy mount — pages that declare a `slug` are served by their
|
|
23
|
+
// COMPOSED nested path (their parent chain), resolved per-request and
|
|
24
|
+
// perspective-aware. Pages routed explicitly above carry no slug and are
|
|
25
|
+
// unaffected; explicit/static routes always win over the mount.
|
|
26
|
+
if (collections.some((collection) => collection.id === 'page')) {
|
|
27
|
+
records.push({
|
|
28
|
+
id: 'r-pages',
|
|
29
|
+
pattern: '/:path*',
|
|
30
|
+
document: { collection: 'page', id: ':path*' },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
return records
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineHostComponents, nextImagePrimitive } from '@elytracms/next'
|
|
2
|
+
import { marketingComponents } from './marketing'
|
|
3
|
+
import { postBodyComponent } from './post-body'
|
|
4
|
+
import { postTeaserComponent } from './post-teaser'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The host app's component surface (EC-144 registration API): base primitives
|
|
8
|
+
* ship by default; project components are real code in THIS repo.
|
|
9
|
+
*
|
|
10
|
+
* EC-194 / EC-208: the marketing components (`project.Hero`, `project.FeatureCard`,
|
|
11
|
+
* `project.Section`) live in `./marketing` (Next-free) — the SAME modules the studio
|
|
12
|
+
* composes the page builder with (via its `@site/components/marketing` alias), so
|
|
13
|
+
* what an editor places in the studio is exactly what renders here (AD-2/AD-11).
|
|
14
|
+
* The post-specific components (`project.PostBody` composition host,
|
|
15
|
+
* `project.PostTeaser`) stay local to this host. `nextImagePrimitive()` swaps the
|
|
16
|
+
* `base.primitives.Image` implementation for `next/image` delivery (EC-152) — the
|
|
17
|
+
* canonical manifest stays.
|
|
18
|
+
*/
|
|
19
|
+
export const hostComponents = defineHostComponents([
|
|
20
|
+
nextImagePrimitive(),
|
|
21
|
+
...marketingComponents,
|
|
22
|
+
postBodyComponent,
|
|
23
|
+
postTeaserComponent,
|
|
24
|
+
])
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineComponent } from '@elytracms/core/component-registry'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { str, type Props } from './shared'
|
|
4
|
+
|
|
5
|
+
export function FeatureCard(props: Props): ReactNode {
|
|
6
|
+
const accent = props.tone === 'accent'
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
data-component="project.FeatureCard"
|
|
10
|
+
style={{
|
|
11
|
+
display: 'flex',
|
|
12
|
+
flexDirection: 'column',
|
|
13
|
+
gap: '0.5rem',
|
|
14
|
+
padding: '1.25rem',
|
|
15
|
+
borderRadius: '0.5rem',
|
|
16
|
+
border: `1px solid ${accent ? 'rgba(56,189,248,0.4)' : 'rgba(148,163,184,0.3)'}`,
|
|
17
|
+
background: accent ? 'rgba(56,189,248,0.08)' : '#ffffff',
|
|
18
|
+
}}
|
|
19
|
+
>
|
|
20
|
+
<div
|
|
21
|
+
style={{
|
|
22
|
+
display: 'grid',
|
|
23
|
+
placeItems: 'center',
|
|
24
|
+
width: '2rem',
|
|
25
|
+
height: '2rem',
|
|
26
|
+
borderRadius: '0.375rem',
|
|
27
|
+
background: accent ? '#38bdf8' : '#e2e8f0',
|
|
28
|
+
color: accent ? '#082f49' : '#0f172a',
|
|
29
|
+
fontWeight: 700,
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
{str(props.title, 'F').charAt(0).toUpperCase()}
|
|
33
|
+
</div>
|
|
34
|
+
<h3 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 600, color: '#0f172a' }}>
|
|
35
|
+
{str(props.title, 'Feature title')}
|
|
36
|
+
</h3>
|
|
37
|
+
<p style={{ margin: 0, fontSize: '0.85rem', color: '#475569' }}>
|
|
38
|
+
{str(props.body, 'A short description of this feature.')}
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const FeatureCardManifest = defineComponent({
|
|
45
|
+
id: 'project.FeatureCard',
|
|
46
|
+
namespace: 'project',
|
|
47
|
+
title: 'Feature Card',
|
|
48
|
+
description: 'A compact card highlighting one feature, with an optional accent tone.',
|
|
49
|
+
category: 'marketing',
|
|
50
|
+
props: {
|
|
51
|
+
title: {
|
|
52
|
+
type: 'text',
|
|
53
|
+
context: 'prop',
|
|
54
|
+
default: 'Validation first',
|
|
55
|
+
validation: { min: 1 },
|
|
56
|
+
form: { label: 'Title' },
|
|
57
|
+
},
|
|
58
|
+
body: {
|
|
59
|
+
type: 'text',
|
|
60
|
+
context: 'prop',
|
|
61
|
+
default: 'Broken bindings and missing components are explicit, never silent.',
|
|
62
|
+
form: { label: 'Body', control: 'textarea' },
|
|
63
|
+
},
|
|
64
|
+
tone: {
|
|
65
|
+
type: 'select',
|
|
66
|
+
context: 'prop',
|
|
67
|
+
default: 'default',
|
|
68
|
+
options: [
|
|
69
|
+
{ value: 'default', label: 'Default' },
|
|
70
|
+
{ value: 'accent', label: 'Accent' },
|
|
71
|
+
],
|
|
72
|
+
form: { label: 'Tone' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
slots: [],
|
|
76
|
+
fixtures: [{ name: 'Default' }, { name: 'Accent', props: { tone: 'accent', title: 'Code-first' } }],
|
|
77
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { defineComponent } from '@elytracms/core/component-registry'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { str, type Props } from './shared'
|
|
4
|
+
|
|
5
|
+
export function Hero(props: Props): ReactNode {
|
|
6
|
+
const align = props.align === 'center' ? 'center' : 'flex-start'
|
|
7
|
+
const textAlign = props.align === 'center' ? 'center' : 'left'
|
|
8
|
+
return (
|
|
9
|
+
<section
|
|
10
|
+
data-component="project.Hero"
|
|
11
|
+
style={{
|
|
12
|
+
display: 'flex',
|
|
13
|
+
flexDirection: 'column',
|
|
14
|
+
gap: '0.75rem',
|
|
15
|
+
alignItems: align,
|
|
16
|
+
textAlign,
|
|
17
|
+
padding: '3rem 2rem',
|
|
18
|
+
borderRadius: '0.75rem',
|
|
19
|
+
background: 'linear-gradient(135deg, #0f172a, #1e293b)',
|
|
20
|
+
color: '#f8fafc',
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{str(props.eyebrow) ? (
|
|
24
|
+
<span style={{ fontSize: '0.7rem', fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#7dd3fc' }}>
|
|
25
|
+
{str(props.eyebrow)}
|
|
26
|
+
</span>
|
|
27
|
+
) : null}
|
|
28
|
+
<h1 style={{ margin: 0, fontSize: '2.25rem', fontWeight: 700, letterSpacing: '-0.02em' }}>
|
|
29
|
+
{str(props.title, 'Your headline here')}
|
|
30
|
+
</h1>
|
|
31
|
+
{str(props.subtitle) ? (
|
|
32
|
+
<p style={{ margin: 0, maxWidth: '40ch', fontSize: '1rem', opacity: 0.82 }}>{str(props.subtitle)}</p>
|
|
33
|
+
) : null}
|
|
34
|
+
{str(props.ctaLabel) ? (
|
|
35
|
+
<span style={{ marginTop: '0.5rem', alignSelf: align, borderRadius: '0.5rem', background: '#38bdf8', color: '#082f49', padding: '0.55rem 1.1rem', fontSize: '0.9rem', fontWeight: 600 }}>
|
|
36
|
+
{str(props.ctaLabel)}
|
|
37
|
+
</span>
|
|
38
|
+
) : null}
|
|
39
|
+
</section>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const HeroManifest = defineComponent({
|
|
44
|
+
id: 'project.Hero',
|
|
45
|
+
namespace: 'project',
|
|
46
|
+
title: 'Hero',
|
|
47
|
+
description: 'A full-width hero banner with eyebrow, headline, subtitle, and a call to action.',
|
|
48
|
+
category: 'marketing',
|
|
49
|
+
props: {
|
|
50
|
+
eyebrow: { type: 'text', context: 'prop', default: 'Introducing', form: { label: 'Eyebrow' } },
|
|
51
|
+
title: {
|
|
52
|
+
type: 'text',
|
|
53
|
+
context: 'prop',
|
|
54
|
+
default: 'Build your site, your way',
|
|
55
|
+
validation: { min: 1 },
|
|
56
|
+
form: { label: 'Headline' },
|
|
57
|
+
},
|
|
58
|
+
subtitle: {
|
|
59
|
+
type: 'text',
|
|
60
|
+
context: 'prop',
|
|
61
|
+
default: 'One canonical graph powers editing, content, and delivery.',
|
|
62
|
+
form: { label: 'Subtitle', control: 'textarea' },
|
|
63
|
+
},
|
|
64
|
+
ctaLabel: { type: 'text', context: 'prop', default: 'Get started', form: { label: 'Button label' } },
|
|
65
|
+
align: {
|
|
66
|
+
type: 'select',
|
|
67
|
+
context: 'prop',
|
|
68
|
+
default: 'left',
|
|
69
|
+
options: [
|
|
70
|
+
{ value: 'left', label: 'Left' },
|
|
71
|
+
{ value: 'center', label: 'Center' },
|
|
72
|
+
],
|
|
73
|
+
form: { label: 'Alignment' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
slots: [],
|
|
77
|
+
fixtures: [
|
|
78
|
+
{ name: 'Default' },
|
|
79
|
+
{ name: 'Centered', props: { align: 'center', eyebrow: 'New', title: 'Ship faster' } },
|
|
80
|
+
],
|
|
81
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ComponentManifest } from '@elytracms/core/component-registry'
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
import { Hero, HeroManifest } from './hero'
|
|
4
|
+
import { FeatureCard, FeatureCardManifest } from './feature-card'
|
|
5
|
+
import { Section, SectionManifest } from './section'
|
|
6
|
+
import type { MarketingComponent, Props } from './shared'
|
|
7
|
+
|
|
8
|
+
export type { MarketingComponent } from './shared'
|
|
9
|
+
export { Hero, HeroManifest } from './hero'
|
|
10
|
+
export { FeatureCard, FeatureCardManifest } from './feature-card'
|
|
11
|
+
export { Section, SectionManifest } from './section'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* This project's REAL components (manifest + implementation pairs). The host
|
|
15
|
+
* renders them in production; the studio (`apps/builder`) imports the manifests +
|
|
16
|
+
* implementations through its `@site/components/marketing` alias and composes the
|
|
17
|
+
* page builder with THESE — so what an editor places is exactly what ships
|
|
18
|
+
* (AD-2/AD-11). They live in this repo, not a vendor package.
|
|
19
|
+
*/
|
|
20
|
+
export const marketingComponents: MarketingComponent[] = [
|
|
21
|
+
{ manifest: HeroManifest, implementation: Hero },
|
|
22
|
+
{ manifest: FeatureCardManifest, implementation: FeatureCard },
|
|
23
|
+
{ manifest: SectionManifest, implementation: Section as ComponentType<Props> },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
/** The component ids this set registers — the canvas `allow` vocabulary references these. */
|
|
27
|
+
export const marketingComponentIds = marketingComponents.map((c) => c.manifest.id)
|
|
28
|
+
|
|
29
|
+
export const marketingManifests: ComponentManifest[] = marketingComponents.map((c) => c.manifest)
|
|
30
|
+
export const marketingImplementations: Record<string, ComponentType<Props>> = Object.fromEntries(
|
|
31
|
+
marketingComponents.map((c) => [c.manifest.id, c.implementation]),
|
|
32
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineComponent } from '@elytracms/core/component-registry'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { str, type Props } from './shared'
|
|
4
|
+
|
|
5
|
+
export function Section(props: Props & { body?: ReactNode }): ReactNode {
|
|
6
|
+
return (
|
|
7
|
+
<section
|
|
8
|
+
data-component="project.Section"
|
|
9
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '1rem', padding: '2rem 0' }}
|
|
10
|
+
>
|
|
11
|
+
{str(props.heading) ? (
|
|
12
|
+
<h2 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700, color: '#0f172a' }}>{str(props.heading)}</h2>
|
|
13
|
+
) : null}
|
|
14
|
+
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
|
|
15
|
+
{props.body}
|
|
16
|
+
</div>
|
|
17
|
+
</section>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SectionManifest = defineComponent({
|
|
22
|
+
id: 'project.Section',
|
|
23
|
+
namespace: 'project',
|
|
24
|
+
title: 'Section',
|
|
25
|
+
description: 'A titled section whose body holds a grid of nested blocks.',
|
|
26
|
+
category: 'layout',
|
|
27
|
+
props: {
|
|
28
|
+
heading: { type: 'text', context: 'prop', default: 'Why teams choose us', form: { label: 'Heading' } },
|
|
29
|
+
// A `blocks` prop is a child region (slot) — nesting other components, so the
|
|
30
|
+
// builder shows real composition with this repo's own components.
|
|
31
|
+
body: {
|
|
32
|
+
type: 'blocks',
|
|
33
|
+
context: 'prop',
|
|
34
|
+
cardinality: 'many',
|
|
35
|
+
allow: ['project.FeatureCard', 'base.primitives.Text', 'base.primitives.Heading'],
|
|
36
|
+
form: { label: 'Body' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
slots: [],
|
|
40
|
+
fixtures: [{ name: 'Default' }],
|
|
41
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComponentManifest } from '@elytracms/core/component-registry'
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared helpers for this project's REAL marketing components (`project.Hero`,
|
|
6
|
+
* `project.FeatureCard`, `project.Section`). These are Next-free (they import
|
|
7
|
+
* `defineComponent` from `@elytracms/core/component-registry`, NOT `@elytracms/next`, and
|
|
8
|
+
* render plain elements — no `next/image`, no server-only modules) so the SAME
|
|
9
|
+
* component renders in the Vite-based studio canvas AND the Next host (AD-2/AD-11).
|
|
10
|
+
*/
|
|
11
|
+
export type Props = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
export function str(value: unknown, fallback = ''): string {
|
|
14
|
+
return typeof value === 'string' && value.length > 0 ? value : fallback
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A manifest + implementation pair for one of this project's components. */
|
|
18
|
+
export interface MarketingComponent {
|
|
19
|
+
manifest: ComponentManifest
|
|
20
|
+
implementation: ComponentType<Props>
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { defineComponent } from '@elytracms/next'
|
|
3
|
+
import type { HostComponent } from '@elytracms/next'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `project.PostBody` — the post template's composition surface (EC-188, AD-12).
|
|
7
|
+
* Its `content` prop is bound to the post document's `content` block field; the
|
|
8
|
+
* `composition: { valueProp: 'content' }` manifest flag makes the renderer resolve
|
|
9
|
+
* that prop to the stored ComponentNode tree and hand it to this component as
|
|
10
|
+
* already-rendered `children` (via `renderComposition`, with the live context — so
|
|
11
|
+
* the composed blocks ship with the same fallbacks and binding resolution as the
|
|
12
|
+
* page). The implementation is a thin semantic wrapper; the renderer owns the tree.
|
|
13
|
+
*/
|
|
14
|
+
function PostBody(props: Record<string, unknown>): ReactNode {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
data-component="project.PostBody"
|
|
18
|
+
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
|
|
19
|
+
>
|
|
20
|
+
{props.children as ReactNode}
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const postBodyComponent: HostComponent = {
|
|
26
|
+
manifest: defineComponent({
|
|
27
|
+
id: 'project.PostBody',
|
|
28
|
+
namespace: 'project',
|
|
29
|
+
title: 'Post body',
|
|
30
|
+
category: 'content',
|
|
31
|
+
props: {
|
|
32
|
+
// The composition value (a ComponentNode tree). EC-190/EC-186: a `blocks`
|
|
33
|
+
// field-def IS the composition prop — the renderer narrows and renders the
|
|
34
|
+
// stored tree as this node's children (composition host, below).
|
|
35
|
+
content: {
|
|
36
|
+
type: 'blocks',
|
|
37
|
+
context: 'prop',
|
|
38
|
+
// `many`: the post body is an ordered list of blocks (EC-186).
|
|
39
|
+
cardinality: 'many',
|
|
40
|
+
form: { label: 'Content' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
slots: [],
|
|
44
|
+
composition: { valueProp: 'content' },
|
|
45
|
+
}),
|
|
46
|
+
implementation: PostBody,
|
|
47
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { defineComponent } from '@elytracms/next'
|
|
3
|
+
import type { HostComponent } from '@elytracms/next'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `project.PostTeaser` — the repeater item template for the home page's post
|
|
7
|
+
* list. Its props are bound per-item (`repeaterItem` mode) to the delivery
|
|
8
|
+
* shape of the `post` collection.
|
|
9
|
+
*/
|
|
10
|
+
function PostTeaser(props: Record<string, unknown>): ReactNode {
|
|
11
|
+
const title = typeof props.title === 'string' ? props.title : ''
|
|
12
|
+
const slug = typeof props.slug === 'string' ? props.slug : ''
|
|
13
|
+
return (
|
|
14
|
+
<article
|
|
15
|
+
data-component="project.PostTeaser"
|
|
16
|
+
style={{ padding: '1rem 1.25rem', border: '1px solid #d8dee7', borderRadius: '0.5rem' }}
|
|
17
|
+
>
|
|
18
|
+
<h3 style={{ margin: 0 }}>{slug ? <a href={`/blog/${slug}`}>{title}</a> : title}</h3>
|
|
19
|
+
</article>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const postTeaserComponent: HostComponent = {
|
|
24
|
+
manifest: defineComponent({
|
|
25
|
+
id: 'project.PostTeaser',
|
|
26
|
+
namespace: 'project',
|
|
27
|
+
title: 'Post teaser',
|
|
28
|
+
category: 'content',
|
|
29
|
+
props: {
|
|
30
|
+
title: {
|
|
31
|
+
type: 'text',
|
|
32
|
+
context: 'prop',
|
|
33
|
+
default: '',
|
|
34
|
+
form: { label: 'Title' },
|
|
35
|
+
},
|
|
36
|
+
slug: {
|
|
37
|
+
type: 'text',
|
|
38
|
+
context: 'prop',
|
|
39
|
+
default: '',
|
|
40
|
+
form: { label: 'Slug' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
slots: [],
|
|
44
|
+
}),
|
|
45
|
+
implementation: PostTeaser,
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Your site's DELIVERY theme — plain CSS for the page-builder blocks. The
|
|
3
|
+
* frontend imports nothing from here directly (its components carry their own
|
|
4
|
+
* styles); this file is `@import`ed into the studio canvas preview so the blocks
|
|
5
|
+
* look the same in the editor as they do live. Author it as normal global site
|
|
6
|
+
* CSS — it lives only in the isolated preview iframe, so it never leaks into the
|
|
7
|
+
* studio chrome. Add your fonts, colours, and base element styles here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
--site-foreground: #1a202c;
|
|
12
|
+
--site-background: #ffffff;
|
|
13
|
+
--site-accent: #38bdf8;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
margin: 0;
|
|
18
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
19
|
+
color: var(--site-foreground);
|
|
20
|
+
background: var(--site-background);
|
|
21
|
+
line-height: 1.5;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
a {
|
|
25
|
+
color: var(--site-accent);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
img {
|
|
29
|
+
max-width: 100%;
|
|
30
|
+
height: auto;
|
|
31
|
+
}
|