create-raiko 1.0.2 → 1.0.3
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/index.mjs +95 -42
- package/package.json +4 -2
- package/prompt.mjs +132 -51
- package/templates-common.mjs +1 -1
- package/templates.mjs +11 -0
- package/welcome.mjs +329 -0
package/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
|
|
|
4
4
|
import { dirname, join, resolve, basename } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import { randomBytes } from 'node:crypto'
|
|
7
|
-
import { select, multiselect, text, confirm, spin, col } from './prompt.mjs'
|
|
7
|
+
import { select, multiselect, text, confirm, spin, col, BACK } from './prompt.mjs'
|
|
8
8
|
import { generate, FRONTENDS, CSS, ICONS, run } from './templates.mjs'
|
|
9
9
|
|
|
10
10
|
const argv = process.argv.slice(2)
|
|
@@ -31,53 +31,106 @@ const ROLE_PRESETS = {
|
|
|
31
31
|
viewer: { users: [], posts: ['list', 'read'] },
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
${col.y(' > ^ < ')} ${col.d('every runtime. strikes first.')}
|
|
38
|
-
|
|
34
|
+
// compact banner on narrow terminals so nothing wraps
|
|
35
|
+
console.log(
|
|
36
|
+
(process.stdout.columns || 80) >= 56
|
|
37
|
+
? `\n ${col.y(' /\\_/\\ ')} ${col.b('raiko')}${col.d('.js')}\n ${col.y(' ( o.o ) ')} ${col.d('backend and frontend in one file tree')}\n ${col.y(' > ^ < ')} ${col.d('every runtime. strikes first.')}\n`
|
|
38
|
+
: `\n ${col.y('( o.o )')} ${col.b('raiko')}${col.d('.js')}\n`,
|
|
39
|
+
)
|
|
39
40
|
|
|
40
41
|
const positional = argv.find((a, i) => !a.startsWith('-') && !(argv[i - 1] ?? '').startsWith('--'))
|
|
41
|
-
const
|
|
42
|
+
const interactive = !yes && !!(process.stdin.isTTY && process.stdout.isTTY)
|
|
43
|
+
|
|
42
44
|
// npm package name rules: the name also lands in generated code, so nothing else is allowed
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
console.error(`\n ${col.r('error[RK0801]')}: folder ${name} is not empty\n = help: pick another name or pass --force\n`)
|
|
50
|
-
process.exit(1)
|
|
45
|
+
const nameError = (n) => {
|
|
46
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,213}$/.test(basename(n)))
|
|
47
|
+
return `${col.r('error[RK0803]')}: "${basename(n).replace(/[^\x20-\x7e]/g, '?')}" is not a valid project name\n = help: lowercase letters, digits, dot, dash and underscore, like my-app`
|
|
48
|
+
const d = resolve(n)
|
|
49
|
+
if (existsSync(d) && readdirSync(d).length && !argv.includes('--force')) return `${col.r('error[RK0801]')}: folder ${n} is not empty\n = help: pick another name or pass --force`
|
|
50
|
+
return null
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
53
|
+
const NO_DB = 'none'
|
|
54
|
+
const steps = [
|
|
55
|
+
{ key: 'name', def: () => positional ?? 'raiko-app', given: () => positional, validate: nameError, ask: (a, back) => text('Project name', a.name ?? 'raiko-app', back) },
|
|
56
|
+
{ key: 'template', def: () => 'full', ask: (a, back) => select('Template', [{ value: 'full', label: 'Full', hint: 'pages, API, chat, everything below' }, { value: 'minimal', label: 'Minimal', hint: 'one page and one API route' }], a.template ?? 'full', back) },
|
|
57
|
+
{ key: 'frontend', def: () => 'raiko', ask: (a, back) => select('Frontend', FRONTENDS, a.frontend ?? 'raiko', back) },
|
|
58
|
+
{
|
|
59
|
+
key: 'css',
|
|
60
|
+
def: () => 'monsoon',
|
|
61
|
+
validate: (v, a) => (v === 'stylex' && a.frontend !== 'react' ? `${col.r('error[RK0802]')}: StyleX needs the React frontend\n = help: pick React, or another CSS option` : null),
|
|
62
|
+
ask: (a, back) => {
|
|
63
|
+
const opts = CSS.filter((c) => !c.only || c.only.includes(a.frontend))
|
|
64
|
+
return select('CSS', opts, opts.some((o) => o.value === a.css) ? a.css : 'monsoon', back)
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'icons',
|
|
69
|
+
def: () => 'glyphfox',
|
|
70
|
+
ask: (a, back) => {
|
|
71
|
+
const opts = ICONS.filter((c) => !c.only || c.only.includes(a.frontend))
|
|
72
|
+
return select('Icons', opts, opts.some((o) => o.value === a.icons) ? a.icons : 'glyphfox', back)
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{ key: 'motion', def: () => false, ask: (a, back) => confirm('Animations with Motion (formerly Framer Motion)?', a.motion ?? false, back) },
|
|
76
|
+
{ key: 'orm', def: () => NO_DB, ask: (a, back) => select('Database ORM', [{ value: 'kastor', label: 'Kastor', hint: 'TypeScript ORM, no codegen' }, { value: 'prisma', label: 'Prisma' }, { value: 'drizzle', label: 'Drizzle' }, { value: NO_DB, label: 'No database' }], a.orm ?? NO_DB, back) },
|
|
77
|
+
{ key: 'db', when: (a) => a.orm !== NO_DB, skip: NO_DB, def: () => 'sqlite', ask: (a, back) => select('Database', [{ value: 'sqlite', label: 'SQLite', hint: 'a file, no server needed' }, { value: 'postgres', label: 'PostgreSQL' }], a.db && a.db !== NO_DB ? a.db : 'sqlite', back) },
|
|
78
|
+
{ key: 'auth', list: true, def: () => [], ask: (a, back) => multiselect('Sign in methods', [{ value: 'credentials', label: 'Email and password' }, { value: 'github', label: 'GitHub' }, { value: 'google', label: 'Google' }, { value: 'discord', label: 'Discord' }, { value: 'gitlab', label: 'GitLab' }], a.auth ?? ['credentials'], back) },
|
|
79
|
+
{ key: 'twofa', when: (a) => a.auth.length > 0, skip: false, def: () => false, ask: (a, back) => confirm('Two factor auth with Google Authenticator (TOTP)?', a.twofa ?? true, back) },
|
|
80
|
+
{ key: 'email', def: () => 'none', ask: (a, back) => select('Email provider', [{ value: 'resend', label: 'Resend' }, { value: 'postmark', label: 'Postmark' }, { value: 'sendgrid', label: 'SendGrid' }, { value: 'smtp', label: 'SMTP', hint: 'any server, via nodemailer' }, { value: 'none', label: 'None for now', hint: 'emails print in the terminal' }], a.email ?? 'none', back) },
|
|
81
|
+
{ key: 'i18n', def: () => false, ask: (a, back) => confirm('Translations (en, pt-BR, es, fr, de)?', a.i18n ?? false, back) },
|
|
82
|
+
{ key: 'admin', when: (a) => a.orm !== NO_DB, skip: false, def: () => true, ask: (a, back) => confirm('Admin panel at /admin?', a.admin ?? true, back) },
|
|
83
|
+
{ key: 'roles', list: true, when: (a) => a.admin, skip: [], def: () => ['editor'], ask: (a, back) => multiselect('Admin roles (admin always has full access)', [{ value: 'editor', label: 'editor', hint: 'manage posts, read users' }, { value: 'support', label: 'support', hint: 'fix users, cannot list or delete' }, { value: 'viewer', label: 'viewer', hint: 'read only' }], a.roles ?? ['editor'], back) },
|
|
84
|
+
{ key: 'cookies', def: () => 'none', ask: (a, back) => select('Cookie consent banner (cookieowl)', [{ value: 'none', label: 'None' }, { value: 'lgpd', label: 'LGPD', hint: 'Brazil, Portuguese texts' }, { value: 'gdpr', label: 'GDPR', hint: 'Europe, visitor language' }], a.cookies ?? 'none', back) },
|
|
85
|
+
{ key: 'pwa', def: () => false, ask: (a, back) => confirm('Installable app with offline support (installkite)?', a.pwa ?? false, back) },
|
|
86
|
+
{ key: 'deploy', def: () => 'docker', ask: (a, back) => select('Deploy target', [{ value: 'vercel', label: 'Vercel' }, { value: 'cloudflare', label: 'Cloudflare Workers' }, { value: 'docker', label: 'Docker', hint: 'Fly, Railway, Render, any VPS' }, { value: 'node', label: 'Plain server', hint: 'bun or node on your machine' }], a.deploy ?? 'docker', back) },
|
|
87
|
+
{ key: 'pm', def: () => detectedPm, ask: (a, back) => select('Package manager', [{ value: detectedPm, label: detectedPm, hint: 'detected' }, ...['bun', 'npm', 'pnpm', 'yarn'].filter((p) => p !== detectedPm).map((p) => ({ value: p, label: p }))], a.pm ?? detectedPm, back) },
|
|
88
|
+
{ key: 'install', def: () => !argv.includes('--no-install'), ask: (a, back) => confirm('Install dependencies now?', a.install ?? true, back) },
|
|
89
|
+
{ key: 'git', def: () => !argv.includes('--no-git'), ask: (a, back) => confirm('Initialize a git repository?', a.git ?? true, back) },
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
// Walk the steps. Esc, Backspace or the left arrow goes back to the last question that was asked;
|
|
93
|
+
// steps answered by flags or skipped by an earlier answer are not revisited.
|
|
94
|
+
const a = {}
|
|
95
|
+
const history = []
|
|
96
|
+
for (let i = 0; i < steps.length; ) {
|
|
97
|
+
const s = steps[i]
|
|
98
|
+
if (s.when && !s.when(a)) {
|
|
99
|
+
a[s.key] = s.skip
|
|
100
|
+
i++
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const fixed = s.given ? s.given() : flag(s.key)
|
|
104
|
+
if (fixed !== undefined || !interactive) {
|
|
105
|
+
const v = fixed !== undefined ? (s.list ? list(parse(fixed)) : parse(fixed)) : s.def(a)
|
|
106
|
+
const err = s.validate?.(v, a)
|
|
107
|
+
if (err) {
|
|
108
|
+
console.error(`\n ${err}\n`)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
a[s.key] = v
|
|
112
|
+
i++
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
const v = await s.ask(a, history.length > 0)
|
|
116
|
+
if (v === BACK) {
|
|
117
|
+
i = history.pop()
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const err = s.validate?.(v, a)
|
|
121
|
+
if (err) {
|
|
122
|
+
console.log(` ${err}`)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
a[s.key] = v
|
|
126
|
+
history.push(i)
|
|
127
|
+
i++
|
|
74
128
|
}
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const git = await ask('git', () => confirm('Initialize a git repository?', true), !argv.includes('--no-git'))
|
|
129
|
+
|
|
130
|
+
const { name, template, frontend, css, icons, motion, orm, db, auth, twofa, email, i18n, admin, cookies, pwa, deploy, pm, install, git } = a
|
|
131
|
+
const dir = resolve(name)
|
|
132
|
+
const roles = { admin: ROLE_PRESETS.admin }
|
|
133
|
+
for (const r of a.roles ?? []) if (ROLE_PRESETS[r]) roles[r] = ROLE_PRESETS[r]
|
|
81
134
|
|
|
82
135
|
const o = { name: basename(dir), template, frontend, css, icons, motion, orm, db, auth, twofa, email, i18n, admin, roles, cookies, pwa, deploy, pm }
|
|
83
136
|
if (flag('dry') !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-raiko",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Create a Raiko.js app: pick your frontend, CSS, database, auth, email, i18n and deploy target.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"prompt.mjs",
|
|
12
12
|
"templates.mjs",
|
|
13
13
|
"templates-common.mjs",
|
|
14
|
-
"
|
|
14
|
+
"welcome.mjs",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
15
17
|
],
|
|
16
18
|
"engines": {
|
|
17
19
|
"node": ">=20"
|
package/prompt.mjs
CHANGED
|
@@ -1,95 +1,176 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Interactive prompts with arrow keys, no dependencies.
|
|
2
|
+
// Every prompt resolves to its value, or to BACK when the person presses Esc, Backspace or the left arrow.
|
|
2
3
|
import readline from 'node:readline'
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
5
|
+
export const BACK = Symbol('back')
|
|
6
|
+
const tty = !!(process.stdin.isTTY && process.stdout.isTTY)
|
|
7
|
+
const esc = (n) => (s) => (tty ? `\x1b[${n}m${s}\x1b[0m` : s)
|
|
8
|
+
export const col = { y: esc(33), d: esc(2), g: esc(32), b: esc(1), r: esc(31), c: esc(36) }
|
|
9
|
+
|
|
10
|
+
const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '')
|
|
11
|
+
/** Cut a line to the terminal width so it never wraps: a wrapped line would break the redraw. */
|
|
12
|
+
function fit(s) {
|
|
13
|
+
const width = Math.max(20, (process.stdout.columns || 80) - 1)
|
|
14
|
+
if (visible(s).length <= width) return s
|
|
15
|
+
let out = ''
|
|
16
|
+
let n = 0
|
|
17
|
+
for (const part of s.split(/(\x1b\[[0-9;]*m)/)) {
|
|
18
|
+
if (part.startsWith('\x1b[')) {
|
|
19
|
+
out += part
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
for (const ch of part) {
|
|
23
|
+
if (n >= width - 1) return out + '\x1b[0m' + (tty ? '…' : '...')
|
|
24
|
+
out += ch
|
|
25
|
+
n++
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Draw a block of lines in place: move up over the previous block, clear it, write the new one. */
|
|
32
|
+
function painter() {
|
|
33
|
+
let last = 0
|
|
34
|
+
return {
|
|
35
|
+
draw(lines) {
|
|
36
|
+
let out = ''
|
|
37
|
+
if (last) out += `\x1b[${last}A\r`
|
|
38
|
+
out += '\x1b[0J' + lines.map(fit).join('\n') + '\n'
|
|
39
|
+
process.stdout.write(out)
|
|
40
|
+
last = lines.length
|
|
41
|
+
},
|
|
42
|
+
clear() {
|
|
43
|
+
if (last) process.stdout.write(`\x1b[${last}A\r\x1b[0J`)
|
|
44
|
+
last = 0
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
7
48
|
|
|
8
49
|
function keys(onKey) {
|
|
9
50
|
readline.emitKeypressEvents(process.stdin)
|
|
10
51
|
process.stdin.setRawMode(true)
|
|
11
52
|
process.stdin.resume()
|
|
12
|
-
const h = (
|
|
13
|
-
if (k
|
|
53
|
+
const h = (str, k = {}) => {
|
|
54
|
+
if (k.ctrl && k.name === 'c') {
|
|
14
55
|
process.stdout.write('\x1b[?25h\n')
|
|
15
56
|
process.exit(130)
|
|
16
57
|
}
|
|
17
|
-
onKey(k
|
|
58
|
+
onKey(k, str)
|
|
18
59
|
}
|
|
19
60
|
process.stdin.on('keypress', h)
|
|
61
|
+
process.stdout.write('\x1b[?25l')
|
|
20
62
|
return () => {
|
|
21
63
|
process.stdin.off('keypress', h)
|
|
22
64
|
process.stdin.setRawMode(false)
|
|
23
65
|
process.stdin.pause()
|
|
66
|
+
process.stdout.write('\x1b[?25h')
|
|
24
67
|
}
|
|
25
68
|
}
|
|
26
69
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
70
|
+
const isBack = (k) => k.name === 'escape' || k.name === 'left' || k.name === 'backspace'
|
|
71
|
+
const legend = (multi, canBack) =>
|
|
72
|
+
col.d(` ${multi ? 'space to mark, ' : ''}arrows to move, enter to ${multi ? 'confirm' : 'choose'}${canBack ? ', esc to go back' : ''}`)
|
|
73
|
+
const done = (question, answer) => fit(`${col.g("+")} ${question} ${col.y(answer)}`)
|
|
74
|
+
|
|
75
|
+
/** Single choice. options: [{ value, label, hint }]. `initial` is a value or an index. */
|
|
76
|
+
export function select(question, options, initial = 0, canBack = true) {
|
|
77
|
+
let i = typeof initial === 'number' ? initial : Math.max(0, options.findIndex((o) => o.value === initial))
|
|
78
|
+
if (!tty) return Promise.resolve(options[i].value)
|
|
30
79
|
return new Promise((resolve) => {
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const stop = keys((k) => {
|
|
40
|
-
if (k.name === 'up') i = (i - 1 + options.length) % options.length
|
|
41
|
-
else if (k.name === 'down' || k.name === 'tab') i = (i + 1) % options.length
|
|
42
|
-
else if (
|
|
80
|
+
const p = painter()
|
|
81
|
+
const render = () =>
|
|
82
|
+
p.draw([
|
|
83
|
+
`${col.c('?')} ${col.b(question)}`,
|
|
84
|
+
...options.map((o, n) => ` ${n === i ? col.y('>') : ' '} ${n === i ? col.b(o.label) : o.label}${o.hint ? ' ' + col.d(o.hint) : ''}`),
|
|
85
|
+
legend(false, canBack),
|
|
86
|
+
])
|
|
87
|
+
render()
|
|
88
|
+
const stop = keys((k, str) => {
|
|
89
|
+
if (k.name === 'up' || k.name === 'k') i = (i - 1 + options.length) % options.length
|
|
90
|
+
else if (k.name === 'down' || k.name === 'j' || k.name === 'tab') i = (i + 1) % options.length
|
|
91
|
+
else if (str && /^[1-9]$/.test(str) && Number(str) <= options.length) i = Number(str) - 1
|
|
92
|
+
else if (k.name === 'return' || k.name === 'enter' || k.name === 'right') {
|
|
43
93
|
stop()
|
|
44
|
-
|
|
94
|
+
p.clear()
|
|
95
|
+
process.stdout.write(done(question, options[i].label) + '\n')
|
|
45
96
|
return resolve(options[i].value)
|
|
97
|
+
} else if (canBack && isBack(k)) {
|
|
98
|
+
stop()
|
|
99
|
+
p.clear()
|
|
100
|
+
return resolve(BACK)
|
|
46
101
|
} else return
|
|
47
|
-
|
|
102
|
+
render()
|
|
48
103
|
})
|
|
49
104
|
})
|
|
50
105
|
}
|
|
51
106
|
|
|
52
|
-
/** Multiple choice
|
|
53
|
-
export function multiselect(question, options, initial = []) {
|
|
54
|
-
|
|
107
|
+
/** Multiple choice: space marks, enter confirms. */
|
|
108
|
+
export function multiselect(question, options, initial = [], canBack = true) {
|
|
109
|
+
const on = new Set(initial)
|
|
110
|
+
if (!tty) return Promise.resolve([...on])
|
|
55
111
|
return new Promise((resolve) => {
|
|
56
112
|
let i = 0
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const stop = keys((k) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
else if (k.name === '
|
|
69
|
-
else if (k.name === '
|
|
113
|
+
const p = painter()
|
|
114
|
+
const render = () =>
|
|
115
|
+
p.draw([
|
|
116
|
+
`${col.c('?')} ${col.b(question)}`,
|
|
117
|
+
...options.map((o, n) => ` ${n === i ? col.y('>') : ' '} ${on.has(o.value) ? col.g('[x]') : col.d('[ ]')} ${n === i ? col.b(o.label) : o.label}${o.hint ? ' ' + col.d(o.hint) : ''}`),
|
|
118
|
+
legend(true, canBack),
|
|
119
|
+
])
|
|
120
|
+
render()
|
|
121
|
+
const stop = keys((k, str) => {
|
|
122
|
+
const o = options[i]
|
|
123
|
+
if (k.name === 'up' || k.name === 'k') i = (i - 1 + options.length) % options.length
|
|
124
|
+
else if (k.name === 'down' || k.name === 'j' || k.name === 'tab') i = (i + 1) % options.length
|
|
125
|
+
else if (k.name === 'space' || str === 'x') on.has(o.value) ? on.delete(o.value) : on.add(o.value)
|
|
126
|
+
else if (str === 'a') options.every((x) => on.has(x.value)) ? on.clear() : options.forEach((x) => on.add(x.value))
|
|
127
|
+
else if (k.name === 'return' || k.name === 'enter') {
|
|
128
|
+
stop()
|
|
129
|
+
p.clear()
|
|
130
|
+
const picked = options.filter((x) => on.has(x.value))
|
|
131
|
+
process.stdout.write(done(question, picked.map((x) => x.label).join(', ') || 'none') + '\n')
|
|
132
|
+
return resolve(picked.map((x) => x.value))
|
|
133
|
+
} else if (canBack && (k.name === 'escape' || k.name === 'left' || k.name === 'backspace')) {
|
|
70
134
|
stop()
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return resolve(picked.map((o) => o.value))
|
|
135
|
+
p.clear()
|
|
136
|
+
return resolve(BACK)
|
|
74
137
|
} else return
|
|
75
|
-
|
|
138
|
+
render()
|
|
76
139
|
})
|
|
77
140
|
})
|
|
78
141
|
}
|
|
79
142
|
|
|
80
|
-
|
|
143
|
+
/** Text input with a default. Esc goes back; Backspace on an empty field also goes back. */
|
|
144
|
+
export function text(question, initial = '', canBack = true) {
|
|
81
145
|
if (!tty) return Promise.resolve(initial)
|
|
82
146
|
return new Promise((resolve) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
147
|
+
let value = ''
|
|
148
|
+
const p = painter()
|
|
149
|
+
const render = () => p.draw([`${col.c('?')} ${col.b(question)} ${value ? value : col.d(initial)}${tty ? '\x1b[7m \x1b[0m' : ''}`, col.d(` type and press enter${canBack ? ', esc to go back' : ''}`)])
|
|
150
|
+
render()
|
|
151
|
+
const stop = keys((k, str) => {
|
|
152
|
+
if (k.name === 'return' || k.name === 'enter') {
|
|
153
|
+
stop()
|
|
154
|
+
p.clear()
|
|
155
|
+
const v = value.trim() || initial
|
|
156
|
+
process.stdout.write(done(question, v) + '\n')
|
|
157
|
+
return resolve(v)
|
|
158
|
+
}
|
|
159
|
+
if (canBack && (k.name === 'escape' || (k.name === 'backspace' && !value))) {
|
|
160
|
+
stop()
|
|
161
|
+
p.clear()
|
|
162
|
+
return resolve(BACK)
|
|
163
|
+
}
|
|
164
|
+
if (k.name === 'backspace') value = value.slice(0, -1)
|
|
165
|
+
else if (str && !k.ctrl && !k.meta && str.length === 1 && str >= ' ') value += str
|
|
166
|
+
else return
|
|
167
|
+
render()
|
|
87
168
|
})
|
|
88
169
|
})
|
|
89
170
|
}
|
|
90
171
|
|
|
91
|
-
export const confirm = (question, initial = true) =>
|
|
92
|
-
select(question,
|
|
172
|
+
export const confirm = (question, initial = true, canBack = true) =>
|
|
173
|
+
select(question, [{ value: true, label: 'Yes' }, { value: false, label: 'No' }], initial ? 0 : 1, canBack)
|
|
93
174
|
|
|
94
175
|
/** Little animated mascot while something runs. */
|
|
95
176
|
export async function spin(label, fn) {
|
package/templates-common.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Files shared by every frontend: config, database, auth, email, i18n, deploy.
|
|
2
2
|
export const V = {
|
|
3
|
-
raiko: '^1.0.
|
|
3
|
+
raiko: '^1.0.3', typescript: '5.6.3', bunTypes: '1.1.13', nodeTypes: '22.9.0', tsx: '4.19.2', esbuild: '0.24.0', vite: '5.4.11', terser: '5.36.0',
|
|
4
4
|
kastor: '0.2.0', prisma: '5.22.0', drizzle: '0.38.3', drizzleKit: '0.30.1', betterSqlite: '11.7.0', pg: '8.13.1', postgresJs: '3.4.5',
|
|
5
5
|
glyphfox: '1.3.0', lucide: '0.469.0', heroicons: '2.2.0', motion: '11.15.0', nodemailer: '6.9.16',
|
|
6
6
|
}
|
package/templates.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Generates every file of a new project from the installer answers.
|
|
2
|
+
import { welcomeFiles } from './welcome.mjs'
|
|
2
3
|
import { V, LOCALES, isVite, run, exec, dbFiles, configFile, sharedFiles, serverPieces, authFiles, emailFiles, i18nFiles, adminFiles, deployFiles, envExample, gitignore } from './templates-common.mjs'
|
|
3
4
|
|
|
4
5
|
const json = (v) => JSON.stringify(v, null, 2) + '\n'
|
|
@@ -623,6 +624,16 @@ export function generate(input) {
|
|
|
623
624
|
...adminFiles(o),
|
|
624
625
|
...deployFiles(o),
|
|
625
626
|
}
|
|
627
|
+
// the starter page that teaches the first steps, the same design in every framework
|
|
628
|
+
Object.assign(files, welcomeFiles(o))
|
|
629
|
+
for (const main of ['web/src/main.tsx', 'web/src/main.ts']) {
|
|
630
|
+
if (files[main]) files[main] = files[main].replace(/^(import [^\n]*\n)(?!import)/m, "$1import './welcome.css'\n")
|
|
631
|
+
}
|
|
632
|
+
if (!isVite(o)) {
|
|
633
|
+
files['app/layout.tsx'] = files['app/layout.tsx'].replace('<meta name="viewport" content="width=device-width, initial-scale=1" />', '<meta name="viewport" content="width=device-width, initial-scale=1" />\n <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;600&display=swap" />\n <link rel="stylesheet" href="/welcome.css" />')
|
|
634
|
+
} else {
|
|
635
|
+
files['web/index.html'] = files['web/index.html'].replace('<link rel="icon" href="/favicon.svg" />', '<link rel="icon" href="/favicon.svg" />\n <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;600&display=swap" />')
|
|
636
|
+
}
|
|
626
637
|
if (!isVite(o)) {
|
|
627
638
|
files['public/favicon.svg'] = FAVICON
|
|
628
639
|
files['.raiko/manifest.ts'] = '// replaced by `raiko dev` and `raiko build`\nexport default {}\n'
|
package/welcome.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// The starter page every template opens on: same design, same steps, framework specific markup.
|
|
2
|
+
import { isVite } from './templates-common.mjs'
|
|
3
|
+
|
|
4
|
+
export const WELCOME_CSS = `/* Starter page. Delete this file and its import when you build your own UI. */
|
|
5
|
+
.rk-welcome { --bg: #ffffff; --fg: #0a0a0a; --muted: #5c5c5c; --line: #e5e5e5; --soft: #f4f4f5; --accent: #c99400; --code: #f4f4f5;
|
|
6
|
+
min-height: 100vh; display: grid; grid-template-rows: 1fr auto; background: var(--bg); color: var(--fg);
|
|
7
|
+
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased;
|
|
8
|
+
background-image: radial-gradient(circle at 50% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 45%);
|
|
9
|
+
padding-left: max(20px, env(safe-area-inset-left, 0px)); padding-right: max(20px, env(safe-area-inset-right, 0px)) }
|
|
10
|
+
@media (prefers-color-scheme: dark) { .rk-welcome { --bg: #000000; --fg: #ededed; --muted: #a1a1a1; --line: #262626; --soft: #111111; --accent: #ffd23f; --code: #1a1a1a } }
|
|
11
|
+
.rk-welcome * { box-sizing: border-box }
|
|
12
|
+
.rk-main { width: 100%; max-width: 560px; margin: 0 auto; padding: 96px 0 48px; display: flex; flex-direction: column; gap: 32px }
|
|
13
|
+
.rk-logo { display: flex; align-items: center; gap: 10px; font-size: 22px; font-weight: 700; letter-spacing: -0.03em }
|
|
14
|
+
.rk-logo svg { width: 34px; height: 34px }
|
|
15
|
+
.rk-logo span { color: var(--muted); font-weight: 500 }
|
|
16
|
+
.rk-steps { margin: 0; padding-left: 20px; list-style: decimal; font: 14px/1.9 "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted) }
|
|
17
|
+
.rk-steps li { padding-left: 6px } .rk-steps li::marker { color: var(--muted) }
|
|
18
|
+
.rk-steps code { font: inherit; color: var(--fg); background: var(--code); padding: 2px 6px; border-radius: 5px; font-weight: 600 }
|
|
19
|
+
.rk-ctas { display: flex; gap: 12px; flex-wrap: wrap }
|
|
20
|
+
.rk-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 46px; padding: 0 20px; border-radius: 999px; font-size: 15px; font-weight: 500; text-decoration: none; border: 1px solid var(--line); color: var(--fg); background: transparent; transition: background .15s, border-color .15s }
|
|
21
|
+
.rk-btn:hover { background: var(--soft) } .rk-btn.solid { background: var(--fg); color: var(--bg); border-color: var(--fg) } .rk-btn.solid:hover { opacity: .88 }
|
|
22
|
+
.rk-btn svg { width: 16px; height: 16px }
|
|
23
|
+
.rk-cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden }
|
|
24
|
+
.rk-card { background: var(--bg); padding: 16px 18px; text-decoration: none; color: var(--fg); display: block; transition: background .15s }
|
|
25
|
+
.rk-card:hover { background: var(--soft) } .rk-card b { display: block; font-size: 14px; font-weight: 600 } .rk-card small { color: var(--muted); font-size: 13px }
|
|
26
|
+
.rk-status { display: flex; align-items: center; gap: 8px; font: 13px "Geist Mono", ui-monospace, monospace; color: var(--muted) }
|
|
27
|
+
.rk-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af } .rk-dot.ok { background: #22c55e; box-shadow: 0 0 0 4px rgba(34,197,94,.18) } .rk-dot.bad { background: #ef4444 }
|
|
28
|
+
.rk-foot { display: flex; gap: 22px; flex-wrap: wrap; justify-content: center; padding: 28px 0 36px; font-size: 14px }
|
|
29
|
+
.rk-foot a { color: var(--muted); text-decoration: none; display: inline-flex; align-items: center; gap: 6px } .rk-foot a:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 4px }
|
|
30
|
+
.rk-welcome :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px }
|
|
31
|
+
@media (max-width: 520px) { .rk-main { padding-top: 56px } .rk-cards { grid-template-columns: 1fr } .rk-ctas .rk-btn { flex: 1 1 100% } }
|
|
32
|
+
`
|
|
33
|
+
|
|
34
|
+
export const BOLT = '<svg viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" rx="14" fill="currentColor"/><path d="M36 10 18 36h12l-3 18 19-28H34l2-16z" fill="#ffd23f"/></svg>'
|
|
35
|
+
const ARROW = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4"/></svg>'
|
|
36
|
+
|
|
37
|
+
/** Everything the page shows, built from the installer answers. */
|
|
38
|
+
export function welcomeData(o) {
|
|
39
|
+
const file = { raiko: 'app/page.tsx', react: 'web/src/App.tsx', preact: 'web/src/App.tsx', solid: 'web/src/App.tsx', vue: 'web/src/App.vue', svelte: 'web/src/App.svelte', angular: 'web/src/app.component.ts' }[o.frontend]
|
|
40
|
+
const cards = [
|
|
41
|
+
{ href: '/api/hello?name=Raiko', title: 'API route', desc: isVite(o) ? 'server/index.ts' : 'app/api/hello/route.ts' },
|
|
42
|
+
{ href: '/docs', title: 'API docs', desc: 'OpenAPI, generated from your schemas' },
|
|
43
|
+
]
|
|
44
|
+
if (o.template === 'full' && !isVite(o)) cards.push({ href: '/chat', title: 'Chat demo', desc: 'encrypted WebSocket, open two tabs' })
|
|
45
|
+
if (o.auth.length) cards.push({ href: isVite(o) ? '/auth/session' : '/login', title: 'Sign in', desc: o.twofa ? 'with Google Authenticator 2FA' : o.auth.join(', ') })
|
|
46
|
+
if (o.admin) cards.push({ href: '/admin', title: 'Admin', desc: 'roles in raiko.config.ts' })
|
|
47
|
+
if (o.email !== 'none' || o.auth.length) cards.push({ href: '/_raiko/mail', title: 'Email previews', desc: 'templates in emails/' })
|
|
48
|
+
if (cards.length % 2) cards.push({ href: '/health', title: 'Health check', desc: 'for your load balancer' })
|
|
49
|
+
return {
|
|
50
|
+
file,
|
|
51
|
+
cards,
|
|
52
|
+
deploy: 'https://kwy404.github.io/Raiko.js/docs/#deploy',
|
|
53
|
+
deployLabel: { vercel: 'Deploy to Vercel', cloudflare: 'Deploy to Cloudflare', docker: 'Deploy with Docker', node: 'Deploy now' }[o.deploy] ?? 'Deploy now',
|
|
54
|
+
deployCmd: o.deploy === 'node' ? 'build' : 'deploy',
|
|
55
|
+
docs: 'https://kwy404.github.io/Raiko.js/docs/',
|
|
56
|
+
foot: [
|
|
57
|
+
{ href: 'https://kwy404.github.io/Raiko.js/docs/', label: 'Learn' },
|
|
58
|
+
{ href: 'https://github.com/kwy404/Raiko.js/tree/main/examples', label: 'Examples' },
|
|
59
|
+
{ href: 'https://kwy404.github.io/Raiko.js/', label: 'Go to raiko.js' },
|
|
60
|
+
],
|
|
61
|
+
ARROW,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const esc = (t) => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
66
|
+
|
|
67
|
+
/** Icon element for the "deploy" button in the icon library the person picked. */
|
|
68
|
+
function iconFor(o) {
|
|
69
|
+
const fw = o.frontend
|
|
70
|
+
if (o.icons === 'glyphfox' && ['react', 'preact', 'solid', 'vue', 'svelte'].includes(fw)) return { imp: `import { RocketIcon } from 'glyphfox/${fw}'`, tag: fw === 'vue' ? '<RocketIcon :size="16" />' : '<RocketIcon size={16} />' }
|
|
71
|
+
if (o.icons === 'lucide') {
|
|
72
|
+
const pkg = { react: 'lucide-react', preact: 'lucide-preact', solid: 'lucide-solid', vue: 'lucide-vue-next', svelte: 'lucide-svelte' }[fw]
|
|
73
|
+
if (pkg) return { imp: `import { Rocket } from '${pkg}'`, tag: fw === 'vue' ? '<Rocket :size="16" />' : '<Rocket size={16} />' }
|
|
74
|
+
}
|
|
75
|
+
if (o.icons === 'heroicons' && (fw === 'react' || fw === 'vue')) return { imp: `import { RocketLaunchIcon } from '@heroicons/${fw}/24/outline'`, tag: fw === 'vue' ? '<RocketLaunchIcon style="width:16px;height:16px" />' : '<RocketLaunchIcon width={16} height={16} />' }
|
|
76
|
+
return { imp: '', tag: '' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The page in JSX (React, Preact, Solid). `cls` is className for React, class for the others. */
|
|
80
|
+
function jsxPage(o, w, cls) {
|
|
81
|
+
const ic = iconFor(o)
|
|
82
|
+
const cards = w.cards.map((c) => ` <a ${cls}="rk-card" href="${c.href}"><b>${c.title}</b><small>${esc(c.desc)}</small></a>`).join('\n')
|
|
83
|
+
const foot = w.foot.map((f) => ` <a href="${f.href}">${f.label}</a>`).join('\n')
|
|
84
|
+
return { ic, body: ` <div ${cls}="rk-welcome">
|
|
85
|
+
<main ${cls}="rk-main">
|
|
86
|
+
<div ${cls}="rk-logo"><span ${cls}="rk-mark" dangerouslySetInnerHTML={{ __html: BOLT }} />raiko<span>.js</span></div>
|
|
87
|
+
<ol ${cls}="rk-steps">
|
|
88
|
+
<li>Get started by editing <code>${w.file}</code>.</li>
|
|
89
|
+
<li>Save and see your changes instantly.</li>
|
|
90
|
+
</ol>
|
|
91
|
+
<div ${cls}="rk-ctas">
|
|
92
|
+
<a ${cls}="rk-btn solid" href="${w.deploy}">${ic.tag ? ic.tag + ' ' : ''}${w.deployLabel}</a>
|
|
93
|
+
<a ${cls}="rk-btn" href="${w.docs}">Read the docs</a>
|
|
94
|
+
</div>
|
|
95
|
+
<div ${cls}="rk-cards">
|
|
96
|
+
${cards}
|
|
97
|
+
</div>
|
|
98
|
+
<p ${cls}="rk-status"><span ${cls}={'rk-dot ' + STATUS_CLASS} />{STATUS_TEXT}</p>
|
|
99
|
+
</main>
|
|
100
|
+
<footer ${cls}="rk-foot">
|
|
101
|
+
${foot}
|
|
102
|
+
</footer>
|
|
103
|
+
</div>` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function welcomeFiles(o) {
|
|
107
|
+
const w = welcomeData(o)
|
|
108
|
+
const f = {}
|
|
109
|
+
const fw = o.frontend
|
|
110
|
+
const boltConst = `const BOLT = ${JSON.stringify(BOLT.replace('fill="currentColor"', 'fill="#0a0a0a"'))}`
|
|
111
|
+
if (fw === 'raiko') {
|
|
112
|
+
const icon = o.icons === 'glyphfox' ? ["import { toSvg } from 'glyphfox'", "toSvg('rocket', { size: 16 })"] : o.icons === 'lucide' ? ["import { Rocket } from 'lucide-static'", 'Rocket'] : null
|
|
113
|
+
const cards = w.cards.map((c) => ` <a class="rk-card" href="${c.href}"><b>${c.title}</b><small>${esc(c.desc)}</small></a>`).join('\n')
|
|
114
|
+
f['public/welcome.css'] = WELCOME_CSS
|
|
115
|
+
f['app/page.tsx'] = `import { raw } from 'raiko.js'
|
|
116
|
+
${icon ? icon[0] + '\n' : ''}
|
|
117
|
+
${boltConst}
|
|
118
|
+
|
|
119
|
+
/** The starter page. It is a server component: it runs on the server and ships no JavaScript. */
|
|
120
|
+
export default async function Home() {
|
|
121
|
+
const renderedAt = new Date().toLocaleTimeString()
|
|
122
|
+
return (
|
|
123
|
+
<div class="rk-welcome" data-animate>
|
|
124
|
+
<main class="rk-main">
|
|
125
|
+
<div class="rk-logo">{raw(BOLT)}raiko<span>.js</span></div>
|
|
126
|
+
<ol class="rk-steps">
|
|
127
|
+
<li>Get started by editing <code>${w.file}</code>.</li>
|
|
128
|
+
<li>Save and see your changes instantly.</li>
|
|
129
|
+
</ol>
|
|
130
|
+
<div class="rk-ctas">
|
|
131
|
+
<a class="rk-btn solid" href="${w.deploy}">${icon ? `{raw(${icon[1]})} ` : ''}${w.deployLabel}</a>
|
|
132
|
+
<a class="rk-btn" href="${w.docs}">Read the docs</a>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="rk-cards">
|
|
135
|
+
${cards}
|
|
136
|
+
</div>
|
|
137
|
+
<p class="rk-status"><span class="rk-dot ok"></span>Server rendered at {renderedAt}</p>
|
|
138
|
+
</main>
|
|
139
|
+
<footer class="rk-foot">
|
|
140
|
+
${w.foot.map((x) => ` <a href="${x.href}">${x.label}</a>`).join('\n')}
|
|
141
|
+
</footer>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
`
|
|
146
|
+
return f
|
|
147
|
+
}
|
|
148
|
+
f['web/src/welcome.css'] = WELCOME_CSS
|
|
149
|
+
const status = `api.get('/api/hello', { query: { name: 'Raiko' } })`
|
|
150
|
+
if (fw === 'react' || fw === 'preact') {
|
|
151
|
+
const R = fw === 'react'
|
|
152
|
+
const { ic, body } = jsxPage(o, w, R ? 'className' : 'class')
|
|
153
|
+
const sx = o.css === 'stylex'
|
|
154
|
+
f['web/src/App.tsx'] = `import { useEffect, useState } from '${R ? 'react' : 'preact/hooks'}'
|
|
155
|
+
${o.motion && R ? "import { motion } from 'motion/react'\n" : ''}${sx ? "import * as stylex from '@stylexjs/stylex'\n" : ''}${ic.imp ? ic.imp + '\n' : ''}import { api } from './api'
|
|
156
|
+
|
|
157
|
+
${boltConst}
|
|
158
|
+
${sx ? "const s = stylex.create({ num: { fontVariantNumeric: 'tabular-nums' } })\n" : ''}
|
|
159
|
+
/** The starter page. Replace it with your app. */
|
|
160
|
+
export default function App() {
|
|
161
|
+
const [api_, setApi] = useState<'wait' | 'ok' | 'bad'>('wait')
|
|
162
|
+
const [msg, setMsg] = useState('Checking the API...')
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
${status}.then((r) => {
|
|
165
|
+
setApi(r.data ? 'ok' : 'bad')
|
|
166
|
+
setMsg(r.data ? \`API says hello to \${r.data.hello}\` : 'The API did not answer: is the server running?')
|
|
167
|
+
})
|
|
168
|
+
}, [])
|
|
169
|
+
const STATUS_CLASS = api_ === 'wait' ? '' : api_
|
|
170
|
+
const STATUS_TEXT = ${sx ? '<span {...stylex.props(s.num)}>{msg}</span>' : 'msg'}
|
|
171
|
+
return (
|
|
172
|
+
${o.motion && R ? body.replace('<main className="rk-main">', '<motion.main className="rk-main" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>').replace(' </main>', ' </motion.main>') : body}
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
`
|
|
176
|
+
}
|
|
177
|
+
if (fw === 'solid') {
|
|
178
|
+
const { ic, body } = jsxPage(o, w, 'class')
|
|
179
|
+
f['web/src/App.tsx'] = `import { createSignal, onMount } from 'solid-js'
|
|
180
|
+
${ic.imp ? ic.imp + '\n' : ''}import { api } from './api'
|
|
181
|
+
|
|
182
|
+
${boltConst}
|
|
183
|
+
|
|
184
|
+
/** The starter page. Replace it with your app. */
|
|
185
|
+
export default function App() {
|
|
186
|
+
const [state, setState] = createSignal<'wait' | 'ok' | 'bad'>('wait')
|
|
187
|
+
const [msg, setMsg] = createSignal('Checking the API...')
|
|
188
|
+
onMount(() =>
|
|
189
|
+
${status}.then((r) => {
|
|
190
|
+
setState(r.data ? 'ok' : 'bad')
|
|
191
|
+
setMsg(r.data ? \`API says hello to \${r.data.hello}\` : 'The API did not answer: is the server running?')
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
return (
|
|
195
|
+
${body.replace("dangerouslySetInnerHTML={{ __html: BOLT }}", 'innerHTML={BOLT}').replace("{'rk-dot ' + STATUS_CLASS}", "{'rk-dot ' + (state() === 'wait' ? '' : state())}").replace('{STATUS_TEXT}', '{msg()}')}
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
`
|
|
199
|
+
}
|
|
200
|
+
const cardsT = (open, close) => w.cards.map((c) => ` ${open}<a class="rk-card" href="${c.href}"><b>${c.title}</b><small>${esc(c.desc)}</small></a>${close}`).join('\n')
|
|
201
|
+
const footT = w.foot.map((x) => ` <a href="${x.href}">${x.label}</a>`).join('\n')
|
|
202
|
+
if (fw === 'vue') {
|
|
203
|
+
const ic = iconFor(o)
|
|
204
|
+
f['web/src/App.vue'] = `<script setup lang="ts">
|
|
205
|
+
import { onMounted, ref } from 'vue'
|
|
206
|
+
${ic.imp ? ic.imp + '\n' : ''}import { api } from './api'
|
|
207
|
+
|
|
208
|
+
${boltConst}
|
|
209
|
+
const state = ref<'wait' | 'ok' | 'bad'>('wait')
|
|
210
|
+
const msg = ref('Checking the API...')
|
|
211
|
+
onMounted(async () => {
|
|
212
|
+
const r = await ${status}
|
|
213
|
+
state.value = r.data ? 'ok' : 'bad'
|
|
214
|
+
msg.value = r.data ? \`API says hello to \${r.data.hello}\` : 'The API did not answer: is the server running?'
|
|
215
|
+
})
|
|
216
|
+
</script>
|
|
217
|
+
|
|
218
|
+
<!-- The starter page. Replace it with your app. -->
|
|
219
|
+
<template>
|
|
220
|
+
<div class="rk-welcome">
|
|
221
|
+
<main class="rk-main">
|
|
222
|
+
<div class="rk-logo"><span v-html="BOLT"></span>raiko<span>.js</span></div>
|
|
223
|
+
<ol class="rk-steps">
|
|
224
|
+
<li>Get started by editing <code>${w.file}</code>.</li>
|
|
225
|
+
<li>Save and see your changes instantly.</li>
|
|
226
|
+
</ol>
|
|
227
|
+
<div class="rk-ctas">
|
|
228
|
+
<a class="rk-btn solid" href="${w.deploy}">${ic.tag ? ic.tag + ' ' : ''}${w.deployLabel}</a>
|
|
229
|
+
<a class="rk-btn" href="${w.docs}">Read the docs</a>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="rk-cards">
|
|
232
|
+
${cardsT('', '')}
|
|
233
|
+
</div>
|
|
234
|
+
<p class="rk-status"><span :class="['rk-dot', state === 'wait' ? '' : state]"></span>{{ msg }}</p>
|
|
235
|
+
</main>
|
|
236
|
+
<footer class="rk-foot">
|
|
237
|
+
${footT}
|
|
238
|
+
</footer>
|
|
239
|
+
</div>
|
|
240
|
+
</template>
|
|
241
|
+
`
|
|
242
|
+
}
|
|
243
|
+
if (fw === 'svelte') {
|
|
244
|
+
const ic = iconFor(o)
|
|
245
|
+
f['web/src/App.svelte'] = `<script lang="ts">
|
|
246
|
+
${ic.imp ? ' ' + ic.imp + '\n' : ''} import { api } from './api'
|
|
247
|
+
|
|
248
|
+
${boltConst}
|
|
249
|
+
let state = $state<'wait' | 'ok' | 'bad'>('wait')
|
|
250
|
+
let msg = $state('Checking the API...')
|
|
251
|
+
${status}.then((r) => {
|
|
252
|
+
state = r.data ? 'ok' : 'bad'
|
|
253
|
+
msg = r.data ? \`API says hello to \${r.data.hello}\` : 'The API did not answer: is the server running?'
|
|
254
|
+
})
|
|
255
|
+
</script>
|
|
256
|
+
|
|
257
|
+
<!-- The starter page. Replace it with your app. -->
|
|
258
|
+
<div class="rk-welcome">
|
|
259
|
+
<main class="rk-main">
|
|
260
|
+
<div class="rk-logo">{@html BOLT}raiko<span>.js</span></div>
|
|
261
|
+
<ol class="rk-steps">
|
|
262
|
+
<li>Get started by editing <code>${w.file}</code>.</li>
|
|
263
|
+
<li>Save and see your changes instantly.</li>
|
|
264
|
+
</ol>
|
|
265
|
+
<div class="rk-ctas">
|
|
266
|
+
<a class="rk-btn solid" href="${w.deploy}">${ic.tag ? ic.tag + ' ' : ''}${w.deployLabel}</a>
|
|
267
|
+
<a class="rk-btn" href="${w.docs}">Read the docs</a>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="rk-cards">
|
|
270
|
+
${cardsT('', '')}
|
|
271
|
+
</div>
|
|
272
|
+
<p class="rk-status"><span class="rk-dot {state === 'wait' ? '' : state}"></span>{msg}</p>
|
|
273
|
+
</main>
|
|
274
|
+
<footer class="rk-foot">
|
|
275
|
+
${footT}
|
|
276
|
+
</footer>
|
|
277
|
+
</div>
|
|
278
|
+
`
|
|
279
|
+
}
|
|
280
|
+
if (fw === 'angular') {
|
|
281
|
+
f['web/src/app.component.ts'] = `import { Component, signal } from '@angular/core'
|
|
282
|
+
import { DomSanitizer } from '@angular/platform-browser'
|
|
283
|
+
import { api } from './api'
|
|
284
|
+
|
|
285
|
+
${boltConst}
|
|
286
|
+
|
|
287
|
+
/** The starter page. Replace it with your app. */
|
|
288
|
+
@Component({
|
|
289
|
+
selector: 'app-root',
|
|
290
|
+
standalone: true,
|
|
291
|
+
template: \`
|
|
292
|
+
<div class="rk-welcome">
|
|
293
|
+
<main class="rk-main">
|
|
294
|
+
<div class="rk-logo"><span [innerHTML]="bolt"></span>raiko<span>.js</span></div>
|
|
295
|
+
<ol class="rk-steps">
|
|
296
|
+
<li>Get started by editing <code>${w.file}</code>.</li>
|
|
297
|
+
<li>Save and see your changes instantly.</li>
|
|
298
|
+
</ol>
|
|
299
|
+
<div class="rk-ctas">
|
|
300
|
+
<a class="rk-btn solid" href="${w.deploy}">${w.deployLabel}</a>
|
|
301
|
+
<a class="rk-btn" href="${w.docs}">Read the docs</a>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="rk-cards">
|
|
304
|
+
${w.cards.map((c) => ` <a class="rk-card" href="${c.href}"><b>${c.title}</b><small>${esc(c.desc)}</small></a>`).join('\n')}
|
|
305
|
+
</div>
|
|
306
|
+
<p class="rk-status"><span class="rk-dot" [class.ok]="state() === 'ok'" [class.bad]="state() === 'bad'"></span>{{ msg() }}</p>
|
|
307
|
+
</main>
|
|
308
|
+
<footer class="rk-foot">
|
|
309
|
+
${w.foot.map((x) => ` <a href="${x.href}">${x.label}</a>`).join('\n')}
|
|
310
|
+
</footer>
|
|
311
|
+
</div>
|
|
312
|
+
\`,
|
|
313
|
+
})
|
|
314
|
+
export class AppComponent {
|
|
315
|
+
state = signal<'wait' | 'ok' | 'bad'>('wait')
|
|
316
|
+
msg = signal('Checking the API...')
|
|
317
|
+
bolt
|
|
318
|
+
constructor(sanitizer: DomSanitizer) {
|
|
319
|
+
this.bolt = sanitizer.bypassSecurityTrustHtml(BOLT)
|
|
320
|
+
${status}.then((r) => {
|
|
321
|
+
this.state.set(r.data ? 'ok' : 'bad')
|
|
322
|
+
this.msg.set(r.data ? \`API says hello to \${r.data.hello}\` : 'The API did not answer: is the server running?')
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
`
|
|
327
|
+
}
|
|
328
|
+
return f
|
|
329
|
+
}
|