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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -4
  3. package/index.js +8 -5
  4. package/package.json +11 -4
  5. package/src/args.js +93 -0
  6. package/src/cli.js +113 -0
  7. package/src/prompts.js +40 -0
  8. package/src/scaffold.js +96 -0
  9. package/template/CONNECT.md +89 -0
  10. package/template/README.md +51 -0
  11. package/template/cms/blocks.ts +17 -0
  12. package/template/cms/collections/asset.ts +13 -0
  13. package/template/cms/collections/author.ts +12 -0
  14. package/template/cms/collections/index.ts +11 -0
  15. package/template/cms/collections/page.ts +45 -0
  16. package/template/cms/collections/post.ts +104 -0
  17. package/template/cms/collections/settings.ts +27 -0
  18. package/template/cms/index.ts +11 -0
  19. package/template/cms/redirects.ts +7 -0
  20. package/template/cms/routes.ts +34 -0
  21. package/template/components/index.ts +24 -0
  22. package/template/components/marketing/feature-card.tsx +77 -0
  23. package/template/components/marketing/hero.tsx +81 -0
  24. package/template/components/marketing/index.tsx +32 -0
  25. package/template/components/marketing/section.tsx +41 -0
  26. package/template/components/marketing/shared.ts +21 -0
  27. package/template/components/post-body.tsx +47 -0
  28. package/template/components/post-teaser.tsx +46 -0
  29. package/template/components/theme.css +31 -0
  30. package/template/dot-gitignore +34 -0
  31. package/template/elytra.config.ts +39 -0
  32. package/template/frontend/app/[[...slug]]/page.tsx +22 -0
  33. package/template/frontend/app/api/revalidate/route.ts +14 -0
  34. package/template/frontend/app/layout.tsx +14 -0
  35. package/template/frontend/app/not-found.tsx +8 -0
  36. package/template/frontend/app/sitemap.ts +22 -0
  37. package/template/frontend/dot-env +14 -0
  38. package/template/frontend/lib/content.ts +68 -0
  39. package/template/frontend/lib/host.ts +9 -0
  40. package/template/frontend/lib/live-content.ts +270 -0
  41. package/template/frontend/lib/project-config.ts +22 -0
  42. package/template/frontend/next.config.mjs +19 -0
  43. package/template/frontend/package.json +25 -0
  44. package/template/frontend/tsconfig.json +39 -0
  45. package/template/package.json +22 -0
  46. package/template/pnpm-workspace.yaml +3 -0
  47. package/template/studio/convex/assets.ts +1 -0
  48. package/template/studio/convex/auth.config.ts +3 -0
  49. package/template/studio/convex/auth.ts +6 -0
  50. package/template/studio/convex/cliTokens.ts +1 -0
  51. package/template/studio/convex/cms.ts +1 -0
  52. package/template/studio/convex/content.ts +1 -0
  53. package/template/studio/convex/delivery.ts +1 -0
  54. package/template/studio/convex/functions.ts +1 -0
  55. package/template/studio/convex/graphs.ts +1 -0
  56. package/template/studio/convex/guard.ts +1 -0
  57. package/template/studio/convex/http.ts +6 -0
  58. package/template/studio/convex/members.ts +1 -0
  59. package/template/studio/convex/publishing.ts +1 -0
  60. package/template/studio/convex/references.ts +1 -0
  61. package/template/studio/convex/schema.ts +1 -0
  62. package/template/studio/convex/sync.ts +4 -0
  63. package/template/studio/convex/tsconfig.json +17 -0
  64. package/template/studio/convex/users.ts +1 -0
  65. package/template/studio/convex/webhooks.ts +1 -0
  66. package/template/studio/dot-env +18 -0
  67. package/template/studio/package.json +34 -0
  68. package/template/studio/src/routeTree.gen.ts +104 -0
  69. package/template/studio/src/router.tsx +25 -0
  70. package/template/studio/src/routes/$projectId.$.tsx +14 -0
  71. package/template/studio/src/routes/__root.tsx +119 -0
  72. package/template/studio/src/routes/index.tsx +17 -0
  73. package/template/studio/src/routes/sign-in.tsx +159 -0
  74. package/template/studio/src/styles/app.css +11 -0
  75. package/template/studio/src/styles/canvas.css +23 -0
  76. package/template/studio/src/vite-env.d.ts +1 -0
  77. package/template/studio/tsconfig.json +20 -0
  78. package/template/studio/vite.config.ts +26 -0
  79. package/template/turbo.json +18 -0
