diff --git a/TODO.txt b/TODO.txt index 3054831..1db6521 100644 --- a/TODO.txt +++ b/TODO.txt @@ -37,8 +37,9 @@ [x] `toes pull` [x] `toes push` [x] `toes sync` -[ ] `toes new --spa` -[ ] `toes new --ssr` +[x] `toes new --spa` +[x] `toes new --ssr` +[x] `toes new --bare` [ ] needs to either check toes.local or take something like TOES_URL ## webui diff --git a/src/cli/commands/manage.ts b/src/cli/commands/manage.ts index a536bbb..e9c59c1 100644 --- a/src/cli/commands/manage.ts +++ b/src/cli/commands/manage.ts @@ -1,5 +1,5 @@ import type { App } from '@types' -import { generateTemplates } from '@templates' +import { generateTemplates, type TemplateType } from '@templates' import color from 'kleur' import { existsSync, mkdirSync, writeFileSync } from 'fs' 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 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)) { console.error(`Directory already exists: ${name}`) return @@ -71,12 +82,18 @@ export async function newApp(name?: string) { 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 - 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)) { writeFileSync(join(appPath, filename), content) } diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 3317eb4..500be21 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,4 +1,6 @@ import { program } from 'commander' +import { readFileSync } from 'fs' + import color from 'kleur' import { getApp, @@ -19,7 +21,7 @@ import { program .name('toes') - .version('0.0.1', '-v, --version') + .version('v0.0.3', '-v, --version') .addHelpText('beforeAll', (ctx) => { if (ctx.command === program) { return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n') @@ -99,6 +101,9 @@ program .command('new') .description('Create a new toes app') .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) program diff --git a/src/shared/templates.ts b/src/shared/templates.ts index 1e2a143..bcc0170 100644 --- a/src/shared/templates.ts +++ b/src/shared/templates.ts @@ -1,69 +1,59 @@ +import { readdirSync, readFileSync, statSync } from 'fs' +import { join, relative } from 'path' import { DEFAULT_EMOJI } from './types' -export interface AppTemplates { - 'index.tsx': string - 'package.json': string - 'tsconfig.json': string - '.npmrc': string - 'src/pages/index.tsx': string +export type TemplateType = 'ssr' | 'bare' | 'spa' + +export type AppTemplates = Record + +interface TemplateVars { + APP_NAME: string + APP_EMOJI: string } -const tsconfig = { - 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, - }, +const TEMPLATES_DIR = join(import.meta.dirname, '../../templates') + +function readDir(dir: string): string[] { + const files: string[] = [] + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) { + files.push(...readDir(path)) + } else { + files.push(path) + } + } + return files } -export function generateTemplates(appName: string): AppTemplates { - const packageJson = { - name: appName, - 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: DEFAULT_EMOJI, - }, - devDependencies: { - '@types/bun': 'latest', - }, - peerDependencies: { - typescript: '^5.9.2', - }, - dependencies: { - '@because/hype': '*', - '@because/forge': '*', - '@because/howl': '*', - }, +function replaceVars(content: string, vars: TemplateVars): string { + return content + .replace(/\$\$APP_NAME\$\$/g, vars.APP_NAME) + .replace(/\$\$APP_EMOJI\$\$/g, vars.APP_EMOJI) +} + +export function generateTemplates(appName: string, template: TemplateType = 'ssr'): AppTemplates { + const vars: TemplateVars = { + APP_NAME: appName, + APP_EMOJI: DEFAULT_EMOJI, } - return { - '.npmrc': 'registry=https://npm.nose.space', - 'package.json': JSON.stringify(packageJson, null, 2) + '\n', - 'src/pages/index.tsx': `export default () =>

${appName}

`, - 'index.tsx': `import { Hype } from '@because/hype'\nconst app = new Hype\nexport default app.defaults`, - 'tsconfig.json': JSON.stringify(tsconfig, null, 2) + '\n', + const result: AppTemplates = {} + + // Read shared files from templates/ + for (const filename of ['.npmrc', 'package.json', 'tsconfig.json']) { + const path = join(TEMPLATES_DIR, filename) + 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 } diff --git a/templates/.npmrc b/templates/.npmrc new file mode 100644 index 0000000..6c57d5c --- /dev/null +++ b/templates/.npmrc @@ -0,0 +1 @@ +registry=https://npm.nose.space diff --git a/templates/bare/index.tsx b/templates/bare/index.tsx new file mode 100644 index 0000000..724f3a9 --- /dev/null +++ b/templates/bare/index.tsx @@ -0,0 +1,7 @@ +import { Hype } from '@because/hype' + +const app = new Hype() + +app.get('/', c => c.text('$$APP_NAME$$')) + +export default app.defaults diff --git a/templates/package.json b/templates/package.json new file mode 100644 index 0000000..2da212f --- /dev/null +++ b/templates/package.json @@ -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": "*" + } +} diff --git a/templates/spa/index.tsx b/templates/spa/index.tsx new file mode 100644 index 0000000..9b61e99 --- /dev/null +++ b/templates/spa/index.tsx @@ -0,0 +1 @@ +export { default } from './src/server' diff --git a/templates/spa/pub/img/bite1.png b/templates/spa/pub/img/bite1.png new file mode 100644 index 0000000..bdc5d37 Binary files /dev/null and b/templates/spa/pub/img/bite1.png differ diff --git a/templates/spa/pub/img/bite2.png b/templates/spa/pub/img/bite2.png new file mode 100644 index 0000000..f0be640 Binary files /dev/null and b/templates/spa/pub/img/bite2.png differ diff --git a/templates/spa/pub/img/burger.png b/templates/spa/pub/img/burger.png new file mode 100644 index 0000000..292aef3 Binary files /dev/null and b/templates/spa/pub/img/burger.png differ diff --git a/templates/spa/src/client/App.tsx b/templates/spa/src/client/App.tsx new file mode 100644 index 0000000..4cb5ef6 --- /dev/null +++ b/templates/spa/src/client/App.tsx @@ -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 ( + +

It works!

+

Count: {count}

+
+ +   + +
+
+ ) + } catch (error) { + console.error('Render error:', error) + return <>

Error

{error instanceof Error ? error : new Error(String(error))}
+ } +} + +const root = document.getElementById('root')! +render(, root) diff --git a/templates/spa/src/css/main.css b/templates/spa/src/css/main.css new file mode 100644 index 0000000..62bcd77 --- /dev/null +++ b/templates/spa/src/css/main.css @@ -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; +} \ No newline at end of file diff --git a/templates/spa/src/pages/index.tsx b/templates/spa/src/pages/index.tsx new file mode 100644 index 0000000..126eeb5 --- /dev/null +++ b/templates/spa/src/pages/index.tsx @@ -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 () => <> + + + hype + + + + + +