├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bun.lock ├── docs ├── Hyper.svg ├── h(⚡️).svg ├── thomas.jpg └── versions.md ├── package.json ├── packages ├── blink │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── assets │ │ ├── img │ │ │ ├── 1200x630.jpg │ │ │ ├── favicon-96x96.png │ │ │ └── favicon.ico │ │ ├── normalise.css │ │ └── style.css │ ├── example.config.json │ ├── package.json │ ├── src │ │ ├── config.ts │ │ ├── index.ts │ │ ├── middleware │ │ │ └── auth.ts │ │ ├── pages │ │ │ ├── auth.ts │ │ │ ├── dashboard.ts │ │ │ └── links.ts │ │ ├── setup.ts │ │ ├── store.ts │ │ ├── types.ts │ │ └── utils.ts │ └── tsconfig.json ├── h2h │ ├── LICENSE │ ├── h2h.js │ └── index.html ├── hyper │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── scripts │ │ ├── prepack.ts │ │ └── publish.sh │ ├── src │ │ ├── attributes.ts │ │ ├── browser.test.ts │ │ ├── context.internal.ts │ │ ├── context.test.ts │ │ ├── context.ts │ │ ├── domutils.ts │ │ ├── element.ts │ │ ├── elements.ts │ │ ├── guessEnv.ts │ │ ├── index.html │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── aria.ts │ │ │ ├── attributes.ts │ │ │ ├── dom.extra.ts │ │ │ ├── dom.ts │ │ │ ├── emptyElements.ts │ │ │ ├── global-attributes.ts │ │ │ └── tags.ts │ │ ├── list.test.ts │ │ ├── list.ts │ │ ├── node.test.ts │ │ ├── node.ts │ │ ├── parse.ts │ │ ├── render │ │ │ ├── dom.ts │ │ │ └── html.ts │ │ ├── router.ts │ │ ├── state.test.ts │ │ ├── state.ts │ │ └── util.ts │ └── tsconfig.json ├── mark │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── reference.hm │ ├── src │ │ ├── Context.ts │ │ ├── block.ts │ │ ├── common.ts │ │ ├── hypermark.ts │ │ ├── index.ts │ │ ├── inline.ts │ │ ├── parser.test.ts │ │ ├── types.ts │ │ ├── value.test.ts │ │ └── value.ts │ └── tsconfig.json ├── scripts │ ├── domlib.ts │ ├── fetchARIA.ts │ ├── fetchAttributes.ts │ ├── fetchGlobalAttributes.ts │ ├── fetchTags.ts │ ├── gen.ts │ ├── package.json │ └── util │ │ ├── getSpecialType.ts │ │ ├── html2md.ts │ │ └── hypertyper.ts ├── serve │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── comments.txt │ │ ├── core.ts │ │ ├── eventsource.ts │ │ ├── index.ts │ │ ├── methods.ts │ │ ├── mod.ts │ │ ├── passthrough.ts │ │ ├── serve.test.ts │ │ ├── spec.ts │ │ ├── utils.ts │ │ └── ws.ts │ └── tsconfig.json ├── todo │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ │ └── favicon │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-96x96.png │ │ │ ├── favicon.ico │ │ │ ├── site.webmanifest │ │ │ ├── web-app-manifest-192x192.png │ │ │ └── web-app-manifest-512x512.png │ ├── src │ │ ├── App.ts │ │ ├── _ │ │ │ ├── diff.tsx │ │ │ ├── hyper.tsx │ │ │ └── index.tsx │ │ └── styles.css │ ├── tsconfig.json │ └── vite.config.ts ├── url │ ├── .gitignore │ ├── README.md │ ├── blog │ │ ├── blog.md │ │ └── express.jpg │ ├── package.json │ ├── src │ │ ├── HyperURL.ts │ │ ├── index.ts │ │ ├── parse.test.ts │ │ └── parse.ts │ └── tsconfig.json └── web │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ └── assets │ │ ├── fonts │ │ ├── GeistMono[wght].woff2 │ │ ├── Geist[wght].woff2 │ │ └── fonts.css │ │ ├── prism │ │ ├── prism.css │ │ └── prism.js │ │ └── style.css │ ├── src │ ├── content │ │ └── docs.md │ ├── index.ts │ └── pages │ │ ├── home.ts │ │ └── layout.ts │ └── tsconfig.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | browser-test/test.browser.js 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "semi": true, 5 | "singleQuote": false, 6 | "quoteProps": "consistent", 7 | "jsxSingleQuote": false, 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": true, 10 | "arrowParens": "avoid", 11 | "printWidth": 100 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "deno.enable": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Feathers Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Hyperactive 3 |
4 | 5 |
6 |

hyperactive

7 |
8 | 9 | Hyperactive is a powerful set of tools to build reactive web applications. 10 | 11 | We're currently working on a 2.0 release, which will include fully reactive client-side rendering. To try the latest version, you can get `hyper`: 12 | 13 | ```bash 14 | npm install https://gethyper.dev 15 | 16 | yarn add https://gethyper.dev 17 | 18 | pnpm add https://gethyper.dev 19 | 20 | bun install https://gethyper.dev 21 | ``` 22 | 23 | Hyperactive is also available on [NPM](https://www.npmjs.com/package/@hyperactive/hyper). 24 | 25 | This is not a release version, so expect some bugs. 26 | 27 | [![Hyperactive Version 2.0.0-beta.8](https://img.shields.io/static/v1?label=Version&message=2.0.0-beta.8&style=for-the-badge&labelColor=FF6A00&color=fff)](https://npmjs.com/package/@hyperactive/hyper) 28 | 29 |
30 |

Usage

31 |
32 | 33 | ### On the server 34 | 35 | ```TypeScript 36 | import { renderHTML } from "@hyperactive/hyper"; 37 | import { div, p, h1, br } from "@hyperactive/hyper/elements"; 38 | 39 | assertEquals( 40 | renderHTML( 41 | section( 42 | { class: "container" }, 43 | div( 44 | img({ src: "/hero.jpg" }), 45 | h1("Hello World"), 46 | ), 47 | ), 48 | ), 49 | `

Hello World

`, 50 | ); 51 | ``` 52 | 53 | ### In the browser 54 | 55 | [![@types/web 0.0.234](https://img.shields.io/static/v1?label=@types/web&message=0.0.234&style=for-the-badge&labelColor=ff0000&color=fff)](https://npmjs.com/package/@types/web) 56 | 57 | Please install `@types/web` to use Hyperactive in the browser. Your package manager will automatically install the correct version of `@types/web` for you by default. See the [versions](./docs/versions.md) table for the correct version of `@types/web` for each version of Hyperactive. 58 | 59 | ```bash 60 | bun install @types/web 61 | ``` 62 | 63 | ```TypeScript 64 | import { State, renderDOM } from "@hyperactive/hyper"; 65 | import { div, p, button } from "@hyperactive/hyper/elements"; 66 | 67 | const s = new State(0); 68 | 69 | const root = document.getElementById("root"); 70 | 71 | renderDOM( 72 | root, 73 | div( 74 | p("You clicked ", s, " times"), 75 | button( 76 | { on: { click: () => s.update(s.value + 1) } }, 77 | "Increment" 78 | ), 79 | ), 80 | ); 81 | 82 | ``` 83 | 84 |
85 |

Testimonials

86 |
87 | 88 |
89 | Thomas's testimonial 90 |
91 | -------------------------------------------------------------------------------- /docs/Hyper.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/h(⚡️).svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/thomas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/docs/thomas.jpg -------------------------------------------------------------------------------- /docs/versions.md: -------------------------------------------------------------------------------- 1 | # Versions 2 | 3 | Hyperactive expects a certain version of `@types/web` to be installed. This is because the library is built from the `@types/web` package, and the version of `@types/web` is used to determine the version of the library. 4 | 5 | In the future, this may be relaxed, but for now, it is required if you work with Hyperactive on the client side. This decision was made because the library can be used in a server-side context, and the version of `@types/web` is not required in that case. Depending on a global `lib.dom.d.ts` pollutes the global scope and can cause unexpected type errors. 6 | 7 | This table shows the version of `@types/web` that Hyperactive expects to be installed. 8 | 9 | | Hyperactive Version | @types/web Version | 10 | | ------------------- | ------------------ | 11 | | 2.0.0-beta.8 | 0.0.234 | 12 | | 2.0.0-beta.7 | 0.0.232 | 13 | | 2.0.0-beta.6 | 0.0.232 | 14 | | 2.0.0-beta.5 | 0.0.232 | 15 | | 2.0.0-beta.4 | 0.0.232 | 16 | | 2.0.0-beta.3 | 0.0.188 | 17 | | 2.0.0-beta.2 | 0.0.188 | 18 | | 2.0.0-beta.1 | 0.0.188 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperactive", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "type": "module", 8 | "scripts": { 9 | "build": "cd packages/hyper && bun run build" 10 | }, 11 | "dependencies": { 12 | "typescript": "^5.7.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/blink/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | *.db* 4 | config.json 5 | 6 | # Logs 7 | 8 | logs 9 | _.log 10 | npm-debug.log_ 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Caches 17 | 18 | .cache 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | 22 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 23 | 24 | # Runtime data 25 | 26 | pids 27 | _.pid 28 | _.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | 37 | coverage 38 | *.lcov 39 | 40 | # nyc test coverage 41 | 42 | .nyc_output 43 | 44 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 45 | 46 | .grunt 47 | 48 | # Bower dependency directory (https://bower.io/) 49 | 50 | bower_components 51 | 52 | # node-waf configuration 53 | 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | 58 | build/Release 59 | 60 | # Dependency directories 61 | 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Snowpack dependency directory (https://snowpack.dev/) 66 | 67 | web_modules/ 68 | 69 | # TypeScript cache 70 | 71 | *.tsbuildinfo 72 | 73 | # Optional npm cache directory 74 | 75 | .npm 76 | 77 | # Optional eslint cache 78 | 79 | .eslintcache 80 | 81 | # Optional stylelint cache 82 | 83 | .stylelintcache 84 | 85 | # Microbundle cache 86 | 87 | .rpt2_cache/ 88 | .rts2_cache_cjs/ 89 | .rts2_cache_es/ 90 | .rts2_cache_umd/ 91 | 92 | # Optional REPL history 93 | 94 | .node_repl_history 95 | 96 | # Output of 'npm pack' 97 | 98 | *.tgz 99 | 100 | # Yarn Integrity file 101 | 102 | .yarn-integrity 103 | 104 | # dotenv environment variable files 105 | 106 | .env 107 | .env.development.local 108 | .env.test.local 109 | .env.production.local 110 | .env.local 111 | 112 | # parcel-bundler cache (https://parceljs.org/) 113 | 114 | .parcel-cache 115 | 116 | # Next.js build output 117 | 118 | .next 119 | out 120 | 121 | # Nuxt.js build / generate output 122 | 123 | .nuxt 124 | dist 125 | 126 | # Gatsby files 127 | 128 | # Comment in the public line in if your project uses Gatsby and not Next.js 129 | 130 | # https://nextjs.org/blog/next-9-1#public-directory-support 131 | 132 | # public 133 | 134 | # vuepress build output 135 | 136 | .vuepress/dist 137 | 138 | # vuepress v2.x temp and cache directory 139 | 140 | .temp 141 | 142 | # Docusaurus cache and generated files 143 | 144 | .docusaurus 145 | 146 | # Serverless directories 147 | 148 | .serverless/ 149 | 150 | # FuseBox cache 151 | 152 | .fusebox/ 153 | 154 | # DynamoDB Local files 155 | 156 | .dynamodb/ 157 | 158 | # TernJS port file 159 | 160 | .tern-port 161 | 162 | # Stores VSCode versions used for testing VSCode extensions 163 | 164 | .vscode-test 165 | 166 | # yarn v2 167 | 168 | .yarn/cache 169 | .yarn/unplugged 170 | .yarn/build-state.yml 171 | .yarn/install-state.gz 172 | .pnp.* 173 | 174 | # IntelliJ based IDEs 175 | .idea 176 | 177 | # Finder (MacOS) folder config 178 | .DS_Store 179 | -------------------------------------------------------------------------------- /packages/blink/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Feathers Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/blink/README.md: -------------------------------------------------------------------------------- 1 | # @feathers-studio/blink 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run src/index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.34. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/blink/assets/img/1200x630.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/blink/assets/img/1200x630.jpg -------------------------------------------------------------------------------- /packages/blink/assets/img/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/blink/assets/img/favicon-96x96.png -------------------------------------------------------------------------------- /packages/blink/assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/blink/assets/img/favicon.ico -------------------------------------------------------------------------------- /packages/blink/example.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "database": "store.db", 4 | "users": [ 5 | { 6 | "username": "admin", 7 | "password": "hunter2_ifYouDontChangeThisPasswordYouDeserveToBeHacked" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/blink/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@feathers-studio/blink", 3 | "module": "src/index.ts", 4 | "type": "module", 5 | "dependencies": { 6 | "@hyperactive/hyper": "workspace:*", 7 | "cookie": "^1.0.2", 8 | "jsdom": "^25.0.1", 9 | "mime-types": "^2.1.35", 10 | "nanoid": "^5.0.9" 11 | }, 12 | "devDependencies": { 13 | "@types/bun": "latest", 14 | "@types/mime-types": "^2.1.4" 15 | }, 16 | "peerDependencies": { 17 | "typescript": "^5.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/blink/src/config.ts: -------------------------------------------------------------------------------- 1 | export interface ConfigUser { 2 | username: string; 3 | password: string; 4 | } 5 | 6 | export interface Config { 7 | port: number; 8 | database: string; 9 | users: ConfigUser[]; 10 | } 11 | 12 | let config: Config = { 13 | port: 3000, 14 | database: "blink.db", 15 | users: [], 16 | }; 17 | 18 | try { 19 | config = Object.assign(config, (await Bun.file("config.json").json()) as Partial); 20 | } catch { 21 | console.log("Config not found, using defaults. Create a config to be able to login."); 22 | } 23 | 24 | export { config }; 25 | -------------------------------------------------------------------------------- /packages/blink/src/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from "./config"; 2 | 3 | import { extname, join } from "node:path"; 4 | import { authenticate } from "./middleware/auth"; 5 | import { login, logout, loginPage } from "./pages/auth"; 6 | import { dashboard } from "./pages/dashboard"; 7 | import * as links from "./pages/links"; 8 | import { lookup } from "mime-types"; 9 | import { generateETagFromFile, seconds } from "./utils"; 10 | 11 | const ASSETS_ROOT = join(__dirname, "../assets/"); 12 | 13 | Bun.serve({ 14 | port: config.port, 15 | async fetch(request, server) { 16 | const method = request.method; 17 | const url = new URL(request.url); 18 | { 19 | const proto = request.headers.get("x-forwarded-proto"); 20 | const host = request.headers.get("x-forwarded-host"); 21 | if (proto) url.protocol = proto; 22 | if (host) url.host = host; 23 | } 24 | const ip = request.headers.get("x-forwarded-for") ?? server.requestIP(request)?.address ?? null; 25 | 26 | console.log(`[${new Date().toISOString()}] ${method} ${url.pathname}${url.search} (${ip})`); 27 | 28 | const accept = request.headers.get("Accept"); 29 | const withJSON = accept?.includes("application/json") ?? false; 30 | 31 | if (method === "GET") { 32 | if (url.pathname.startsWith("/assets/")) { 33 | const assetPath = join(ASSETS_ROOT, url.pathname.slice("/assets/".length)); 34 | if (!assetPath.startsWith(ASSETS_ROOT)) { 35 | return new Response("Access Denied", { status: 403 }); 36 | } 37 | return new Response(Bun.file(assetPath), { 38 | headers: { 39 | "Content-Type": lookup(extname(assetPath)) || "application/octet-stream", 40 | "Cache-Control": `public, max-age=${seconds(10)}`, 41 | "ETag": await generateETagFromFile(assetPath), 42 | }, 43 | }); 44 | } 45 | } 46 | 47 | if (method === "POST") { 48 | if (url.pathname === "/login") return login(request, { ip, withJSON }); 49 | if (url.pathname === "/logout") return logout(request, { withJSON }); 50 | } 51 | 52 | const user = await authenticate(request); 53 | if (user instanceof Response) { 54 | if (url.pathname === "/") { 55 | return loginPage(request, { url }); 56 | } 57 | } else { 58 | if (url.pathname === "/") { 59 | return dashboard(request, { user, url }); 60 | } 61 | 62 | if (url.pathname === "/links") { 63 | if (method === "POST") return links.POST(request, { user, withJSON }); 64 | } 65 | } 66 | 67 | return links.GET(request, { url, ip, withJSON }); 68 | }, 69 | }); 70 | 71 | console.log(`Blink is running on http://localhost:${config.port}`); 72 | -------------------------------------------------------------------------------- /packages/blink/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { parse as cookie } from "cookie"; 2 | import { queries } from "../store"; 3 | import type { User } from "../types"; 4 | import { redirectClear } from "../utils"; 5 | 6 | export async function authenticate(request: Request): Promise { 7 | const token = cookie(request.headers.get("cookie") ?? "").token; 8 | if (!token) return redirectClear("/?error=Your session has expired, please login again"); 9 | 10 | const session = queries.sessions.access(token); 11 | if (!session) return redirectClear("/?error=Your session has expired, please login again"); 12 | 13 | const user = queries.users.getById(session.user_id); 14 | if (!user) return redirectClear("/?error=Your session has expired, please login again"); 15 | 16 | return user; 17 | } 18 | -------------------------------------------------------------------------------- /packages/blink/src/pages/auth.ts: -------------------------------------------------------------------------------- 1 | import { body, form, h1, head, hgroup, input, link, p, title } from "@hyperactive/hyper/elements"; 2 | import { parse as cookie } from "cookie"; 3 | import { queries } from "../store"; 4 | import { days, generateMeta, html, json, redirectClear } from "../utils"; 5 | 6 | interface LoginParams { 7 | ip: string | null; 8 | withJSON: boolean; 9 | } 10 | 11 | export async function login(request: Request, { ip, withJSON }: LoginParams) { 12 | const body = await request.formData(); 13 | const username = body.get("username")?.toString(); 14 | const password = body.get("password")?.toString(); 15 | 16 | if (!username || !password) { 17 | if (withJSON) return json({ error: "Invalid username or password" }, 401); 18 | return new Response("Invalid username or password", { status: 401 }); 19 | } 20 | 21 | const result = queries.users.getByUsername(username); 22 | if (!result) { 23 | if (withJSON) return json({ error: "Invalid username or password" }, 401); 24 | return redirectClear("/?error=Invalid username or password"); 25 | } 26 | 27 | if (!(await Bun.password.verify(password, result.password))) { 28 | if (withJSON) return json({ error: "Invalid username or password" }, 401); 29 | return redirectClear("/?error=Invalid username or password"); 30 | } 31 | 32 | const session = queries.sessions.create({ 33 | token: crypto.randomUUID(), 34 | user_id: result.id, 35 | ip_address: ip, 36 | user_agent: request.headers.get("user-agent"), 37 | expires_at: new Date(Date.now() + days(30) * 1000).toISOString(), 38 | }); 39 | 40 | if (!session) { 41 | if (withJSON) return json({ error: "Failed to create session" }, 500); 42 | return redirectClear("/?error=Failed to create session"); 43 | } 44 | 45 | if (withJSON) return json({ token: session.token, expires_at: session.expires_at }, 200); 46 | return redirectClear("/", { 47 | "Set-Cookie": `token=${session.token}; Max-Age=${days(30)}; HttpOnly; Secure; SameSite=Strict`, 48 | }); 49 | } 50 | 51 | interface LogoutParams { 52 | withJSON: boolean; 53 | } 54 | 55 | export async function logout(request: Request, { withJSON }: LogoutParams) { 56 | const token = cookie(request.headers.get("cookie") ?? "").token; 57 | if (!token) { 58 | if (withJSON) return json({ error: "No token" }, 401); 59 | return redirectClear("/?error=No token"); 60 | } 61 | 62 | const changes = await queries.sessions.logout(token); 63 | 64 | if (!changes.changes) { 65 | if (withJSON) return json({ error: "Could not find active session" }, 401); 66 | return redirectClear("/?error=Could not find active session"); 67 | } 68 | 69 | if (withJSON) return json({}, 200); 70 | return redirectClear("/", { 71 | "Set-Cookie": `token=; Max-Age=0; HttpOnly; Secure; SameSite=Strict`, 72 | }); 73 | } 74 | 75 | export async function loginPage(request: Request, { url }: { url: URL }) { 76 | const error = url.searchParams.get("error"); 77 | 78 | return html( 79 | // @ts-expect-error "prefix" is not a valid prop, but og protocol uses it 80 | { prefix: "http://ogp.me/ns#" }, 81 | head( 82 | title("Blink"), 83 | link({ rel: "icon", type: "image/png", href: "/assets/img/favicon-96x96.png", sizes: "96x96" }), 84 | link({ rel: "shortcut icon", href: "/assets/img/favicon.ico" }), 85 | link({ rel: "stylesheet", href: "/assets/style.css" }), 86 | ...generateMeta({ title: "Blink", description: "Shorten links using Blink" }, url.protocol + "//" + url.host), 87 | ), 88 | body( 89 | { class: "container" }, 90 | h1("Blink"), 91 | form( 92 | { action: "/login", method: "POST", enctype: "application/x-www-form-urlencoded" }, 93 | input({ name: "username", type: "text", placeholder: "Username" }), 94 | input({ name: "password", type: "password", placeholder: "Password" }), 95 | input({ type: "submit", value: "Login" }), 96 | ), 97 | hgroup(error && p({ class: "muted" }, "Error: ", error)), 98 | ), 99 | )(); 100 | } 101 | -------------------------------------------------------------------------------- /packages/blink/src/pages/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { parse as cookie } from "cookie"; 2 | import { 3 | a, 4 | body, 5 | button, 6 | form, 7 | h1, 8 | head, 9 | hgroup, 10 | i, 11 | input, 12 | link, 13 | meta, 14 | nav, 15 | p, 16 | section, 17 | table, 18 | tbody, 19 | td, 20 | th, 21 | thead, 22 | title, 23 | tr, 24 | } from "@hyperactive/hyper/elements"; 25 | import { queries } from "../store"; 26 | import type { User } from "../types"; 27 | import { html, days } from "../utils"; 28 | 29 | export async function dashboard(request: Request, { user, url }: { user: User; url: URL }) { 30 | const error = url.searchParams.get("error"); 31 | const _title = url.searchParams.get("title") ?? ""; 32 | const _target = url.searchParams.get("target") ?? ""; 33 | 34 | const created = url.searchParams.get("created"); 35 | const created_link = created ? queries.links.get(created) : null; 36 | 37 | const page = Number(url.searchParams.get("page")) || 1; 38 | const limit = Number(url.searchParams.get("limit")) || 10; 39 | 40 | const listLinks = queries.links.list({ user_id: user.id, page, limit }); 41 | 42 | const session = cookie(request.headers.get("cookie") ?? "").token; 43 | 44 | return html( 45 | {}, 46 | head( 47 | title("Blink"), 48 | meta({ name: "viewport", content: "width=device-width, initial-scale=1" }), 49 | link({ rel: "icon", type: "image/png", href: "/assets/img/favicon-96x96.png", sizes: "96x96" }), 50 | link({ rel: "shortcut icon", href: "/assets/img/favicon.ico" }), 51 | link({ rel: "stylesheet", href: "/assets/style.css" }), 52 | ), 53 | body( 54 | { class: "container" }, 55 | nav( 56 | h1(a({ href: "/" }, "Blink")), 57 | form(input({ class: "link", type: "submit", value: "Logout", formaction: "/logout", formmethod: "POST" })), 58 | ), 59 | section( 60 | form( 61 | { role: "group", action: "/links", method: "POST", enctype: "application/x-www-form-urlencoded" }, 62 | input({ name: "target", type: "text", placeholder: "https://example.com", value: _target }), 63 | input({ name: "title", type: "text", placeholder: "Title (optional)", value: _title }), 64 | button("Shorten!"), 65 | ), 66 | hgroup( 67 | error 68 | ? p({ class: "muted" }, "Error: ", error) 69 | : created_link && 70 | p( 71 | { class: "muted" }, 72 | "Link created: ", 73 | a({ href: "/" + created_link.slug }, url.host, "/", created_link.slug), 74 | ), 75 | ), 76 | ), 77 | listLinks.length && 78 | table( 79 | thead(tr(th("Title"), th("Short URL"), th("Target URL"), th("Visits"))), 80 | tbody( 81 | ...listLinks.map(link => 82 | tr( 83 | td(p(link.title || i("-"))), 84 | td( 85 | a({ href: "/" + link.slug, target: "_blank", rel: "noopener noreferrer" }, url.host, "/", link.slug), 86 | ), 87 | td(a({ href: link.target, target: "_blank", rel: "noopener noreferrer" }, link.target)), 88 | td(String(link.visits)), 89 | ), 90 | ), 91 | ), 92 | ), 93 | ), 94 | )({ 95 | // Refresh Cookie expires every time the page is loaded 96 | "Set-Cookie": `token=${session}; Max-Age=${days(30)}; HttpOnly; Secure; SameSite=Strict`, 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /packages/blink/src/pages/links.ts: -------------------------------------------------------------------------------- 1 | import { a, body, head, link as link_tag, meta, noscript, p } from "@hyperactive/hyper/elements"; 2 | import { customAlphabet } from "nanoid"; 3 | import { queries } from "../store"; 4 | import type { User } from "../types"; 5 | import { html, fetchMeta, generateMeta, json, redirect, type Meta } from "../utils"; 6 | 7 | const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz_-"; 8 | const nanoid = customAlphabet(alphabet, 8); 9 | 10 | interface PostLinkParams { 11 | user: User; 12 | withJSON: boolean; 13 | } 14 | 15 | export async function POST(request: Request, { user, withJSON }: PostLinkParams) { 16 | const formData = await request.formData(); 17 | const title = formData.get("title")?.toString(); 18 | let target = formData.get("target")?.toString(); 19 | 20 | if (!target) { 21 | if (withJSON) return json({ error: "Target is required", title }, 400); 22 | return redirect("/", {}, { error: "Target is required", title }); 23 | } 24 | 25 | let meta: Meta | null = null; 26 | 27 | try { 28 | const url = new URL(target); 29 | target = url.toString(); 30 | meta = await fetchMeta(target); 31 | } catch (error) { 32 | if (withJSON) return json({ error: "Invalid target URL", title, target }, 400); 33 | return redirect("/", {}, { error: "Invalid target URL", title, target }); 34 | } 35 | 36 | let slug: string; 37 | let attempts = 0; 38 | while (queries.links.get((slug = nanoid()))) { 39 | attempts++; 40 | if (attempts > 100) { 41 | if (withJSON) return json({ error: "Failed to generate unique slug", title, target }, 500); 42 | return redirect("/", {}, { error: "Failed to generate unique slug", title, target }); 43 | } 44 | } 45 | 46 | if (title) (meta.title = title), (meta.meta_title = title); 47 | 48 | const link = queries.links.create({ ...meta, target, slug, user_id: user.id }); 49 | 50 | if (!link) { 51 | if (withJSON) return json({ error: "Failed to create link", title, target }, 500); 52 | return redirect("/", {}, { error: "Failed to create link", title, target }); 53 | } 54 | 55 | if (withJSON) { 56 | const { slug, target, title, description, meta_title, meta_description, meta_image } = link; 57 | return json({ slug, target, title, description, meta_title, meta_description, meta_image }); 58 | } 59 | 60 | return redirect("/", {}, { created: slug }); 61 | } 62 | 63 | interface LinkParams { 64 | url: URL; 65 | ip: string | null; 66 | withJSON: boolean; 67 | } 68 | 69 | export async function GET(request: Request, { url, ip, withJSON }: LinkParams) { 70 | const slug = url.pathname.slice(1); 71 | 72 | const link = queries.links.get(slug); 73 | if (!link) { 74 | if (withJSON) return json({ error: "Link not found" }, 404); 75 | return new Response("Link not found", { status: 404 }); 76 | } 77 | 78 | const user_agent = request.headers.get("user-agent"); 79 | queries.visits.create({ link_id: link.id, ip_address: ip, user_agent }); 80 | 81 | const tags = generateMeta(link, url.protocol + "//" + url.host); 82 | 83 | if (withJSON) { 84 | const { slug, target, title, description, meta_title, meta_description, meta_image } = link; 85 | return json({ slug, target, title, description, meta_title, meta_description, meta_image }); 86 | } 87 | 88 | return html( 89 | // @ts-expect-error "prefix" is not a valid prop, but og protocol uses it 90 | { prefix: "http://ogp.me/ns#" }, 91 | head( 92 | link_tag({ rel: "icon", type: "image/png", href: "/assets/img/favicon-96x96.png", sizes: "96x96" }), 93 | link_tag({ rel: "shortcut icon", href: "/assets/img/favicon.ico" }), 94 | ...tags, 95 | meta({ "http-equiv": "refresh", "content": `0; url=${link.target}` }), 96 | ), 97 | body( 98 | p("Redirecting you to ", a({ href: link.target }, link.target)), 99 | // script(`window.location.href = ${JSON.stringify(l.target)};`), 100 | noscript("If you are not redirected automatically, please ", a({ href: link.target }, "click here")), 101 | ), 102 | )(); 103 | } 104 | -------------------------------------------------------------------------------- /packages/blink/src/store.ts: -------------------------------------------------------------------------------- 1 | import { config } from "./config"; 2 | import { db } from "./setup"; 3 | import type { User, Session, Link, Visit } from "./types"; 4 | 5 | export const queries = { 6 | users: { 7 | getByUsername: (username: string) => 8 | db.query(`SELECT * FROM users WHERE username = :username`).get({ username }), 9 | getById: (id: number) => db.query(`SELECT * FROM users WHERE id = :id`).get({ id }), 10 | create: (user: Omit) => 11 | db 12 | .query>( 13 | `INSERT INTO users (username, password) VALUES (:username, :password) RETURNING *`, 14 | ) 15 | .get(user), 16 | list: () => db.query(`SELECT * FROM users`).all(), 17 | update: (user: Pick) => 18 | db 19 | .query>( 20 | `UPDATE users SET username = :username, password = :password WHERE username = :username`, 21 | ) 22 | .run(user), 23 | }, 24 | sessions: { 25 | create: (session: Omit) => 26 | db 27 | .query>( 28 | `INSERT INTO sessions (token, user_id, ip_address, user_agent, expires_at) 29 | VALUES (:token, :user_id, :ip_address, :user_agent, :expires_at) RETURNING *`, 30 | ) 31 | .get(session), 32 | access: (token: string) => 33 | db 34 | .query( 35 | `UPDATE sessions 36 | SET last_active_at = CURRENT_TIMESTAMP, expires_at = datetime('now', '+30 days') 37 | WHERE token = :token AND logged_out_at IS NULL AND expires_at > CURRENT_TIMESTAMP 38 | RETURNING *`, 39 | ) 40 | .get({ token }), 41 | logout: (token: string) => 42 | db 43 | .query(`UPDATE sessions SET logged_out_at = CURRENT_TIMESTAMP WHERE token = :token`) 44 | .run({ token }), 45 | }, 46 | links: { 47 | get: (slug: string) => db.query(`SELECT * FROM links WHERE slug = :slug`).get({ slug }), 48 | create: (link: Omit) => 49 | db 50 | .query>( 51 | `INSERT INTO links (title, description, meta_title, meta_description, meta_image, target, slug, user_id) 52 | VALUES (:title, :description, :meta_title, :meta_description, :meta_image, :target, :slug, :user_id) 53 | RETURNING *`, 54 | ) 55 | .get(link), 56 | list: ({ user_id, page = 1, limit = 10 }: { user_id: number; page: number; limit: number }) => 57 | db 58 | .query( 59 | `SELECT links.*, 60 | (SELECT COUNT(*) FROM visits WHERE visits.link_id = links.id) AS visits 61 | FROM links 62 | WHERE links.user_id = :user_id 63 | ORDER BY links.created_at DESC 64 | LIMIT :limit 65 | OFFSET :offset`, 66 | ) 67 | .all({ user_id, offset: (page - 1) * limit, limit }), 68 | }, 69 | visits: { 70 | create: (visit: Omit) => 71 | db 72 | .query>( 73 | `INSERT INTO visits (link_id, ip_address, user_agent) 74 | VALUES (:link_id, :ip_address, :user_agent) RETURNING *`, 75 | ) 76 | .get(visit), 77 | }, 78 | }; 79 | 80 | { 81 | for (const user of config.users) { 82 | const existing = queries.users.getByUsername(user.username); 83 | if (!existing) { 84 | console.log(`Creating user ${user.username}`); 85 | queries.users.create({ username: user.username, password: await Bun.password.hash(user.password) }); 86 | } else if (!(await Bun.password.verify(user.password, existing.password))) { 87 | console.log(`Updating password for user ${user.username}`); 88 | queries.users.update({ username: user.username, password: await Bun.password.hash(user.password) }); 89 | } 90 | } 91 | } 92 | 93 | const users = queries.users.list(); 94 | console.log(users.length, "users in the database:", users.map(user => user.username).join(", ")); 95 | -------------------------------------------------------------------------------- /packages/blink/src/types.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: number; 3 | username: string; 4 | password: string; 5 | created_at: string; 6 | updated_at: string; 7 | }; 8 | 9 | export type Session = { 10 | id: number; 11 | token: string; 12 | user_id: number; 13 | ip_address: string | null; 14 | user_agent: string | null; 15 | expires_at: string; 16 | logged_out_at: string | null; 17 | created_at: string; 18 | updated_at: string; 19 | }; 20 | 21 | export type Link = { 22 | id: number; 23 | user_id: number; 24 | slug: string; 25 | target: string; 26 | title?: string | null; 27 | description?: string | null; 28 | meta_title?: string | null; 29 | meta_description?: string | null; 30 | meta_image?: string | null; 31 | created_at: string; 32 | updated_at: string; 33 | }; 34 | 35 | export type Visit = { 36 | id: number; 37 | link_id: number; 38 | ip_address?: string | null; 39 | user_agent?: string | null; 40 | created_at: string; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/blink/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/h2h/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Feathers Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/h2h/h2h.js: -------------------------------------------------------------------------------- 1 | import { Parser } from "https://esm.sh/htmlparser2@9.0.0"; 2 | 3 | export const h2h = (input, stream, opts) => { 4 | const elements = new Set(); 5 | let indentLevel = 0; 6 | let attrOpen = false; 7 | let textWritten = false; 8 | let justClosed = false; 9 | let size = 0; 10 | const streamWrite = stream.write; 11 | stream.write = (...chunk) => ((size += chunk.length), console.log(size), streamWrite(...chunk)); 12 | 13 | const parser = new Parser( 14 | { 15 | onopentag(name, attr) { 16 | elements.add(name); 17 | if (justClosed) { 18 | justClosed = false; 19 | } 20 | if (textWritten) { 21 | stream.write(", "); 22 | textWritten = false; 23 | } 24 | if (attrOpen) attrOpen = false; 25 | stream.write("\n" + "\t".repeat(indentLevel++) + `${name}`); 26 | let attrKeys = Object.keys(attr); 27 | if (attrKeys.length) { 28 | if (opts?.useFancySelectors) { 29 | let selector = ""; 30 | if (attr.id) selector += "#" + attr.id.split(" ").join("#"); 31 | if (attr.class) selector += "." + attr.class.split(" ").join("."); 32 | if (selector) stream.write(`["${selector}"]`); 33 | 34 | delete attr.class; 35 | delete attr.id; 36 | } 37 | 38 | stream.write("("); 39 | 40 | attrKeys = Object.keys(attr); 41 | if (attrKeys.length) { 42 | stream.write(JSON.stringify(attr), ", "); 43 | attrOpen = true; 44 | } 45 | } else { 46 | stream.write("("); 47 | } 48 | }, 49 | 50 | oncomment(comments) { 51 | justClosed = false; 52 | (comments = comments.trim()) && 53 | comments.split("\n").forEach(comment => stream.write("\n// " + "\t".repeat(indentLevel) + comment.trim())); 54 | }, 55 | 56 | ontext(text) { 57 | if (!text.trim()) return; 58 | if (textWritten) { 59 | stream.write(", "); 60 | textWritten = false; 61 | } 62 | if (attrOpen) attrOpen = false; 63 | justClosed = false; 64 | stream.write("`" + text.trim().replace(/`/g, "\\`") + "`"); 65 | textWritten = true; 66 | }, 67 | 68 | onclosetag() { 69 | if (justClosed) stream.pop(); 70 | if (textWritten) { 71 | textWritten = false; 72 | } 73 | if (attrOpen) { 74 | stream.pop(); 75 | stream.pop(); 76 | attrOpen = false; 77 | } 78 | justClosed = true; 79 | stream.write("),"); 80 | indentLevel--; 81 | }, 82 | 83 | onend() { 84 | if (justClosed) stream.pop(); 85 | if (size) stream.write(";\n"); 86 | stream.end(); 87 | }, 88 | }, 89 | { decodeEntities: true }, 90 | ); 91 | 92 | parser.write(input); 93 | parser.end(); 94 | return { elements, stream }; 95 | }; 96 | -------------------------------------------------------------------------------- /packages/h2h/index.html: -------------------------------------------------------------------------------- 1 | 2 | 114 | 115 |

