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,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,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 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/assets'
|