create-raiko 1.0.1 → 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 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
- console.log(`
35
- ${col.y(' /\\_/\\ ')} ${col.b('raiko')}${col.d('.js')}
36
- ${col.y(' ( o.o ) ')} ${col.d('backend and frontend in one file tree')}
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 name = positional ?? (yes ? 'raiko-app' : await text('Project name', 'raiko-app'))
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
- if (!/^[a-z0-9][a-z0-9._-]{0,213}$/.test(basename(name))) {
44
- console.error(`\n ${col.r('error[RK0803]')}: "${basename(name).replace(/[^\x20-\x7e]/g, '?')}" is not a valid project name\n = help: lowercase letters, digits, dot, dash and underscore, like my-app\n`)
45
- process.exit(1)
46
- }
47
- const dir = resolve(name)
48
- if (existsSync(dir) && readdirSync(dir).length && !argv.includes('--force')) {
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 template = await ask('template', () => select('Template', [{ value: 'full', label: 'Full', hint: 'pages, API, chat over WebSocket, everything you pick below' }, { value: 'minimal', label: 'Minimal', hint: 'one page and one API route' }]), 'full')
54
- const frontend = await ask('frontend', () => select('Frontend', FRONTENDS), 'raiko')
55
- const css = await ask('css', () => select('CSS', CSS.filter((c) => !c.only || c.only.includes(frontend))), 'monsoon')
56
- if (css === 'stylex' && frontend !== 'react') {
57
- console.error(` ${col.r('error[RK0802]')}: StyleX needs the React frontend\n = help: pick --frontend react, or another CSS option`)
58
- process.exit(1)
59
- }
60
- const icons = await ask('icons', () => select('Icons', ICONS.filter((c) => !c.only || c.only.includes(frontend))), 'glyphfox')
61
- const motion = await ask('motion', () => confirm('Animations with Motion (formerly Framer Motion)?', false), false)
62
- const orm = await ask('orm', () => select('Database ORM', [{ value: 'kastor', label: 'Kastor', hint: 'TypeScript ORM, no codegen' }, { value: 'prisma', label: 'Prisma' }, { value: 'drizzle', label: 'Drizzle' }, { value: 'none', label: 'No database' }]), 'none')
63
- const db = orm === 'none' ? 'none' : await ask('db', () => select('Database', [{ value: 'sqlite', label: 'SQLite', hint: 'a file, no server needed' }, { value: 'postgres', label: 'PostgreSQL' }]), 'sqlite')
64
- const auth = list(await ask('auth', () => 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' }], ['credentials']), []))
65
- const twofa = auth.length ? await ask('twofa', () => confirm('Two factor auth with Google Authenticator (TOTP)?', true), false) : false
66
- const email = await ask('email', () => 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' }]), 'none')
67
- const i18n = await ask('i18n', () => confirm('Translations (en, pt-BR, es, fr, de)?', false), false)
68
- const admin = orm === 'none' ? false : await ask('admin', () => confirm('Admin panel at /admin?', true), true)
69
-
70
- let roles = { admin: ROLE_PRESETS.admin }
71
- if (admin) {
72
- const picked = list(await ask('roles', () => multiselect('Admin roles (admin always has full access)', [{ value: 'editor', label: 'editor', hint: 'manage posts, read users' }, { value: 'support', label: 'support', hint: 'read and fix users, cannot list or delete' }, { value: 'viewer', label: 'viewer', hint: 'read only' }], ['editor']), 'editor'))
73
- for (const r of picked) if (ROLE_PRESETS[r]) roles[r] = ROLE_PRESETS[r]
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
- const cookies = await ask('cookies', () => 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' }]), 'none')
76
- const pwa = await ask('pwa', () => confirm('Installable app with offline support (installkite)?', false), false)
77
- const deploy = await ask('deploy', () => 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' }]), 'docker')
78
- const pm = await ask('pm', () => select('Package manager', [{ value: detectedPm, label: detectedPm, hint: 'detected' }, ...['bun', 'npm', 'pnpm', 'yarn'].filter((p) => p !== detectedPm).map((p) => ({ value: p, label: p }))]), detectedPm)
79
- const install = await ask('install', () => confirm('Install dependencies now?', true), !argv.includes('--no-install'))
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.1",
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
- "README.md"
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
- // Tiny interactive prompts with arrow keys, no dependencies.
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 tty = process.stdin.isTTY && process.stdout.isTTY
5
- const esc = (n) => `\x1b[${n}m`
6
- export const col = { y: (s) => `${esc(33)}${s}${esc(0)}`, d: (s) => `${esc(2)}${s}${esc(0)}`, g: (s) => `${esc(32)}${s}${esc(0)}`, b: (s) => `${esc(1)}${s}${esc(0)}`, r: (s) => `${esc(31)}${s}${esc(0)}`, c: (s) => `${esc(36)}${s}${esc(0)}` }
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 = (_, k) => {
13
- if (k?.ctrl && k.name === 'c') {
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
- /** Single choice. options: [{ value, label, hint }] */
28
- export function select(question, options, initial = 0) {
29
- if (!tty) return Promise.resolve(options[initial].value)
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
- let i = initial
32
- const draw = (first) => {
33
- if (!first) process.stdout.write(`\x1b[${options.length}A`)
34
- for (const [n, o] of options.entries())
35
- process.stdout.write(`\x1b[2K ${n === i ? col.y('>') : ' '} ${n === i ? col.b(o.label) : o.label}${o.hint ? ' ' + col.d(o.hint) : ''}\n`)
36
- }
37
- process.stdout.write(`\x1b[?25l${col.c('?')} ${col.b(question)} ${col.d('(arrows, enter)')}\n`)
38
- draw(true)
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 (k.name === 'return') {
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
- process.stdout.write(`\x1b[${options.length + 1}A\x1b[0J${col.g('+')} ${question} ${col.y(options[i].label)}\n\x1b[?25h`)
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
- draw(false)
102
+ render()
48
103
  })
49
104
  })
50
105
  }
51
106
 
52
- /** Multiple choice with space. */
53
- export function multiselect(question, options, initial = []) {
54
- if (!tty) return Promise.resolve(initial)
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 on = new Set(initial)
58
- const draw = (first) => {
59
- if (!first) process.stdout.write(`\x1b[${options.length}A`)
60
- for (const [n, o] of options.entries())
61
- process.stdout.write(`\x1b[2K ${n === i ? col.y('>') : ' '} ${on.has(o.value) ? col.g('[x]') : col.d('[ ]')} ${o.label}${o.hint ? ' ' + col.d(o.hint) : ''}\n`)
62
- }
63
- process.stdout.write(`\x1b[?25l${col.c('?')} ${col.b(question)} ${col.d('(space to toggle, enter)')}\n`)
64
- draw(true)
65
- const stop = keys((k) => {
66
- if (k.name === 'up') i = (i - 1 + options.length) % options.length
67
- else if (k.name === 'down') i = (i + 1) % options.length
68
- else if (k.name === 'space') on.has(options[i].value) ? on.delete(options[i].value) : on.add(options[i].value)
69
- else if (k.name === 'return') {
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
- const picked = options.filter((o) => on.has(o.value))
72
- process.stdout.write(`\x1b[${options.length + 1}A\x1b[0J${col.g('+')} ${question} ${col.y(picked.map((o) => o.label).join(', ') || 'none')}\n\x1b[?25h`)
73
- return resolve(picked.map((o) => o.value))
135
+ p.clear()
136
+ return resolve(BACK)
74
137
  } else return
75
- draw(false)
138
+ render()
76
139
  })
77
140
  })
78
141
  }
79
142
 
80
- export function text(question, initial = '') {
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
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
84
- rl.question(`${col.c('?')} ${col.b(question)} ${col.d(`(${initial})`)} `, (a) => {
85
- rl.close()
86
- resolve(a.trim() || initial)
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, initial ? [{ value: true, label: 'Yes' }, { value: false, label: 'No' }] : [{ value: false, label: 'No' }, { value: true, label: 'Yes' }])
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) {
@@ -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.1', 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',
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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
+ }