HTML to Hyperactive

116 | 117 |
118 |
119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 |
129 | 153 | -------------------------------------------------------------------------------- /packages/hyper/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | lib/ 4 | !src/lib/ 5 | 6 | # Logs 7 | 8 | logs 9 | _.log 10 | npm-debug.log_ 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Caches 17 | 18 | .cache 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | 22 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 23 | 24 | # Runtime data 25 | 26 | pids 27 | _.pid 28 | _.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | 37 | coverage 38 | *.lcov 39 | 40 | # nyc test coverage 41 | 42 | .nyc_output 43 | 44 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 45 | 46 | .grunt 47 | 48 | # Bower dependency directory (https://bower.io/) 49 | 50 | bower_components 51 | 52 | # node-waf configuration 53 | 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | 58 | build/Release 59 | 60 | # Dependency directories 61 | 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Snowpack dependency directory (https://snowpack.dev/) 66 | 67 | web_modules/ 68 | 69 | # TypeScript cache 70 | 71 | *.tsbuildinfo 72 | 73 | # Optional npm cache directory 74 | 75 | .npm 76 | 77 | # Optional eslint cache 78 | 79 | .eslintcache 80 | 81 | # Optional stylelint cache 82 | 83 | .stylelintcache 84 | 85 | # Microbundle cache 86 | 87 | .rpt2_cache/ 88 | .rts2_cache_cjs/ 89 | .rts2_cache_es/ 90 | .rts2_cache_umd/ 91 | 92 | # Optional REPL history 93 | 94 | .node_repl_history 95 | 96 | # Output of 'npm pack' 97 | 98 | *.tgz 99 | 100 | # Yarn Integrity file 101 | 102 | .yarn-integrity 103 | 104 | # dotenv environment variable files 105 | 106 | .env 107 | .env.development.local 108 | .env.test.local 109 | .env.production.local 110 | .env.local 111 | 112 | # parcel-bundler cache (https://parceljs.org/) 113 | 114 | .parcel-cache 115 | 116 | # Next.js build output 117 | 118 | .next 119 | out 120 | 121 | # Nuxt.js build / generate output 122 | 123 | .nuxt 124 | dist 125 | 126 | # Gatsby files 127 | 128 | # Comment in the public line in if your project uses Gatsby and not Next.js 129 | 130 | # https://nextjs.org/blog/next-9-1#public-directory-support 131 | 132 | # public 133 | 134 | # vuepress build output 135 | 136 | .vuepress/dist 137 | 138 | # vuepress v2.x temp and cache directory 139 | 140 | .temp 141 | 142 | # Docusaurus cache and generated files 143 | 144 | .docusaurus 145 | 146 | # Serverless directories 147 | 148 | .serverless/ 149 | 150 | # FuseBox cache 151 | 152 | .fusebox/ 153 | 154 | # DynamoDB Local files 155 | 156 | .dynamodb/ 157 | 158 | # TernJS port file 159 | 160 | .tern-port 161 | 162 | # Stores VSCode versions used for testing VSCode extensions 163 | 164 | .vscode-test 165 | 166 | # yarn v2 167 | 168 | .yarn/cache 169 | .yarn/unplugged 170 | .yarn/build-state.yml 171 | .yarn/install-state.gz 172 | .pnp.* 173 | 174 | # IntelliJ based IDEs 175 | .idea 176 | 177 | # Finder (MacOS) folder config 178 | .DS_Store 179 | -------------------------------------------------------------------------------- /packages/hyper/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Feathers Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/hyper/README.md: -------------------------------------------------------------------------------- 1 |
2 | Hyperactive 3 |
4 | 5 |
6 |

hyperactive

7 |
8 | 9 | Hyperactive is a powerful set of tools to build reactive web applications. 10 | 11 | We're currently working on a 2.0 release, which will include fully reactive client-side rendering. To try the latest version, you can get `hyper`: 12 | 13 | ```bash 14 | npm install https://gethyper.dev 15 | 16 | yarn add https://gethyper.dev 17 | 18 | pnpm add https://gethyper.dev 19 | 20 | bun install https://gethyper.dev 21 | ``` 22 | 23 | Hyperactive is also available on [NPM](https://www.npmjs.com/package/@hyperactive/hyper). 24 | 25 | This is not a release version, so expect some bugs. 26 | 27 | [![Hyperactive Version 2.0.0-beta.8](https://img.shields.io/static/v1?label=Version&message=2.0.0-beta.8&style=for-the-badge&labelColor=FF6A00&color=fff)](https://npmjs.com/package/@hyperactive/hyper) 28 | 29 |
30 |

Usage

31 |
32 | 33 | ### On the server 34 | 35 | ```TypeScript 36 | import { renderHTML } from "@hyperactive/hyper"; 37 | import { div, p, h1, br } from "@hyperactive/hyper/elements"; 38 | 39 | assertEquals( 40 | renderHTML( 41 | section( 42 | { class: "container" }, 43 | div( 44 | img({ src: "/hero.jpg" }), 45 | h1("Hello World"), 46 | ), 47 | ), 48 | ), 49 | `

Hello World

`, 50 | ); 51 | ``` 52 | 53 | ### In the browser 54 | 55 | [![@types/web 0.0.234](https://img.shields.io/static/v1?label=@types/web&message=0.0.234&style=for-the-badge&labelColor=ff0000&color=fff)](https://npmjs.com/package/@types/web) 56 | 57 | Please install `@types/web` to use Hyperactive in the browser. Your package manager will automatically install the correct version of `@types/web` for you by default. See the [versions](./docs/versions.md) table for the correct version of `@types/web` for each version of Hyperactive. 58 | 59 | ```bash 60 | bun install @types/web 61 | ``` 62 | 63 | ```TypeScript 64 | import { State, renderDOM } from "@hyperactive/hyper"; 65 | import { div, p, button } from "@hyperactive/hyper/elements"; 66 | 67 | const s = new State(0); 68 | 69 | const root = document.getElementById("root"); 70 | 71 | renderDOM( 72 | root, 73 | div( 74 | p("You clicked ", s, " times"), 75 | button( 76 | { on: { click: () => s.update(s.value + 1) } }, 77 | "Increment" 78 | ), 79 | ), 80 | ); 81 | 82 | ``` 83 | 84 |
85 |

Testimonials

