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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 finaldream
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,7 +1,48 @@
1
1
  # create-elytra
2
2
 
3
- Placeholder package for the future Elytra CMS project initializer.
3
+ Scaffold a new [Elytra](https://www.npmjs.com/package/@elytracms/core) project
4
+ the code-first web CMS with a native visual page builder.
4
5
 
5
- This package reserves the `npm create elytra` command while Elytra CMS is in
6
- early development. The real initializer will replace this placeholder before
7
- public release.
6
+ ```bash
7
+ npm create elytra@latest my-site
8
+ # or: pnpm create elytra my-site
9
+ ```
10
+
11
+ This generates a pnpm workspace with a visual **studio** (Vite + TanStack Start)
12
+ and a delivery **frontend** (Next.js App Router) over one canonical project
13
+ graph. It starts in **playground mode** — in-memory, seeded, no signup — so you
14
+ can edit immediately, then connect a Convex deployment when you're ready
15
+ (`CONNECT.md` in the generated project).
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ npm create elytra@latest [directory] [options]
21
+
22
+ Options:
23
+ --name <name> Project name (default: derived from the directory)
24
+ --mode <mode> playground (default) | connected
25
+ --playground Start in in-memory playground mode (no signup)
26
+ --connected Scaffold wired for a Convex deployment (you provision it)
27
+ -y, --yes Accept defaults, skip prompts
28
+ ```
29
+
30
+ ## What you get
31
+
32
+ ```
33
+ my-site/
34
+ ├── elytra.config.ts # project config-as-code
35
+ ├── cms/ # collections, routes, redirects (your content model)
36
+ ├── components/ # your page-builder components
37
+ ├── studio/ # the visual builder
38
+ └── frontend/ # the delivery site
39
+ ```
40
+
41
+ Then:
42
+
43
+ ```bash
44
+ cd my-site
45
+ pnpm install
46
+ pnpm --filter studio dev # edit immediately, no signup
47
+ pnpm --filter frontend dev # the delivery site
48
+ ```
package/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { argv, exit } from 'node:process'
3
+ import { run } from './src/cli.js'
2
4
 
3
- console.error("Elytra CMS is not released yet.");
4
- console.error("This package reserves the future `npm create elytra` initializer.");
5
- console.error("Follow the project for release updates before using this command.");
6
-
7
- process.exitCode = 1;
5
+ run(argv.slice(2))
6
+ .then((code) => exit(code))
7
+ .catch((error) => {
8
+ console.error(`\ncreate-elytra failed: ${error?.message ?? error}`)
9
+ exit(1)
10
+ })
package/package.json CHANGED
@@ -1,20 +1,27 @@
1
1
  {
2
2
  "name": "create-elytra",
3
- "version": "0.0.0",
3
+ "author": "finaldream <hi@finaldream.de>",
4
+ "version": "0.0.1",
4
5
  "description": "Project initializer for Elytra CMS.",
5
- "license": "UNLICENSED",
6
+ "license": "MIT",
6
7
  "type": "module",
7
8
  "bin": {
8
9
  "create-elytra": "./index.js"
9
10
  },
10
11
  "files": [
11
12
  "index.js",
13
+ "src",
14
+ "template",
12
15
  "README.md"
13
16
  ],
14
17
  "engines": {
15
18
  "node": ">=20"
16
19
  },
20
+ "devDependencies": {
21
+ "vitest": "^3.0.0"
22
+ },
17
23
  "scripts": {
18
- "start": "node ./index.js"
24
+ "start": "node ./index.js",
25
+ "test": "vitest run"
19
26
  }
20
- }
27
+ }
package/src/args.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Argument parsing for `create-elytra` (EC-198). Zero-dependency, deterministic,
3
+ * unit-testable. Supports:
4
+ *
5
+ * npm create elytra@latest [directory] [options]
6
+ *
7
+ * Options:
8
+ * --name <name> Human-readable project name (default: derived from directory)
9
+ * --mode <mode> 'playground' (default) | 'connected'
10
+ * --playground Shorthand for --mode playground
11
+ * --connected Shorthand for --mode connected
12
+ * -y, --yes Skip prompts; accept defaults
13
+ * -h, --help Print usage
14
+ * --version Print version
15
+ */
16
+
17
+ export const MODES = ['playground', 'connected']
18
+
19
+ export function parseArgs(argv) {
20
+ const result = {
21
+ directory: undefined,
22
+ name: undefined,
23
+ mode: undefined,
24
+ yes: false,
25
+ help: false,
26
+ version: false,
27
+ errors: [],
28
+ }
29
+
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const arg = argv[i]
32
+ switch (arg) {
33
+ case '-h':
34
+ case '--help':
35
+ result.help = true
36
+ break
37
+ case '--version':
38
+ result.version = true
39
+ break
40
+ case '-y':
41
+ case '--yes':
42
+ result.yes = true
43
+ break
44
+ case '--playground':
45
+ result.mode = 'playground'
46
+ break
47
+ case '--connected':
48
+ result.mode = 'connected'
49
+ break
50
+ case '--name':
51
+ result.name = argv[++i]
52
+ if (result.name === undefined) result.errors.push('--name requires a value')
53
+ break
54
+ case '--mode': {
55
+ const value = argv[++i]
56
+ if (value === undefined) result.errors.push('--mode requires a value')
57
+ else if (!MODES.includes(value)) result.errors.push(`--mode must be one of: ${MODES.join(', ')}`)
58
+ else result.mode = value
59
+ break
60
+ }
61
+ default:
62
+ if (arg.startsWith('-')) {
63
+ result.errors.push(`Unknown option: ${arg}`)
64
+ } else if (result.directory === undefined) {
65
+ result.directory = arg
66
+ } else {
67
+ result.errors.push(`Unexpected argument: ${arg}`)
68
+ }
69
+ }
70
+ }
71
+
72
+ return result
73
+ }
74
+
75
+ export const HELP_TEXT = `
76
+ create-elytra — scaffold a new Elytra CMS project
77
+
78
+ Usage:
79
+ npm create elytra@latest [directory] [options]
80
+
81
+ Options:
82
+ --name <name> Project name (default: derived from the directory)
83
+ --mode <mode> playground (default) | connected
84
+ --playground Start in in-memory playground mode (no signup, edit immediately)
85
+ --connected Scaffold wired for a Convex deployment (you provision it)
86
+ -y, --yes Accept defaults, skip prompts
87
+ -h, --help Show this help
88
+ --version Show version
89
+
90
+ Examples:
91
+ npm create elytra@latest my-site
92
+ npm create elytra@latest my-site --connected
93
+ `.trimStart()
package/src/cli.js ADDED
@@ -0,0 +1,113 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { stdout } from 'node:process'
5
+ import { HELP_TEXT, parseArgs } from './args.js'
6
+ import { prompt, promptChoice } from './prompts.js'
7
+ import { isWritableTarget, scaffold, slugify } from './scaffold.js'
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url))
10
+ const packageRoot = resolve(here, '..')
11
+ const templateDir = join(packageRoot, 'template')
12
+
13
+ function log(line = '') {
14
+ stdout.write(`${line}\n`)
15
+ }
16
+
17
+ async function readVersion() {
18
+ try {
19
+ const pkg = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
20
+ return pkg.version ?? '0.0.0'
21
+ } catch {
22
+ return '0.0.0'
23
+ }
24
+ }
25
+
26
+ const MODE_CHOICES = [
27
+ { value: 'playground', label: 'Playground — in-memory, no signup, edit immediately (recommended)' },
28
+ { value: 'connected', label: 'Connected — wired for your own Convex deployment' },
29
+ ]
30
+
31
+ /**
32
+ * Run the scaffolder. `argv` is the raw args after the node binary + script.
33
+ * `interactive` (default true) lets tests force the non-prompting path.
34
+ * Returns an exit code.
35
+ */
36
+ export async function run(argv, { interactive = true } = {}) {
37
+ const args = parseArgs(argv)
38
+
39
+ if (args.help) {
40
+ log(HELP_TEXT)
41
+ return 0
42
+ }
43
+ if (args.version) {
44
+ log(await readVersion())
45
+ return 0
46
+ }
47
+ if (args.errors.length > 0) {
48
+ for (const error of args.errors) log(`error: ${error}`)
49
+ log('')
50
+ log(HELP_TEXT)
51
+ return 1
52
+ }
53
+
54
+ const canPrompt = interactive && !args.yes
55
+
56
+ let directory = args.directory
57
+ if (!directory) {
58
+ directory = canPrompt ? await prompt('Project directory', 'my-elytra-site') : 'my-elytra-site'
59
+ }
60
+
61
+ const targetDir = resolve(process.cwd(), directory)
62
+ if (!(await isWritableTarget(targetDir))) {
63
+ log(`error: target directory "${directory}" already exists and is not empty.`)
64
+ return 1
65
+ }
66
+
67
+ const name =
68
+ args.name ??
69
+ (canPrompt ? await prompt('Project name', directory) : directory)
70
+
71
+ const mode =
72
+ args.mode ??
73
+ (canPrompt ? await promptChoice('Backend mode', MODE_CHOICES, 'playground') : 'playground')
74
+
75
+ const slug = slugify(name)
76
+ const vars = {
77
+ projectName: name,
78
+ projectSlug: slug,
79
+ elytraMode: mode,
80
+ // The studio .env line: playground sets the flag; connected leaves it commented.
81
+ studioModeEnv:
82
+ mode === 'playground'
83
+ ? 'VITE_ELYTRA_MODE=playground'
84
+ : '# VITE_ELYTRA_MODE=playground\n# VITE_CONVEX_URL=https://<your-deployment>.convex.cloud',
85
+ }
86
+
87
+ log('')
88
+ log(`Scaffolding ${name} → ${directory} (${mode} mode)`)
89
+ const written = await scaffold({ templateDir, targetDir, vars })
90
+ log(` ${written.length} files written.`)
91
+ log('')
92
+
93
+ printNextSteps({ directory, mode })
94
+ return 0
95
+ }
96
+
97
+ function printNextSteps({ directory, mode }) {
98
+ log('Next steps:')
99
+ log(` cd ${directory}`)
100
+ log(' pnpm install')
101
+ if (mode === 'playground') {
102
+ log(' pnpm dev # studio + frontend together (playground — edit immediately, no signup)')
103
+ log('')
104
+ log('Playground is in-memory: changes reset on reload. When ready to persist,')
105
+ log('see CONNECT.md to provision Convex and flip to connected mode.')
106
+ } else {
107
+ log(' # 1. Provision Convex: cd studio && npx convex dev')
108
+ log(' # 2. Put the deployment URL in studio/.env and elytra.config.ts')
109
+ log(' pnpm dev # studio + frontend + Convex together')
110
+ log('')
111
+ log('See CONNECT.md for the full Convex + Vercel walkthrough.')
112
+ }
113
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,40 @@
1
+ import { createInterface } from 'node:readline/promises'
2
+ import { stdin, stdout } from 'node:process'
3
+
4
+ /**
5
+ * Minimal interactive prompts (EC-198) built on node:readline — no dependency.
6
+ * Used only to fill gaps the user did not pass as flags; `--yes` skips all of
7
+ * this. Designed to be bypassable so the scaffolder stays fully scriptable.
8
+ */
9
+
10
+ export async function prompt(question, fallback) {
11
+ const rl = createInterface({ input: stdin, output: stdout })
12
+ try {
13
+ const suffix = fallback ? ` (${fallback})` : ''
14
+ const answer = (await rl.question(`${question}${suffix}: `)).trim()
15
+ return answer || fallback || ''
16
+ } finally {
17
+ rl.close()
18
+ }
19
+ }
20
+
21
+ export async function promptChoice(question, choices, fallback) {
22
+ const rl = createInterface({ input: stdin, output: stdout })
23
+ try {
24
+ const lines = choices.map((c, i) => ` ${i + 1}) ${c.label}`).join('\n')
25
+ const fallbackIndex = Math.max(
26
+ 0,
27
+ choices.findIndex((c) => c.value === fallback),
28
+ )
29
+ const answer = (
30
+ await rl.question(`${question}\n${lines}\nChoose [${fallbackIndex + 1}]: `)
31
+ ).trim()
32
+ if (!answer) return choices[fallbackIndex].value
33
+ const num = Number.parseInt(answer, 10)
34
+ if (Number.isInteger(num) && num >= 1 && num <= choices.length) return choices[num - 1].value
35
+ const byValue = choices.find((c) => c.value === answer)
36
+ return byValue ? byValue.value : choices[fallbackIndex].value
37
+ } finally {
38
+ rl.close()
39
+ }
40
+ }
@@ -0,0 +1,96 @@
1
+ import { cp, mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { join, relative } from 'node:path'
4
+
5
+ /**
6
+ * The scaffolder core (EC-198). Pure-ish filesystem transform: copy a template
7
+ * tree into a target directory, substitute `{{token}}` placeholders in text
8
+ * files, and un-escape `dot-` prefixed names back to dotfiles. Kept dependency-
9
+ * free and separate from the CLI shell so it is unit-testable.
10
+ */
11
+
12
+ /** Files we never run token substitution / dotfile-renaming over (binary-ish). */
13
+ const BINARY_EXTENSIONS = new Set([
14
+ '.png',
15
+ '.jpg',
16
+ '.jpeg',
17
+ '.gif',
18
+ '.webp',
19
+ '.ico',
20
+ '.woff',
21
+ '.woff2',
22
+ '.ttf',
23
+ '.otf',
24
+ ])
25
+
26
+ /** A template basename of `dot-foo` materializes as `.foo` (escapes npm's dotfile-strip on publish). */
27
+ export function materializeName(name) {
28
+ return name.startsWith('dot-') ? `.${name.slice(4)}` : name
29
+ }
30
+
31
+ /** Replace every `{{key}}` occurrence from `vars`. Unknown tokens are left untouched. */
32
+ export function applyTokens(content, vars) {
33
+ return content.replace(/\{\{(\w+)\}\}/g, (match, key) =>
34
+ Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match,
35
+ )
36
+ }
37
+
38
+ function isBinary(path) {
39
+ const dot = path.lastIndexOf('.')
40
+ return dot !== -1 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase())
41
+ }
42
+
43
+ /** True when the directory does not exist, or exists and is empty. */
44
+ export async function isWritableTarget(dir) {
45
+ if (!existsSync(dir)) return true
46
+ const entries = await readdir(dir)
47
+ return entries.length === 0
48
+ }
49
+
50
+ /**
51
+ * Copy `templateDir` → `targetDir`, applying token substitution to text files and
52
+ * `dot-` → `.` renaming to every path segment. Returns the list of relative paths
53
+ * written (materialized names), sorted, for logging/tests.
54
+ */
55
+ export async function scaffold({ templateDir, targetDir, vars = {} }) {
56
+ const written = []
57
+
58
+ async function walk(srcDir, destDir) {
59
+ await mkdir(destDir, { recursive: true })
60
+ const entries = await readdir(srcDir, { withFileTypes: true })
61
+ for (const entry of entries) {
62
+ const srcPath = join(srcDir, entry.name)
63
+ const destPath = join(destDir, materializeName(entry.name))
64
+ if (entry.isDirectory()) {
65
+ await walk(srcPath, destPath)
66
+ } else if (entry.isFile()) {
67
+ if (isBinary(srcPath)) {
68
+ await cp(srcPath, destPath)
69
+ } else {
70
+ const raw = await readFile(srcPath, 'utf8')
71
+ await writeFile(destPath, applyTokens(raw, vars))
72
+ }
73
+ written.push(relative(targetDir, destPath))
74
+ }
75
+ }
76
+ }
77
+
78
+ await walk(templateDir, targetDir)
79
+ written.sort()
80
+ return written
81
+ }
82
+
83
+ /** Derive a safe npm/package slug from a freeform project name. */
84
+ export function slugify(name) {
85
+ return (
86
+ name
87
+ .toLowerCase()
88
+ .trim()
89
+ .replace(/[^a-z0-9]+/g, '-')
90
+ .replace(/^-+|-+$/g, '')
91
+ .replace(/-{2,}/g, '-') || 'elytra-site'
92
+ )
93
+ }
94
+
95
+ // Re-export node helpers used by the CLI shell so it imports from one place.
96
+ export { rename, stat }
@@ -0,0 +1,89 @@
1
+ # Connect {{projectName}} to a backend
2
+
3
+ Playground mode is in-memory and ephemeral. To persist content, collaborate, and
4
+ publish a live site, connect a [Convex](https://www.convex.dev) deployment
5
+ (Convex holds your content; structure stays as code in this repo).
6
+
7
+ ## 1. Provision Convex
8
+
9
+ ```bash
10
+ cd studio
11
+ npx convex dev # creates a dev deployment, prints its URL, generates convex/_generated
12
+ ```
13
+
14
+ This walks you through Convex login/project creation and writes `CONVEX_DEPLOYMENT`
15
+ for you. Note the deployment URL it prints, e.g.
16
+ `https://your-deployment-123.convex.cloud`.
17
+
18
+ ## 2. Point the studio at it
19
+
20
+ In `studio/.env`:
21
+
22
+ ```bash
23
+ # Comment out playground mode:
24
+ # VITE_ELYTRA_MODE=playground
25
+
26
+ # Set your deployment URL:
27
+ VITE_CONVEX_URL=https://your-deployment-123.convex.cloud
28
+ ```
29
+
30
+ And in `elytra.config.ts`, uncomment and fill `convex.deployment`:
31
+
32
+ ```ts
33
+ convex: { deployment: 'https://your-deployment-123.convex.cloud' },
34
+ ```
35
+
36
+ Connected mode requires sign-in. Configure an auth provider in
37
+ `studio/convex/auth.config.ts` (or use the dev-login env for local testing).
38
+
39
+ ## 3. Point the frontend at it
40
+
41
+ In `frontend/.env`:
42
+
43
+ ```bash
44
+ ELYTRA_CONVEX_URL=https://your-deployment-123.convex.cloud
45
+ ELYTRA_PROJECT_ID=<your-project-id>
46
+ ```
47
+
48
+ ## 4. Instant publish
49
+
50
+ A publish in the studio can refresh the live frontend within seconds (no
51
+ rebuild). The revalidate route is already scaffolded at
52
+ `frontend/app/api/revalidate/route.ts` — you only need to wire the env across the
53
+ two systems. The pieces:
54
+
55
+ **Frontend (host) env** — `frontend/.env` (and Vercel):
56
+
57
+ ```bash
58
+ REVALIDATE_SECRET=<a shared secret you choose>
59
+ STUDIO_ORIGIN=<your studio origin, e.g. https://studio.example.com>
60
+ ```
61
+
62
+ **Studio Convex deployment env** — set with `convex env set` (from `studio/`):
63
+
64
+ ```bash
65
+ npx convex env set CANVAS_PROJECT_SCOPE <same value as ELYTRA_PROJECT_ID>
66
+ npx convex env set CANVAS_REVALIDATE_ENDPOINT <your-frontend-url>/api/revalidate
67
+ npx convex env set CANVAS_REVALIDATE_SECRET <the same secret as REVALIDATE_SECRET>
68
+ ```
69
+
70
+ `CANVAS_PROJECT_SCOPE` **must equal** the host's `ELYTRA_PROJECT_ID`, and
71
+ `CANVAS_REVALIDATE_SECRET` **must equal** the host's `REVALIDATE_SECRET` — the
72
+ scope must match the cache tags the host stamped, and the secret signs the
73
+ webhook.
74
+
75
+ > **The deploy-order trap.** Setting Convex env vars does **not** redeploy your
76
+ > functions. If your deployment predates these vars (or a package bump), `publish`
77
+ > keeps silently no-op'ing until you re-push: run `npx convex dev` (or
78
+ > `pnpm dev`, which wraps it) so the functions pick up the new env. The studio
79
+ > shows a **banner** when any of these three vars is unset, and the Publish button
80
+ > reports failures — so a misconfigured pipeline is visible, not a silent success.
81
+
82
+ **Verify the round-trip:** publish a change in the studio and confirm the host
83
+ logs a `200` at `/api/revalidate` and the page updates within a few seconds.
84
+
85
+ ## 5. Deploy
86
+
87
+ Both apps deploy as standard Vercel projects: the studio as a static SPA, the
88
+ frontend as a Next.js app. Set the same env vars in each project's Vercel
89
+ settings.
@@ -0,0 +1,51 @@
1
+ # {{projectName}}
2
+
3
+ A website built with [Elytra](https://www.npmjs.com/package/@elytracms/core) — the
4
+ code-first web CMS with a native visual page builder. This repo is a pnpm
5
+ workspace with two apps over one canonical project graph:
6
+
7
+ ```
8
+ {{projectSlug}}/
9
+ ├── elytra.config.ts # project config-as-code (identity, structure, components)
10
+ ├── cms/ # collections, routes, redirects — your content model, in code
11
+ ├── components/ # your page-builder components (manifest + implementation)
12
+ ├── studio/ # the visual builder (Vite + TanStack Start)
13
+ └── frontend/ # the delivery site (Next.js App Router)
14
+ ```
15
+
16
+ The **database holds content only**. Your schema, routes, and components live in
17
+ this repo as code — edit them here, jump-to-definition, version them in git.
18
+
19
+ ## Getting started
20
+
21
+ ```bash
22
+ pnpm install
23
+ pnpm dev # starts the studio + frontend together (one Ctrl-C stops both)
24
+ ```
25
+
26
+ `pnpm dev` runs [`elytra dev`](https://www.npmjs.com/package/@elytracms/cli),
27
+ which supervises the studio (http://localhost:5180) and the delivery frontend
28
+ (http://localhost:3000) as one process group — and, in connected mode, Convex too.
29
+
30
+ The studio starts in **playground mode**: an in-memory, seeded starter project,
31
+ no signup, editable immediately. Changes reset on reload — it's a sandbox to try
32
+ the builder. The frontend renders the same starter content locally.
33
+
34
+ ```bash
35
+ pnpm build # `elytra build` — builds studio + frontend (deploys Convex when connected),
36
+ # failing as a whole if any step fails
37
+ ```
38
+
39
+ ## Make it real
40
+
41
+ When you're ready to persist content and publish, follow
42
+ [`CONNECT.md`](./CONNECT.md) to provision a Convex deployment and switch to
43
+ **connected mode**. Then deploy the studio and frontend (e.g. to Vercel).
44
+
45
+ ## Editing your site
46
+
47
+ - **Content model** — `cms/collections/*.ts`. Add fields, collections, relations.
48
+ - **Routes** — `cms/routes.ts`. Map URLs to documents.
49
+ - **Components** — `components/`. Your `project.*` blocks; the studio composes
50
+ pages from exactly these, so the editor preview matches what ships.
51
+ - **Project config** — `elytra.config.ts`. Identity, locales, component set.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The composition `allow` vocabulary — this project's code leash (AD-11). Editors
3
+ * compose pages and posts from these top-level blocks: the platform primitives plus
4
+ * this repo's own `project.*` components (defined in `../components/marketing`). The
5
+ * studio reads this from the project's `components` ref and composes against the
6
+ * repo's real components, not demo fixtures.
7
+ */
8
+ export const PROJECT_BLOCKS = [
9
+ 'base.primitives.Heading',
10
+ 'base.primitives.Text',
11
+ 'base.primitives.Image',
12
+ 'base.primitives.Button',
13
+ 'base.primitives.Stack',
14
+ 'project.Hero',
15
+ 'project.FeatureCard',
16
+ 'project.Section',
17
+ ]
@@ -0,0 +1,13 @@
1
+ import type { CollectionDef } from '@elytracms/core/cms-core'
2
+
3
+ /** The asset collection (EC-017). */
4
+ export const asset: CollectionDef = {
5
+ id: 'asset',
6
+ kind: 'asset',
7
+ titleField: 'filename',
8
+ fields: [
9
+ { name: 'filename', type: 'text', validation: { required: true } },
10
+ { name: 'alt', type: 'text', localized: true },
11
+ { name: 'url', type: 'text', validation: { required: true } },
12
+ ],
13
+ }
@@ -0,0 +1,12 @@
1
+ import type { CollectionDef } from '@elytracms/core/cms-core'
2
+
3
+ /** An author collection used as a relation target. */
4
+ export const author: CollectionDef = {
5
+ id: 'author',
6
+ kind: 'document',
7
+ titleField: 'name',
8
+ fields: [
9
+ { name: 'name', type: 'text', filterable: true, validation: { required: true } },
10
+ { name: 'bio', type: 'richText' },
11
+ ],
12
+ }
@@ -0,0 +1,11 @@
1
+ import type { CollectionDef } from '@elytracms/core/cms-core'
2
+ import { asset } from './asset'
3
+ import { author } from './author'
4
+ import { post } from './post'
5
+ import { page } from './page'
6
+ import { settings } from './settings'
7
+
8
+ export { asset, author, post, page, settings }
9
+
10
+ /** This project's CMS collections (AD-11: structure is code, owned by this repo). */
11
+ export const collections: CollectionDef[] = [asset, author, post, page, settings]