├── 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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------