86 |
87 | 88 |
89 | Thomas's testimonial 90 |
91 | -------------------------------------------------------------------------------- /packages/hyper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hyperactive/hyper", 3 | "version": "2.0.0-beta.8", 4 | "module": "lib/index.js", 5 | "sideEffects": false, 6 | "devDependencies": { 7 | "@types/bun": "latest", 8 | "@types/semver": "^7.5.8", 9 | "semver": "^7.6.3" 10 | }, 11 | "peerDependencies": { 12 | "@types/web": "0.0.234" 13 | }, 14 | "peerDependenciesMeta": { 15 | "@types/web": { 16 | "optional": true 17 | } 18 | }, 19 | "scripts": { 20 | "check": "tsc --noEmit", 21 | "build": "tsc", 22 | "test": "bun test --coverage", 23 | "prepack": "bun run scripts/prepack.ts", 24 | "prepare": "bun run build" 25 | }, 26 | "exports": { 27 | ".": "./lib/index.js", 28 | "./elements": "./lib/elements.js", 29 | "./dom": "./lib/lib/dom.js" 30 | }, 31 | "type": "module", 32 | "files": [ 33 | "lib", 34 | "src" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /packages/hyper/scripts/prepack.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { join } from "node:path"; 3 | import rcompare from "semver/functions/rcompare"; 4 | 5 | const pkg = Bun.file(join(import.meta.dir, "../package.json")); 6 | const pkgJson = await pkg.json(); 7 | const hyperVersion = pkgJson.version; 8 | const typesWebVersion = pkgJson.peerDependencies["@types/web"]; 9 | 10 | if (typeof hyperVersion !== "string") { 11 | throw new Error("Failed to find Hyperactive version in package.json. Check scripts/prepack.ts"); 12 | } 13 | 14 | if (typeof typesWebVersion !== "string") { 15 | throw new Error("Failed to find @types/web version in package.json. Check scripts/prepack.ts"); 16 | } 17 | 18 | const promptfree = Bun.argv.includes("--yes") || Bun.argv.includes("-y"); 19 | 20 | if (!promptfree) { 21 | console.log(`Hyperactive ${hyperVersion} requires @types/web ${typesWebVersion}.`); 22 | 23 | const answer = await prompt("Update README.md and docs/versions.md? (Y/n)"); 24 | 25 | if (answer === "n") { 26 | console.log("Skipping"); 27 | process.exit(0); 28 | } 29 | } 30 | 31 | { 32 | const readme = Bun.file(join(import.meta.dir, "../../../README.md")); 33 | const readmeText = await readme.text(); 34 | 35 | // match ![Hyperactive Version 2.0.0-beta.1] 36 | const oldVersion = readmeText.match(/!\[Hyperactive Version (?.+?)\]/)?.groups?.version; 37 | if (!oldVersion) { 38 | throw new Error("Failed to find old Hyperactive version in README.md. Check scripts/postinstall.ts"); 39 | } 40 | 41 | const oldTypesWebVersion = readmeText.match(/!\[@types\/web (?.+?)\]/)?.groups?.version; 42 | if (!oldTypesWebVersion) { 43 | throw new Error("Failed to find old @types/web version in README.md. Check scripts/postinstall.ts"); 44 | } 45 | 46 | console.log(); 47 | console.log("Updating README.md"); 48 | console.log(`Hyperactive from ${oldVersion} to ${hyperVersion}`); 49 | console.log(`@types/web from ${oldTypesWebVersion} to ${typesWebVersion}`); 50 | console.log(); 51 | 52 | const newReadme = readmeText.replaceAll(oldVersion, hyperVersion).replaceAll(oldTypesWebVersion, typesWebVersion); 53 | await readme.writer().write(newReadme); 54 | 55 | await Bun.write(join(import.meta.dir, "../README.md"), newReadme); 56 | } 57 | 58 | { 59 | const versions = Bun.file(join(import.meta.dir, "../../../docs/versions.md")); 60 | const versionsText = await versions.text(); 61 | 62 | // parse all table rows 63 | let versionsRows = (versionsText.match(/^\| (.*) \| (.*) \|$/gm)?.slice(2) ?? []).map(row => { 64 | const [hyper, types] = row 65 | .split("|") 66 | .slice(1, 3) 67 | .map(cell => cell.trim()); 68 | return { hyper, types }; 69 | }); 70 | 71 | versionsRows = versionsRows.filter(row => row.hyper !== hyperVersion); 72 | versionsRows.push({ hyper: hyperVersion, types: typesWebVersion }); 73 | versionsRows.sort((a, b) => rcompare(a.hyper, b.hyper)); 74 | 75 | const newVersions = versionsRows.map(row => `| ${row.hyper} | ${row.types} |`).join("\n"); 76 | 77 | await Bun.write(versions, ""); 78 | // replace everything after | ------------------- | ------------------ | 79 | const MARKER = "| ------------------- | ------------------ |"; 80 | const indexOfMarker = versionsText.indexOf(MARKER); 81 | const newVersionsText = versionsText.slice(0, indexOfMarker) + MARKER + "\n" + newVersions; 82 | await versions.writer().write(newVersionsText); 83 | } 84 | -------------------------------------------------------------------------------- /packages/hyper/scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if filename is provided 4 | if [ -z "$1" ]; then 5 | echo "Usage: $0 " 6 | exit 1 7 | fi 8 | 9 | # Configure AWS CLI with Scaleway credentials 10 | aws configure set aws_access_key_id $SCW_ACCESS_KEY 11 | aws configure set aws_secret_access_key $SCW_SECRET_KEY 12 | aws configure set region fr-par # or your region 13 | aws configure set endpoint_url https://$SCW_ENDPOINT 14 | 15 | # Upload file and make it public 16 | aws s3 cp "$1" s3://pkg/$(basename "$1") --acl public-read 17 | 18 | # Print the public URL 19 | echo "File uploaded successfully!" 20 | echo "Public URL: https://$SCW_ENDPOINT/pkg/$(basename "$1")" 21 | -------------------------------------------------------------------------------- /packages/hyper/src/attributes.ts: -------------------------------------------------------------------------------- 1 | import type { Tag } from "./lib/tags.ts"; 2 | import type { Attributes as Attr } from "./lib/attributes.ts"; 3 | import { ReadonlyState } from "./state.ts"; 4 | import { AriaAttributes } from "./lib/aria.ts"; 5 | import { MaybeArray, MaybeState, MaybeString } from "./util.ts"; 6 | 7 | type Aria = { [K in keyof AriaAttributes]?: AriaAttributes[K] | ReadonlyState }; 8 | 9 | /** Re-export of Attributes with State support */ 10 | export type Attributes = 11 | { 12 | 13 | // prettier-ignore 14 | [K in keyof Attr]?: 15 | K extends "ref" | "on" ? Attr[K] 16 | : K extends "aria" ? Aria 17 | : K extends "class" ? MaybeState> 18 | : Attr[K] | ReadonlyState[K]>; 19 | 20 | }; 21 | -------------------------------------------------------------------------------- /packages/hyper/src/browser.test.ts: -------------------------------------------------------------------------------- 1 | import { renderDOM, State } from "./index.ts"; 2 | import { div, input, p, ul, button, li, form } from "./elements.ts"; 3 | 4 | import type { Document } from "./lib/dom.ts"; 5 | import { List, Member } from "./list.ts"; 6 | declare const document: Document; 7 | const root = document.getElementById("root")!; 8 | 9 | // { 10 | // const state = new State(""); 11 | 12 | // renderDOM( 13 | // root, 14 | // div( 15 | // { class: "container" }, 16 | // input({ 17 | // type: "text", 18 | // // value: state.value, 19 | // on: { 20 | // input(e) { 21 | // const value = (e.target as any).value; 22 | // state.set(value); 23 | // }, 24 | // }, 25 | // }), 26 | // p(state.to(s => (s === "Bye" ? h1("Good", b("bye!")) : span("Hello ", s, " !")))), 27 | // ), 28 | // ); 29 | // } 30 | 31 | { 32 | // Hyperactive version 33 | 34 | const Todo = (todo: Member<{ id: number; content: string }>) => { 35 | const state = new State(todo.value.content); 36 | 37 | return li( 38 | // span(todo.index.to(i => String(i + 1))), 39 | form( 40 | p(todo.to(t => t.content || "Untitled")), 41 | input({ 42 | type: "text", 43 | value: state, 44 | on: { input: e => state.set((e.target as any).value) }, 45 | }), 46 | button( 47 | { 48 | on: { 49 | async click() { 50 | // await fetch("...", { 51 | // method: "PUT", 52 | // body: JSON.stringify({ value: state.value }), 53 | // }); 54 | todo.set({ ...todo.value, content: state.value }); 55 | }, 56 | }, 57 | }, 58 | "Update", 59 | ), 60 | button( 61 | { 62 | on: { 63 | async click() { 64 | // await fetch("...", { 65 | // method: "DELETE", 66 | // body: JSON.stringify({ value: state.value }), 67 | // }); 68 | todo.remove(); 69 | }, 70 | }, 71 | }, 72 | "Delete", 73 | ), 74 | ), 75 | ); 76 | }; 77 | 78 | const App = () => { 79 | const todos = new List([ 80 | { id: 1, content: "Hyperactive" }, 81 | { id: 2, content: "Jigza" }, 82 | { id: 3, content: "Telegraf" }, 83 | ]); 84 | 85 | // const interval = setInterval(() => { 86 | // todos.append({ id: todos.size.value + 1, content: "Untitled" }); 87 | // }, 1000); 88 | 89 | return div.container( 90 | // button({ on: { click: () => clearInterval(interval) } }, "STOP"), 91 | ul(todos.each(Todo)), 92 | button( 93 | { on: { click: () => todos.append({ id: todos.size.value + 1, content: "" }) } }, 94 | "Add", 95 | ), 96 | ); 97 | }; 98 | 99 | renderDOM(root, App()); 100 | } 101 | -------------------------------------------------------------------------------- /packages/hyper/src/context.internal.ts: -------------------------------------------------------------------------------- 1 | import { HyperNodeish } from "./node"; 2 | 3 | export class Provider { 4 | constructor(public readonly contextId: symbol, public readonly contextValue: T, public readonly contextualChild: HyperNodeish) {} 5 | } 6 | 7 | export class Consumer { 8 | constructor(public readonly contextId: symbol, public readonly renderWithContext: (value: T) => HyperNodeish) {} 9 | } -------------------------------------------------------------------------------- /packages/hyper/src/context.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "bun:test"; 2 | import { div, p, span } from "./elements.ts"; 3 | import { Context, renderHTML } from "./index.ts"; 4 | 5 | describe("Context", () => { 6 | it("should create a simple context", () => { 7 | const context = new Context(() => "test"); 8 | 9 | const nested = context.with(value => p(value)); 10 | const tree = context.provider("value", div(nested)); 11 | 12 | expect(renderHTML(tree)).toBe("

value

"); 13 | }); 14 | 15 | it("should create a nested context", () => { 16 | const context = new Context(() => "test"); 17 | 18 | const nested2 = context.with(value => p(value)); 19 | const nested = context.provider("other value", nested2); 20 | const tree = context.provider("value", div(nested)); 21 | 22 | expect(renderHTML(tree)).toBe("

other value

"); 23 | }); 24 | 25 | it("should throw an error if a context is not found", () => { 26 | const context = new Context(() => "test", "ErrorContext"); 27 | 28 | const tree = context.with(value => div(p(value))); 29 | 30 | expect(() => renderHTML(tree)).toThrow("Requested context for (id: ErrorContext) not found. Was the Context Provider used?"); 31 | }); 32 | 33 | it("should handle multiple distinct contexts correctly", () => { 34 | const stringContext = new Context(() => "default string"); 35 | const numberContext = new Context(() => 0); 36 | 37 | const tree = stringContext.provider( 38 | "hello", 39 | numberContext.provider( 40 | 123, 41 | stringContext.with(sVal => numberContext.with(nVal => p(`${sVal} ${nVal}`))), 42 | ), 43 | ); 44 | 45 | expect(renderHTML(tree)).toBe("

hello 123

"); 46 | }); 47 | 48 | it("should handle objects as context values", () => { 49 | type MyObject = { message: string; count: number }; 50 | const objectContext = new Context(() => ({ message: "default", count: 0 })); 51 | 52 | const tree = objectContext.provider( 53 | { message: "custom", count: 42 }, 54 | objectContext.with(obj => p(`${obj.message} ${obj.count}`)), 55 | ); 56 | 57 | expect(renderHTML(tree)).toBe("

custom 42

"); 58 | }); 59 | 60 | it("should allow null as a provided context value", () => { 61 | // Default value is non-null to distinguish it from an explicit null 62 | const nullableContext = new Context(() => "default string"); 63 | 64 | const tree = nullableContext.provider( 65 | null, // Explicitly providing null 66 | nullableContext.with(value => p(value === null ? "was null" : value!)), 67 | ); 68 | 69 | expect(renderHTML(tree)).toBe("

was null

"); 70 | }); 71 | 72 | it("should allow undefined as a provided context value", () => { 73 | const undefinedContext = new Context(() => "default"); 74 | 75 | const tree = undefinedContext.provider( 76 | undefined, 77 | undefinedContext.with(value => p(value === undefined ? "was undefined" : value!)), 78 | ); 79 | expect(renderHTML(tree)).toBe("

was undefined

"); 80 | }); 81 | 82 | it("should handle functions as context values", () => { 83 | const funcContext = new Context<() => string>(() => () => "default func"); 84 | 85 | const tree = funcContext.provider( 86 | () => "dynamic value from func", 87 | funcContext.with(fn => p(fn())), 88 | ); 89 | expect(renderHTML(tree)).toBe("

dynamic value from func

"); 90 | }); 91 | 92 | it("multiple sibling consumers should receive the same context value", () => { 93 | const sharedContext = new Context(() => "default"); 94 | 95 | const sibling1 = sharedContext.with(value => p(value)); 96 | const sibling2 = sharedContext.with(value => span(value)); 97 | const tree = sharedContext.provider("shared", div(sibling1, sibling2)); 98 | expect(renderHTML(tree)).toBe("

shared

shared
"); 99 | }); 100 | 101 | it("inner provider of the same context overrides outer provider for its descendants only", () => { 102 | const themeContext = new Context(() => "light"); 103 | 104 | const themedButton = (label: string) => themeContext.with(theme => p(`${label}: ${theme}`)); 105 | 106 | const tree = themeContext.provider( 107 | "dark", 108 | div( 109 | themedButton("Button A"), 110 | themeContext.provider("contrast", themedButton("Button B")), 111 | themedButton("Button C"), 112 | ), 113 | ); 114 | expect(renderHTML(tree)).toBe("

Button A: dark

Button B: contrast

Button C: dark

"); 115 | }); 116 | 117 | it("consumer of one context is unaffected by providers of a different context", () => { 118 | const contextA = new Context(() => "A_default"); 119 | const contextB = new Context(() => "B_default"); 120 | 121 | const tree = contextA.provider( 122 | "Value A", 123 | contextB.provider( 124 | "Value B", 125 | // This consumer should only care about ContextA 126 | contextA.with(valA => p(`A: ${valA}`)), 127 | ), 128 | ); 129 | expect(renderHTML(tree)).toBe("

A: Value A

"); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /packages/hyper/src/context.ts: -------------------------------------------------------------------------------- 1 | import { HyperNodeish } from "./node.ts"; 2 | import { randId } from "./util.ts"; 3 | import * as ContextInternal from "./context.internal.ts"; 4 | 5 | export class Context { 6 | id: symbol; 7 | 8 | constructor(public readonly defaultCtx: () => T, public readonly name: string = randId()) { 9 | this.id = Symbol(name); 10 | } 11 | 12 | provider(value: T, child: HyperNodeish) { 13 | return new ContextInternal.Provider(this.id, value, child); 14 | } 15 | 16 | with(fn: (value: T) => HyperNodeish) { 17 | return new ContextInternal.Consumer(this.id, fn); 18 | } 19 | 20 | static isContext(x: any): x is ContextInternal.Provider | ContextInternal.Consumer { 21 | return x instanceof ContextInternal.Provider || x instanceof ContextInternal.Consumer; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/hyper/src/domutils.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import { State } from "./state.ts"; 4 | import type { HyperNode } from "./node.ts"; 5 | import type { HTMLInputElement } from "./lib/dom.ts"; 6 | 7 | export const bind = , S extends State>(node: N, state: S): N => { 8 | const oldRef = node.attrs.ref || (() => {}); 9 | 10 | node.attrs.ref = (el: HTMLInputElement) => { 11 | el.value = state.value; 12 | state.listen(value => (el.value = value)); 13 | el.addEventListener("input", e => state.publish((e?.target as unknown as { value: string }).value)); 14 | oldRef(el as any); 15 | }; 16 | 17 | return node; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/hyper/src/element.ts: -------------------------------------------------------------------------------- 1 | import { h, HyperNode, normaliseParams } from "./node.ts"; 2 | import { parseSelector } from "./parse.ts"; 3 | import { isNonNullable, MaybeString } from "./util.ts"; 4 | import type { HyperNodeish, NonEmptyElement } from "./node.ts"; 5 | import type { Tag } from "./lib/tags.ts"; 6 | import type { EmptyElements } from "./lib/emptyElements.ts"; 7 | import type { Attributes } from "./attributes.ts"; 8 | import { ReadonlyState, State } from "./state.ts"; 9 | 10 | export namespace Hyper { 11 | export interface Empty { 12 | // no children for empty tags 13 | (props?: Attributes): HyperNode; 14 | [selector: string]: Hyper.Empty; 15 | } 16 | 17 | export interface Base { 18 | (props: Attributes): HyperNode; 19 | (...childNodes: HyperNodeish[]): HyperNode; 20 | (props: Attributes, ...childNodes: HyperNodeish[]): HyperNode; 21 | [selector: string]: Hyper.Base; 22 | } 23 | 24 | export type Element = T extends EmptyElements ? Hyper.Empty : Hyper.Base; 25 | } 26 | 27 | export type Elements = { [k in Tag]: Hyper.Element }; 28 | 29 | function createSelectorProxy( 30 | element: T, 31 | hyperElement: Hyper.Element, 32 | loaded?: string, 33 | ): Hyper.Element { 34 | type hE = Hyper.Element; 35 | 36 | return new Proxy(hyperElement, { 37 | // Do NOT cache the target _ like we do below in elements, 38 | // because if users use this feature a lot, it'll leak memory 39 | // Just let elements.a.hello be a new function every time 40 | // and elements.a.hello !== elements.a.hello 41 | // It's okay -- don't fret about it 42 | get(_: hE, selector: string) { 43 | const parsed = parseSelector([loaded, selector].filter(Boolean).join(" ")); 44 | 45 | const hyperElement = function hyperElement( 46 | props?: Attributes | HyperNodeish, 47 | ...childNodes: HyperNodeish[] 48 | ) { 49 | const { attrs, children } = normaliseParams(props, childNodes); 50 | 51 | const className = new State(""); 52 | 53 | if (ReadonlyState.isState(attrs.class)) { 54 | className.set(attrs.class.value); 55 | attrs.class.pipe(className); 56 | } else className.set(attrs.class); 57 | 58 | if (parsed.class) className.setWith(c => [parsed.class, c].flatMap(x => (x ? x : [])).filter(Boolean)); 59 | 60 | const merged = { ...attrs, id: parsed.id || attrs.id, class: className }; 61 | 62 | return new HyperNode(element, merged, children.filter(isNonNullable)); 63 | } as hE; 64 | 65 | return createSelectorProxy(element, hyperElement, selector); 66 | }, 67 | }); 68 | } 69 | 70 | export function create(element: T) { 71 | const hyperElement = function hyperElement(...params: any[]) { 72 | return h(element as NonEmptyElement, ...params); 73 | } as Elements[T]; 74 | 75 | return /* @__PURE__ */ createSelectorProxy(element, hyperElement); 76 | } 77 | -------------------------------------------------------------------------------- /packages/hyper/src/guessEnv.ts: -------------------------------------------------------------------------------- 1 | // declare expected globals to avoid ts-ignore 2 | declare const process: { version: unknown; isBun?: boolean; versions?: { node?: string; deno?: string; bun?: string } }; 3 | declare const window: { Deno: unknown; document: unknown }; 4 | declare const navigator: { userAgent: string }; 5 | 6 | export const guessEnv = () => { 7 | if (typeof process !== "undefined" && process.version) { 8 | if (process.isBun) return "bun"; 9 | if (process.versions?.deno) return "deno"; 10 | return "node"; 11 | } 12 | if (typeof window !== "undefined") { 13 | if (window.Deno) return "deno"; 14 | if (window.document) return "browser" + (navigator.userAgent ? ` (${navigator.userAgent})` : ""); 15 | } 16 | return undefined; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/hyper/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hyperactive! 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/hyper/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "bun:test"; 2 | 3 | import { renderHTML, trust } from "./index.ts"; 4 | import { div, p, h1, br, input } from "./elements.ts"; 5 | 6 | test("renderHTML simple", () => { 7 | expect(renderHTML(div({ id: "hello", class: "world" }, "Hello world"))).toBe( 8 | `
Hello world
`, 9 | ); 10 | }); 11 | 12 | // we don't support data-attr yet, due to weird TS bugs 13 | test("renderHTML simple with data-attr", () => { 14 | expect(renderHTML(div({ "id": "hello", "class": "world", "data-attr": "value" }, "Hello world"))).toBe( 15 | `
Hello world
`, 16 | ); 17 | }); 18 | 19 | test("renderHTML simple - escaping attribute and text nodes", () => { 20 | expect( 21 | renderHTML( 22 | div( 23 | { 24 | id: "hello", 25 | class: "world", 26 | style: `content: '"&"'`, 27 | }, 28 | "<'\"Hello&world\"'>", 29 | ), 30 | ), 31 | ).toBe( 32 | `
<'"Hello&world"'>
`, 33 | ); 34 | }); 35 | 36 | test("renderHTML simple - class array", () => { 37 | expect( 38 | renderHTML( 39 | div( 40 | { 41 | id: "hello", 42 | class: ["d-flex", "justify-content-between", "mb-3", false], 43 | style: `content: '"&"'`, 44 | }, 45 | "<'\"Hello&world\"'>", 46 | ), 47 | ), 48 | ).toBe( 49 | `
<'"Hello&world"'>
`, 50 | ); 51 | }); 52 | 53 | test("renderHTML complex", () => { 54 | expect( 55 | renderHTML( 56 | div( 57 | { 58 | id: "hello", 59 | class: "world", 60 | ref: el => console.log(el), 61 | on: { mousemove: e => console.log(e) }, 62 | }, 63 | p(h1({ class: "hello" }, "hello world", br())), 64 | ), 65 | ), 66 | ).toBe(`

hello world

`); 67 | }); 68 | 69 | test("renderHTML ARIA props", () => { 70 | expect( 71 | renderHTML( 72 | div({ 73 | id: "hello", 74 | class: "world", 75 | role: "note", 76 | aria: { disabled: "true" }, 77 | }), 78 | ), 79 | ).toBe(`
`); 80 | }); 81 | 82 | test("renderHTML with HTML characters", () => { 83 | expect(renderHTML(p(""))).toBe(`

<test />

`); 84 | }); 85 | 86 | test("renderHTML with trusted HTML", () => { 87 | expect(renderHTML(p(trust("")))).toBe(`

