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
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
"
|
|
3
|
+
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
+
"version": "0.0.1",
|
|
4
5
|
"description": "Project initializer for Elytra CMS.",
|
|
5
|
-
"license": "
|
|
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
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -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]
|