toes new --<template>

This commit is contained in:
Chris Wanstrath 2026-01-30 12:24:58 -08:00
parent f4e13dd932
commit f12854fc04
21 changed files with 266 additions and 67 deletions

View File

@ -37,8 +37,9 @@
[x] `toes pull` [x] `toes pull`
[x] `toes push` [x] `toes push`
[x] `toes sync` [x] `toes sync`
[ ] `toes new --spa` [x] `toes new --spa`
[ ] `toes new --ssr` [x] `toes new --ssr`
[x] `toes new --bare`
[ ] needs to either check toes.local or take something like TOES_URL [ ] needs to either check toes.local or take something like TOES_URL
## webui ## webui

View File

@ -1,5 +1,5 @@
import type { App } from '@types' import type { App } from '@types'
import { generateTemplates } from '@templates' import { generateTemplates, type TemplateType } from '@templates'
import color from 'kleur' import color from 'kleur'
import { existsSync, mkdirSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, writeFileSync } from 'fs'
import { basename, join } from 'path' import { basename, join } from 'path'
@ -55,10 +55,21 @@ export async function listApps() {
} }
} }
export async function newApp(name?: string) { interface NewAppOptions {
ssr?: boolean
bare?: boolean
spa?: boolean
}
export async function newApp(name: string | undefined, options: NewAppOptions) {
const appPath = name ? join(process.cwd(), name) : process.cwd() const appPath = name ? join(process.cwd(), name) : process.cwd()
const appName = name ?? basename(process.cwd()) const appName = name ?? basename(process.cwd())
// Determine template type from flags
let template: TemplateType = 'ssr'
if (options.bare) template = 'bare'
else if (options.spa) template = 'spa'
if (name && existsSync(appPath)) { if (name && existsSync(appPath)) {
console.error(`Directory already exists: ${name}`) console.error(`Directory already exists: ${name}`)
return return
@ -71,12 +82,18 @@ export async function newApp(name?: string) {
return return
} }
const ok = await confirm(`Create ${color.bold(appName)} in ${appPath}?`) const templateLabel = template === 'ssr' ? '' : ` (${template})`
const ok = await confirm(`Create ${color.bold(appName)}${templateLabel} in ${appPath}?`)
if (!ok) return if (!ok) return
mkdirSync(join(appPath, 'src', 'pages'), { recursive: true }) const templates = generateTemplates(appName, template)
// Create directories for all template files
for (const filename of Object.keys(templates)) {
const dir = join(appPath, filename, '..')
mkdirSync(dir, { recursive: true })
}
const templates = generateTemplates(appName)
for (const [filename, content] of Object.entries(templates)) { for (const [filename, content] of Object.entries(templates)) {
writeFileSync(join(appPath, filename), content) writeFileSync(join(appPath, filename), content)
} }

View File

@ -1,4 +1,6 @@
import { program } from 'commander' import { program } from 'commander'
import { readFileSync } from 'fs'
import color from 'kleur' import color from 'kleur'
import { import {
getApp, getApp,
@ -19,7 +21,7 @@ import {
program program
.name('toes') .name('toes')
.version('0.0.1', '-v, --version') .version('v0.0.3', '-v, --version')
.addHelpText('beforeAll', (ctx) => { .addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) { if (ctx.command === program) {
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n') return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')
@ -99,6 +101,9 @@ program
.command('new') .command('new')
.description('Create a new toes app') .description('Create a new toes app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.option('--ssr', 'SSR template with pages directory (default)')
.option('--bare', 'minimal template with no pages')
.option('--spa', 'single-page app with client-side rendering')
.action(newApp) .action(newApp)
program program

View File

@ -1,69 +1,59 @@
import { readdirSync, readFileSync, statSync } from 'fs'
import { join, relative } from 'path'
import { DEFAULT_EMOJI } from './types' import { DEFAULT_EMOJI } from './types'
export interface AppTemplates { export type TemplateType = 'ssr' | 'bare' | 'spa'
'index.tsx': string
'package.json': string export type AppTemplates = Record<string, string>
'tsconfig.json': string
'.npmrc': string interface TemplateVars {
'src/pages/index.tsx': string APP_NAME: string
APP_EMOJI: string
} }
const tsconfig = { const TEMPLATES_DIR = join(import.meta.dirname, '../../templates')
compilerOptions: {
lib: ['ESNext'], function readDir(dir: string): string[] {
target: 'ESNext', const files: string[] = []
module: 'Preserve', for (const entry of readdirSync(dir)) {
moduleDetection: 'force', const path = join(dir, entry)
jsx: 'react-jsx', if (statSync(path).isDirectory()) {
jsxImportSource: 'hono/jsx', files.push(...readDir(path))
allowJs: true, } else {
moduleResolution: 'bundler', files.push(path)
allowImportingTsExtensions: true, }
verbatimModuleSyntax: true, }
noEmit: true, return files
strict: true,
skipLibCheck: true,
noFallthroughCasesInSwitch: true,
noUncheckedIndexedAccess: true,
noImplicitOverride: true,
noUnusedLocals: false,
noUnusedParameters: false,
noPropertyAccessFromIndexSignature: false,
},
} }
export function generateTemplates(appName: string): AppTemplates { function replaceVars(content: string, vars: TemplateVars): string {
const packageJson = { return content
name: appName, .replace(/\$\$APP_NAME\$\$/g, vars.APP_NAME)
module: 'index.tsx', .replace(/\$\$APP_EMOJI\$\$/g, vars.APP_EMOJI)
type: 'module', }
private: true,
scripts: { export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates {
toes: 'bun run --watch index.tsx', const vars: TemplateVars = {
start: 'bun toes', APP_NAME: appName,
dev: 'bun run --hot index.tsx', APP_EMOJI: DEFAULT_EMOJI,
},
toes: {
icon: DEFAULT_EMOJI,
},
devDependencies: {
'@types/bun': 'latest',
},
peerDependencies: {
typescript: '^5.9.2',
},
dependencies: {
'@because/hype': '*',
'@because/forge': '*',
'@because/howl': '*',
},
} }
return { const result: AppTemplates = {}
'.npmrc': 'registry=https://npm.nose.space',
'package.json': JSON.stringify(packageJson, null, 2) + '\n', // Read shared files from templates/
'src/pages/index.tsx': `export default () => <h1>${appName}</h1>`, for (const filename of ['.npmrc', 'package.json', 'tsconfig.json']) {
'index.tsx': `import { Hype } from '@because/hype'\nconst app = new Hype\nexport default app.defaults`, const path = join(TEMPLATES_DIR, filename)
'tsconfig.json': JSON.stringify(tsconfig, null, 2) + '\n', const content = readFileSync(path, 'utf-8')
result[filename] = replaceVars(content, vars)
} }
// Read template-specific files
const templateDir = join(TEMPLATES_DIR, template)
for (const path of readDir(templateDir)) {
const filename = relative(templateDir, path)
const content = readFileSync(path, 'utf-8')
result[filename] = replaceVars(content, vars)
}
return result
} }

1
templates/.npmrc Normal file
View File

@ -0,0 +1 @@
registry=https://npm.nose.space

7
templates/bare/index.tsx Normal file
View File

@ -0,0 +1,7 @@
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.text('$$APP_NAME$$'))
export default app.defaults

25
templates/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "$$APP_NAME$$",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx",
"start": "bun toes",
"dev": "bun run --hot index.tsx"
},
"toes": {
"icon": "$$APP_EMOJI$$"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.2"
},
"dependencies": {
"@because/hype": "*",
"@because/forge": "*",
"@because/howl": "*"
}
}

1
templates/spa/index.tsx Normal file
View File

@ -0,0 +1 @@
export { default } from './src/server'

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1,36 @@
import { render, useState } from 'hono/jsx/dom'
import { define } from '@because/forge'
const Wrapper = define({
margin: '0 auto',
marginTop: 50,
width: '50vw',
border: '1px solid black',
padding: 24,
textAlign: 'center'
})
export default function App() {
const [count, setCount] = useState(0)
try {
return (
<Wrapper>
<h1>It works!</h1>
<h2>Count: {count}</h2>
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
&nbsp;
<button onClick={() => setCount(c => c && c - 1)}>-</button>
</div>
</Wrapper>
)
} catch (error) {
console.error('Render error:', error)
return <><h1>Error</h1><pre>{error instanceof Error ? error : new Error(String(error))}</pre></>
}
}
const root = document.getElementById('root')!
render(<App />, root)

View File

@ -0,0 +1,40 @@
section {
max-width: 500px;
margin: 0 auto;
text-align: center;
font-size: 200%;
}
h1 {
margin-top: 0;
}
.hype {
display: inline-block;
padding: 0.3rem 0.8rem;
background: linear-gradient(45deg,
#ff00ff 0%,
#00ffff 33%,
#ffff00 66%,
#ff00ff 100%);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
color: black;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 700;
border-radius: 4px;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
ul {
list-style-type: none;
}

View File

@ -0,0 +1,31 @@
import { $ } from 'bun'
const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7)
|| await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown')
export default () => <>
<html lang="en">
<head>
<title>hype</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
<script dangerouslySetInnerHTML={{
__html: `
window.GIT_HASH = '${GIT_HASH}';
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
`
}} />
</head>
<body>
<div id="viewport">
<main>
<div id="root" />
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
</main>
</div>
</body>
</html>
</>

View File

@ -0,0 +1,8 @@
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
// custom routes go here
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
export default app.defaults

View File

1
templates/ssr/index.tsx Normal file
View File

@ -0,0 +1 @@
export { default } from './src/server'

View File

@ -0,0 +1 @@
export default () => <h1>$$APP_NAME$$</h1>

View File

@ -0,0 +1,5 @@
import { Hype } from '@because/hype'
const app = new Hype()
export default app.defaults

29
templates/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}

View File

@ -1,4 +1,5 @@
{ {
"exclude": ["templates"],
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": [ "lib": [