`); 88 | }); 89 | 90 | test("renderHTML with boolean attributes", () => { 91 | expect(renderHTML(input({ disabled: true }))).toBe(``); 92 | 93 | expect(renderHTML(input({ disabled: false }))).toBe(``); 94 | }); 95 | 96 | test("renderHTML with numeric attributes", () => { 97 | expect(renderHTML(input({ step: 5 }))).toBe(``); 98 | }); 99 | 100 | test("renderHTML with undefined attributes", () => { 101 | expect(renderHTML(input({ step: undefined }))).toBe(``); 102 | }); 103 | 104 | test("renderHTML with emptyElements", () => { 105 | expect(renderHTML(br())).toBe(`
`); 106 | }); 107 | 108 | test("renderHTML skips events", () => { 109 | expect( 110 | renderHTML( 111 | input({ 112 | on: { 113 | input: e => console.log((e?.target as unknown as { value: string }).value), 114 | }, 115 | }), 116 | ), 117 | ).toBe(``); 118 | }); 119 | 120 | test("renderHTML with simple selector syntax", () => { 121 | expect( 122 | renderHTML( 123 | // 124 | div["hello"]("test"), 125 | ), 126 | ).toBe(`
test
`); 127 | }); 128 | 129 | test("renderHTML with multiple selector syntax", () => { 130 | expect( 131 | renderHTML( 132 | // 133 | div.hello.world("test"), 134 | ), 135 | ).toBe(`
test
`); 136 | }); 137 | 138 | test("renderHTML with complex selector syntax", () => { 139 | expect( 140 | renderHTML( 141 | // 142 | div[".hello#id.world"].container("test"), 143 | ), 144 | ).toBe(`
test
`); 145 | }); 146 | 147 | test("renderHTML with selector syntax AND attributes", () => { 148 | expect( 149 | renderHTML( 150 | // 151 | div[".hello#id.world"].container({ class: "flex", title: "Flex Container" }, "test"), 152 | ), 153 | ).toBe(`
test
`); 154 | }); 155 | -------------------------------------------------------------------------------- /packages/hyper/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Attributes } from "./attributes.ts"; 2 | export { h, trust, HyperNode, type HyperChild, type HyperNodeish, isHyperNodeish } from "./node.ts"; 3 | export { Context } from "./context.ts"; 4 | export { State, ReadonlyState } from "./state.ts"; 5 | export { List, ReadonlyList, Member, ReadonlyMember } from "./list.ts"; 6 | export { renderHTML } from "./render/html.ts"; 7 | export { renderDOM } from "./render/dom.ts"; 8 | 9 | // export * from "./domutils.ts"; 10 | // export * from "./history.ts"; 11 | // export * from "./router.ts"; 12 | -------------------------------------------------------------------------------- /packages/hyper/src/lib/dom.extra.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Console, 3 | Document, 4 | GlobalEventHandlersEventMap, 5 | WindowOrWorkerGlobalScope, 6 | } from "./dom.ts"; 7 | export type { Document, HTMLElement, HTMLElementTagNameMap, Node, Text } from "./dom.ts"; 8 | 9 | export const domGlobal = globalThis as unknown as { 10 | document?: Document; 11 | setTimeout?: WindowOrWorkerGlobalScope["setTimeout"]; 12 | clearTimeout?: WindowOrWorkerGlobalScope["clearTimeout"]; 13 | setInterval?: WindowOrWorkerGlobalScope["setInterval"]; 14 | clearInterval?: WindowOrWorkerGlobalScope["clearInterval"]; 15 | console?: Console; 16 | }; 17 | 18 | type EMap = GlobalEventHandlersEventMap; 19 | 20 | export type DOMEvents = { 21 | on: { [Event in keyof EMap]?: (e: EMap[Event]) => void }; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/hyper/src/lib/emptyElements.ts: -------------------------------------------------------------------------------- 1 | import type { SetContents } from "../util.ts"; 2 | 3 | export const EmptyElements = new Set([ 4 | "area", 5 | "base", 6 | "br", 7 | "col", 8 | "embed", 9 | "hr", 10 | "img", 11 | "input", 12 | "link", 13 | "meta", 14 | "source", 15 | "track", 16 | "wbr", 17 | ] as const); 18 | 19 | export type EmptyElements = SetContents; 20 | -------------------------------------------------------------------------------- /packages/hyper/src/lib/tags.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by a script. 2 | // Do not manually edit this file. 3 | 4 | export type CustomTag = `${string}-${string}`; 5 | 6 | export type Tag = 7 | | CustomTag 8 | | "a" 9 | | "abbr" 10 | | "address" 11 | | "area" 12 | | "article" 13 | | "aside" 14 | | "audio" 15 | | "b" 16 | | "base" 17 | | "bdi" 18 | | "bdo" 19 | | "blockquote" 20 | | "body" 21 | | "br" 22 | | "button" 23 | | "canvas" 24 | | "caption" 25 | | "cite" 26 | | "code" 27 | | "col" 28 | | "colgroup" 29 | | "data" 30 | | "datalist" 31 | | "dd" 32 | | "del" 33 | | "details" 34 | | "dfn" 35 | | "dialog" 36 | | "div" 37 | | "dl" 38 | | "dt" 39 | | "em" 40 | | "embed" 41 | | "fencedframe" 42 | | "fieldset" 43 | | "figcaption" 44 | | "figure" 45 | | "footer" 46 | | "form" 47 | | "h1" 48 | | "h2" 49 | | "h3" 50 | | "h4" 51 | | "h5" 52 | | "h6" 53 | | "head" 54 | | "header" 55 | | "hgroup" 56 | | "hr" 57 | | "html" 58 | | "i" 59 | | "iframe" 60 | | "img" 61 | | "input" 62 | | "ins" 63 | | "kbd" 64 | | "label" 65 | | "legend" 66 | | "li" 67 | | "link" 68 | | "main" 69 | | "map" 70 | | "mark" 71 | | "math" 72 | | "menu" 73 | | "meta" 74 | | "meter" 75 | | "nav" 76 | | "noscript" 77 | | "object" 78 | | "ol" 79 | | "optgroup" 80 | | "option" 81 | | "output" 82 | | "p" 83 | | "picture" 84 | | "pre" 85 | | "progress" 86 | | "q" 87 | | "rp" 88 | | "rt" 89 | | "ruby" 90 | | "s" 91 | | "samp" 92 | | "script" 93 | | "search" 94 | | "section" 95 | | "select" 96 | | "selectedcontent" 97 | | "slot" 98 | | "small" 99 | | "source" 100 | | "span" 101 | | "strong" 102 | | "style" 103 | | "sub" 104 | | "summary" 105 | | "sup" 106 | | "svg" 107 | | "table" 108 | | "tbody" 109 | | "td" 110 | | "template" 111 | | "textarea" 112 | | "tfoot" 113 | | "th" 114 | | "thead" 115 | | "time" 116 | | "title" 117 | | "tr" 118 | | "track" 119 | | "u" 120 | | "ul" 121 | | "var" 122 | | "video" 123 | | "wbr"; 124 | -------------------------------------------------------------------------------- /packages/hyper/src/node.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "bun:test"; 2 | 3 | import { h, normaliseParams } from "./node.ts"; 4 | import { State } from "./state.ts"; 5 | import { List } from "./list.ts"; 6 | import { p } from "./elements.ts"; 7 | 8 | const testNode = h("p", "test"); 9 | const testState = new State("5"); 10 | const testReadonlyState = testState.to(v => String(v + 5)); 11 | const testListState = new List([5, 10]).each(v => h("p", String(v))); 12 | const element = p(testState, "/", testState); 13 | 14 | test("normaliseParams: string", () => { 15 | expect(normaliseParams("test")).toEqual({ attrs: {}, children: ["test"] }); 16 | }); 17 | 18 | test("normaliseParams: node", () => { 19 | expect(normaliseParams(testNode)).toEqual({ attrs: {}, children: [testNode] }); 20 | }); 21 | 22 | test("normaliseParams: attributes only", () => { 23 | expect(normaliseParams({ id: "test" })).toEqual({ attrs: { id: "test" }, children: [] }); 24 | }); 25 | 26 | test("normaliseParams: attributes and string child", () => { 27 | expect(normaliseParams({ id: "test" }, ["test"])).toEqual({ attrs: { id: "test" }, children: ["test"] }); 28 | }); 29 | 30 | test("normaliseParams: attributes and node child", () => { 31 | expect(normaliseParams({ id: "test" }, [testNode])).toEqual({ 32 | attrs: { id: "test" }, 33 | children: [testNode], 34 | }); 35 | }); 36 | 37 | test("normaliseParams: state", () => { 38 | expect(normaliseParams(testState)).toEqual({ attrs: {}, children: [testState] }); 39 | }); 40 | 41 | test("normaliseParams: readonly state", () => { 42 | expect(normaliseParams(testReadonlyState)).toEqual({ attrs: {}, children: [testReadonlyState] }); 43 | }); 44 | 45 | test("normaliseParams: list state", () => { 46 | expect(normaliseParams(testListState)).toEqual({ attrs: {}, children: [testListState] }); 47 | }); 48 | 49 | test("normaliseParams: state with children", () => { 50 | expect(element).toEqual({ 51 | tag: "p", 52 | attrs: {}, 53 | children: [testState, "/", testState], 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /packages/hyper/src/node.ts: -------------------------------------------------------------------------------- 1 | import type { Tag } from "./lib/tags.ts"; 2 | import type { EmptyElements } from "./lib/emptyElements.ts"; 3 | import type { Attributes } from "./attributes.ts"; 4 | import { Falsy, isFalsy, isNonNullable } from "./util.ts"; 5 | import { ReadonlyState, State } from "./state.ts"; 6 | import * as Context from "./context.internal.ts"; 7 | import { Context as Context2 } from "./context.ts"; 8 | import { List, ReadonlyList } from "./list.ts"; 9 | 10 | export type NonEmptyElement = Exclude; 11 | 12 | export type HyperTextNode = string; 13 | 14 | export class HyperHTMLStringNode { 15 | constructor(public htmlString: string) {} 16 | } 17 | 18 | export class HyperComment { 19 | constructor(public text: string) {} 20 | } 21 | 22 | export class HyperNode { 23 | constructor(public tag: T, public attrs: Attributes, public children: HyperNodeish[]) {} 24 | } 25 | 26 | export type HyperChild = 27 | | HyperNode 28 | | HyperHTMLStringNode 29 | | HyperTextNode 30 | | HyperComment; 31 | 32 | export type HyperNodeish = 33 | | HyperChild 34 | | Falsy 35 | | Array 36 | | ReadonlyState 37 | | ReadonlyList 38 | | List 39 | | Context.Provider 40 | | Context.Consumer; 41 | 42 | export const isHyperChild = (n: any): n is HyperChild => 43 | n instanceof HyperNode || 44 | n instanceof HyperHTMLStringNode || 45 | n instanceof HyperComment || 46 | typeof n === "string" || 47 | Array.isArray(n) || 48 | ReadonlyState.isState(n) || 49 | List.isList(n); 50 | 51 | export const isHyperNodeish = (x: any): x is HyperNodeish => 52 | isHyperChild(x) || isFalsy(x) || State.isState(x) || Context2.isContext(x); 53 | 54 | export function normaliseParams( 55 | props?: Attributes | HyperNodeish, 56 | childNodes?: HyperNodeish[], 57 | ) { 58 | const [attrs, children]: [Attributes, HyperNodeish[]] = isHyperNodeish(props) 59 | ? [{}, [props, ...(childNodes || [])]] 60 | : [props || {}, childNodes || []]; 61 | 62 | return { attrs, children }; 63 | } 64 | 65 | export function h>( 66 | elem: Tag, 67 | props?: Attrs | Falsy, 68 | ): HyperNode; 69 | 70 | export function h( 71 | elem: Tag, 72 | ...children: HyperNodeish[] 73 | ): HyperNode; 74 | 75 | export function h>( 76 | elem: Tag, 77 | props: Attrs, 78 | ...children: HyperNodeish[] 79 | ): HyperNode; 80 | 81 | export function h>( 82 | elem: Tag, 83 | props?: Attrs | HyperNodeish | Falsy, 84 | ...children: HyperNodeish[] 85 | ): HyperNode; 86 | 87 | export function h>( 88 | elem: Tag, 89 | props?: Attrs | HyperNodeish | Falsy, 90 | ): HyperNode; 91 | 92 | export function h( 93 | tag: T, 94 | props?: Attributes | HyperNodeish, 95 | ...childNodes: HyperNodeish[] 96 | ): HyperNode { 97 | const { attrs, children } = normaliseParams(props, childNodes); 98 | return new HyperNode(tag, attrs, children.filter(isNonNullable)); 99 | } 100 | 101 | export function trust(html: string) { 102 | return new HyperHTMLStringNode(html); 103 | } 104 | -------------------------------------------------------------------------------- /packages/hyper/src/parse.ts: -------------------------------------------------------------------------------- 1 | export type Attrs = { 2 | tag: string; 3 | id: string; 4 | class: string[]; 5 | }; 6 | 7 | const digits = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]); 8 | 9 | export const parseSelector = (selector: string, { tagMode = false } = {}) => { 10 | selector = selector.trim(); 11 | 12 | const attrs: Partial = { tag: undefined, id: undefined }; 13 | const classlist = new Set(); 14 | 15 | let started = false, 16 | buffer = "", 17 | bufferType: keyof Attrs | "tag" | undefined = undefined; 18 | 19 | const flush = () => { 20 | if (buffer) { 21 | buffer = buffer.trim(); 22 | if (bufferType) 23 | if (bufferType === "id" && attrs.id) throw new Error(`Cannot declare multiple IDs: ${attrs.id} ${buffer}`); 24 | else if (bufferType === "tag" || bufferType == "id") attrs[bufferType] = buffer; 25 | else classlist.add(buffer); 26 | } 27 | buffer = ""; 28 | bufferType = undefined; 29 | }; 30 | 31 | const update = (char: string, type?: keyof typeof attrs) => { 32 | // !buffer implies this is the first character of current match 33 | if (!buffer) 34 | if (char === "-" || char === "_" || digits.has(char)) 35 | // if match starts with -_0-9, error 36 | throw new Error(`${bufferType || type} cannot start with char: ${char}`); 37 | 38 | buffer += char; 39 | if (type) bufferType = type; 40 | }; 41 | 42 | for (const char of selector) { 43 | if (char === " ") 44 | if (bufferType === "id") update(char); 45 | else flush(); 46 | else if (char === ".") flush(), (bufferType = "class"); 47 | else if (char === "#") flush(), (bufferType = "id"); 48 | else if (!started && char) update(char, tagMode ? "tag" : "class"); 49 | else if (bufferType) update(char); 50 | else update(char, "class"); 51 | 52 | started = true; 53 | } 54 | // attempt an update on end of string 55 | flush(); 56 | 57 | attrs.class = [...classlist]; 58 | 59 | if (!attrs.tag) { 60 | if (tagMode) attrs.tag = "div"; 61 | else attrs.tag = ""; 62 | } 63 | 64 | return attrs; 65 | }; 66 | -------------------------------------------------------------------------------- /packages/hyper/src/render/html.ts: -------------------------------------------------------------------------------- 1 | import { HyperComment, HyperHTMLStringNode, type HyperNodeish } from "../node.ts"; 2 | import { EmptyElements } from "../lib/emptyElements.ts"; 3 | import { ReadonlyState, State } from "../state.ts"; 4 | import { escapeAttr, escapeTextNode, isFalsy } from "../util.ts"; 5 | import type { Tag } from "../lib/tags.ts"; 6 | import type { Attributes } from "../attributes.ts"; 7 | import { List } from "../list.ts"; 8 | import * as Context from "../context.internal.ts"; 9 | 10 | function aria(attrs: Attributes["aria"]) { 11 | if (!attrs) return ""; 12 | 13 | return Object.entries(attrs) 14 | .map(([attr, v]) => { 15 | const key = `aria-${attr}`; 16 | let value = ReadonlyState.isState(v) ? v.value : v; 17 | // @ts-expect-error TODO: some aria properties will allow booleans in the future 18 | if (value === true) value = ""; 19 | if (!value) return ""; 20 | return `${key}="${value}"`; 21 | }) 22 | .join(" "); 23 | } 24 | 25 | function attrifyHTML(attrs: Attributes): string { 26 | return Object.entries(attrs) 27 | .map(([k, v]) => { 28 | const attr = k as keyof Attributes; 29 | 30 | if (attr === "on") return false; 31 | 32 | if (attr === "aria") { 33 | const value = attrs[attr]; 34 | return aria(value); 35 | } 36 | 37 | if (attr === "init" || attr === "attach") return false; 38 | 39 | let value = v as Attributes[typeof attr]; 40 | if (ReadonlyState.isState(value)) value = value.value; 41 | 42 | if (value === true) return attr; 43 | if (value === "") return attr; 44 | if (Array.isArray(value)) return `${attr}="${escapeAttr(value.filter(Boolean).join(" "))}"`; 45 | if (value) return `${attr}="${escapeAttr(String(value))}"`; 46 | }) 47 | .filter(Boolean) 48 | .join(" "); 49 | } 50 | 51 | export interface Environment { 52 | registry: ReadonlyMap>; 53 | } 54 | 55 | const comment = ""; 56 | 57 | function toHTML(node: HyperNodeish, environment: Environment): string { 58 | if (node instanceof Context.Provider) { 59 | const registry = new Map(environment.registry); 60 | registry.set(node.contextId, node); 61 | return toHTML(node.contextualChild, { ...environment, registry }); 62 | } 63 | 64 | if (node instanceof Context.Consumer) { 65 | const ctx = environment.registry.get(node.contextId); 66 | if (!ctx) 67 | throw new Error( 68 | `Requested context for (id: ${String(node.contextId).slice( 69 | 7, 70 | -1, 71 | )}) not found. Was the Context Provider used?`, 72 | ); 73 | return toHTML(node.renderWithContext(ctx.contextValue), environment); 74 | } 75 | 76 | if (node instanceof HyperHTMLStringNode) return node.htmlString; 77 | if (State.isState(node)) return renderHTML(node.value); 78 | if (List.isList(node)) return comment + node.toArray().map(renderHTML).join("") + comment; 79 | if (Array.isArray(node)) return comment + node.map(renderHTML).join("") + comment; 80 | 81 | if (typeof node === "string") return escapeTextNode(node); 82 | if (isFalsy(node)) return ""; 83 | if (node instanceof HyperComment) return ""; 84 | 85 | let stringified = "<" + node.tag; 86 | 87 | const attr = attrifyHTML(node.attrs); 88 | 89 | if (attr) stringified += " " + attr; 90 | 91 | if (EmptyElements.has(node.tag as EmptyElements)) stringified += " />"; 92 | else if (node.children.length) 93 | stringified += 94 | ">" + node.children.map(child => toHTML(child, environment)).join("") + ``; 95 | else stringified += `>`; 96 | 97 | return stringified; 98 | } 99 | 100 | export function renderHTML(node: HyperNodeish): string { 101 | return toHTML(node, { registry: new Map() }); 102 | } 103 | -------------------------------------------------------------------------------- /packages/hyper/src/router.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck This file is not yet ready for use 2 | 3 | import { type HyperNode } from "./node.ts"; 4 | import { State } from "./state.ts"; 5 | 6 | type RouteFragment = [(path: string) => boolean, HyperNode | null]; 7 | 8 | export function leaf( 9 | pathlike: (path: string) => boolean, 10 | node: HyperNode | null, 11 | ): RouteFragment { 12 | return [pathlike, node]; 13 | } 14 | 15 | export function router(...routes: RouteFragment[]): State | null> { 16 | const match = (routes: RouteFragment[], location: Location): HyperNode | null => { 17 | for (const [pattern, node] of routes) if (pattern(location.pathname)) return node; 18 | return null; 19 | }; 20 | 21 | // Initial setup 22 | const state = new State | null>(match(routes, history.location)); 23 | 24 | // Update when history is updated 25 | history.listen(update => state.set(match(routes, update.location))); 26 | 27 | return state; 28 | } 29 | -------------------------------------------------------------------------------- /packages/hyper/src/state.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, jest } from "bun:test"; 2 | import { setTimeout as sleep } from "timers/promises"; 3 | import { State } from "./state"; 4 | 5 | describe("State", () => { 6 | it("should be able to be created", () => { 7 | const state = new State(0); 8 | expect(state.value).toBe(0); 9 | }); 10 | 11 | it("should be able to be reference-checked", () => { 12 | const state = new State(0); 13 | expect(State.isState(state)).toBe(true); 14 | expect(State.isState(state.readonly())).toBe(true); 15 | expect(State.isState(1)).toBe(false); 16 | expect(State.isState({})).toBe(false); 17 | }); 18 | 19 | it("should be able to be updated", () => { 20 | const state = new State(0); 21 | state.set(1); 22 | expect(state.value).toBe(1); 23 | }); 24 | 25 | it("should be able to be updated with a function", () => { 26 | const state = new State(0); 27 | state.setWith(value => value + 1); 28 | state.setWith(value => value + 1); 29 | expect(state.value).toBe(2); 30 | }); 31 | 32 | it("should be able to be listened to", () => { 33 | const state = new State(0); 34 | const listener = jest.fn(); 35 | state.listen(listener); 36 | state.set(1); 37 | expect(listener).toHaveBeenCalledWith(1); 38 | }); 39 | 40 | it("should be able to be unlistened to", () => { 41 | const state = new State(0); 42 | const listener = jest.fn(); 43 | const unsubscribe = state.listen(listener); 44 | state.set(1); 45 | unsubscribe(); 46 | state.set(2); 47 | expect(listener).toHaveBeenCalledWith(1); 48 | expect(listener).not.toHaveBeenCalledWith(2); 49 | }); 50 | 51 | it("should be able to be readonly", () => { 52 | const state = new State(0); 53 | const readonly = state.readonly(); 54 | expect(readonly.value).toBe(0); 55 | }); 56 | 57 | it("should be able to update a readonly", () => { 58 | const state = new State(0); 59 | const readonly = state.readonly(); 60 | state.set(1); 61 | expect(readonly.value).toBe(1); 62 | }); 63 | 64 | it("should be transformable", () => { 65 | const state = new State(0); 66 | const transformed = state.to(value => value + 1); 67 | state.set(100); 68 | expect(transformed.value).toBe(101); 69 | }); 70 | 71 | it("should be able to be filtered", () => { 72 | const state = new State(0); 73 | const filtered = state.if(value => value % 2 === 0); 74 | state.set(1); 75 | expect(filtered.value).toBe(0); 76 | state.set(2); 77 | expect(filtered.value).toBe(2); 78 | }); 79 | 80 | it("should call listeners when filtered", () => { 81 | const state = new State(0); 82 | const filtered = state.if(value => value % 2 === 0); 83 | const listener = jest.fn(); 84 | filtered.listen(listener); 85 | state.set(1); 86 | expect(listener).not.toHaveBeenCalled(); 87 | state.set(2); 88 | expect(listener).toHaveBeenCalledWith(2); 89 | }); 90 | 91 | it("should be able to be composed", () => { 92 | const a = new State(0); 93 | const b = new State(1); 94 | const composed = State.compose({ a, b }); 95 | a.set(100); 96 | b.set(200); 97 | expect(composed.value).toEqual({ a: 100, b: 200 }); 98 | }); 99 | 100 | it("should call listeners when composed", () => { 101 | const a = new State(0); 102 | const b = new State(1); 103 | const composed = State.compose({ a, b }); 104 | const listener = jest.fn(); 105 | composed.listen(listener); 106 | a.set(100); 107 | expect(listener).toHaveBeenCalledWith({ a: 100, b: 1 }); 108 | }); 109 | 110 | it("should flow into another state", () => { 111 | const a = new State(0); 112 | const b = new State(1); 113 | a.pipe(b); 114 | a.set(100); 115 | expect(b.value).toBe(100); 116 | }); 117 | 118 | it("should flow into a composed state", () => { 119 | const a = new State(0); 120 | const b = new State(1); 121 | const c = State.compose({ a, b }); 122 | a.pipe(b); 123 | a.set(100); 124 | expect(c.value).toEqual({ a: 100, b: 100 }); 125 | }); 126 | 127 | it("should be able to be debounced", async () => { 128 | const state = new State(0); 129 | const debounced = state.debounce(100); 130 | 131 | const listener = jest.fn(); 132 | debounced.listen(listener); 133 | 134 | state.set(1); 135 | expect(state.value).toBe(1); 136 | 137 | state.set(2); 138 | expect(state.value).toBe(2); 139 | 140 | expect(debounced.value).toBe(0); 141 | 142 | await sleep(10); 143 | 144 | state.set(3); 145 | expect(state.value).toBe(3); 146 | 147 | expect(debounced.value).toBe(0); 148 | 149 | await sleep(100); 150 | expect(debounced.value).toBe(3); 151 | 152 | expect(listener).toHaveBeenCalledWith(3); 153 | expect(listener).toHaveBeenCalledTimes(1); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /packages/hyper/src/state.ts: -------------------------------------------------------------------------------- 1 | import { domGlobal } from "./lib/dom.extra"; 2 | 3 | export type Subscriber = (value: any) => void; 4 | 5 | type ComposedStateValue> = { 6 | [key in keyof Obj]: Obj[key]["value"]; 7 | }; 8 | 9 | export class ReadonlyState { 10 | protected subscribers: Subscriber[] = []; 11 | protected state: { value: T }; 12 | 13 | constructor(value: T, private source?: ReadonlyState) { 14 | this.state = { value }; 15 | if (source) source.listen(value => (this.state.value = value)); 16 | } 17 | 18 | get value(): T { 19 | return this.state.value; 20 | } 21 | 22 | to(transformer: (t: T) => U): ReadonlyState { 23 | const s = new State(null as U); 24 | // publish transformed changes when value changes 25 | this.listen(value => s.set(transformer(value))); 26 | // This has to be done after the above listener is attached. 27 | // Otherwise if this.set is called within the transformer, 28 | // the listener will not be called, and `s` will not get updated 29 | s.set(transformer(this.value)); 30 | // return readonly so transformed state can't be published into 31 | return s.readonly(); 32 | } 33 | 34 | pipe(state: State) { 35 | this.listen(value => state.set(value)); 36 | } 37 | 38 | static isState(x: any): x is State | ReadonlyState { 39 | return x instanceof State || x instanceof ReadonlyState; 40 | } 41 | 42 | /** 43 | * Compose multiple states into a single state 44 | */ 45 | static compose( 46 | refs: RefMap, 47 | ): ReadonlyState> { 48 | type Composed = ComposedStateValue; 49 | 50 | // lazily initialised below 51 | const initialValue = {} as Composed; 52 | const merged = new State(initialValue); 53 | 54 | for (const key in refs) { 55 | initialValue[key] = refs[key].value; 56 | refs[key].listen(updated => merged.setWith(value => ({ ...value, [key]: updated }))); 57 | } 58 | 59 | return merged.readonly(); 60 | } 61 | 62 | if(predicate: (value: T) => boolean): ReadonlyState { 63 | const filtered = new State(predicate(this.value) ? this.value : null); 64 | this.listen(value => { 65 | if (predicate(value)) filtered.set(value); 66 | }); 67 | return filtered.readonly(); 68 | } 69 | 70 | debounce(ms: number): ReadonlyState { 71 | const setTimeout = domGlobal.setTimeout; 72 | const clearTimeout = domGlobal.clearTimeout; 73 | 74 | if (!setTimeout || !clearTimeout) { 75 | throw new Error("setTimeout and clearTimeout are not available"); 76 | } 77 | 78 | const debounced = new State(this.value); 79 | let timeout: ReturnType; 80 | 81 | this.listen(value => { 82 | clearTimeout(timeout); 83 | timeout = setTimeout(() => debounced.set(value), ms); 84 | }); 85 | 86 | return debounced.readonly(); 87 | } 88 | 89 | listen(listener: (value: T) => void): () => void { 90 | if (this.source) return this.source.listen(listener); 91 | 92 | this.subscribers.push(listener); 93 | // Return cleanup function 94 | return () => { 95 | const index = this.subscribers.indexOf(listener); 96 | if (index > -1) this.subscribers.splice(index, 1); 97 | }; 98 | } 99 | } 100 | 101 | export class State extends ReadonlyState { 102 | constructor(value: T) { 103 | super(value); 104 | } 105 | 106 | set(next: T) { 107 | if (this.state.value === next) return this.value; 108 | this.state.value = next; 109 | this.subscribers.forEach(subscriber => subscriber(this.value)); 110 | return this.value; 111 | } 112 | 113 | setWith(updater: (value: T) => T) { 114 | return this.set(updater(this.value)); 115 | } 116 | 117 | readonly(): ReadonlyState { 118 | return new ReadonlyState(this.value, this); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /packages/hyper/src/util.ts: -------------------------------------------------------------------------------- 1 | import { ReadonlyState } from "./state"; 2 | 3 | export type Distribute = { [K in T]: U }[T]; 4 | export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( 5 | k: infer I, 6 | ) => void 7 | ? I 8 | : never; 9 | export type Keyof = Extract; 10 | 11 | const escapables = { 12 | "<": "<", 13 | ">": ">", 14 | "&": "&", 15 | "'": "'", 16 | '"': """, 17 | }; 18 | 19 | export const escapeAttr = (s: string) => 20 | s.replace(/<|>|&|"|'/g, r => escapables[r as keyof typeof escapables] || r); 21 | 22 | export const escapeTextNode = (s: string) => 23 | s.replace(/<|>|&/g, r => escapables[r as keyof typeof escapables] || r); 24 | 25 | export type SetContents = T extends Set ? U : never; 26 | 27 | // no 0n since bigint support is 2020+ 28 | export const Falsy = new Set([false, "", 0, null, undefined] as const); 29 | export type Falsy = SetContents; 30 | export type MaybeString = string | Falsy; 31 | export type MaybeArray = T | T[]; 32 | export type MaybeState = T | ReadonlyState; 33 | 34 | // deno-lint-ignore no-explicit-any 35 | export const isFalsy = (n: any): n is Falsy => { 36 | if (n == null) return true; 37 | if (n === false) return true; 38 | if (n === 0) return true; 39 | if (n === "") return true; 40 | return false; 41 | }; 42 | 43 | export const isNonNullable = (n: T): n is NonNullable => n != null; 44 | 45 | export const unreachable = (arg: never): never => { 46 | throw new Error("Unreachable"); 47 | }; 48 | 49 | export const randId = () => Math.random().toString(36).substring(2, 10); 50 | -------------------------------------------------------------------------------- /packages/hyper/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ES2022"], 5 | "target": "ES2022", 6 | "module": "ES6", 7 | "moduleDetection": "force", 8 | "moduleResolution": "node", 9 | 10 | "allowImportingTsExtensions": true, 11 | "rewriteRelativeImportExtensions": true, 12 | "declaration": true, 13 | 14 | // Best practices 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "noFallthroughCasesInSwitch": true, 18 | 19 | // Some stricter flags (disabled by default) 20 | "noUnusedLocals": false, 21 | "noUnusedParameters": false, 22 | "noPropertyAccessFromIndexSignature": false, 23 | 24 | "outDir": "./lib", 25 | 26 | "types": [] 27 | }, 28 | "include": ["src/**/*"], 29 | "exclude": ["src/**/*.test.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/mark/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /packages/mark/README.md: -------------------------------------------------------------------------------- 1 | # @hyperactive/mark 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run src/index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/mark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hyperactive/mark", 3 | "module": "src/index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5.0.0" 10 | } 11 | } -------------------------------------------------------------------------------- /packages/mark/src/Context.ts: -------------------------------------------------------------------------------- 1 | import type { HypermarkDocument } from "./types.ts"; 2 | import { ParseError, count_char, get_line_neighbours, squiggly } from "./common.ts"; 3 | 4 | export interface ParseOptions { 5 | tab_size?: number; 6 | filename?: string; 7 | } 8 | 9 | const capture_error_presentation = (ctx: ParserContext, error: string) => { 10 | const file = ctx.filename ?? ":"; 11 | let message = "\n\n\n"; 12 | message += get_line_neighbours(ctx.input, ctx.line).join("\n"); 13 | message += `\n${squiggly(ctx.column)}\n`; 14 | message += `\n${error}\n`; 15 | message += " ".repeat(8) + `at ${file}:${ctx.line}:${ctx.column}\n`; 16 | return message; 17 | }; 18 | 19 | export function is_newline(ctx: ParserContext) { 20 | return ctx.is("\n"); 21 | } 22 | 23 | export function is_inline_whitespace(ctx: ParserContext) { 24 | return ctx.is(" ") || ctx.is("\t"); 25 | } 26 | 27 | export function is_whitespace(ctx: ParserContext) { 28 | return is_inline_whitespace(ctx) || is_newline(ctx); 29 | } 30 | 31 | export class ParserContext { 32 | index: number; 33 | line: number; 34 | column: number; 35 | tab_size: number; 36 | filename?: string; 37 | 38 | constructor(public input: string, public doc: HypermarkDocument, options?: ParseOptions) { 39 | this.index = 0; 40 | this.line = 1; 41 | this.column = 1; 42 | this.tab_size = options?.tab_size ?? 4; 43 | this.filename = options?.filename; 44 | } 45 | 46 | error(expected: string, found: string) { 47 | let message = `Expected "${expected}", found "${found.replace(/\n/g, "\\n")}"\n`; 48 | return new ParseError(this.index, this.line, this.column, message, this.filename); 49 | } 50 | 51 | unexpected(found: string) { 52 | let message = `Unexpected "${found.replace(/\n/g, "\\n")}"\n`; 53 | const presentation = capture_error_presentation(this, message); 54 | return new ParseError(this.index, this.line, this.column, presentation, this.filename); 55 | } 56 | 57 | peek(offset: number = 0, count: number = 1) { 58 | return this.input.slice(this.index + offset, this.index + offset + count); 59 | } 60 | 61 | checkpoint() { 62 | const i = this.index; 63 | const l = this.line; 64 | const c = this.column; 65 | 66 | return (): undefined => { 67 | this.index = i; 68 | this.line = l; 69 | this.column = c; 70 | return undefined; 71 | }; 72 | } 73 | 74 | eof() { 75 | return this.index >= this.input.length; 76 | } 77 | 78 | is(...str: (string | RegExp)[]) { 79 | return str.some(s => { 80 | if (typeof s === "string") return this.peek(0, s.length) === s; 81 | return s.test(this.peek()); 82 | }); 83 | } 84 | 85 | not(...str: (string | RegExp)[]) { 86 | return !this.is(...str); 87 | } 88 | 89 | consume(n: number | string = 1) { 90 | if (typeof n === "string") { 91 | if (this.not(n)) throw this.error(n, this.peek() ?? "EOF"); 92 | n = n.length; 93 | } 94 | 95 | const slice = this.input.slice(this.index, this.index + n); 96 | const count = count_char(this.input, "\n", this.index, this.index + n); 97 | this.index += n; 98 | 99 | if (count.count > 0) { 100 | // If we found newlines, update line and reset column 101 | this.line += count.count; 102 | // Column should be the number of characters after the last newline + 1 103 | this.column = count.last_index === -1 ? 1 : n - count.last_index; 104 | } else { 105 | // If no newlines, just increment the column 106 | this.column += n; 107 | } 108 | 109 | return slice; 110 | } 111 | 112 | consume_if(str: string): string | undefined { 113 | if (this.is(str)) return this.consume(str.length); 114 | return undefined; 115 | } 116 | 117 | expect(str: string) { 118 | if (this.not(str)) throw this.error(str, this.peek()); 119 | this.consume(str.length); 120 | } 121 | 122 | nomnom() { 123 | while (is_inline_whitespace(this)) this.consume(); 124 | } 125 | 126 | nomnomnom() { 127 | while (is_whitespace(this)) this.consume(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /packages/mark/src/common.ts: -------------------------------------------------------------------------------- 1 | export class ParseError extends Error { 2 | constructor( 3 | public index: number, 4 | public line: number, 5 | public column: number, 6 | message: string, 7 | public filename?: string, 8 | ) { 9 | super(message); 10 | this.name = "ParseError"; 11 | } 12 | } 13 | 14 | export class DecoratorStartMarker { 15 | type: "decorator-start" = "decorator-start"; 16 | toString(): string { 17 | return ">"; 18 | } 19 | } 20 | 21 | export class DecoratorEndMarker { 22 | type: "decorator-end" = "decorator-end"; 23 | toString(): string { 24 | return "<@"; 25 | } 26 | } 27 | 28 | export const count_char = (input: string, char: string, from: number, to: number) => { 29 | let count = 0; 30 | let last_index = -1; 31 | for (let i = from; i < to; i++) { 32 | if (input[i] === char) { 33 | count++; 34 | last_index = i - from; 35 | } 36 | } 37 | return { count, last_index }; 38 | }; 39 | 40 | export const normal_line_number = (line_number: number) => { 41 | let num = line_number.toString(); 42 | if (num.length > 3) num = "-" + num.slice(-3); 43 | else if (num.length < 4) num = num.padStart(4, " "); 44 | return num; 45 | }; 46 | 47 | export const get_line_neighbours = ( 48 | input: string, 49 | line_number: number, 50 | count: number = 5, 51 | ): string[] => { 52 | const lines = input.split("\n"); 53 | return lines 54 | .slice(Math.max(0, line_number - count), line_number) 55 | .map((line, i) => `${normal_line_number(line_number - count + i)} | ${line}`); 56 | }; 57 | 58 | const RED = "\x1B[38;2;255;0;0m"; 59 | const RESET = "\x1B[0m"; 60 | 61 | export const squiggly = (column: number) => { 62 | return RED + " ".repeat(7 + column - 1) + "^^^" + RESET; 63 | }; 64 | 65 | export const limited_log = (n: number) => { 66 | return (...args: any[]) => { 67 | if (n > 0) { 68 | console.log(...args); 69 | n--; 70 | } 71 | }; 72 | }; 73 | -------------------------------------------------------------------------------- /packages/mark/src/hypermark.ts: -------------------------------------------------------------------------------- 1 | import { HypermarkDocument } from "./types.ts"; 2 | import { limited_log } from "./common.ts"; 3 | import { ParserContext } from "./Context.ts"; 4 | import { parse_blocks } from "./block.ts"; 5 | export interface ParseOptions { 6 | filename?: string; 7 | tab_size?: number; 8 | } 9 | 10 | export function parse(input: string, options?: ParseOptions) { 11 | const doc = new HypermarkDocument([]); 12 | const ctx = new ParserContext(input, doc, options); 13 | // @ts-expect-error normalise decorators after parsing 14 | doc.blocks = parse_blocks(ctx, true); 15 | return doc; 16 | } 17 | -------------------------------------------------------------------------------- /packages/mark/src/index.ts: -------------------------------------------------------------------------------- 1 | export * as Types from "./types.ts"; 2 | -------------------------------------------------------------------------------- /packages/mark/src/value.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "bun:test"; 2 | import { try_param_list, try_param_object, try_param_string } from "./value.ts"; 3 | import { ParserContext } from "./Context.ts"; 4 | import { HypermarkDocument } from "./types.ts"; 5 | 6 | const getCtx = (input: string) => { 7 | const doc = new HypermarkDocument(); 8 | return new ParserContext(input, doc); 9 | }; 10 | 11 | describe("Value", () => { 12 | describe("try_param_string", () => { 13 | it("parses a string surrounded by single quotes", () => { 14 | const ctx = getCtx("'Hello world'"); 15 | 16 | expect(try_param_string(ctx)).toBe("Hello world"); 17 | }); 18 | 19 | it("parses a string surrounded by double quotes", () => { 20 | const ctx = getCtx('"Hello world"'); 21 | expect(try_param_string(ctx)).toBe("Hello world"); 22 | }); 23 | 24 | it("parses a string with escaped quotes", () => { 25 | const ctx = getCtx("'Hello \\'world\\''"); 26 | expect(try_param_string(ctx)).toBe("Hello 'world'"); 27 | }); 28 | 29 | it("parses a string with escaped backslashes", () => { 30 | const ctx = getCtx("'Hello \\\\world\\\\'"); 31 | expect(try_param_string(ctx)).toBe("Hello \\world\\"); 32 | }); 33 | 34 | it("parses an incomplete string", () => { 35 | const ctx = getCtx("'Hello world"); 36 | expect(try_param_string(ctx)).toBeUndefined(); 37 | }); 38 | 39 | it("parses a string with a newline", () => { 40 | const ctx = getCtx("'Hello\nworld'"); 41 | expect(try_param_string(ctx)).toBeUndefined(); 42 | }); 43 | }); 44 | 45 | describe("try_param_list", () => { 46 | it("parses a list of values", () => { 47 | const ctx = getCtx("[1, 2, 3]"); 48 | expect(try_param_list(ctx)).toEqual([1, 2, 3]); 49 | }); 50 | 51 | it("parses a list of values with whitespace", () => { 52 | const ctx = getCtx("[1\t,\n 2,\n\t3]"); 53 | expect(try_param_list(ctx)).toEqual([1, 2, 3]); 54 | }); 55 | 56 | it("parses a list of values with a trailing comma", () => { 57 | const ctx = getCtx("[1, 2, 3,]"); 58 | expect(try_param_list(ctx)).toEqual([1, 2, 3]); 59 | }); 60 | 61 | it("parses a list of values with a trailing comma and whitespace", () => { 62 | const ctx = getCtx("[1, 2, 3,]"); 63 | expect(try_param_list(ctx)).toEqual([1, 2, 3]); 64 | }); 65 | 66 | it("bails on an invalid list", () => { 67 | const ctx = getCtx("[,]"); 68 | expect(try_param_list(ctx)).toBeUndefined(); 69 | }); 70 | 71 | it("bails on an incomplete list", () => { 72 | const ctx = getCtx("[1, 2, 3"); 73 | expect(try_param_list(ctx)).toBeUndefined(); 74 | }); 75 | }); 76 | 77 | describe("try_param_object", () => { 78 | it("parses an empty object", () => { 79 | const ctx = getCtx("{}"); 80 | expect(try_param_object(ctx)).toEqual({}); 81 | }); 82 | 83 | it("parses an object with simple keys", () => { 84 | const ctx = getCtx("{a: 1, b: 2, c: 3}"); 85 | expect(try_param_object(ctx)).toEqual({ a: 1, b: 2, c: 3 }); 86 | }); 87 | 88 | it("parses an object with simple keys with trailing commas", () => { 89 | const ctx = getCtx("{a: 1, b: 2, c: 3,}"); 90 | expect(try_param_object(ctx)).toEqual({ a: 1, b: 2, c: 3 }); 91 | }); 92 | 93 | it("parses an object with some quoted keys", () => { 94 | const ctx = getCtx('{"a bird": 1, "by plane": 2, cat: 3}'); 95 | expect(try_param_object(ctx)).toEqual({ "a bird": 1, "by plane": 2, "cat": 3 }); 96 | }); 97 | 98 | it("parses an object with a trailing comma", () => { 99 | const ctx = getCtx('{"a": 1, "b": 2, "c": 3,}'); 100 | expect(try_param_object(ctx)).toEqual({ a: 1, b: 2, c: 3 }); 101 | }); 102 | 103 | it("parses an object with a trailing comma and whitespace", () => { 104 | const ctx = getCtx('{\t"a": 1,\n "b": 2, \t "c"\n: 3,}'); 105 | expect(try_param_object(ctx)).toEqual({ a: 1, b: 2, c: 3 }); 106 | }); 107 | 108 | it("bails on an invalid object", () => { 109 | const ctx = getCtx("{a: 1 b: 2, c: 3}"); 110 | expect(try_param_object(ctx)).toBeUndefined(); 111 | }); 112 | 113 | it("bails if the object is incomplete", () => { 114 | const ctx = getCtx('{"a": 1, "b": 2, "c": 3'); 115 | expect(try_param_object(ctx)).toBeUndefined(); 116 | }); 117 | 118 | it("bails if the object has too many commas", () => { 119 | { 120 | const ctx = getCtx('{"a": 1, "b": 2,, "c": 3,}'); 121 | expect(try_param_object(ctx)).toBeUndefined(); 122 | } 123 | { 124 | const ctx = getCtx('{"a": 1, "b": 2,"c": 3,,}'); 125 | expect(try_param_object(ctx)).toBeUndefined(); 126 | } 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /packages/mark/src/value.ts: -------------------------------------------------------------------------------- 1 | import type { Value } from "./types.ts"; 2 | import type { ParserContext } from "./Context.ts"; 3 | 4 | export function ident(ctx: ParserContext): string | undefined { 5 | let buffer = ""; 6 | if (ctx.is(/^[A-Za-z_]$/)) buffer += ctx.consume(); 7 | while (ctx.is(/^[A-Za-z0-9_-]$/)) buffer += ctx.consume(); 8 | return buffer.length > 0 ? buffer : undefined; 9 | } 10 | 11 | export function try_param_string(ctx: ParserContext): string | undefined { 12 | const revert = ctx.checkpoint(); 13 | 14 | const marker = ctx.consume_if('"') ?? ctx.consume_if("'"); 15 | 16 | if (marker === undefined) return undefined; 17 | 18 | let buffer = ""; 19 | while (true) { 20 | // if the next character is a backslash, escape the following character 21 | if (ctx.consume_if("\\")) { 22 | buffer += ctx.consume(); 23 | continue; 24 | } 25 | 26 | // found the marker, consume it and complete the string 27 | if (ctx.consume_if(marker)) break; 28 | 29 | // found end of file or newline before closing quote 30 | if (ctx.eof() || ctx.is("\n")) return revert(); 31 | 32 | buffer += ctx.consume(); 33 | } 34 | 35 | return buffer; 36 | } 37 | 38 | export function try_param_number(ctx: ParserContext): number | undefined { 39 | const revert = ctx.checkpoint(); 40 | let buffer = ""; 41 | 42 | while (ctx.is(/^[0-9]$/)) buffer += ctx.consume(); 43 | // if didn't find a number to parse 44 | if (buffer.length === 0) return revert(); 45 | 46 | if (ctx.is(".")) { 47 | buffer += ctx.consume(); 48 | let decimal = ""; 49 | while (ctx.is(/^[0-9]$/)) decimal += ctx.consume(); 50 | 51 | //if only found a dot, not a decimal 52 | if (decimal.length === 0) return revert(); 53 | 54 | buffer += "." + decimal; 55 | } 56 | 57 | return parseFloat(buffer); 58 | } 59 | 60 | export function try_param_null(ctx: ParserContext): null | undefined { 61 | if (ctx.consume_if("null")) return null; 62 | return undefined; 63 | } 64 | 65 | export function try_param_boolean(ctx: ParserContext): boolean | undefined { 66 | if (ctx.consume_if("true")) return true; 67 | if (ctx.consume_if("false")) return false; 68 | return undefined; 69 | } 70 | 71 | export function try_param_list(ctx: ParserContext): Value[] | undefined { 72 | const revert = ctx.checkpoint(); 73 | 74 | const params: Value[] = []; 75 | 76 | if (ctx.not("[")) return undefined; 77 | ctx.consume(); // consume the opening bracket 78 | 79 | let first = true; 80 | while (ctx.not("]")) { 81 | ctx.nomnomnom(); 82 | 83 | if (ctx.eof()) return revert(); 84 | 85 | // expect a comma if not the first value 86 | // trailing commas are supported 87 | if (!first) if (!ctx.consume_if(",")) return revert(); 88 | ctx.nomnomnom(); 89 | 90 | const value = try_param_value(ctx); 91 | if (value === undefined) break; 92 | params.push(value); 93 | 94 | first = false; 95 | } 96 | 97 | if (!ctx.consume_if("]")) return revert(); 98 | return params; 99 | } 100 | 101 | export function try_param_object(ctx: ParserContext): { [key: string]: Value } | undefined { 102 | const revert = ctx.checkpoint(); 103 | 104 | const params: { [key: string]: Value } = {}; 105 | 106 | if (ctx.not("{")) return undefined; 107 | ctx.consume(); // consume the opening bracket 108 | ctx.nomnomnom(); 109 | 110 | let first = true; 111 | while (ctx.not("}")) { 112 | ctx.nomnomnom(); 113 | 114 | if (ctx.eof()) return revert(); 115 | 116 | // expect a comma if not the first value 117 | // trailing commas are allowed 118 | if (!first) if (!ctx.consume_if(",")) return revert(); 119 | ctx.nomnomnom(); 120 | 121 | const key = ident(ctx) ?? try_param_string(ctx); 122 | // didn't find a key, could not parse an object 123 | if (!key) break; 124 | ctx.nomnomnom(); 125 | 126 | ctx.expect(":"); 127 | ctx.nomnomnom(); 128 | 129 | const value = try_param_value(ctx); 130 | // didn't find a value, could not parse an object 131 | if (value === undefined) return revert(); 132 | params[key] = value; 133 | first = false; 134 | } 135 | 136 | if (ctx.not("}")) return revert(); 137 | ctx.consume(); // consume the closing bracket 138 | return params; 139 | } 140 | 141 | export function try_param_value(ctx: ParserContext): Value | undefined { 142 | let value: Value | undefined; 143 | 144 | if ((value = try_param_string(ctx)) !== undefined) return value; 145 | if ((value = try_param_number(ctx)) !== undefined) return value; 146 | if ((value = try_param_null(ctx)) !== undefined) return value; 147 | if ((value = try_param_boolean(ctx)) !== undefined) return value; 148 | if ((value = try_param_list(ctx)) !== undefined) return value; 149 | if ((value = try_param_object(ctx)) !== undefined) return value; 150 | return undefined; 151 | } 152 | -------------------------------------------------------------------------------- /packages/mark/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/scripts/domlib.ts: -------------------------------------------------------------------------------- 1 | export async function domlib() { 2 | const res = await fetch("https://unpkg.com/@types/web/index.d.ts"); 3 | // unpkg will redirect us to the versioned URL 4 | const version = res.url.split("@types/web@")[1].split("/index.d.ts")[0]; 5 | 6 | return [ 7 | version, 8 | async function* () { 9 | console.log("Using @types/web version", version); 10 | const domlib = await res.text(); 11 | 12 | const NBSP = String.fromCharCode(160); 13 | 14 | const sanelib = domlib 15 | .replace(`\n/// \n`, "") 16 | .replaceAll("...arguments:", "...args:") 17 | .replaceAll(" ", "\t") 18 | .replaceAll(NBSP, " "); 19 | 20 | const or = (list: string[]) => list.map(each => `(${each})`).join("|"); 21 | 22 | const idRegex = "(_|$|[a-zA-Z])(_|$|[a-zA-Z0-9])+"; 23 | const body = ".*{(\\n(.+))*?\\n}"; 24 | 25 | const single = String.raw`\/\*\* .+ \*\/`; 26 | const multi = String.raw`\/\*\*(\n.*?)+\*\/`; 27 | 28 | const doc = or([single, multi]); 29 | 30 | const iface = `\ninterface (?${idRegex})${body}`; 31 | const type = `\ntype (?${idRegex}).*`; 32 | const declareVar = `\ndeclare var (?${idRegex})${body};`; 33 | const declareFun = `\ndeclare function (?${idRegex}).*`; 34 | 35 | const constructed = RegExp(`(?${doc})?(?${or([iface, type, declareVar, declareFun])})`, "g"); 36 | 37 | function* exec(str: string, regexp: RegExp): Generator { 38 | let result; 39 | while ((result = regexp.exec(str))) yield result; 40 | } 41 | 42 | function getType(group: { [k: string]: string }) { 43 | for (const key in group) { 44 | const value = group[key]; 45 | if (value) return { key, value: group[key] }; 46 | } 47 | } 48 | 49 | type ParsedItem = { 50 | name: string; 51 | type: "interface" | "type" | "declareVar" | "declareFun"; 52 | match: string; 53 | doc?: string; 54 | }; 55 | 56 | const parsed = [...exec(sanelib, constructed)] 57 | .map(({ groups: { match, doc, ...groups } = {} }): ParsedItem | undefined => { 58 | const { key, value } = getType(groups) || {}; 59 | 60 | if (key && value) { 61 | return { 62 | name: value, 63 | type: key as ParsedItem["type"], 64 | match: match, 65 | ...(doc && { doc }), 66 | }; 67 | } 68 | }) 69 | .filter((each): each is ParsedItem => !!each); 70 | 71 | yield ` 72 | /*! ***************************************************************************** 73 | This lib was borrowed and modified under the Apache 2.0 license from 74 | the @types/web package, originally published by Microsoft Corporation. 75 | 76 | See the full license here: https://github.com/microsoft/TypeScript-DOM-lib-generator/blob/main/LICENSE.txt 77 | 78 | This modified version is based on @types/web version ${version}. 79 | ***************************************************************************** */ 80 | 81 | export const domLibVersion = "${version}"; 82 | `.trim(); 83 | 84 | for (const item of parsed) { 85 | yield "\n"; 86 | 87 | let ret = item.match.slice(1); 88 | if (item.type === "interface") ret = "export " + ret; 89 | if (item.doc) ret = item.doc + "\n" + ret; 90 | 91 | yield ret; 92 | } 93 | }, 94 | ] as const; 95 | } 96 | -------------------------------------------------------------------------------- /packages/scripts/fetchARIA.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom"; 2 | import * as typer from "./util/hypertyper.ts"; 3 | 4 | const chunk = (arr: X[], size: number): X[][] => 5 | Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size)); 6 | 7 | export async function* fetchARIA() { 8 | { 9 | yield typer.preamble + "\n\n"; 10 | } 11 | 12 | { 13 | const html = await fetch("https://w3c.github.io/using-aria/") 14 | // 15 | .then(res => res.text()); 16 | 17 | const { document } = new JSDOM(html).window; 18 | 19 | const roles = [...document.querySelector("#html-aria-gaps > section > ol")!.childNodes] 20 | .map(each => each.textContent?.trim().replace(/`/g, "")) 21 | .filter(Boolean) 22 | .map(each => `"${each}"`); 23 | 24 | yield* typer.statement(typer.exports(typer.type("AriaRoles", typer.union(roles)))); 25 | } 26 | 27 | yield "\n\n"; 28 | 29 | { 30 | const html = await fetch("https://www.w3.org/TR/wai-aria-1.0/states_and_properties").then(res => res.text()); 31 | const { document } = new JSDOM(html).window; 32 | 33 | const allData = [...document.querySelectorAll("#index_state_prop dt, #index_state_prop dd")]; 34 | 35 | const attributeTypes = typer.type( 36 | "AriaAttributes", 37 | typer.withGeneric( 38 | "Partial", 39 | typer.struct( 40 | chunk(allData, 2).map(([dt, dd]) => ({ 41 | prop: dt.textContent!.replace(/\(state\)|aria-/g, "").trim(), 42 | desc: dd.textContent!.trim(), 43 | type: "string", 44 | })), 45 | ), 46 | ), 47 | ); 48 | 49 | yield* typer.statement(typer.exports(attributeTypes)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/scripts/fetchGlobalAttributes.ts: -------------------------------------------------------------------------------- 1 | // TODO(mkr): various issues with this script, possibly demands a cleaner parser rewrite 2 | 3 | import { JSDOM } from "jsdom"; 4 | 5 | import { getSpecialType } from "./util/getSpecialType.ts"; 6 | import * as typer from "./util/hypertyper.ts"; 7 | 8 | export async function* fetchGlobalAttributes() { 9 | const html = await fetch("https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes").then(res => 10 | res.text(), 11 | ); 12 | const { document } = new JSDOM(html).window; 13 | 14 | const exists = (x: T | null | undefined): x is T => x != null; 15 | 16 | const chunk = (arr: T[], size: number) => 17 | Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size)); 18 | 19 | const globalAttr = chunk( 20 | [...document.querySelectorAll('section[aria-labelledby="list_of_global_attributes"] dl')] 21 | // 22 | .flatMap(x => [...x.children]), 23 | 2, 24 | ) 25 | .map(([dt, dd]): typer.Prop => { 26 | const prop = dt.textContent!.trim(); 27 | const desc = dd.textContent!.trim(); 28 | const ul = (dd as unknown as Element).querySelector("ul"); 29 | const type = 30 | getSpecialType(prop)(prop) || 31 | (ul 32 | ? typer.collectSync( 33 | typer.union( 34 | Array.from(ul.querySelectorAll("li code")) 35 | .map(code => code.textContent?.trim()) 36 | .filter(exists), 37 | ), 38 | ) 39 | : "string"); 40 | 41 | return { prop, desc, type }; 42 | }) 43 | .filter( 44 | each => 45 | !( 46 | // remove props matching these conditions 47 | ( 48 | each.prop === "virtualkeyboardpolicy" || // re-evaluate later, MDN doesn't mark this as experimental in this location 49 | each.prop === "role" || // will be added by fetchARIA.ts 50 | each.prop === "data-*" || // will be re-added manually as data-${string} in fetchAttributes.ts 51 | each.prop.includes("Non-standard") || 52 | each.prop.includes("Deprecated") || 53 | each.prop.includes("Experimental") 54 | ) 55 | ), 56 | ); 57 | 58 | const globalAttrType = typer.exports(typer.iface("GlobalAttrs", typer.struct(globalAttr))); 59 | 60 | { 61 | yield `import { MaybeString } from "../util.ts";\n\n`; 62 | yield* globalAttrType; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/scripts/fetchTags.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom"; 2 | import { html2md } from "./util/html2md.ts"; 3 | import * as typer from "./util/hypertyper.ts"; 4 | 5 | export async function* fetchTags() { 6 | const html = await fetch("https://developer.mozilla.org/en-US/docs/Web/HTML/Element").then(res => res.text()); 7 | const { document } = new JSDOM(html).window; 8 | 9 | const baseURL = "https://developer.mozilla.org"; 10 | 11 | const tags = ( 12 | [ 13 | ...document.querySelectorAll("section:not([aria-labelledby=obsolete_and_deprecated_elements]) tr"), 14 | ] as Element[] 15 | ) 16 | .flatMap(x => { 17 | const y = x.children[0]; 18 | const description = html2md(x.children[1].innerHTML, { baseURL }); 19 | return [...(y.querySelectorAll("a") as unknown as HTMLCollection)].map(x => { 20 | let href = ""; 21 | for (const attr of x.attributes) if (attr.nodeName === "href") href = attr.value; 22 | return { title: x.textContent!.replace(/<|>/g, ""), href, description }; 23 | }); 24 | }) 25 | .sort((a, b) => a.title.localeCompare(b.title)); 26 | 27 | { 28 | const custom = typer.statement(typer.exports(typer.type("CustomTag", "`${string}-${string}`"))); 29 | const tag = typer.statement( 30 | typer.exports(typer.type("Tag", typer.union(["CustomTag"].concat(tags.map(x => `"${x.title}"`))))), 31 | ); 32 | 33 | yield { 34 | file: "tags.ts", 35 | *content() { 36 | yield typer.preamble; 37 | yield "\n\n"; 38 | yield* custom; 39 | yield "\n\n"; 40 | yield* tag; 41 | }, 42 | }; 43 | } 44 | 45 | { 46 | // why does even exist? Won't be supported via import syntax because it's a reserved keyword 47 | const types = typer.flatMap( 48 | tags.filter(tag => tag.title !== "var"), 49 | function* (opts) { 50 | yield* (function* ({ title, href, description }: typeof opts) { 51 | yield "\n\n"; 52 | yield* typer.desc([description, typer.see(baseURL + href, "MDN | " + title)].join("\n\n")); 53 | yield* typer.statement(typer.exports(typer.constant(title, `create("${title}")`))); 54 | })(opts); 55 | }, 56 | ); 57 | 58 | yield { 59 | file: "../elements.ts", 60 | *content() { 61 | yield typer.preamble; 62 | yield "\n\n"; 63 | yield* typer.imports("./element.ts", { imports: ["create"] }); 64 | yield* types; 65 | }, 66 | }; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/scripts/gen.ts: -------------------------------------------------------------------------------- 1 | import { fetchGlobalAttributes } from "./fetchGlobalAttributes.ts"; 2 | import { fetchAttributes } from "./fetchAttributes.ts"; 3 | import { fetchARIA } from "./fetchARIA.ts"; 4 | import { fetchTags } from "./fetchTags.ts"; 5 | import { domlib } from "./domlib.ts"; 6 | 7 | import { join } from "node:path"; 8 | 9 | const requested = Bun.argv[2]?.split(",") || ["aria", "attributes", "global-attributes", "tags", "dom"]; 10 | 11 | async function writeTo(target: string, generator: Generator | AsyncGenerator) { 12 | // Truncate the file first 13 | await Bun.write(target, ""); 14 | 15 | const file = Bun.file(target); 16 | const writer = file.writer(); 17 | for await (const chunk of generator) { 18 | let written = 0; 19 | while (written < chunk.length) { 20 | written += await writer.write(chunk.slice(written)); 21 | } 22 | } 23 | await writer.write("\n"); 24 | await writer.end(); 25 | } 26 | 27 | const root = join(import.meta.dir, "../hyper/src/lib/"); 28 | 29 | if (requested.includes("global-attributes")) { 30 | const target = join(root, "global-attributes.ts"); 31 | console.log(`Writing ${target}`); 32 | await writeTo(target, fetchGlobalAttributes()); 33 | } 34 | 35 | if (requested.includes("attributes")) { 36 | const target = join(root, "attributes.ts"); 37 | console.log(`Writing ${target}`); 38 | await writeTo(target, fetchAttributes()); 39 | } 40 | 41 | if (requested.includes("aria")) { 42 | const target = join(root, "aria.ts"); 43 | console.log(`Writing ${target}`); 44 | await writeTo(target, fetchARIA()); 45 | } 46 | 47 | if (requested.includes("tags")) { 48 | for await (const out of fetchTags()) { 49 | const { file, content } = out; 50 | const target = join(root, file); 51 | console.log(`Writing ${target}`); 52 | await writeTo(target, content()); 53 | } 54 | } 55 | 56 | if (requested.includes("dom")) { 57 | const target = join(root, "dom.ts"); 58 | console.log(`Writing ${target}`); 59 | const [version, lib] = await domlib(); 60 | await writeTo(target, lib()); 61 | 62 | const pkg = Bun.file(join(import.meta.dir, "../hyper/package.json")); 63 | const pkgJson = await pkg.json(); 64 | pkgJson.peerDependencies["@types/web"] = version; 65 | await pkg.writer().write(JSON.stringify(pkgJson, null, "\t") + "\n"); 66 | } 67 | -------------------------------------------------------------------------------- /packages/scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hyperactive/scripts", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "build": "bun run gen.ts" 7 | }, 8 | "dependencies": { 9 | "jsdom": "^25.0.1" 10 | }, 11 | "devDependencies": { 12 | "@types/jsdom": "^21.1.7" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/scripts/util/getSpecialType.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom"; 2 | 3 | const html = await fetch("https://html.spec.whatwg.org/multipage/indices.html").then(res => res.text()); 4 | 5 | const { document } = new JSDOM(html).window; 6 | 7 | const rows = [...document.querySelectorAll("#attributes-1 tbody tr")] 8 | .map(row => [...row.children]) 9 | .map(([name, , , value]) => ({ 10 | name: name.textContent!.trim(), 11 | value: value.textContent!.trim(), 12 | })); 13 | 14 | type Entry = [string, (el: string) => string | null]; 15 | 16 | const boolean: Entry[] = [...new Set(rows.filter(row => row.value === "Boolean attribute").map(row => row.name))].map( 17 | name => [name, () => "boolean"], 18 | ); 19 | 20 | const isEnum = /(".+";\s*)*(".+"\s*)/; 21 | 22 | const union = (...parts: string[]) => parts.map(part => `"${part}"`).join(" | "); 23 | 24 | const mime = "`${string}/${string}`"; 25 | 26 | const manual: Entry[] = [ 27 | ["class", () => `MaybeString | MaybeString[]`], 28 | ["as", () => `string`], 29 | ["sandbox", () => `string`], 30 | ["step", () => `number | "any"`], 31 | ["dir", (el: string) => (el === "bdo" ? union("ltr", "rtl") : union("ltr", "rtl", "auto"))], 32 | [ 33 | "type", 34 | (el: string) => 35 | ({ 36 | a: mime, 37 | link: mime, 38 | embed: mime, 39 | object: mime, 40 | source: mime, 41 | button: union("submit", "reset", "button"), 42 | ol: union("1", "a", "A", "i", "I"), 43 | script: `"module" | "importmap" | ${mime}`, 44 | input: union( 45 | "button", 46 | "checkbox", 47 | "color", 48 | "date", 49 | "datetime-local", 50 | "email", 51 | "file", 52 | "hidden", 53 | "image", 54 | "month", 55 | "number", 56 | "password", 57 | "radio", 58 | "range", 59 | "reset", 60 | "search", 61 | "submit", 62 | "tel", 63 | "text", 64 | "time", 65 | "url", 66 | "week", 67 | ), 68 | }[el] || null), 69 | ], 70 | [ 71 | "value", 72 | (el: string) => 73 | ({ 74 | button: `string`, 75 | option: `string`, 76 | data: `string`, 77 | input: `string`, 78 | param: `string`, 79 | li: `number`, 80 | meter: `number`, 81 | progress: `number`, 82 | }[el] || null), 83 | ], 84 | // ["autocomplete", () => `boolean`], 85 | // ["draggable", () => `boolean`], 86 | ]; 87 | 88 | const parseEnum = (str: string) => [...str.matchAll(/"\S+"/g)].flat().map(each => each.replaceAll('"', "")); 89 | 90 | const enums = rows 91 | .filter(row => isEnum.test(row.value.trim())) 92 | .filter(row => !manual.map(each => each[0]).includes(row.name)) 93 | .map(row => [row.name, union(...(parseEnum(row.value) || []))]) 94 | .map(([name, union]): Entry => [name, () => union]); 95 | 96 | const numeric = [ 97 | "cols", 98 | "colspan", 99 | "height", 100 | "width", 101 | "high", 102 | "low", 103 | "max", 104 | "maxlength", 105 | "min", 106 | "minlength", 107 | "optimum", 108 | "rows", 109 | "rowspan", 110 | "size", 111 | "start", 112 | "tabindex", 113 | ].map((name): Entry => [name, () => "number"]); 114 | 115 | const def = () => null; 116 | 117 | const specialTypes = Object.fromEntries(([] as Entry[]).concat(boolean, enums, numeric, manual)); 118 | 119 | const specialKeys = new Set(Object.keys(specialTypes)); 120 | 121 | export const isSpecial = (attr: string) => specialKeys.has(attr); 122 | 123 | export const getSpecialType = (attr: string) => specialTypes[attr] || def; 124 | -------------------------------------------------------------------------------- /packages/scripts/util/html2md.ts: -------------------------------------------------------------------------------- 1 | const escapables = { 2 | "<": "<", 3 | ">": ">", 4 | "&": "&", 5 | "'": "'", 6 | """: '"', 7 | }; 8 | 9 | const escaped = new RegExp(Object.keys(escapables).join("|"), "g"); 10 | 11 | export const unescape = (s: string) => s.replace(escaped, r => escapables[r as keyof typeof escapables] || r); 12 | 13 | export const html2md = (html: string, { baseURL }: { baseURL?: string } = {}) => 14 | unescape( 15 | html 16 | .replace(/(?.+?)<\/code>/g, (_, _1, _2, _3, { contents }) => contents) 17 | .replace(/(?.+?)<\/strong>/g, (_, _1, _2, _3, { contents }) => `**${contents}**`) 18 | .replace( 19 | /(?.+?)<\/a>/g, 20 | (_, _1, _2, _3, _4, { contents, href }) => `[${contents}](${new URL(href, baseURL).toString()})`, 21 | ), 22 | ); 23 | -------------------------------------------------------------------------------- /packages/serve/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /packages/serve/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Feathers Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/serve/README.md: -------------------------------------------------------------------------------- 1 | # hyperserve 2 | 3 | Part of the [hyperactive](https://github.com/feathers-studio/hyperactive) project. A lean server library that's functional by design and integrates seamlessly with hyperactive itself. 4 | 5 | ## Getting started 6 | 7 | ```TypeScript 8 | import { get, router, serve } from "https://deno.land/x/hyperactive/serve.ts"; 9 | 10 | const server = serve( 11 | { port: 3000 }, 12 | router( 13 | get("/", (ctx, next) => next(), (ctx) => ctx.respond("Hello world")), 14 | get("/foo", (ctx) => ctx.respond("Foo")), 15 | get("/bar", (ctx) => ctx.respond("Bar")), 16 | ), 17 | ); 18 | 19 | console.log("Listening on port", 3000); 20 | server.start(); 21 | ``` 22 | 23 | ## Using websockets 24 | 25 | `hyperserve` supports websockets straight in your application! It's as simple as calling the `ws` util. 26 | 27 | ```TypeScript 28 | serve( 29 | { port: 3000 }, 30 | router( 31 | get("/", (ctx, next) => next(), (ctx) => ctx.respond("Hello world")), 32 | ws("/foo", async (socket) => { 33 | socket.addEventListener("message", (e) => console.log(e.data)); 34 | }), 35 | ), 36 | ); 37 | ``` 38 | 39 | ## Using server-sent events 40 | 41 | [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) is an alternative to websockets when you only need to publish data one-way from the server. 42 | 43 | ```TypeScript 44 | serve( 45 | { port: 3000 }, 46 | router( 47 | get("/", (ctx, next) => next(), (ctx) => ctx.respond("Hello world")), 48 | eventsource("/notifs", async (ctx) => { 49 | setInterval(() => { 50 | // Sends a notification every second 51 | ctx.event({ event: "notification", data: "You have a message" }); 52 | }, 1000); 53 | }), 54 | ), 55 | ); 56 | ``` 57 | -------------------------------------------------------------------------------- /packages/serve/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hyperactive/serve", 3 | "module": "src/index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5.0.0" 10 | }, 11 | "exports": { 12 | ".": "./src/index.ts", 13 | "./utils": "./src/utils.ts" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/serve/src/comments.txt: -------------------------------------------------------------------------------- 1 | Request -> Response is very nice for testing 2 | 3 | Since Request#ip doesn't exist, so Bun had to invent server.requestIP(request) 4 | 5 | For middleware-like things, Node style ServerResponse objects are more useful than standard Response; for example, a middleware that checks if user is logged in and extends their session expiry would need to Set-Cookie unrelated to the actual request handler 6 | 7 | Request -> Response pattern has yet another big issue; you cannot do something after response is sent (like logging request time) -------------------------------------------------------------------------------- /packages/serve/src/core.ts: -------------------------------------------------------------------------------- 1 | import { type HyperNode, renderHTML } from "../hyper/mod.ts"; 2 | 3 | export type Next = () => Promise; 4 | 5 | export type Middleware = (ctx: Context, next: Next) => Promise; 6 | 7 | export type Context = { 8 | request: Request; 9 | responded: boolean; 10 | respond: (body?: Response | BodyInit | null, init?: ResponseInit) => Promise; 11 | html: (body: string, init?: ResponseInit) => Promise; 12 | render: (body: HyperNode, init?: ResponseInit) => Promise; 13 | state: Partial; 14 | }; 15 | 16 | export function o(f: Middleware, g: Middleware): Middleware { 17 | return (ctx: Context, next: Next) => g(ctx, () => f(ctx, next)); 18 | } 19 | 20 | export function router(...fs: Middleware[]): Middleware { 21 | if (fs.length === 0) throw new TypeError("router requires at least one Middleware"); 22 | return fs.reduceRight(o); 23 | } 24 | 25 | const h404: Middleware = ctx => { 26 | return ctx.respond(`Cannot ${ctx.request.method} ${new URL(ctx.request.url).pathname}`, { status: 404 }); 27 | }; 28 | 29 | function Context(e: Deno.RequestEvent): Context { 30 | let responded = false; 31 | 32 | const self: Context = { 33 | request: e.request, 34 | get responded() { 35 | return responded; 36 | }, 37 | respond(body, init) { 38 | if (responded) throw new Error("Can't call respond() twice"); 39 | responded = true; 40 | return e.respondWith(body instanceof Response ? body : new Response(body, init)); 41 | }, 42 | html(body, init) { 43 | const headers = new Headers(init?.headers); 44 | headers.set("Content-Type", "text/html; charset=UTF-8"); 45 | return self.respond(body, { 46 | headers, 47 | status: init?.status, 48 | statusText: init?.statusText, 49 | }); 50 | }, 51 | render(body, init) { 52 | return self.html("" + renderHTML(body), init); 53 | }, 54 | state: {}, 55 | }; 56 | 57 | return self; 58 | } 59 | 60 | export const noop = async (): Promise => void 0; 61 | 62 | export function serve(opts: Deno.ListenOptions, handler: Middleware) { 63 | async function handleHttp(conn: Deno.Conn) { 64 | for await (const e of Deno.serveHttp(conn)) { 65 | const ctx = Context(e); 66 | handler(ctx, () => h404(ctx, noop)); 67 | } 68 | } 69 | 70 | const server = Deno.listen(opts); 71 | 72 | async function start() { 73 | for await (const conn of server) handleHttp(conn); 74 | } 75 | 76 | return { 77 | start, 78 | close() { 79 | server.close(); 80 | }, 81 | }; 82 | } 83 | 84 | export function filter( 85 | predicate: (ctx: Context) => boolean, 86 | middleware: Middleware, 87 | ): Middleware { 88 | return (ctx, next) => (predicate(ctx) ? middleware(ctx, next) : next()); 89 | } 90 | -------------------------------------------------------------------------------- /packages/serve/src/eventsource.ts: -------------------------------------------------------------------------------- 1 | import { readableStreamFromReader, writeAll } from "https://deno.land/std@0.125.0/streams/conversion.ts"; 2 | 3 | import { type Context, filter, type Middleware } from "./core.ts"; 4 | import { PassThrough } from "./passthrough.ts"; 5 | 6 | export type Event = { 7 | event?: string; 8 | data: string; 9 | id?: string; 10 | retry?: number; 11 | }; 12 | 13 | export type EventSourceContext = { 14 | request: Request; 15 | comment: (content: string) => Promise; 16 | event: (e: Event) => Promise; 17 | ended: boolean; 18 | startResponse: (headers?: HeadersInit) => Promise; 19 | state: Partial; 20 | }; 21 | 22 | type EventSourceHandler = (ctx: EventSourceContext) => Promise; 23 | 24 | /** 25 | * Converts `{ data: "Hello\nWorld", id: "1" }` to 26 | * 27 | * ``` 28 | * data: Hello 29 | * data: World 30 | * id: 1 31 | * ``` 32 | */ 33 | function eventToString(o: Record) { 34 | return Object.entries(o) 35 | .map(([k, v]) => 36 | String(v) 37 | .split("\n") 38 | .map(line => `${k}: ${line}`) 39 | .join("\n"), 40 | ) 41 | .join("\n"); 42 | } 43 | 44 | async function write(buf: PassThrough, content: string) { 45 | if (!content) return; 46 | return void (await writeAll(buf, new TextEncoder().encode(content + "\n\n"))); 47 | } 48 | 49 | function EventSourceContext(ctx: Context): EventSourceContext { 50 | const passthrough = new PassThrough(); 51 | const stream = readableStreamFromReader(passthrough); 52 | 53 | const self: EventSourceContext = { 54 | request: ctx.request, 55 | comment: content => write(passthrough, ": " + content), 56 | event: event => write(passthrough, eventToString(event)), 57 | ended: false, 58 | startResponse: headersInit => { 59 | const headers = new Headers(headersInit); 60 | headers.set("Cache-Control", "no-store"); 61 | headers.set("Content-Type", "text/event-stream"); 62 | return ctx 63 | .respond(stream, { headers }) 64 | .then(() => { 65 | self.ended = true; 66 | }) 67 | .catch(e => { 68 | self.ended = true; 69 | return Promise.reject(e); 70 | }); 71 | }, 72 | state: ctx.state, 73 | }; 74 | 75 | return self as EventSourceContext; 76 | } 77 | 78 | export function eventsource(pattern: string, handler: EventSourceHandler): Middleware { 79 | const urlPattern = new URLPattern({ pathname: pattern }); 80 | const pred = (ctx: Context) => urlPattern.test(ctx.request.url); 81 | return filter(pred, async ctx => { 82 | return handler(EventSourceContext(ctx)); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /packages/serve/src/index.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello via Bun!"); -------------------------------------------------------------------------------- /packages/serve/src/methods.ts: -------------------------------------------------------------------------------- 1 | import { type Context, filter, type Middleware, router } from "./core.ts"; 2 | 3 | type Methods = "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH"; 4 | 5 | export function method(m: Methods) { 6 | return (pattern: string, ...middleware: Middleware[]): Middleware => { 7 | const urlPattern = new URLPattern({ pathname: pattern }); 8 | const pred = (ctx: Context) => ctx.request.method === m && urlPattern.test(ctx.request.url); 9 | return filter(pred, router(...middleware)); 10 | }; 11 | } 12 | 13 | export const options = method("OPTIONS"); 14 | export const get = method("GET"); 15 | export const post = method("POST"); 16 | export const put = method("PUT"); 17 | export const patch = method("PATCH"); 18 | export const del = method("DELETE"); 19 | 20 | export const use = router; 21 | -------------------------------------------------------------------------------- /packages/serve/src/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./core.ts"; 2 | export * from "./methods.ts"; 3 | export * from "./ws.ts"; 4 | export * from "./eventsource.ts"; 5 | -------------------------------------------------------------------------------- /packages/serve/src/serve.test.ts: -------------------------------------------------------------------------------- 1 | import { readableStreamFromReader } from "https://deno.land/std@0.125.0/streams/conversion.ts"; 2 | import { type Context, eventsource, get, type Middleware, router, serve, ws } from "./mod.ts"; 3 | import { PassThrough } from "./passthrough.ts"; 4 | 5 | type User = { name: string }; 6 | const State = new WeakMap(); 7 | 8 | const sleep = (t: number) => new Promise(r => setTimeout(r, t)); 9 | 10 | type Cookies = { cookies: string[] }; 11 | const cookies: Middleware = (ctx, next) => next(); 12 | 13 | const server = serve( 14 | { port: 4000 }, 15 | router( 16 | cookies, 17 | router( 18 | get("/", ctx => { 19 | const url = new URL(ctx.request.url); 20 | new URL("", url); 21 | return ctx.respond( 22 | JSON.stringify({ 23 | hash: url.hash, 24 | host: url.host, 25 | hostname: url.hostname, 26 | href: url.href, 27 | origin: url.origin, 28 | password: url.password, 29 | pathname: url.pathname, 30 | port: url.port, 31 | protocol: url.protocol, 32 | search: url.search, 33 | searchParams: [...url.searchParams.entries()], 34 | }), 35 | ); 36 | }), 37 | // get("/", (ctx, next) => { 38 | // const passthrough = new PassThrough(); 39 | // let cancel = false; 40 | // setInterval(() => { 41 | // if (cancel) return; 42 | // passthrough.write(new TextEncoder().encode("Hello\r\n")); 43 | // }, 1000); 44 | // return ctx.respond(readableStreamFromReader(passthrough), { headers: { "Transfer-Encoding": "chunked" } }) 45 | // .catch(() => { 46 | // cancel = true; 47 | // console.log("Caught"); 48 | // }); 49 | // }), 50 | // get("/favicon.ico", (ctx) => ctx.respond(new ArrayBuffer(0))), 51 | // ws("/socket", async (socket) => { 52 | // socket.addEventListener("message", (e) => console.log(e.data)); 53 | // // socket.addEventListener(""); 54 | // }), 55 | // get("/", (ctx) => { 56 | // return ctx.respond(ctx.state.user?.name); 57 | // }), 58 | // eventsource("/notifs", async (ctx) => { 59 | // try { 60 | // const resp = ctx.startResponse(); 61 | 62 | // console.log("connected"); 63 | // await ctx.comment("comment"); 64 | // await sleep(1000); 65 | // await ctx.event({ data: "helloxxx", event: "hello" }); 66 | 67 | // await resp; 68 | // } catch (e) { 69 | // console.log(e); 70 | // } 71 | // }), 72 | ), 73 | ), 74 | ); 75 | 76 | server.start(); 77 | console.log("Listening on port", 4000); 78 | -------------------------------------------------------------------------------- /packages/serve/src/spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | post( 4 | "/:userId/posts?search&advanced&count", 5 | 6 | // params only parses 7 | 8 | /* 9 | userId always exists as string, 10 | but params() parses it to number 11 | */ 12 | params({ 13 | userId: number, 14 | groupId: number, 15 | //^^^^^ TS ERROR 16 | }), 17 | 18 | // query validates and parses 19 | 20 | /* 21 | search always exists as ?: string 22 | query() ensures it must exist as string 23 | it also parses the existence of advanced to boolean 24 | and parses count to number 25 | */ 26 | query({ 27 | search: string, 28 | advanced: boolean, 29 | count: number, 30 | }), 31 | 32 | // body only validates 33 | 34 | body({ 35 | searchToken: string, 36 | }), 37 | 38 | handler, 39 | ); 40 | -------------------------------------------------------------------------------- /packages/serve/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { stat } from "node:fs/promises"; 2 | 3 | export const days = (n: number) => n * 60 * 60 * 24; 4 | export const hours = (n: number) => n * 60 * 60; 5 | export const minutes = (n: number) => n * 60; 6 | export const seconds = (n: number) => n; 7 | 8 | export async function generateETagFromFile(filePath: string): Promise { 9 | const stats = await stat(filePath); 10 | const mtime = stats.mtime.getTime().toString(); 11 | const size = stats.size.toString(); 12 | const rawETag = `${mtime}-${size}`; 13 | 14 | const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawETag)); 15 | const hashArray = new Uint8Array(hashBuffer); 16 | let hashHex = ""; 17 | for (let i = 0; i < hashArray.length; i++) { 18 | hashHex += hashArray[i].toString(16).padStart(2, "0"); 19 | } 20 | return `"${hashHex}"`; 21 | } 22 | 23 | export const redirect = (url: string, status: 301 | 302 = 302, headers: Record = {}) => 24 | new Response(null, { status, headers: { Location: url, ...headers } }); 25 | 26 | export const json = (body: any, status = 200, headers: Record = {}) => 27 | new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...headers } }); 28 | 29 | export const html = (content: string, status = 200, headers: Record = {}) => 30 | new Response("" + content, { status, headers: { "Content-Type": "text/html", ...headers } }); 31 | -------------------------------------------------------------------------------- /packages/serve/src/ws.ts: -------------------------------------------------------------------------------- 1 | import { type Context, filter, type Middleware } from "./core.ts"; 2 | 3 | export type WebSocketHandler = (socket: WebSocket) => Promise; 4 | 5 | export function ws(pattern: string, handler: WebSocketHandler): Middleware { 6 | const urlPattern = new URLPattern({ pathname: pattern }); 7 | const pred = (ctx: Context) => urlPattern.test(ctx.request.url); 8 | return filter(pred, async ctx => { 9 | const { response, socket } = Deno.upgradeWebSocket(ctx.request); 10 | await ctx.respond(response); 11 | return handler(socket); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /packages/serve/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/todo/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /packages/todo/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Feathers Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/todo/README.md: -------------------------------------------------------------------------------- 1 | # @feathers-studio/todo 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run src/index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/todo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hyperdo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /packages/todo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@feathers-studio/todo", 3 | "module": "src/index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "bun run vite" 7 | }, 8 | "devDependencies": { 9 | "@types/bun": "latest", 10 | "@types/react": "^19.0.2", 11 | "@types/react-dom": "^19.0.2", 12 | "@types/web": "^0.0.188" 13 | }, 14 | "peerDependencies": { 15 | "typescript": "^5.0.0" 16 | }, 17 | "dependencies": { 18 | "@hyperactive/hyper": "workspace:*", 19 | "vite": "^6.0.5" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/todo/public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/todo/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/todo/public/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/todo/public/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /packages/todo/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/todo/public/favicon/favicon.ico -------------------------------------------------------------------------------- /packages/todo/public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Hyperdo", 3 | "short_name": "Hyperdo", 4 | "icons": [ 5 | { 6 | "src": "/favicon/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/favicon/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /packages/todo/public/favicon/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/todo/public/favicon/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /packages/todo/public/favicon/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/todo/public/favicon/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /packages/todo/src/App.ts: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | 3 | import { div, h2, p, button, form, input, label, svg, li, header, ul } from "@hyperactive/hyper/elements"; 4 | import { trust, Member, List, State, renderDOM } from "@hyperactive/hyper"; 5 | import type { Window } from "@hyperactive/hyper/dom"; 6 | 7 | declare const window: Window; 8 | 9 | // setup phase, write your states anywhere! 10 | 11 | const loading = new State(true); 12 | const todos = new List(); 13 | 14 | window.addEventListener("load", () => { 15 | const fromMem = JSON.parse(window.localStorage.getItem("todos") || "[]") as Item[]; 16 | if (!fromMem.length) { 17 | fromMem.push({ 18 | title: "Get started!", 19 | id: window.crypto.randomUUID(), 20 | completed: false, 21 | }); 22 | } 23 | fromMem.forEach(todo => todos.append(todo)); 24 | loading.set(false); 25 | }); 26 | 27 | todos.listen(() => { 28 | window.localStorage.setItem("todos", JSON.stringify(todos.toArray())); 29 | }); 30 | 31 | const S = (path: string) => { 32 | return svg( 33 | // @ts-expect-error SVG attributes are not typed correctly? 34 | { width: "32", height: "32", fill: "#000000", viewBox: "0 0 256 256" }, 35 | trust(path), 36 | ); 37 | }; 38 | 39 | export const Hero = (completed: State, total: State) => { 40 | return header( 41 | div(h2("Tasks done"), p("頑張って〜!")), 42 | div( 43 | { class: "progress" }, 44 | p( 45 | completed.to(v => String(v)), 46 | " / ", 47 | total.to(v => String(v)), 48 | ), 49 | ), 50 | ); 51 | }; 52 | 53 | export function Form(todos: List) { 54 | const handleSubmit = (event: any) => { 55 | event.preventDefault(); 56 | const title = event.target.todo.value; 57 | if (!title) return; 58 | todos.append({ title, id: window.crypto.randomUUID(), completed: false }); 59 | event.target.reset(); 60 | }; 61 | 62 | return form( 63 | { on: { submit: handleSubmit } }, 64 | label( 65 | input({ 66 | type: "text", 67 | name: "todo", 68 | id: "todo", 69 | disabled: loading, 70 | placeholder: loading.to(l => (l ? "Loading from local storage..." : "Write your next task")), 71 | autofocus: true, 72 | ref: el => loading.listen(l => l || el.focus()), 73 | }), 74 | ), 75 | button( 76 | { title: "Add task" }, 77 | S( 78 | ``, 79 | ), 80 | ), 81 | ); 82 | } 83 | 84 | export interface Item { 85 | id: string; 86 | title: string; 87 | completed: boolean; 88 | } 89 | 90 | export function LocalEditableInput(item: Member) { 91 | const value = new State(item.value.title); 92 | 93 | return input({ 94 | type: "text", 95 | value, 96 | on: { 97 | blur: () => item.setWith(i => ({ ...i, title: value.value })), 98 | change: e => value.set((e.target as any).value), 99 | }, 100 | }); 101 | } 102 | 103 | export function Item(item: Member) { 104 | return li( 105 | { class: item.to(i => (i.completed ? "completed" : "")) }, 106 | button( 107 | { 108 | title: item.to(i => (i.completed ? "Mark as not completed" : "Mark as completed")), 109 | on: { click: () => item.setWith(i => ({ ...i, completed: !i.completed })) }, 110 | }, 111 | svg( 112 | // @ts-expect-error SVG attributes are not typed correctly? 113 | { width: "32", height: "32", fill: "#000000", viewBox: "0 0 256 256" }, 114 | trust(''), 115 | ), 116 | ), 117 | LocalEditableInput(item), 118 | button( 119 | { title: "Delete", on: { click: () => item.remove() } }, 120 | S( 121 | '', 122 | ), 123 | ), 124 | ); 125 | } 126 | 127 | export function Home() { 128 | const completed = todos.filter(todo => todo.completed).size; 129 | const total = todos.size; 130 | 131 | return div.container( 132 | // 133 | Hero(completed, total), 134 | Form(todos), 135 | ul(todos.each(todo => Item(todo))), 136 | ); 137 | } 138 | 139 | renderDOM(window.document.getElementById("app")!, Home()); 140 | -------------------------------------------------------------------------------- /packages/todo/src/_/diff.tsx: -------------------------------------------------------------------------------- 1 | // // @ts-nocheck 2 | 3 | // type Todo = { id: number; content: string }; 4 | 5 | // const Todo = ({ setList, todo, index }: { setList: (list: Todo[]) => void; todo: Todo; index: number }) => { 6 | // const [state, setState] = useState(todo.content); 7 | 8 | // return ( 9 | //
  • 10 | // {index + 1} 11 | //
    12 | //

    {todo.content}

    13 | // setState(e.target.value)} /> 14 | // 21 | //
    22 | //
  • 23 | // ); 24 | // }; 25 | 26 | // const App = () => { 27 | // const [todos, setTodos] = useState([ 28 | // { id: 1, content: "Hyperactive" }, 29 | // { id: 2, content: "Jigza" }, 30 | // { id: 3, content: "Telegraf" }, 31 | // ]); 32 | 33 | // return ( 34 | //
    35 | //
      36 | // {todos.map((todo, index) => ( 37 | // 38 | // ))} 39 | //
    40 | // 41 | //
    42 | // ); 43 | // }; 44 | 45 | // createRoot(root).render(App); 46 | -------------------------------------------------------------------------------- /packages/todo/src/_/hyper.tsx: -------------------------------------------------------------------------------- 1 | // // @ts-nocheck 2 | 3 | // type Todo = { id: number; content: string }; 4 | 5 | // const Todo = (todo: ListMember) => { 6 | // const state = new State(todo.content); 7 | 8 | // return ( 9 | //
  • 10 | // {todo.index.transform(i => String(i + 1))} 11 | //
    12 | //

    {todo.transform(t => t.content)}

    13 | // state.update(e.target.value)} /> 14 | // 21 | //
    22 | //
  • 23 | // ); 24 | // }; 25 | 26 | // const App = () => { 27 | // const todos = new ListState([ 28 | // { id: 1, content: "Hyperactive" }, 29 | // { id: 2, content: "Jigza" }, 30 | // { id: 3, content: "Telegraf" }, 31 | // ]); 32 | 33 | // return ( 34 | //
    35 | //
      36 | // {todos.transform(todo => ( 37 | // 38 | // ))} 39 | //
    40 | // 41 | //
    42 | // ); 43 | // }; 44 | 45 | // renderDOM(root, ); 46 | -------------------------------------------------------------------------------- /packages/todo/src/_/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck test 2 | /* eslint-disable */ 3 | 4 | { 5 | // Hyperactive function version (current) 6 | 7 | const Todo = (todo: State<{ id: number; content: string }>) => { 8 | const state = new State(todo.value.content); 9 | 10 | return li( 11 | span(todo.index.transform(i => String(i + 1))), 12 | form( 13 | p(todo.transform(t => t.content)), 14 | input({ type: "text", value: state, on: { change: e => state.update(e.target.value) } }), 15 | button({ 16 | on: { 17 | async click() { 18 | await fetch("...", { 19 | method: "PUT", 20 | body: JSON.stringify({ value: state.value }), 21 | }); 22 | todo.update({ ...todo, content: state.value }); 23 | }, 24 | }, 25 | }), 26 | ), 27 | ); 28 | }; 29 | 30 | const App = () => { 31 | const todos = new ListState([ 32 | { id: 1, content: "Hyperactive" }, 33 | { id: 2, content: "Jigza" }, 34 | { id: 3, content: "Telegraf" }, 35 | ]); 36 | 37 | return div( 38 | { class: "container" }, 39 | ol(todos.transform(Todo)), 40 | button({ on: { click: () => todos.push({ id: todos.length + 1, content: "" }) } }, "Add"), 41 | ); 42 | }; 43 | 44 | renderDOM(root, App()); 45 | } 46 | 47 | { 48 | // Hyperactive JSX version (future) 49 | 50 | type Todo = { id: number; content: string }; 51 | 52 | const Todo = ({ list, todo }: { list: ListState; todo: ListMember }) => { 53 | const state = new State(todo.content); 54 | 55 | return ( 56 |
  • 57 | {todo.index.transform(i => String(i + 1))} 58 |
    59 |

    {todo.transform(t => t.content)}

    60 | state.update(e.target.value)} /> 61 | 68 |
    69 |
  • 70 | ); 71 | }; 72 | 73 | const App = () => { 74 | const todos = new ListState([ 75 | { id: 1, content: "Hyperactive" }, 76 | { id: 2, content: "Jigza" }, 77 | { id: 3, content: "Telegraf" }, 78 | ]); 79 | 80 | return ( 81 |
    82 |
      83 | {todos.transform(todo => ( 84 | 85 | ))} 86 |
    87 | 88 |
    89 | ); 90 | }; 91 | 92 | renderDOM(root, ); 93 | } 94 | 95 | { 96 | // React version 97 | 98 | const Todo = ({ setList, todo, index }: { setList: (list: { id: number; content: string }[]) => void; todo: { id: number; content: string }; index: number }) => { 99 | const [value, setValue] = useState(todo.content); 100 | 101 | return ( 102 |
  • 103 | {index + 1} 104 |
    105 |

    {todo.content}

    106 | setValue(e.target.value)} /> 107 | 114 |
    115 |
  • 116 | ); 117 | }; 118 | 119 | const App = () => { 120 | const [todos, setTodos] = useState([ 121 | { id: 1, content: "Hyperactive" }, 122 | { id: 2, content: "Jigza" }, 123 | { id: 3, content: "Telegraf" }, 124 | ]); 125 | 126 | return ( 127 |
    128 |
      129 | {todos.map((todo, index) => ( 130 | 131 | ))} 132 |
    133 | 134 |
    135 | ); 136 | }; 137 | 138 | createRoot(root).render(App); 139 | } 140 | -------------------------------------------------------------------------------- /packages/todo/src/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-colour: #ffaa00; 3 | --secondary-colour: #f0f0f0; 4 | --background-colour: #f0f0f0; 5 | --border-colour: #ccc; 6 | --text-colour: #000000; 7 | --gaps: 0.8rem; 8 | font-size: 18px; 9 | } 10 | 11 | * { 12 | padding: 0; 13 | margin: 0; 14 | box-sizing: border-box; 15 | } 16 | 17 | *:focus-visible { 18 | outline: 2px solid var(--primary-colour); 19 | } 20 | 21 | body { 22 | background-color: var(--background-colour); 23 | } 24 | 25 | .container { 26 | max-width: 40rem; 27 | margin: 0 auto; 28 | padding: 2rem; 29 | display: flex; 30 | flex-direction: column; 31 | gap: var(--gaps); 32 | } 33 | 34 | header { 35 | display: flex; 36 | justify-content: space-between; 37 | align-items: center; 38 | padding: 1.2rem 1.5rem; 39 | border: 1px solid var(--border-colour); 40 | border-radius: 0.5rem; 41 | 42 | & .progress { 43 | display: grid; 44 | place-items: center; 45 | gap: 1rem; 46 | background-color: var(--primary-colour); 47 | width: 5.5rem; 48 | height: 5.5rem; 49 | border-radius: 50%; 50 | font-size: 1.5rem; 51 | color: white; 52 | } 53 | } 54 | 55 | form { 56 | display: flex; 57 | gap: var(--gaps); 58 | 59 | & label { 60 | height: 2.5rem; 61 | width: 100%; 62 | } 63 | 64 | & input { 65 | height: 100%; 66 | width: 100%; 67 | border: none; 68 | border-radius: 0.5rem; 69 | padding: 0.5rem 0.8rem; 70 | font-size: 0.9rem; 71 | background-color: hsl(from var(--background-colour) h s calc(l - 5)); 72 | } 73 | 74 | & button { 75 | height: 2.5rem; 76 | width: 2.5rem; 77 | background: var(--primary-colour); 78 | padding: 0.25rem; 79 | border: none; 80 | border-radius: 0.5rem; 81 | cursor: pointer; 82 | display: grid; 83 | place-items: center; 84 | 85 | &:hover { 86 | background: hsl(from var(--primary-colour) h s calc(l + 5)); 87 | } 88 | 89 | &:hover svg, 90 | &:focus svg { 91 | rotate: 90deg; 92 | transition: rotate 150ms ease-in-out; 93 | } 94 | } 95 | 96 | & svg { 97 | height: 1.5rem; 98 | width: 1.5rem; 99 | border: none; 100 | fill: white; 101 | } 102 | } 103 | 104 | ul { 105 | display: flex; 106 | flex-direction: column; 107 | gap: var(--gaps); 108 | list-style: none; 109 | width: 100%; 110 | 111 | & li { 112 | display: flex; 113 | border: 1px solid var(--border-colour); 114 | border-radius: 0.5rem; 115 | width: 100%; 116 | position: relative; 117 | 118 | & > * { 119 | padding: 0.6rem; 120 | transition: background 150ms ease-in-out, opacity 150ms ease-in-out; 121 | } 122 | 123 | & > *:hover { 124 | background: hsl(from var(--secondary-colour) h s calc(l - 5)); 125 | } 126 | 127 | :focus-visible { 128 | outline: none; 129 | } 130 | 131 | & button { 132 | --size: calc(0.6rem * 2 + 1.25rem); 133 | width: var(--size); 134 | height: var(--size); 135 | min-width: var(--size); 136 | min-height: var(--size); 137 | 138 | background: transparent; 139 | border: none; 140 | cursor: pointer; 141 | display: grid; 142 | place-items: center; 143 | 144 | & svg, 145 | & svg * { 146 | width: 1.25rem; 147 | height: 1.25rem; 148 | transition: fill 150ms ease-in-out, stroke 150ms ease-in-out; 149 | } 150 | 151 | & svg circle { 152 | fill: transparent; 153 | stroke: var(--border-colour); 154 | stroke-width: 0.8rem; 155 | } 156 | 157 | &:hover svg { 158 | fill: var(--primary-colour); 159 | } 160 | } 161 | 162 | & input { 163 | min-height: 100%; 164 | width: 100%; 165 | font-size: 0.8rem; 166 | text-align: left; 167 | overflow: hidden; 168 | text-overflow: ellipsis; 169 | white-space: nowrap; 170 | border: none; 171 | background: transparent; 172 | cursor: pointer; 173 | } 174 | 175 | & input:focus { 176 | background: hsl(from var(--secondary-colour) h s calc(l - 10)); 177 | } 178 | 179 | &.completed { 180 | & > * { 181 | opacity: 0.3; 182 | } 183 | 184 | & svg circle { 185 | fill: var(--border-colour); 186 | } 187 | } 188 | 189 | &::after { 190 | content: ""; 191 | position: absolute; 192 | top: 50%; 193 | left: -2%; 194 | width: 104%; 195 | height: 1px; 196 | background: hsl(from var(--border-colour) h s calc(l - 15)); 197 | 198 | transform: scaleX(0); 199 | transform-origin: left; 200 | transition: transform 400ms ease-in-out; 201 | } 202 | 203 | &.completed::after { 204 | transform: scaleX(1); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /packages/todo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // disable ambient types 23 | "types": [], 24 | 25 | // Some stricter flags (disabled by default) 26 | "noUnusedLocals": false, 27 | "noUnusedParameters": false, 28 | "noPropertyAccessFromIndexSignature": false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/todo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | server: { 5 | port: 10000, 6 | host: true, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/url/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /packages/url/README.md: -------------------------------------------------------------------------------- 1 | # @hyperactive/url 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run src/index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/url/blog/blog.md: -------------------------------------------------------------------------------- 1 | # Designing a spec'd path parser from scratch 2 | 3 | > This blog post is a part of a series of blog posts about the design of Hyperserve, a modern server framework being built for JavaScript. It's part of the broader Hyperactive project, a modern suite of web application development tools. 4 | 5 | ## The problem 6 | 7 | To route requests in a web server, we need to parse a "path spec", so to say, which is a string that describes a path with parameters. You may be familiar with this from frameworks like Express. Express allows named parameters (`:param`) and Regex parameters (`?`, `+`, `*`, and `()`) in path specs. Let's start with the most common one. 8 | 9 | ```ts 10 | app.get("/users/:username", (req, res) => { 11 | // 「 { username: string } 」 12 | res.send(`Hello ${req.params.username}`); 13 | // ^^^^^^ 14 | }); 15 | ``` 16 | 17 | `"/users/:username"` is what I call a path spec. It describes a path that matches the pattern and captures the `username` parameter. If you use TypeScript and have `@types/express` installed, you may have noticed that `req.params` is of type `{ username: string }`. This is because `@types/express` takes advantage of TypeScript's template literal types to model the path spec. 18 | 19 | But there's a big problem here. 20 | 21 | ![Express's route params](./express.jpg) 22 | 23 | TypeScript cannot parse the string with these limitations. The union formed by the set of these characters is too large, so TypeScript will give up on recursion. So what does @types/express do? It lies. Instead of parsing accepted characters, it delimits the path spec by the chars `/ . -`. Worse yet, it doesn't even support Regex parameters. 24 | 25 | This means that if you have a path spec like `/users/:username`, it will be parsed as `/users/:username` and the `username` parameter will be captured as `username`. 26 | 27 | [Zig] 28 | If we had this it would've been possible and easy 29 | Zig can express it at type level 30 | So what does @types/express do? It lies. It'll accept chars not in the allowed set and it's delimited only by the chars / . - 31 | So if you have /:user@:password/ for some ill-advised reason, it'll show types as 32 | 33 | params: { "user@:password": string } 34 | 35 | and not 36 | 37 | { user: string, password: string } 38 | which runtime Express will give you 39 | There are several other limitations of @types/express, its path parser does not express most of what Express allows 40 | It's not a tradeoff I'm willing to make for Hyperserve, it has to model types accurately. So my parser only delimits by / . - 41 | Some Regex params are supported in Express, but not in @types/express 42 | Okay let's look at the URL spec for paths: 43 | https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 44 | unreserved = ALPHA / DIGIT / "-" / "." / "\_" / "~" 45 | 46 | pct-encoded = "%" HEXDIG HEXDIG 47 | 48 | sub-delims = "!" / "$" / "&" / "'" / "(" / ")" 49 | / "\*" / "+" / "," / ";" / "=" 50 | 51 | pchar = unreserved / pct-encoded / sub-delims / ":" / "@" 52 | So basically 53 | 54 | ALPHA | DIGIT | "-" | "." | "\_" | "~" | "!" | "$" | "&" | "'" | "(" | ")" | "\*" | "+" | "," | ";" | "=" | ":" | "@" 55 | 56 | Is all the chars allowed in a path segment, along with %HH 57 | -------------------------------------------------------------------------------- /packages/url/blog/express.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/url/blog/express.jpg -------------------------------------------------------------------------------- /packages/url/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hyperactive/url", 3 | "module": "src/index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "prepublishOnly": "bun run build", 7 | "build": "tsc" 8 | }, 9 | "devDependencies": { 10 | "@types/bun": "^1.1.14" 11 | }, 12 | "peerDependencies": { 13 | "typescript": "^5.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/url/src/HyperURL.ts: -------------------------------------------------------------------------------- 1 | type QuerySpec = Record; 2 | 3 | // Maybe this should allow URLs without domains etc? 4 | export class HyperURL extends URL { 5 | public route: string; 6 | public params: P; 7 | #query: QuerySpec; 8 | 9 | constructor({ 10 | url, 11 | route, 12 | params, 13 | spec: { query }, 14 | }: { 15 | url: string; 16 | route: string; 17 | params: P; 18 | spec: { query: QuerySpec }; 19 | }) { 20 | super(url); 21 | 22 | this.route = route; 23 | this.params = params; 24 | this.#query = query; 25 | } 26 | 27 | get query() { 28 | const spec = this.#query; 29 | return [...this.searchParams.entries()].reduce((q, [k, v]) => { 30 | if (spec[k]) ((q[k] as string[]) || (q[k] = [])).push(v); 31 | else q[k] = v; 32 | return q; 33 | }, {} as Record) as unknown as Q; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/url/src/index.ts: -------------------------------------------------------------------------------- 1 | export { HyperURL } from "./HyperURL.ts"; 2 | export { parse } from "./parse.ts"; 3 | -------------------------------------------------------------------------------- /packages/url/src/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "bun:test"; 2 | import { parsePathSpec } from "./parse.ts"; 3 | 4 | test("parsePathSpec", () => { 5 | const x = parsePathSpec("/x~:x/~:y.:p/asa/:z/"); 6 | expect(x).toEqual([ 7 | { type: "interjunct", value: "/" }, 8 | { type: "literal", value: "x~" }, 9 | { type: "param", value: "x" }, 10 | { type: "interjunct", value: "/" }, 11 | { type: "literal", value: "~" }, 12 | { type: "param", value: "y" }, 13 | { type: "interjunct", value: "." }, 14 | { type: "param", value: "p" }, 15 | { type: "interjunct", value: "/" }, 16 | { type: "literal", value: "asa" }, 17 | { type: "interjunct", value: "/" }, 18 | { type: "param", value: "z" }, 19 | { type: "interjunct", value: "/" }, 20 | ]); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/url/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ES2022"], 5 | "target": "ES2022", 6 | "module": "CommonJS", 7 | "moduleDetection": "force", 8 | "moduleResolution": "node", 9 | 10 | "allowImportingTsExtensions": true, 11 | "rewriteRelativeImportExtensions": true, 12 | "declaration": true, 13 | 14 | // Best practices 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "noFallthroughCasesInSwitch": true, 18 | 19 | // Some stricter flags (disabled by default) 20 | "noUnusedLocals": false, 21 | "noUnusedParameters": false, 22 | "noPropertyAccessFromIndexSignature": false, 23 | 24 | "outDir": "./lib" 25 | }, 26 | "include": ["src/**/*"], 27 | "exclude": ["src/**/*.test.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # @hyperactive/web 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run src/index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hyperactive/web", 3 | "module": "src/index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5.7.2" 10 | }, 11 | "dependencies": { 12 | "@hyperactive/hyper": "workspace:*", 13 | "@hyperactive/serve": "workspace:*", 14 | "marked": "^15.0.5", 15 | "mime-types": "^2.1.35" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/public/assets/fonts/GeistMono[wght].woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/web/public/assets/fonts/GeistMono[wght].woff2 -------------------------------------------------------------------------------- /packages/web/public/assets/fonts/Geist[wght].woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-studio/hyperactive/cbdcc56ddfee693e41370fa03516b555f4389978/packages/web/public/assets/fonts/Geist[wght].woff2 -------------------------------------------------------------------------------- /packages/web/public/assets/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | /* Geist Variable Font */ 2 | @font-face { 3 | font-family: "Geist"; 4 | src: url("./Geist[wght].woff2") format("woff2"); 5 | font-weight: 100 900; 6 | font-style: normal; 7 | font-display: swap; 8 | } 9 | 10 | /* GeistMono Variable Font */ 11 | @font-face { 12 | font-family: "GeistMono"; 13 | src: url("./GeistMono[wght].woff2") format("woff2"); 14 | font-weight: 100 950; 15 | font-style: normal; 16 | font-display: swap; 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/public/assets/prism/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.29.0 2 | https://prismjs.com/download.html#themes=prism-okaidia&languages=css+clike+javascript+bash+typescript&plugins=line-highlight+line-numbers */ 3 | code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#272822}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#8292a2}.token.punctuation{color:#f8f8f2}.token.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#f92672}.token.boolean,.token.number{color:#ae81ff}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#a6e22e}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#e6db74}.token.keyword{color:#66d9ef}.token.important,.token.regex{color:#fd971f}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help} 4 | pre[data-line]{position:relative;padding:1em 0 1em 3em}.line-highlight{position:absolute;left:0;right:0;padding:inherit 0;margin-top:1em;background:hsla(24,20%,50%,.08);background:linear-gradient(to right,hsla(24,20%,50%,.1) 70%,hsla(24,20%,50%,0));pointer-events:none;line-height:inherit;white-space:pre}@media print{.line-highlight{-webkit-print-color-adjust:exact;color-adjust:exact}}.line-highlight:before,.line-highlight[data-end]:after{content:attr(data-start);position:absolute;top:.4em;left:.6em;min-width:1em;padding:0 .5em;background-color:hsla(24,20%,50%,.4);color:#f4f1ef;font:bold 65%/1.5 sans-serif;text-align:center;vertical-align:.3em;border-radius:999px;text-shadow:none;box-shadow:0 1px #fff}.line-highlight[data-end]:after{content:attr(data-end);top:auto;bottom:.4em}.line-numbers .line-highlight:after,.line-numbers .line-highlight:before{content:none}pre[id].linkable-line-numbers span.line-numbers-rows{pointer-events:all}pre[id].linkable-line-numbers span.line-numbers-rows>span:before{cursor:pointer}pre[id].linkable-line-numbers span.line-numbers-rows>span:hover:before{background-color:rgba(128,128,128,.2)} 5 | pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right} 6 | -------------------------------------------------------------------------------- /packages/web/public/assets/style.css: -------------------------------------------------------------------------------- 1 | @import url("./fonts/fonts.css"); 2 | @import url("./prism/prism.css"); 3 | 4 | :root { 5 | --brand-gradient: linear-gradient(to right, #ff6a00, #ffaa00); 6 | --primary-colour: #ff6a00; 7 | --secondary-colour: #f0f0f0; 8 | --background-colour: #ffffff; 9 | --border-colour: #ccc; 10 | --text-colour: #000000; 11 | --gaps: 0.8rem; 12 | font-size: 18px; 13 | scroll-behavior: smooth; 14 | } 15 | 16 | @media (max-width: 768px) { 17 | :root { 18 | font-size: 16px; 19 | } 20 | } 21 | 22 | * { 23 | padding: 0; 24 | margin: 0; 25 | box-sizing: border-box; 26 | font-family: "Geist", sans-serif; 27 | } 28 | 29 | *:focus-visible { 30 | outline: 2px solid var(--primary-colour); 31 | } 32 | 33 | body { 34 | min-height: 100vh; 35 | max-width: 100vw; 36 | overflow-x: hidden; 37 | background: var(--background-colour); 38 | padding-block: 5.5rem; 39 | } 40 | 41 | button, 42 | .button { 43 | width: max-content; 44 | display: inline-block; 45 | cursor: pointer; 46 | padding: 0.6rem 1rem; 47 | border: 2px solid var(--text-colour); 48 | text-decoration: none; 49 | text-transform: uppercase; 50 | color: var(--text-colour); 51 | font-size: 1rem; 52 | 53 | position: relative; 54 | z-index: 1; 55 | transition: color 500ms ease; 56 | 57 | &::before, 58 | &::after { 59 | content: ""; 60 | position: absolute; 61 | inset: 0; 62 | border-radius: inherit; 63 | transition: opacity 500ms ease; 64 | } 65 | 66 | &::before { 67 | z-index: -2; 68 | background-color: var(--background-colour); 69 | } 70 | 71 | &::after { 72 | z-index: -1; 73 | background-color: var(--text-colour); 74 | transform: scaleX(0); 75 | transform-origin: left; 76 | transition: transform 500ms ease; 77 | } 78 | 79 | &:hover { 80 | color: var(--background-colour); 81 | &::after { 82 | transform: scaleX(1); 83 | } 84 | } 85 | } 86 | 87 | .container { 88 | width: 100%; 89 | max-width: 100rem; 90 | margin: 0 auto; 91 | padding-inline: 3.5rem; 92 | display: flex; 93 | flex-direction: column; 94 | gap: var(--gaps); 95 | } 96 | 97 | .text_gradient { 98 | background-image: var(--brand-gradient); 99 | background-clip: text; 100 | -webkit-text-fill-color: transparent; 101 | } 102 | 103 | .marker { 104 | font-size: 0.8rem; 105 | font-weight: 200; 106 | line-height: 1; 107 | padding: 0.2rem 0.5rem 0.24rem; 108 | border-radius: 0.5rem; 109 | 110 | border-width: 1px; 111 | border-style: solid; 112 | border-color: transparent; 113 | 114 | background-image: linear-gradient(white, white), var(--brand-gradient); 115 | background-origin: border-box; 116 | background-clip: padding-box, border-box; 117 | 118 | cursor: pointer; 119 | 120 | position: relative; 121 | z-index: 1; 122 | 123 | &::before { 124 | content: ""; 125 | position: absolute; 126 | inset: 0; 127 | border-radius: inherit; 128 | background-image: var(--brand-gradient); 129 | z-index: -1; 130 | opacity: 0; 131 | transition: opacity 500ms ease; 132 | } 133 | 134 | &:hover { 135 | &::before { 136 | opacity: 1; 137 | } 138 | } 139 | 140 | & .tooltip { 141 | opacity: 0; 142 | position: absolute; 143 | top: calc(100% + 0.5rem); 144 | right: 0%; 145 | width: max-content; 146 | max-width: 10rem; 147 | transition: opacity 500ms ease; 148 | pointer-events: none; 149 | line-height: 1.2; 150 | font-weight: 500; 151 | text-align: right; 152 | } 153 | 154 | &:hover { 155 | & .tooltip { 156 | opacity: 1; 157 | } 158 | } 159 | } 160 | 161 | header { 162 | display: flex; 163 | justify-content: space-between; 164 | align-items: center; 165 | width: 100%; 166 | } 167 | 168 | header .logo svg { 169 | width: 3.5rem; 170 | height: auto; 171 | } 172 | 173 | & code { 174 | &, 175 | & * { 176 | font-family: "GeistMono", monospace; 177 | font-size: 0.85rem; 178 | } 179 | padding: 0.1rem 0.25rem; 180 | background-color: hsl(from var(--border-colour) h s calc(l + 10)); 181 | border-radius: 0.2rem; 182 | } 183 | 184 | pre { 185 | overflow-x: auto; 186 | max-width: 40rem; 187 | background-color: rgb(0 0 0 / 0.9); 188 | color: rgb(255 255 255 / 0.9); 189 | padding: 1.5rem 1.5rem; 190 | border-radius: 0.4rem; 191 | 192 | & code { 193 | background: none; 194 | padding: 0; 195 | /* font-size: inherit; */ 196 | } 197 | } 198 | 199 | .hero { 200 | height: 85vh; 201 | & * { 202 | font-family: "GeistMono", monospace; 203 | } 204 | 205 | & main { 206 | display: flex; 207 | flex-direction: column; 208 | justify-content: center; 209 | /* padding-top: 10.5rem; */ 210 | height: 100%; 211 | 212 | * { 213 | margin: 0; 214 | line-height: 1.1; 215 | } 216 | 217 | h1 { 218 | font-size: 3.4rem; 219 | text-transform: lowercase; 220 | font-weight: 300; 221 | } 222 | 223 | p { 224 | font-size: 2.1rem; 225 | font-weight: 300; 226 | max-width: 35rem; 227 | } 228 | 229 | .button { 230 | margin-top: 4rem; 231 | } 232 | } 233 | } 234 | 235 | #docs article { 236 | max-width: 50rem; 237 | overflow-x: auto; 238 | 239 | & > * { 240 | margin-top: 1.2rem; 241 | } 242 | 243 | & p { 244 | line-height: 1.5; 245 | } 246 | 247 | & h2:not(:first-child) { 248 | margin-top: 3rem; 249 | position: relative; 250 | 251 | &::before { 252 | content: ""; 253 | position: absolute; 254 | left: 0; 255 | top: -1.2rem; 256 | height: 1px; 257 | width: 100%; 258 | background-color: var(--border-colour); 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /packages/web/src/content/docs.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | If you use npm: 4 | 5 | ```bash 6 | npm install https://gethyper.dev 7 | ``` 8 | 9 | Or, if you use pnpm, yarn, or bun: 10 | 11 | ```bash 12 | pnpm add https://gethyper.dev 13 | ``` 14 | 15 | ## Basic Usage 16 | 17 | ### On the Server 18 | 19 | `renderHTML` is used to render a Hyperactive component to a HTML string. Use this like a template engine. 20 | 21 | ```TypeScript 22 | import { renderHTML } from "@hyperactive/hyper"; 23 | import { html, head, body, section, img, h1, title, link } from "@hyperactive/hyper/elements"; 24 | 25 | const GreeterPage = (name: string) => 26 | renderHTML( 27 | html( 28 | head( 29 | title("Greeter"), 30 | link({ rel: "stylesheet", href: "/assets/style.css" }), 31 | ), 32 | body( 33 | section( 34 | { class: "container" }, 35 | img({ src: "/hero.jpg" }), 36 | h1("Hello ", name), 37 | ), 38 | ), 39 | ), 40 | ); 41 | 42 | Bun.serve({ 43 | port: 3000, 44 | fetch(req) { 45 | if (req.url === "/") { 46 | return new Response( 47 | GreeterPage("World"), 48 | { headers: { "Content-Type": "text/html" } }, 49 | ); 50 | } 51 | }, 52 | }); 53 | ``` 54 | 55 | ### In the Browser 56 | 57 | `renderDOM` is used to render a Hyperactive component to the DOM. 58 | 59 | This is a truly reactive system, where the DOM is updated whenever relevant state changes. Unlike frameworks like React, Hyperactive doesn't use a virtual DOM. Instead, it remembers what state changes affect which DOM nodes, and only updates the DOM nodes that need to be updated. Unlike Svelte, Hyperactive doesn't use a compiler. Instead, it uses a runtime library that is designed to be as small and fast as possible. This is the ideal: a Hyperscript that is more convenient as React, fast as Svelte, and as reactive as Solid. 60 | 61 | [![@types/web 0.0.188](https://img.shields.io/static/v1?label=@types/web&message=0.0.188&style=for-the-badge&labelColor=ff0000&color=fff)](https://npmjs.com/package/@types/web) 62 | 63 | Please install `@types/web` to use Hyperactive in the browser. Your package manager will automatically install the correct version of `@types/web` for you by default. See the [versions](./docs/versions.md) table for the correct version of `@types/web` for each version of Hyperactive. 64 | 65 | ```bash 66 | bun add @types/web 67 | ``` 68 | 69 | ```TypeScript 70 | import { State, renderDOM } from "@hyperactive/hyper"; 71 | import { div, p, button } from "@hyperactive/hyper/elements"; 72 | 73 | const count = new State(0); 74 | 75 | const root = document.getElementById("root"); 76 | 77 | renderDOM( 78 | root, 79 | div( 80 | p("You clicked ", count, " times"), 81 | button( 82 | { on: { click: () => count.set(count.value + 1) } }, 83 | "Increment" 84 | ), 85 | ), 86 | ); 87 | 88 | ``` 89 | 90 | Notice how there are no components, nor is state boxed inside of them. Instead, state is just a plain variable that can be used anywhere. Components can still be used for convenience and to encapsulate state, but they disappear while Hyperactive renders them. Hyperactive only remembers the DOM node to update. In this example, the `div` element is updated when the `count` state changes. The rest of the tree is never updated, so Hyperactive doesn't manage them. 91 | 92 | Let's refactor this example to use a component. 93 | 94 | ```TypeScript 95 | const Counter = () => { 96 | const count = new State(0); 97 | 98 | return div( 99 | p("You clicked ", count, " times"), 100 | button( 101 | { on: { click: () => count.set(count.value + 1) } }, 102 | "Increment" 103 | ), 104 | ); 105 | }; 106 | 107 | renderDOM(root, Counter()); 108 | ``` 109 | 110 |
    111 | But where is my JSX?! 112 | 113 | Hyperactive doesn't use JSX. Instead, we use a simple, declarative JavaScript syntax that is easy to understand and write. In the future, we may consider adding JSX support, and we welcome any contributions in this direction. Ideally we may only need to adapt our `h` function and add `Fragment` support. 114 | 115 |
    116 | -------------------------------------------------------------------------------- /packages/web/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "bun"; 2 | import { join, extname } from "node:path"; 3 | import { lookup } from "mime-types"; 4 | import { generateETagFromFile, html, redirect, seconds } from "@hyperactive/serve/utils"; 5 | import { Home } from "./pages/home"; 6 | import { Layout } from "./pages/layout"; 7 | 8 | const port = process.env.PORT || 3000; 9 | const S3_ASSETS_ROOT = "https://gethyper.s3.fr-par.scw.cloud/assets"; 10 | const ASSETS_ROOT = join(import.meta.dir, "../public/assets"); 11 | 12 | // User agent strings 13 | // 'user-agent': 'npm/10.1.0 node/v20.8.1 linux x64 workspaces/false' 14 | // 'user-agent': 'pnpm/8.10.5 npm/? node/v20.8.1 linux x64' 15 | // 'user-agent': 'yarn/1.22.21 npm/? node/v20.8.1 linux x64' 16 | // 'user-agent': 'Bun/1.0.14' 17 | 18 | const layout = Layout("Hyperactive - a powerful toolbox for modern web application development"); 19 | 20 | serve({ 21 | port, 22 | async fetch(req) { 23 | const url = new URL(req.url); 24 | const method = req.method; 25 | const pathname = url.pathname; 26 | const ua = req.headers.get("user-agent"); 27 | const isNPMLike = ua?.includes("npm") || ua?.includes("Bun"); 28 | 29 | if (isNPMLike) { 30 | if (pathname === "/") 31 | return new Response(null, { 32 | status: 301, 33 | headers: { location: "https://gethyper.s3.fr-par.scw.cloud/pkg/hyperactive-hyper-2.0.0-beta.1.tgz" }, 34 | }); 35 | } 36 | 37 | if (method === "GET") { 38 | // if (pathname.startsWith("/fonts/")) return redirect(S3_ASSETS_ROOT + pathname); 39 | 40 | if (url.pathname.startsWith("/assets/")) { 41 | const assetPath = join(ASSETS_ROOT, url.pathname.slice("/assets/".length)); 42 | if (!assetPath.startsWith(ASSETS_ROOT)) { 43 | return new Response("Access Denied", { status: 403 }); 44 | } 45 | return new Response(Bun.file(assetPath), { 46 | headers: { 47 | "Content-Type": lookup(extname(assetPath)) || "application/octet-stream", 48 | // "Cache-Control": `public, max-age=${seconds(10)}`, 49 | // "ETag": await generateETagFromFile(assetPath), 50 | }, 51 | }); 52 | } 53 | 54 | if (pathname === "/") return html(layout(await Home())); 55 | } 56 | 57 | return new Response("Not found", { status: 404 }); 58 | }, 59 | }); 60 | 61 | console.log(`Server is running on port ${port}`); 62 | -------------------------------------------------------------------------------- /packages/web/src/pages/home.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | import { List, trust } from "@hyperactive/hyper"; 3 | import { a, article, br, div, h1, header, main, p, section, span } from "@hyperactive/hyper/elements"; 4 | import { marked } from "marked"; 5 | 6 | const logo = trust(await Bun.file(join(import.meta.dir, "../../../../docs/h(⚡️).svg")).text()); 7 | 8 | const docs = join(import.meta.dir, "../content/docs.md"); 9 | 10 | export async function Home() { 11 | const content = trust(await marked.parse(await Bun.file(docs).text())); 12 | 13 | return new List([ 14 | section.container.hero( 15 | header( 16 | span.logo(logo), 17 | div.marker("beta", span.tooltip("Expect bugs!", br(), "These docs are a work in progress!")), 18 | ), 19 | main( 20 | h1.text_gradient("Hyperactive"), 21 | p("is a powerful toolbox for web application development"), 22 | a.button({ href: "#docs" }, "Get Started"), 23 | ), 24 | ), 25 | section.container["#docs"](article(content)), 26 | ]); 27 | } 28 | -------------------------------------------------------------------------------- /packages/web/src/pages/layout.ts: -------------------------------------------------------------------------------- 1 | import { renderHTML, type HyperNodeish } from "@hyperactive/hyper"; 2 | import { body, head, html, link, meta, script, title as title_tag } from "@hyperactive/hyper/elements"; 3 | 4 | export function Layout(title: string, style = "/assets/style.css") { 5 | return function (children: HyperNodeish) { 6 | return renderHTML( 7 | html( 8 | head( 9 | meta({ charset: "utf-8" }), 10 | meta({ name: "viewport", content: "width=device-width, initial-scale=1.0" }), 11 | title_tag(title), 12 | link({ rel: "stylesheet", href: style }), 13 | ), 14 | body(children, script({ src: "/assets/prism/prism.js" })), 15 | ), 16 | ); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "allowJs": true, 9 | 10 | // Bundler mode 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "noEmit": true, 15 | 16 | // Best practices 17 | "strict": true, 18 | "skipLibCheck": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "types": [], 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | --------------------------------------------------------------------------------