├── jsx ├── jsx-dev-runtime.ts └── jsx-runtime.ts ├── src ├── components │ ├── Footer │ │ ├── footer.module.css │ │ └── index.tsx │ ├── Header │ │ ├── header.module.css │ │ └── index.tsx │ ├── App │ │ ├── app.module.css │ │ └── index.tsx │ ├── StateViewer │ │ └── index.tsx │ └── Router │ │ └── index.tsx ├── lib │ ├── state.ts │ ├── main.tsx │ ├── utils.module.css │ ├── router.ts │ └── global.css └── pages │ ├── About │ └── index.tsx │ ├── NotFound │ └── index.tsx │ └── Home │ └── index.tsx ├── public ├── logo.png └── favicon.png ├── .gitignore ├── env.d.ts ├── index.html ├── package.json ├── tsconfig.json └── README.md /jsx/jsx-dev-runtime.ts: -------------------------------------------------------------------------------- 1 | export { jsx as jsxDEV } from './jsx-runtime' 2 | -------------------------------------------------------------------------------- /src/components/Footer/footer.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | padding: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Header/header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | padding: 1rem; 3 | } 4 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loteoo/hyperapp-starter/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loteoo/hyperapp-starter/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/components/App/app.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: stretch; 6 | & main { 7 | flex: 1; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/state.ts: -------------------------------------------------------------------------------- 1 | import { getLocation, Location } from "/src/lib/router"; 2 | 3 | export interface State { 4 | location: Location; 5 | count: number; 6 | } 7 | 8 | export const init: State = { 9 | location: getLocation(), 10 | count: 0, 11 | }; 12 | -------------------------------------------------------------------------------- /src/pages/About/index.tsx: -------------------------------------------------------------------------------- 1 | import utils from '/src/lib/utils.module.css'; 2 | 3 | const AboutPage = () => { 4 | return ( 5 |
6 |

About

7 |

Hey there!

8 |
9 | ); 10 | }; 11 | 12 | export default AboutPage; 13 | -------------------------------------------------------------------------------- /src/pages/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import utils from '/src/lib/utils.module.css'; 2 | 3 | const NotFound = () => ( 4 |
5 |

404.

6 |

Page not found.

7 | Go back to home page 8 |
9 | ); 10 | 11 | export default NotFound; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { VNode, ElementVNode, Props } from "hyperapp"; 4 | import { State } from '/src/lib/state' 5 | 6 | declare global { 7 | namespace JSX { 8 | type S = State; 9 | type Element = ElementVNode; 10 | interface IntrinsicElements { 11 | [elemName: string]: Props; 12 | } 13 | } 14 | } 15 | 16 | export { }; 17 | -------------------------------------------------------------------------------- /src/components/StateViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import { State } from '/src/lib/state'; 2 | 3 | const StateViewer = ({ state }: { state: State }) => { 4 | return ( 5 |
6 | Show app state 7 |
 8 |         {`state: ${JSON.stringify(state, null, 2)}`}
 9 |       
10 |
11 | ); 12 | }; 13 | 14 | export default StateViewer; 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hyperapp Starter 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperapp-starter", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev --open --host", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "hyperapp": "^2.0.22" 13 | }, 14 | "devDependencies": { 15 | "typescript": "^5.2.2", 16 | "typescript-plugin-css-modules": "^5.1.0", 17 | "vite": "^5.3.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /jsx/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | import { MaybeVNode, h, text } from "hyperapp" 2 | 3 | const childNode = (child: MaybeVNode) => ["string", "number"].includes(typeof child) ? text(child) : child; 4 | 5 | export const jsx = (tag: any, { children, ...props }: any, key: any) => 6 | typeof tag === "function" 7 | ? tag({ ...props, key }, children) 8 | : h( 9 | tag, 10 | { ...props, key }, 11 | [].concat(children).map(childNode) 12 | ) 13 | 14 | export const jsxs = jsx -------------------------------------------------------------------------------- /src/lib/main.tsx: -------------------------------------------------------------------------------- 1 | import { app } from 'hyperapp'; 2 | 3 | import App from '/src/components/App'; 4 | 5 | import { init, State } from '/src/lib/state'; 6 | import { TrackLinkClicks, onPushState } from '/src/lib/router'; 7 | 8 | import './global.css'; 9 | 10 | const view = (state: State) => ( 11 |
12 | {App(state)} 13 |
14 | ); 15 | 16 | app({ init, view, node: document.getElementById('app')!, subscriptions: () => [onPushState] }); 17 | -------------------------------------------------------------------------------- /src/lib/utils.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | max-width: min(32rem, 90vw); 4 | margin: 0 auto; 5 | } 6 | 7 | .group { 8 | display: flex; 9 | align-items: center; 10 | gap: 1rem; 11 | } 12 | 13 | .stack { 14 | display: flex; 15 | flex-direction: column; 16 | gap: 1rem; 17 | } 18 | 19 | /* Responsive Card grid */ 20 | .grid { 21 | display: grid; 22 | grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); 23 | grid-gap: 1rem; 24 | margin: 1rem 0; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Router/index.tsx: -------------------------------------------------------------------------------- 1 | import { State } from '/src/lib/state'; 2 | import Home from '/src/pages/Home'; 3 | import About from '/src/pages/About'; 4 | import NotFound from '/src/pages/NotFound'; 5 | 6 | const Router = (state: State) => { 7 | if (state.location.path === '/') { 8 | return ; 9 | } 10 | if (state.location.path === '/about') { 11 | return ; 12 | } 13 | return ; 14 | }; 15 | 16 | export default Router; 17 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './header.module.css'; 2 | import utils from '/src/lib/utils.module.css'; 3 | 4 | const Header = () => { 5 | return ( 6 |
7 | 14 |
15 | ); 16 | }; 17 | 18 | export default Header; 19 | -------------------------------------------------------------------------------- /src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { State } from '/src/lib/state'; 2 | 3 | import Header from '/src/components/Header'; 4 | import Footer from '/src/components/Footer'; 5 | import Router from '/src/components/Router'; 6 | 7 | import styles from './app.module.css'; 8 | 9 | const App = (state: State) => { 10 | return ( 11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 | ); 19 | }; 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /src/components/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './footer.module.css'; 2 | import utils from '/src/lib/utils.module.css'; 3 | 4 | const Footer = () => { 5 | return ( 6 | 19 | ); 20 | }; 21 | 22 | export default Footer; 23 | -------------------------------------------------------------------------------- /src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import StateViewer from '/src/components/StateViewer'; 2 | import { State } from '/src/lib/state'; 3 | import utils from '/src/lib/utils.module.css'; 4 | 5 | const Increment = (state: State) => ({ 6 | ...state, 7 | count: state.count + 1, 8 | }); 9 | 10 | const HomePage = (state: State) => { 11 | return ( 12 |
13 |

👋 Welcome to hyperapp

14 |
15 |

Current count: {state.count}

16 | 17 |
18 | 19 | Query params test 20 |
21 | ); 22 | }; 23 | 24 | export default HomePage; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | "jsxImportSource": "/jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | 25 | "baseUrl": ".", 26 | "paths": { 27 | "/*": ["./*"] 28 | }, 29 | "plugins": [{ "name": "typescript-plugin-css-modules" }], 30 | }, 31 | "include": ["src", "env.d.ts"] 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Hyperapp starter logo 4 | 5 |

6 |

7 | Hyperapp starter 8 |

9 | 10 | Starter template to get started quickly with Hyperapp + TypeScript + Vite. 11 | 12 | ## 🚀 Quick start: 13 | 14 | [Click here to use this template](https://github.com/loteoo/hyperapp-starter/generate), or run these commands: 15 | 16 | ``` 17 | # Clone project 18 | git clone https://github.com/loteoo/hyperapp-starter.git 19 | 20 | cd hyperapp-starter 21 | 22 | npm i # Install dependencies 23 | npm run dev # Dev server 24 | ``` 25 | 26 | ``` 27 | npm run build # Build for production 28 | npm run preview # Preview production build 29 | ``` 30 | 31 | Or use a [.zip download](https://github.com/loteoo/hyperapp-starter/archive/main.zip) 32 | 33 | ## Features 34 | 35 | - [Vite](https://vitejs.dev/) for dev tooling 36 | - JSX ready to go 37 | - Strict TypeScript 38 | - Typed CSS modules (enable workspace TS version) 39 | - Pages + SPA router & navigation 40 | 41 | ## Live demo 42 | 43 | See live demo here: https://hyperapp-starter.pages.dev/ 44 | 45 | --- 46 | 47 | Basic CRUD actions with hyperapp 2.0 48 | https://github.com/loteoo/hyperapp-todolist 49 | 50 | 53 | 54 | --- 55 | 56 | Give the repo a star if you like this! 57 | -------------------------------------------------------------------------------- /src/lib/router.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from 'hyperapp'; 2 | import { State } from './state'; 3 | 4 | export type InternalPath = `/${string}`; 5 | 6 | export interface Location { 7 | path: InternalPath; 8 | query: Record; 9 | hash: string; 10 | } 11 | 12 | // Navigation util 13 | export const navigate = (path: InternalPath) => { 14 | history.pushState(null, '', path); 15 | setTimeout(() => { 16 | dispatchEvent(new CustomEvent('pushstate')); 17 | }); 18 | }; 19 | 20 | // Get current location 21 | export const getLocation = (): Location => { 22 | const { pathname, search, hash } = window.location; 23 | const query: Record = {}; 24 | for (const [key, value] of new URLSearchParams(search)) { 25 | query[key] = value; 26 | } 27 | return { 28 | path: pathname as InternalPath, 29 | query, 30 | hash, 31 | }; 32 | }; 33 | 34 | // Link click Action 35 | export const TrackLinkClicks = (state: State, ev: MouseEvent) => { 36 | let clicked: HTMLElement | null = ev.target as HTMLElement; 37 | 38 | // Crawl up dom tree, look if click landed inside a tag 39 | const anchor = clicked.closest('a'); 40 | 41 | if (!anchor) { 42 | return state; 43 | } 44 | const href = anchor.getAttribute('href'); 45 | 46 | if (!href?.startsWith('/')) { 47 | return state; 48 | } 49 | 50 | ev.preventDefault(); 51 | ev.stopPropagation(); 52 | navigate(href as InternalPath); 53 | 54 | return state; 55 | }; 56 | 57 | // Route change Subscription 58 | export const onPushState: Subscription = [ 59 | (dispatch) => { 60 | const handleLocationChange = () => { 61 | dispatch((state) => ({ ...state, location: getLocation() })); 62 | }; 63 | addEventListener('pushstate', handleLocationChange); 64 | addEventListener('popstate', handleLocationChange); 65 | return () => { 66 | removeEventListener('pushstate', handleLocationChange); 67 | removeEventListener('popstate', handleLocationChange); 68 | }; 69 | }, 70 | null, 71 | ]; 72 | -------------------------------------------------------------------------------- /src/lib/global.css: -------------------------------------------------------------------------------- 1 | /* === Global styles === */ 2 | 3 | :root { 4 | --font-sans: -apple-system, 'Segoe UI', Roboto, sans-serif; 5 | --font-mono: ui-monospace, Menlo, Consolas, 'Roboto Mono', monospace; 6 | --font-serif: serif; 7 | --text-color: CanvasText; 8 | --background-color: Canvas; 9 | --border-color: light-dark(#ccc, #333); 10 | --alt-background-color: light-dark(ButtonFace, #282828); 11 | --theme-color: #2965f7; 12 | } 13 | 14 | *, 15 | *::before, 16 | *::after { 17 | box-sizing: border-box; 18 | } 19 | 20 | html { 21 | color-scheme: light dark; /* use system */ 22 | /* color-scheme: light; light mode */ 23 | /* color-scheme: dark; dark mode */ 24 | color: var(--text-color); 25 | background-color: var(--background-color); 26 | font-family: var(--font-sans); 27 | accent-color: var(--theme-color); 28 | font-size: clamp(14px, 1.5vw, 16px); 29 | line-height: 1.375; 30 | font-synthesis: none; 31 | text-rendering: optimizeLegibility; 32 | -webkit-font-smoothing: antialiased; 33 | -moz-osx-font-smoothing: grayscale; 34 | } 35 | 36 | ::selection { 37 | background: var(--theme-color); 38 | color: var(--background-color); 39 | } 40 | 41 | :focus-visible { 42 | outline-color: var(--theme-color); 43 | } 44 | ::marker { 45 | color: currentColor; 46 | } 47 | 48 | [id] { 49 | scroll-margin-top: 2ex; 50 | } 51 | 52 | @media (prefers-reduced-motion: reduce) { 53 | *, 54 | *::before, 55 | *::after { 56 | animation-duration: 0.01ms !important; 57 | animation-iteration-count: 1 !important; 58 | transition-duration: 0.01ms !important; 59 | scroll-behavior: auto !important; 60 | } 61 | } 62 | 63 | /* === Root layout === */ 64 | 65 | html { 66 | scroll-behavior: smooth; 67 | display: table; 68 | width: 100%; 69 | height: 100%; 70 | } 71 | 72 | body { 73 | display: table-cell; 74 | } 75 | 76 | #app { 77 | height: 100%; 78 | display: flex; 79 | flex-direction: column; 80 | align-items: stretch; 81 | } 82 | 83 | /* === Typography === */ 84 | 85 | a { 86 | color: var(--theme-color); 87 | text-decoration: none; 88 | &:hover { 89 | text-decoration: underline; 90 | } 91 | } 92 | 93 | code, 94 | kbd, 95 | samp, 96 | pre { 97 | font-family: var(--font-mono); 98 | } 99 | 100 | code { 101 | background-color: var(--alt-background-color); 102 | font-size: 85%; 103 | border-radius: 0.25em; 104 | padding: 0 0.125em; 105 | } 106 | 107 | /* === Forms === */ 108 | 109 | input, 110 | button, 111 | textarea, 112 | select { 113 | background-color: transparent; 114 | border: 1px solid var(--border-color); 115 | color: inherit; 116 | font: inherit; 117 | letter-spacing: inherit; 118 | padding: 0.25em 0.375em; 119 | border-radius: 0.25em; 120 | } 121 | 122 | button { 123 | cursor: pointer; 124 | &:disabled { 125 | cursor: default; 126 | } 127 | } 128 | 129 | textarea { 130 | resize: vertical; 131 | } 132 | 133 | :is(input, textarea)::placeholder { 134 | color: inherit; 135 | opacity: 0.5; 136 | } 137 | 138 | select { 139 | -webkit-appearance: none; 140 | appearance: none; 141 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='4'%3E%3Cpath d='M4 0h6L7 4'/%3E%3C/svg%3E") 142 | no-repeat right center / 1em; 143 | padding-right: 1em; 144 | &[multiple] { 145 | background-image: none; 146 | } 147 | } 148 | 149 | /* === Misc elements === */ 150 | 151 | img, 152 | picture, 153 | video, 154 | canvas, 155 | svg { 156 | display: block; 157 | max-width: 100%; 158 | } 159 | 160 | hr { 161 | margin: 1rem 0; 162 | border: none; 163 | height: 1px; 164 | background-color: var(--border-color); 165 | } 166 | 167 | pre code { 168 | display: block; 169 | padding: 1em; 170 | } 171 | 172 | details { 173 | margin: 1rem 0; 174 | border: 1px solid var(--border-color); 175 | border-radius: 0.25rem; 176 | padding: 0.5rem 1rem; 177 | & summary { 178 | cursor: pointer; 179 | font-weight: bold; 180 | margin: -0.5rem -1rem; 181 | padding: 0.5rem 1rem; 182 | &:focus { 183 | outline: none; 184 | } 185 | } 186 | } 187 | 188 | blockquote { 189 | margin: 1em 0; 190 | padding: 0.5em 2em; 191 | border-left: 0.25rem solid var(--text-color); 192 | & > *:first-child { 193 | margin-top: 0; 194 | } 195 | & > *:last-child { 196 | margin-bottom: 0; 197 | } 198 | } 199 | 200 | table { 201 | border-collapse: collapse; 202 | & caption { 203 | padding: 0.375rem 0.75rem; 204 | } 205 | & th, 206 | & td { 207 | padding: 0.375rem 0.75rem; 208 | border: 1px solid var(--border-color); 209 | } 210 | & th { 211 | font-weight: 600; 212 | } 213 | } 214 | --------------------------------------------------------------------------------