@@ -0,0 +1,34 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+ .yarn/install-state.gz
8
+
9
+ # misc
10
+ .DS_Store
11
+ *.pem
12
+
13
+ # debug
14
+ npm-debug.log*
15
+ yarn-debug.log*
16
+ yarn-error.log*
17
+
18
+ # env files
19
+ .env
20
+ .env.*
21
+ !.env.example
22
+
23
+ # vercel
24
+ .vercel
25
+
26
+ # turbo
27
+ .turbo
28
+
29
+ # typescript
30
+ *.tsbuildinfo
31
+ next-env.d.ts
32
+
33
+ # VS Code
34
+ .vscode
@@ -0,0 +1,39 @@
1
+ import { defineElytraConfig } from '@elytracms/core/studio-core'
2
+ import { collections, redirects, routes } from './cms'
3
+ import { marketingComponents } from './components/marketing'
4
+
5
+ /**
6
+ * {{projectName}} — config-as-code (the Elytra mount contract). Project identity,
7
+ * structure (cms/), and the page-builder components all live HERE, in your repo;
8
+ * editing this file never writes to the backend (the backend holds content only).
9
+ * The studio (`studio/`) reads this config to mount; the delivery frontend
10
+ * (`frontend/`) reads the same `cms/` directly — both by plain path import.
11
+ *
12
+ * Backend mode is chosen by env (studio/.env), not here:
13
+ * - Playground (default): VITE_ELYTRA_MODE=playground — in-memory, seeded, no
14
+ * signup. The studio boots straight into an editable starter project.
15
+ * - Connected: set VITE_CONVEX_URL (and add `convex.deployment` below) after
16
+ * provisioning a deployment. See CONNECT.md.
17
+ */
18
+ export default defineElytraConfig({
19
+ projects: [
20
+ {
21
+ name: '{{projectName}}',
22
+ slug: '{{projectSlug}}',
23
+ locales: ['en'],
24
+ defaultLocale: 'en',
25
+ // Structure is config-owned, authored in this repo's cms/.
26
+ collections,
27
+ routes,
28
+ redirects,
29
+ // Your real page-builder components (manifest + implementation pairs),
30
+ // injected by dependency injection — the same modules the frontend renders.
31
+ components: marketingComponents,
32
+ // Connected mode only — uncomment after provisioning Convex (CONNECT.md):
33
+ // convex: { deployment: 'https://<your-deployment>.convex.cloud' },
34
+ // The public base URL of the delivery site (the studio links live URLs here).
35
+ frontendUrl: 'http://localhost:3000',
36
+ environments: [{ key: 'production', label: 'Production' }],
37
+ },
38
+ ],
39
+ })
@@ -0,0 +1,22 @@
1
+ import { createCanvasRoute } from '@elytracms/next'
2
+ import { hostComponents } from '../../lib/host'
3
+ import { contentScope, loadContent } from '../../lib/content'
4
+
5
+ /**
6
+ * The Elytra canvas route — the site's delivery surface. Mounted at the root
7
+ * optional catch-all `/[[...slug]]`, it serves every page: `/` → the home page,
8
+ * `/{slug}` → the matching `page` document (routes-as-code, `cms/routes.ts`).
9
+ * Redirects via redirect()/permanentRedirect(); unknown URLs via notFound();
10
+ * perspective via draftMode(). Instant publish (with `cache` configured): the
11
+ * studio's publish webhook hits `/api/revalidate` → revalidateTag.
12
+ */
13
+ const route = createCanvasRoute({
14
+ loadContent,
15
+ components: hostComponents,
16
+ // Tag scope = the project id the studio publishes under, so its webhook tags
17
+ // match this site's cached entries.
18
+ cache: { scope: contentScope },
19
+ })
20
+
21
+ export default route.Page
22
+ export const generateMetadata = route.generateMetadata
@@ -0,0 +1,14 @@
1
+ import { createRevalidateRoute } from '@elytracms/next'
2
+
3
+ /**
4
+ * Instant publish webhook receiver: `POST /api/revalidate` with a signed body
5
+ * (HMAC-SHA256, `x-elytra-signature: sha256=<hex>`) drops exactly the cached
6
+ * pages whose fetch-time tag sets contain the carried tags — publish in the
7
+ * studio, fresh content here within seconds, no rebuild. Set REVALIDATE_SECRET
8
+ * (matching the studio's VITE_REVALIDATE_SECRET) and STUDIO_ORIGIN in connected
9
+ * mode. Unsigned/invalid requests: 401, nothing revalidated.
10
+ */
11
+ export const { POST, OPTIONS } = createRevalidateRoute({
12
+ secret: process.env.REVALIDATE_SECRET ?? 'change-me-revalidate-secret',
13
+ ...(process.env.STUDIO_ORIGIN ? { allowOrigin: process.env.STUDIO_ORIGIN } : {}),
14
+ })
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export const metadata = {
4
+ title: { default: '{{projectName}}', template: '%s · {{projectName}}' },
5
+ description: '{{projectName}} — built with Elytra.',
6
+ }
7
+
8
+ export default function RootLayout({ children }: { children: ReactNode }) {
9
+ return (
10
+ <html lang="en">
11
+ <body>{children}</body>
12
+ </html>
13
+ )
14
+ }
@@ -0,0 +1,8 @@
1
+ export default function NotFound() {
2
+ return (
3
+ <main data-page="not-found">
4
+ <h1>404 — Not found</h1>
5
+ <p>No route in the project matched this URL.</p>
6
+ </main>
7
+ )
8
+ }
@@ -0,0 +1,22 @@
1
+ import { createCanvasSitemap } from '@elytracms/next'
2
+ import { loadContent } from '../lib/content'
3
+ import { projectConfig } from '../lib/project-config'
4
+
5
+ /**
6
+ * sitemap.xml from published routes: the helper walks the same route records the
7
+ * canvas route resolves against, resolves every URL in the published perspective,
8
+ * and omits noindex pages. Dynamic routes (`/blog/:slug`) are enumerated through
9
+ * the params provider — one entry per published post.
10
+ */
11
+ export default createCanvasSitemap({
12
+ loadContent,
13
+ baseUrl: projectConfig.frontendUrl ?? process.env.SITE_URL ?? 'http://localhost:3000',
14
+ params: ({ route, client }) => {
15
+ if (route.id !== 'r-post') return null
16
+ const posts = client.listDocuments('post')
17
+ if (!posts.ok) return []
18
+ return posts.documents
19
+ .filter((post) => typeof post['slug'] === 'string')
20
+ .map((post) => ({ slug: post['slug'] as string }))
21
+ },
22
+ })
@@ -0,0 +1,14 @@
1
+ # {{projectName}} — frontend (delivery) environment.
2
+ #
3
+ # Seeded mode (default): no env needed. The site renders the in-memory starter
4
+ # world locally — the same content the playground studio boots into.
5
+ #
6
+ # Connected mode: point the frontend at your Convex deployment so published
7
+ # content is served live. See CONNECT.md.
8
+ #
9
+ # ELYTRA_CONVEX_URL=https://<your-deployment>.convex.cloud
10
+ # ELYTRA_PROJECT_ID=<your-project-id>
11
+ #
12
+ # Instant publish (must match the studio's revalidate secret):
13
+ # REVALIDATE_SECRET=change-me-revalidate-secret
14
+ # STUDIO_ORIGIN=http://localhost:5180
@@ -0,0 +1,68 @@
1
+ import { createContentSnapshot } from '@elytracms/next'
2
+ import type { CanvasContentRequest, ContentSource } from '@elytracms/next'
3
+ import type { RedirectRecord, RouteRecord } from '@elytracms/core/cms-core'
4
+ import { SEED_PROJECT_ID, createSeededPersistenceAdapter } from '@elytracms/core/persistence'
5
+ import { projectConfig } from './project-config'
6
+ import { loadLiveContent } from './live-content'
7
+
8
+ /**
9
+ * Content for the delivery frontend, in one of two modes:
10
+ *
11
+ * - **Live** — when `ELYTRA_CONVEX_URL` + `ELYTRA_PROJECT_ID` are set, every load
12
+ * fetches the delivery snapshot from the Convex deployment (`live-content.ts`).
13
+ * Locales, collections, routes, and redirects come from this repo's own `cms/`
14
+ * (config-owned), never the wire. Draft-mode requests load the token-gated draft.
15
+ * - **Seeded** (default) — the deterministic in-memory starter world, the same
16
+ * one the playground studio boots into, bridged into a `ContentSource`. Lets the
17
+ * site render locally with zero backend; connect Convex to make edits persist.
18
+ */
19
+
20
+ const convexUrl = process.env.ELYTRA_CONVEX_URL
21
+ const projectId = process.env.ELYTRA_PROJECT_ID
22
+
23
+ /** The cache tag scope — the live project id, or the seeded fixture project. */
24
+ export const contentScope: string = projectId ?? SEED_PROJECT_ID
25
+
26
+ /** Materialize a config `routes`/`redirects` value: run a derive, or pass an array through. */
27
+ function materializeRecords<T>(
28
+ input: readonly T[] | ((collections: typeof projectConfig.collections) => T[]),
29
+ ): T[] {
30
+ return typeof input === 'function' ? input(projectConfig.collections) : [...input]
31
+ }
32
+
33
+ const projectRoutes: RouteRecord[] = materializeRecords(projectConfig.routes)
34
+ const projectRedirects: RedirectRecord[] = materializeRecords(projectConfig.redirects)
35
+
36
+ // The seeded world is deterministic, so one snapshot serves every request.
37
+ let seededSource: Promise<ContentSource> | undefined
38
+
39
+ async function buildSeededSource(): Promise<ContentSource> {
40
+ const adapter = await createSeededPersistenceAdapter()
41
+ // Re-save this repo's collections so the snapshot is shaped by YOUR schema.
42
+ await adapter.cms.saveSchema(SEED_PROJECT_ID, [...projectConfig.collections])
43
+ return createContentSnapshot(adapter, SEED_PROJECT_ID, {
44
+ routes: projectRoutes,
45
+ redirects: projectRedirects,
46
+ locales: projectConfig.locales,
47
+ })
48
+ }
49
+
50
+ export function loadContent(request: CanvasContentRequest): Promise<ContentSource> {
51
+ if (convexUrl && projectId) {
52
+ const draftToken = process.env.DRAFT_PREVIEW_TOKEN
53
+ return loadLiveContent(
54
+ {
55
+ convexUrl,
56
+ projectId,
57
+ locales: projectConfig.locales,
58
+ collections: projectConfig.collections,
59
+ routes: projectRoutes,
60
+ redirects: projectRedirects,
61
+ ...(draftToken ? { draftToken } : {}),
62
+ },
63
+ request.perspective,
64
+ )
65
+ }
66
+ seededSource ??= buildSeededSource()
67
+ return seededSource
68
+ }
@@ -0,0 +1,9 @@
1
+ import { hostComponents } from '../../components'
2
+
3
+ /**
4
+ * The host component surface for the @elytracms/next runtime — your repo's OWN
5
+ * page-builder components (the SAME modules the studio composes with, so the
6
+ * editor preview matches what ships). Re-exported here so the app routes import
7
+ * from `lib/` and never reach across the repo root.
8
+ */
9
+ export { hostComponents }
@@ -0,0 +1,270 @@
1
+ import { documentSchema } from '@elytracms/core/cms-core'
2
+ import type {
3
+ CmsDocument,
4
+ CollectionDef,
5
+ LocaleConfig,
6
+ RedirectRecord,
7
+ RouteRecord,
8
+ } from '@elytracms/core/cms-core'
9
+ import type { ContentDocumentSource, Perspective } from '@elytracms/core/content'
10
+ import { createStaticContentSource, mergeRouteRecords, z } from '@elytracms/next'
11
+ import type { CanvasDataSource, ContentSource } from '@elytracms/next'
12
+ import type { AssetRecord } from '@elytracms/core/persistence'
13
+ import { parseProjectGraph } from '@elytracms/core/project-graph'
14
+ import type { ProjectGraph } from '@elytracms/core/project-graph'
15
+
16
+ /**
17
+ * Live `ContentSource` (EC-156): fetch the delivery snapshot from a Convex
18
+ * deployment (`convex/delivery.ts` in the builder app) over Convex's public
19
+ * HTTP query API — a plain `fetch` to `POST {deployment}/api/query`, no
20
+ * Convex npm dependency — and assemble it through the same
21
+ * `createStaticContentSource` path the fixture snapshot uses, so accessor and
22
+ * rendering semantics are byte-identical across the seeded and live modes.
23
+ *
24
+ * Design choice (documented per EC-156): rather than faking a
25
+ * `PersistenceAdapter` around the payload, `@elytracms/next` exports the pure
26
+ * assembly half of its snapshot bridge (`createStaticContentSource`), and
27
+ * this module only (a) fetches, (b) validates the payload against the
28
+ * canonical Zod schemas, and (c) parses the serialized graphs. Least code,
29
+ * maximum reuse — no resolution logic is reimplemented here.
30
+ *
31
+ * Perspective handling: the route helper passes the request's perspective to
32
+ * `loadContent`. Published requests fetch the PUBLIC `delivery:snapshot`
33
+ * (published documents + the publishing-state-pinned graph only). Draft-mode
34
+ * requests fetch `delivery:draftSnapshot`, gated by the deployment's
35
+ * `DRAFT_PREVIEW_TOKEN` — the token never reaches the browser; the host
36
+ * server sends it after Next draft mode (itself token-gated) was enabled.
37
+ *
38
+ * Routes: config-sourced route records (EC-207 — from the host's own
39
+ * `elytra.config.ts`, passed via options) and the implicit per-locale home
40
+ * fallback are MERGED (EC-177 gap 2), never XOR'd — config records win per
41
+ * pattern+locale, and the `/` → first-page fallback fills every locale the
42
+ * config set does not cover. The backend never carries route data.
43
+ */
44
+
45
+ export interface LiveContentOptions {
46
+ /** Convex deployment URL, e.g. `https://scintillating-goldfish-704.convex.cloud`. */
47
+ convexUrl: string
48
+ projectId: string
49
+ /**
50
+ * Project locale config, read from the host's own `elytra.config.ts`
51
+ * (AD-11, EC-182) — NOT from the snapshot. The backend holds data only and no
52
+ * longer carries project settings.
53
+ */
54
+ locales: LocaleConfig
55
+ /**
56
+ * CMS collection schema, read from the host's own `elytra.config.ts`
57
+ * (AD-11, EC-183) — NOT from the snapshot. Drives depth-1 relation population
58
+ * (AD-4) during delivery; the backend no longer carries the schema.
59
+ */
60
+ collections: readonly CollectionDef[]
61
+ /**
62
+ * Routes/redirects, config-owned (AD-3/AD-11, EC-207) — materialized from the
63
+ * host's own `elytra.config.ts` and passed in here, NOT read from the snapshot.
64
+ * The live router resolves URLs from these (merged with the home fallback).
65
+ */
66
+ routes: readonly RouteRecord[]
67
+ redirects: readonly RedirectRecord[]
68
+ /** `DRAFT_PREVIEW_TOKEN` — required for draft-perspective loads. */
69
+ draftToken?: string
70
+ }
71
+
72
+ interface GraphRevisionPayload {
73
+ revision: number
74
+ serializedGraph: string
75
+ }
76
+
77
+ /**
78
+ * The `delivery.ts` payload shape (validated field-by-field below).
79
+ * Single-environment (EC-179): a deployment IS one environment, so the snapshot
80
+ * carries no `environment`.
81
+ */
82
+ interface DeliverySnapshotPayload {
83
+ projectId: string
84
+ // routes/redirects are NO LONGER read from the snapshot (EC-207): they are
85
+ // config-owned and passed via options. The backend may still emit them on
86
+ // older deployments; the host ignores the wire fields.
87
+ /** Persisted data-source definitions (EC-166 LIVE mode); absent on older deployments. */
88
+ sources?: unknown
89
+ documents: unknown[]
90
+ assets: unknown[]
91
+ graphs: { draft: GraphRevisionPayload | null; published: GraphRevisionPayload | null }
92
+ }
93
+
94
+ /**
95
+ * Run one query through Convex's public HTTP API. Response envelope (verified
96
+ * against a live deployment): `{ status: 'success', value }` or
97
+ * `{ status: 'error', errorMessage }`. `cache: 'no-store'` keeps Next's fetch
98
+ * cache out of the loop — the route helper's tagged data cache is the only
99
+ * cache layer, so tag revalidation always reaches the deployment.
100
+ */
101
+ async function runConvexQuery(
102
+ convexUrl: string,
103
+ path: string,
104
+ args: Record<string, unknown>,
105
+ ): Promise<unknown> {
106
+ const response = await fetch(`${convexUrl.replace(/\/$/, '')}/api/query`, {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json' },
109
+ body: JSON.stringify({ path, args, format: 'json' }),
110
+ cache: 'no-store',
111
+ })
112
+ const body = (await response.json().catch(() => undefined)) as
113
+ | { status?: string; value?: unknown; errorMessage?: string; message?: string }
114
+ | undefined
115
+ if (!response.ok || body?.status !== 'success') {
116
+ throw new Error(
117
+ `Convex query "${path}" failed (${response.status}): ${
118
+ body?.errorMessage ?? body?.message ?? 'no error message'
119
+ }`,
120
+ )
121
+ }
122
+ return body.value
123
+ }
124
+
125
+ function parseGraph(payload: GraphRevisionPayload | null): ProjectGraph | null {
126
+ if (!payload) return null
127
+ const parsed = parseProjectGraph(payload.serializedGraph)
128
+ // An unparsable stored graph degrades to "no graph" (the runtime renders
129
+ // explicit fallbacks) — never a crash.
130
+ return parsed.ok ? parsed.graph : null
131
+ }
132
+
133
+ /** Canonical-schema validation: invalid stored rows are skipped, not fatal. */
134
+ function parseDocuments(rows: unknown[]): CmsDocument[] {
135
+ const documents: CmsDocument[] = []
136
+ for (const row of rows) {
137
+ const parsed = documentSchema.safeParse(row)
138
+ if (parsed.success) documents.push(parsed.data)
139
+ }
140
+ return documents
141
+ }
142
+
143
+ /**
144
+ * The snapshot's data-source envelope — the structural `CanvasDataSource`
145
+ * shape `materializeSourcePayloads` consumes (cms query configs themselves are
146
+ * re-validated by `@elytracms/content`'s lenient parser inside `@elytracms/next`).
147
+ */
148
+ const canvasDataSourceSchema = z.object({
149
+ kind: z.string().min(1),
150
+ id: z.string().min(1),
151
+ label: z.string().optional(),
152
+ config: z.unknown().optional(),
153
+ })
154
+
155
+ /**
156
+ * Parse the snapshot's `sources` array (EC-166 LIVE mode). Tolerant by design:
157
+ * an older deployment without the field — or an invalid entry — degrades to no
158
+ * source definitions, which the runtime renders as unresolved-binding
159
+ * fallbacks, never a crash.
160
+ */
161
+ function parseSources(rows: unknown): CanvasDataSource[] {
162
+ if (!Array.isArray(rows)) return []
163
+ const sources: CanvasDataSource[] = []
164
+ for (const row of rows) {
165
+ const parsed = canvasDataSourceSchema.safeParse(row)
166
+ if (parsed.success) sources.push(parsed.data)
167
+ }
168
+ return sources
169
+ }
170
+
171
+ /**
172
+ * Implicit fallback route table (EC-187): `/` → the first `page`-collection
173
+ * document (lowest id), once per configured locale. Merged UNDER the stored
174
+ * records (see module docs). The delivery snapshot is already
175
+ * perspective-filtered server-side (`delivery:snapshot` ships only published
176
+ * content; `delivery:draftSnapshot` ships drafts), so this no longer re-checks
177
+ * the flat `state` flag (EC-224 B-1 — `state` is being retired; in draft mode
178
+ * this now correctly previews the draft home even when unpublished).
179
+ */
180
+ function fallbackHomeRoutes(documents: CmsDocument[], locales: LocaleConfig): RouteRecord[] {
181
+ const homePage = documents
182
+ .filter((doc) => doc.collection === 'page')
183
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0]
184
+ if (!homePage) return []
185
+ return locales.locales.map((locale) => ({
186
+ id: `r-live-home-${locale}`,
187
+ pattern: '/',
188
+ locale,
189
+ document: { collection: 'page', id: homePage.id },
190
+ }))
191
+ }
192
+
193
+ /** Fetch the live snapshot and assemble the `ContentSource` for one request. */
194
+ export async function loadLiveContent(
195
+ options: LiveContentOptions,
196
+ perspective: Perspective,
197
+ ): Promise<ContentSource> {
198
+ const args: Record<string, unknown> = {
199
+ projectId: options.projectId,
200
+ }
201
+
202
+ let payload: DeliverySnapshotPayload
203
+ if (perspective === 'draft') {
204
+ if (!options.draftToken) {
205
+ // Explicit, visible failure: draft mode without a configured token must
206
+ // never silently fall back to published content.
207
+ throw new Error(
208
+ 'Draft preview requested but DRAFT_PREVIEW_TOKEN is not configured on the host.',
209
+ )
210
+ }
211
+ payload = (await runConvexQuery(options.convexUrl, 'delivery:draftSnapshot', {
212
+ ...args,
213
+ token: options.draftToken,
214
+ })) as DeliverySnapshotPayload
215
+ } else {
216
+ payload = (await runConvexQuery(
217
+ options.convexUrl,
218
+ 'delivery:snapshot',
219
+ args,
220
+ )) as DeliverySnapshotPayload
221
+ }
222
+
223
+ // Locales are config-owned (AD-11, EC-182): read from the host's
224
+ // `elytra.config.ts`, passed in via options — never from the snapshot.
225
+ const locales = options.locales
226
+ const draftGraph = parseGraph(payload.graphs.draft)
227
+ const publishedGraph = parseGraph(payload.graphs.published)
228
+ const documents = parseDocuments(payload.documents)
229
+
230
+ // MERGE, not XOR (EC-177 gap 2): config route records win per pattern+locale;
231
+ // the implicit per-locale home fallback fills the rest. EC-207: routes come
232
+ // from the host's config (options), never the wire. EC-187: the home fallback
233
+ // derives `/` from the first `page` document in the (perspective-filtered) set.
234
+ const routes = mergeRouteRecords([...options.routes], fallbackHomeRoutes(documents, locales))
235
+
236
+ // EC-224 B-1: the published snapshot already ships each document as its PINNED
237
+ // published content (delivery resolved the version pointer server-side). Wrap
238
+ // those rows as a `DocumentHistory` so the pure published gate
239
+ // (`selectPerspective`) serves `.published` directly — independent of the flat
240
+ // `state` field, which publish no longer stamps (and B removes).
241
+ //
242
+ // TRUST CONTRACT (the trust boundary moved fully into delivery): the wrap
243
+ // treats whatever the published fetch returned AS published (`published: doc`)
244
+ // and deliberately does NOT re-gate — it cannot, the wire carries no version
245
+ // pointer. Correctness therefore rests ENTIRELY on `delivery:snapshot` shipping
246
+ // published-only content (pinned version snapshots, never a draft — see
247
+ // `convex/delivery.ts`). This drops the host-side defense-in-depth gate; guard
248
+ // the invariant in delivery, not here.
249
+ // Draft-fetch rows stay bare: `selectPerspective('draft')` serves them as-is.
250
+ const documentSources: ContentDocumentSource[] =
251
+ perspective === 'published'
252
+ ? documents.map((doc) => ({ draft: doc, published: doc, versions: [] }))
253
+ : documents
254
+
255
+ return createStaticContentSource({
256
+ projectId: payload.projectId,
257
+ locales,
258
+ routes,
259
+ redirects: [...options.redirects],
260
+ // Collections are config-owned (AD-11, EC-183): from the host's own config,
261
+ // passed in via options — never from the snapshot.
262
+ collections: [...options.collections],
263
+ documents: documentSources,
264
+ assets: payload.assets as AssetRecord[],
265
+ graphs: { draft: draftGraph, published: publishedGraph },
266
+ // EC-166 LIVE mode: the builder's persisted section/template sources, so
267
+ // the route helper materializes their payloads (`materializeSourcePayloads`).
268
+ sources: parseSources(payload.sources),
269
+ })
270
+ }
@@ -0,0 +1,22 @@
1
+ import type { LocaleConfig } from '@elytracms/core/cms-core'
2
+ import { collections, redirects, routes } from '../../cms'
3
+
4
+ /**
5
+ * Host-owned project config for the @elytracms/next runtime. Structure is code:
6
+ * the frontend reads its locales, CMS model, and routing from THIS repo's `cms/`
7
+ * (the same files the studio reads) — never from the delivery snapshot. The
8
+ * backend holds data only. Connection details (the Convex deployment URL) stay
9
+ * in env — see `content.ts`. Mirrors the root `elytra.config.ts`; `slug`/`locales`
10
+ * are inlined so the frontend bundle stays free of `defineElytraConfig`.
11
+ */
12
+ export const projectConfig = {
13
+ slug: '{{projectSlug}}',
14
+ frontendUrl: 'http://localhost:3000',
15
+ locales: {
16
+ default: 'en',
17
+ locales: ['en'],
18
+ } satisfies LocaleConfig,
19
+ collections,
20
+ routes,
21
+ redirects,
22
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Delivery host for the Elytra embedded runtime. The @elytracms/* packages are
3
+ * consumed from the registry as compiled `dist`, so no `transpilePackages` is
4
+ * needed.
5
+ *
6
+ * @type {import('next').NextConfig}
7
+ */
8
+ const nextConfig = {
9
+ images: {
10
+ remotePatterns: [
11
+ // Convex file-storage serve URLs (uploaded assets) in connected mode.
12
+ { protocol: 'https', hostname: '*.convex.cloud' },
13
+ // The starter content's demo images.
14
+ { protocol: 'https', hostname: 'picsum.photos' },
15
+ ],
16
+ },
17
+ }
18
+
19
+ export default nextConfig
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "next build",
7
+ "dev": "next dev --port 3000",
8
+ "start": "next start",
9
+ "lint": "next lint",
10
+ "type-check": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@elytracms/core": "^0.0.7",
14
+ "@elytracms/next": "^0.0.7",
15
+ "next": "^16.2.9",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.10.0",
21
+ "@types/react": "^19.0.0",
22
+ "@types/react-dom": "^19.0.0",
23
+ "typescript": "^5.7.0"
24
+ }
25
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "esnext"
8
+ ],
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "noEmit": true,
14
+ "module": "preserve",
15
+ "isolatedModules": true,
16
+ "jsx": "react-jsx",
17
+ "incremental": true,
18
+ "plugins": [
19
+ {
20
+ "name": "next"
21
+ }
22
+ ],
23
+ "paths": {
24
+ "@/*": [
25
+ "./*"
26
+ ]
27
+ }
28
+ },
29
+ "include": [
30
+ "next-env.d.ts",
31
+ "**/*.ts",
32
+ "**/*.tsx",
33
+ ".next/types/**/*.ts",
34
+ ".next/dev/types/**/*.ts"
35
+ ],
36
+ "exclude": [
37
+ "node_modules"
38
+ ]
39
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{projectSlug}}",
3
+ "description": "{{projectName}} — website on Elytra (studio + frontend).",
4
+ "private": true,
5
+ "packageManager": "pnpm@10.33.2",
6
+ "scripts": {
7
+ "dev": "elytra dev",
8
+ "build": "elytra build",
9
+ "format": "prettier --cache --write .",
10
+ "lint": "turbo run lint",
11
+ "type-check": "turbo run type-check"
12
+ },
13
+ "dependencies": {
14
+ "@elytracms/core": "^0.0.7",
15
+ "@elytracms/next": "^0.0.7"
16
+ },
17
+ "devDependencies": {
18
+ "@elytracms/cli": "^0.0.1",
19
+ "prettier": "^3.4.0",
20
+ "turbo": "^2.3.0"
21
+ }
22
+ }
@@ -0,0 +1,3 @@
1
+ packages:
2
+ - frontend
3
+ - studio
@@ -0,0 +1 @@
1
+ export * from '@elytracms/studio/convex/assets'
@@ -0,0 +1,3 @@
1
+ // EC-252: generic OIDC config (the deployment issues its own JWTs via CONVEX_SITE_URL) —
2
+ // package-shipped, re-exported here so Convex finds convex/auth.config.ts.
3
+ export { default } from '@elytracms/studio/convex/auth.config'