#!/usr/bin/env bun /** * create-stx * * Scaffold a new STX application. * * Usage: * bun create stx my-app * bunx create-stx my-app * npx create-stx my-app */ import fs from 'node:fs' import path from 'node:path' import { spawn } from 'node:child_process' // Colors for terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', red: '\x1b[31m', } function log(message: string) { console.log(message) } function success(message: string) { console.log(`${colors.green}✓${colors.reset} ${message}`) } function error(message: string) { console.error(`${colors.red}✗${colors.reset} ${message}`) } function info(message: string) { console.log(`${colors.blue}ℹ${colors.reset} ${message}`) } // Template files const templates = { 'pages/index.stx': ` {{ title }}

{{ title }}

{{ description }}

0
`, 'components/Button.stx': ` `, 'public/.gitkeep': '', 'public/js/.gitkeep': '', 'lib/store.ts': `/** * Lightweight reactive store implementation * Based on STX state-management patterns */ export type Subscriber = (value: T, previousValue: T | undefined) => void export type Unsubscribe = () => void export interface StoreOptions { name?: string persist?: { key?: string storage?: 'local' | 'session' debounce?: number } onChange?: (value: T, prev: T | undefined) => void } export interface Store { get: () => T set: (value: T | ((prev: T) => T)) => void update: (partial: Partial | ((prev: T) => Partial)) => void subscribe: (subscriber: Subscriber) => Unsubscribe reset: () => void name?: string } export function createStore( initialValue: T, options: StoreOptions = {} ): Store { const { name, persist, onChange } = options let currentValue = { ...initialValue } const subscribers = new Set>() let persistTimeout: ReturnType | null = null // Load persisted value if (persist && typeof window !== 'undefined') { const storage = persist.storage === 'session' ? sessionStorage : localStorage const key = persist.key || \`app:\${name || 'store'}\` try { const stored = storage.getItem(key) if (stored) { currentValue = { ...initialValue, ...JSON.parse(stored) } } } catch { /* Invalid stored value */ } } const persistValue = (value: T) => { if (!persist || typeof window === 'undefined') return const storage = persist.storage === 'session' ? sessionStorage : localStorage const key = persist.key || \`app:\${name || 'store'}\` if (persist.debounce) { if (persistTimeout) clearTimeout(persistTimeout) persistTimeout = setTimeout(() => storage.setItem(key, JSON.stringify(value)), persist.debounce) } else { storage.setItem(key, JSON.stringify(value)) } } const notify = (newValue: T, prevValue: T | undefined) => { for (const subscriber of subscribers) { try { subscriber(newValue, prevValue) } catch (e) { console.error('[store]', e) } } if (onChange) onChange(newValue, prevValue) } return { get: () => currentValue, set: (value) => { const prevValue = currentValue const newValue = typeof value === 'function' ? (value as (prev: T) => T)(currentValue) : value if (newValue !== prevValue) { currentValue = newValue persistValue(newValue) notify(newValue, prevValue) } }, update: (partial) => { const prevValue = currentValue const updates = typeof partial === 'function' ? (partial as (prev: T) => Partial)(currentValue) : partial currentValue = { ...currentValue, ...updates } persistValue(currentValue) notify(currentValue, prevValue) }, subscribe: (subscriber) => { subscribers.add(subscriber) subscriber(currentValue, undefined) return () => subscribers.delete(subscriber) }, reset: () => { const prevValue = currentValue currentValue = { ...initialValue } persistValue(currentValue) notify(currentValue, prevValue) }, name } } `, 'lib/composables/index.ts': `/** * Composables - Browser API utilities (Nuxt-style) */ export * from './use-storage' export * from './use-cookie' `, 'lib/composables/use-storage.ts': `/** * useStorage - Reactive localStorage/sessionStorage wrapper */ export interface StorageOptions { defaultValue?: T serializer?: { read: (raw: string) => T write: (value: T) => string } } export function useStorage( key: string, storage: Storage = localStorage, options: StorageOptions = {} ) { const { defaultValue, serializer = { read: JSON.parse, write: JSON.stringify } } = options let listeners: Array<(value: T | undefined) => void> = [] const get = (): T | undefined => { try { const raw = storage.getItem(key) return raw !== null ? serializer.read(raw) : defaultValue } catch { return defaultValue } } const set = (value: T): void => { try { storage.setItem(key, serializer.write(value)) listeners.forEach(fn => fn(value)) } catch (e) { console.error('[useStorage] Failed to set:', e) } } const remove = (): void => { storage.removeItem(key) listeners.forEach(fn => fn(undefined)) } const subscribe = (fn: (value: T | undefined) => void) => { listeners.push(fn) fn(get()) return () => { listeners = listeners.filter(l => l !== fn) } } return { get, set, remove, subscribe } } export const useLocalStorage = (key: string, options?: StorageOptions) => useStorage(key, localStorage, options) export const useSessionStorage = (key: string, options?: StorageOptions) => useStorage(key, sessionStorage, options) `, 'lib/composables/use-cookie.ts': `/** * useCookie - Cookie management utility */ export interface CookieOptions { expires?: number | Date path?: string domain?: string secure?: boolean sameSite?: 'Strict' | 'Lax' | 'None' } export function getCookie(name: string): string | undefined { const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')) return match ? decodeURIComponent(match[2]) : undefined } export function setCookie(name: string, value: string, options: CookieOptions = {}): void { let cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) if (options.expires) { const date = options.expires instanceof Date ? options.expires : new Date(Date.now() + options.expires * 864e5) cookie += '; expires=' + date.toUTCString() } if (options.path) cookie += '; path=' + options.path if (options.domain) cookie += '; domain=' + options.domain if (options.secure) cookie += '; secure' if (options.sameSite) cookie += '; samesite=' + options.sameSite document.cookie = cookie } export function removeCookie(name: string, options: CookieOptions = {}): void { setCookie(name, '', { ...options, expires: -1 }) } export function useCookie(name: string, options: CookieOptions = {}) { const serialize = (val: T): string => typeof val === 'string' ? val : JSON.stringify(val) const deserialize = (raw: string): T => { try { return JSON.parse(raw) as T } catch { return raw as T } } return { get: (): T | undefined => { const raw = getCookie(name) return raw !== undefined ? deserialize(raw) : undefined }, set: (value: T) => setCookie(name, serialize(value), options), remove: () => removeCookie(name, options) } } `, 'lib/stores/index.ts': `/** * Stores - Central export */ export * from './app' export { createStore } from '../store' export * from '../composables' `, 'lib/stores/app.ts': `/** * App Store - Example application state */ import { createStore } from '../store' export interface AppState { count: number theme: 'light' | 'dark' } export const appStore = createStore({ count: 0, theme: 'light' }, { name: 'app', persist: { key: 'app:state', storage: 'local' } }) export const appActions = { increment: () => appStore.update(s => ({ count: s.count + 1 })), decrement: () => appStore.update(s => ({ count: s.count - 1 })), setTheme: (theme: AppState['theme']) => appStore.update({ theme }) } `, 'lib/build.ts': `/** * Build script to bundle stores for browser use */ import { build } from 'bun' import { writeFileSync, readFileSync } from 'fs' const result = await build({ entrypoints: ['./lib/stores/index.ts'], outdir: './public/js', target: 'browser', format: 'iife', naming: 'stores.js', minify: true, define: { 'process.env.NODE_ENV': '"production"' } }) if (!result.success) { console.error('Build failed:', result.logs) process.exit(1) } // Wrap to export globally const bundlePath = './public/js/stores.js' let bundle = readFileSync(bundlePath, 'utf-8') bundle = bundle.replace(/^\\(\\(\\) => \\{/, 'window.AppStores = (function() {') bundle = bundle.replace(/\\}\\)\\(\\);?\\s*$/, ' return exports_stores;\\n})();') writeFileSync(bundlePath, \`/** * App Stores - Browser Bundle * Auto-generated, do not edit */ \${bundle} \`) console.log('Stores built to public/js/stores.js') `, 'stx.config.ts': `import type { StxConfig } from '@stacksjs/stx' const config: StxConfig = { // Pages directory for file-based routing pagesDir: 'pages', // Components directory componentsDir: 'components', // Public assets directory publicDir: 'public', // Enable caching in production cache: process.env.NODE_ENV === 'production', // Debug mode in development debug: process.env.NODE_ENV !== 'production', } export default config `, 'package.json': (name: string) => JSON.stringify({ name, version: '0.0.1', type: 'module', scripts: { dev: 'bun run build:stores && bun run --bun stx dev', build: 'bun run build:stores && bun run --bun stx build', 'build:stores': 'bun run lib/build.ts', preview: 'bun run --bun stx preview', }, dependencies: {}, devDependencies: { '@stacksjs/stx': 'latest', 'bun-plugin-stx': 'latest', typescript: '^5.0.0', }, }, null, 2), 'bunfig.toml': `preload = ["bun-plugin-stx"] `, 'tsconfig.json': JSON.stringify({ compilerOptions: { target: 'ESNext', module: 'ESNext', moduleResolution: 'bundler', strict: true, esModuleInterop: true, skipLibCheck: true, resolveJsonModule: true, declaration: true, outDir: 'dist', rootDir: '.', }, include: ['**/*.ts', '**/*.stx'], exclude: ['node_modules', 'dist'], }, null, 2), '.gitignore': `# Dependencies node_modules/ # Build output dist/ .stx/ # Environment .env .env.local .env.*.local # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Logs *.log npm-debug.log* `, 'README.md': (name: string) => `# ${name} A modern web application built with [STX](https://stx.sh). ## Getting Started \`\`\`bash # Start development server bun dev # Build for production bun run build # Preview production build bun run preview \`\`\` ## Project Structure \`\`\` ${name}/ pages/ # File-based routing index.stx # Homepage (/) components/ # Reusable components public/ # Static assets stx.config.ts # STX configuration \`\`\` ## Learn More - [STX Documentation](https://stx.sh) - [GitHub Repository](https://github.com/stacksjs/stx) `, } async function createProject(projectName: string, targetDir: string) { log('') log(`${colors.bright}${colors.blue} Creating STX app in ${colors.cyan}${targetDir}${colors.reset}`) log('') // Create directories const dirs = ['pages', 'components', 'public', 'public/js', 'lib', 'lib/stores', 'lib/composables'] for (const dir of dirs) { const dirPath = path.join(targetDir, dir) fs.mkdirSync(dirPath, { recursive: true }) } success('Created project structure') // Write template files for (const [filePath, content] of Object.entries(templates)) { const fullPath = path.join(targetDir, filePath) const fileContent = typeof content === 'function' ? content(projectName) : content fs.writeFileSync(fullPath, fileContent) } success('Created template files') // Install dependencies info('Installing dependencies...') return new Promise((resolve) => { const child = spawn('bun', ['install'], { cwd: targetDir, stdio: 'inherit', }) child.on('close', (code) => { if (code === 0) { success('Installed dependencies') } else { log('') info('Could not install STX packages from npm (not yet published)') info('For local development, link the packages manually:') log(` ${colors.dim}cd ${targetDir}${colors.reset}`) log(` ${colors.dim}bun link @stacksjs/stx${colors.reset}`) log(` ${colors.dim}bun link bun-plugin-stx${colors.reset}`) } resolve() }) child.on('error', () => { info('Could not run bun install') resolve() }) }) } async function main() { const args = process.argv.slice(2) // Get project name from args or prompt let projectName = args[0] if (!projectName) { // Default to 'my-stx-app' if no name provided projectName = 'my-stx-app' info(`No project name provided, using "${projectName}"`) } // Validate project name if (!/^[a-zA-Z0-9-_]+$/.test(projectName)) { error('Project name can only contain letters, numbers, dashes, and underscores') process.exit(1) } const targetDir = path.resolve(process.cwd(), projectName) // Check if directory exists if (fs.existsSync(targetDir)) { const files = fs.readdirSync(targetDir) if (files.length > 0) { error(`Directory "${projectName}" already exists and is not empty`) process.exit(1) } } else { fs.mkdirSync(targetDir, { recursive: true }) } try { await createProject(projectName, targetDir) log('') log(`${colors.green}${colors.bright} Done!${colors.reset} Created ${projectName}`) log('') log(' To get started:') log('') log(` ${colors.cyan}cd ${projectName}${colors.reset}`) log(` ${colors.cyan}bun dev${colors.reset}`) log('') } catch (err) { error(`Failed to create project: ${err}`) process.exit(1) } } main()