├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc.json ├── README.md ├── _assets ├── auto_complete.gif ├── demo.gif └── type_demo.png ├── _config.yml ├── build.mjs ├── ci └── release.mjs ├── example ├── css │ └── page.scss └── js │ ├── components │ ├── benchmark.ts │ ├── readme.ts │ └── todo │ │ ├── 00_input.ts │ │ ├── 01_select.ts │ │ ├── 02_listing.ts │ │ └── todo.ts │ ├── entry.ts │ ├── layout │ ├── body.ts │ ├── footer.ts │ ├── header.ts │ └── page.ts │ └── state.ts ├── excel └── index.ts ├── package.json ├── src ├── noact-elements.ts └── noact.ts ├── tsconfig.json └── webpack.config.mjs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: npm 5 | directory: / 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | on: 3 | push: 4 | branches: 5 | - noact 6 | schedule: 7 | - cron: "0 0 * * *" # daily 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 18 17 | 18 | - uses: actions/checkout@v4 19 | 20 | - run: |- 21 | npm install 22 | 23 | - env: 24 | CI_TOKEN: ${{ secrets.CI_TOKEN }} 25 | run: npm run ci 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.git/ 2 | .DS_Store 3 | node_modules 4 | package-lock.json 5 | out 6 | *.css.d.ts 7 | *.scss.d.ts 8 | *.bak 9 | *.js 10 | *.map 11 | dist/ 12 | artifacts/ 13 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 80, 4 | "semi": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [~~Re~~Noact](https://ms-jpq.github.io/noact/) 2 | 3 | Noact is a minimal **_self-rendering_** Virtual DOM library. 4 | 5 | - **Declarative:** Pretty much like React, without the JSX compilation of course, hence the name. 6 | - **Type safe:** Noact is completely typesafe, which means you get [static type checking][auto complete gif] for free! 7 | - **Simple:** [Only 60 lines][60 lines] of type declarations & rendering code. (and 10ish lines of code-gen code) 8 | 9 | ## [Example App](https://ms-jpq.github.io/noact-page/) 10 | 11 | ## How it feels to write Noact 12 | 13 | ![demo.gif] 14 | 15 | **\- Explosions \-** 16 | 17 | Even has support for **style auto complete** 18 | 19 | ![typedemo.png] 20 | 21 | ## Usage 22 | 23 | Noact is inspired by the syntax of the [elm HTML engine][elm html] 24 | 25 | ```Typescript 26 | import { button, div } from "./NoactElements" 27 | const component1 = div({}, 28 | button({ onclick: () => alert(":D"), txt: "+" }), 29 | div({ txt: "♥" }), 30 | button({ onclick: () => alert("D:"), txt: "-" }) 31 | ) 32 | ``` 33 | 34 | `component1` is a memoized `() => HTMLElement` function, `component1()` will give you back 35 | 36 | ```HTML 37 |
38 | 39 |
40 | 41 |
42 | ``` 43 | 44 | You can use `component1` as it is, or compose it in a Virtual DOM configuration 45 | 46 | ```Typescript 47 | import { createMountPoint } from "./Noact" 48 | const mount = createMountPoint(document.querySelector(`#root`)) 49 | const remount = () => mount( 50 | component1, 51 | span({ txt: new Date().toString() }) 52 | ) 53 | setInterval(remount, 1000) 54 | ``` 55 | 56 | Here the root element will be populated with both `component1` and `span`. Every 1000ms, `#root > span` and only `#root > span` will be updated. 57 | 58 | In essence, `component1` is both the rendering function, and the virtual DOM. 59 | 60 | ## License 61 | 62 | [MIT License][mit] 63 | 64 | [demo.gif]: https://raw.githubusercontent.com/ms-jpq/Noact/noact/_assets/demo.gif 65 | [typedemo.png]: https://raw.githubusercontent.com/ms-jpq/Noact/noact/_assets/type_demo.png 66 | [auto complete gif]: https://github.com/ms-jpq/Noact/blob/noact/_assets/auto_complete.gif 67 | [elm html]: https://package.elm-lang.org/packages/elm/html/latest/ 68 | [mit]: https://github.com/ms-jpq/Noact/blob/noact/LICENSE 69 | [60 lines]: https://github.com/ms-jpq/Noact/blob/noact/src/noact.ts 70 | -------------------------------------------------------------------------------- /_assets/auto_complete.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/noact/03aa56e4bae7b970de5a131206888c2bc8f005da/_assets/auto_complete.gif -------------------------------------------------------------------------------- /_assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/noact/03aa56e4bae7b970de5a131206888c2bc8f005da/_assets/demo.gif -------------------------------------------------------------------------------- /_assets/type_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/noact/03aa56e4bae7b970de5a131206888c2bc8f005da/_assets/type_demo.png -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | title: Noact 2 | 3 | showcase: True 4 | 5 | images: 6 | - https://raw.githubusercontent.com/ms-jpq/Noact/noact/_assets/type_demo.png 7 | -------------------------------------------------------------------------------- /build.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { spawnSync } from "node:child_process" 3 | import { rmSync } from "node:fs" 4 | import { dirname, join } from "node:path" 5 | import { exit } from "node:process" 6 | import { fileURLToPath } from "node:url" 7 | 8 | export const top_level = dirname(fileURLToPath(new URL(import.meta.url))) 9 | const modules = join(top_level, "node_modules") 10 | export const bin = join(modules, ".bin") 11 | export const dist_dir = join(top_level, "dist") 12 | 13 | /** 14 | * @param {{ cwd?: string }} opts 15 | * @param {string} arg 16 | * @param {string[]} args 17 | */ 18 | export const run = ({ cwd = top_level }, arg, ...args) => { 19 | const { error, status } = spawnSync(arg, args, { 20 | stdio: "inherit", 21 | cwd, 22 | }) 23 | 24 | if (error) { 25 | throw error 26 | } 27 | const code = status ?? 1 28 | 29 | if (code) { 30 | exit(code) 31 | } 32 | } 33 | 34 | const main = () => { 35 | rmSync(dist_dir, { recursive: true, force: true }) 36 | run({}, join(bin, "webpack")) 37 | } 38 | 39 | main() 40 | -------------------------------------------------------------------------------- /ci/release.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { filter } from "nda/iso/iterator.js" 3 | import { lstatSync, readdirSync, rmSync, writeFileSync } from "node:fs" 4 | import { join, sep } from "node:path" 5 | import { env } from "node:process" 6 | import { dist_dir, run, top_level } from "../build.mjs" 7 | 8 | /** 9 | * @param {string} path 10 | */ 11 | const is_dir = (path) => { 12 | try { 13 | const stat = lstatSync(path) 14 | return stat.isDirectory() 15 | } catch { 16 | return false 17 | } 18 | } 19 | 20 | const time = new Date().toISOString() 21 | const artifacts_dir = join(top_level, "artifacts") 22 | const diff_guarantee = join(artifacts_dir, "build_record.txt") 23 | 24 | const git_clone = () => { 25 | if (is_dir(artifacts_dir)) { 26 | return 27 | } else { 28 | const token = env["CI_TOKEN"] 29 | const email = "ci@ci.ci" 30 | const username = "ci-bot" 31 | const uri = `https://ms-jpq:${token}@github.com/ms-jpq/noact-page.git` 32 | run({}, "git", "clone", uri, artifacts_dir) 33 | run({ cwd: artifacts_dir }, "git", "config", "user.email", email) 34 | run({ cwd: artifacts_dir }, "git", "config", "user.name", username) 35 | } 36 | } 37 | 38 | const git_commit = () => { 39 | const msg = `CI - ${time}` 40 | run({ cwd: artifacts_dir }, "git", "add", "-A") 41 | run({ cwd: artifacts_dir }, "git", "commit", "-m", msg) 42 | run({ cwd: artifacts_dir }, "git", "push", "--force") 43 | } 44 | 45 | const copy = async () => { 46 | const prev = readdirSync(artifacts_dir) 47 | for (const name of filter(prev, (n) => !n.endsWith(".git"))) { 48 | const path = join(artifacts_dir, name) 49 | rmSync(path, { recursive: true }) 50 | } 51 | run({}, "rsync", "--archive", "--", dist_dir + sep, artifacts_dir + sep) 52 | writeFileSync(diff_guarantee, time) 53 | } 54 | 55 | const main = () => { 56 | git_clone() 57 | copy() 58 | git_commit() 59 | } 60 | 61 | main() 62 | -------------------------------------------------------------------------------- /example/css/page.scss: -------------------------------------------------------------------------------- 1 | @import "@fortawesome/fontawesome-free/css/all.css"; 2 | @import "bootstrap/dist/css/bootstrap-reboot.css"; 3 | @import "cda/dist/entry.css"; 4 | 5 | :root { 6 | --light-grey: rgb(240, 242, 244); 7 | --dark-grey: #e9ecef; 8 | --slight-grey: rgba(0, 0, 0, 0.03); 9 | --border-colour: rgba(0, 0, 0, 0.125); 10 | --border-corner: 0.5em; 11 | --background-white: white; 12 | --input-group-outline: rgba(73, 70, 70, 0.925); 13 | } 14 | 15 | body { 16 | $padding_h: 1em; 17 | padding-left: $padding_h; 18 | padding-right: $padding_h; 19 | 20 | max-width: 100vw; 21 | } 22 | 23 | #container { 24 | $max_width: 60em; 25 | max-width: $max_width; 26 | 27 | > header { 28 | background-color: var(--dark-grey); 29 | } 30 | 31 | > main { 32 | #readme { 33 | #readme-header { 34 | background-color: var(--light-grey); 35 | } 36 | 37 | li, 38 | p { 39 | > * { 40 | &::before, 41 | &::after { 42 | content: " "; 43 | } 44 | } 45 | } 46 | } 47 | 48 | #benchmark-control { 49 | grid-template-columns: 1fr auto; 50 | 51 | #benchmark-title { 52 | grid-area: title; 53 | } 54 | #benchmark-input { 55 | grid-area: input; 56 | } 57 | .benchmark-output { 58 | grid-area: output; 59 | text-align: right; 60 | } 61 | 62 | grid-template-areas: 63 | "title title" 64 | "input output"; 65 | } 66 | 67 | #benchmark-input { 68 | label::after { 69 | content: " "; 70 | display: inline-block; 71 | width: 0.5em; 72 | } 73 | } 74 | 75 | .todo { 76 | .todo-select > button { 77 | > * { 78 | font-weight: 500; 79 | } 80 | 81 | $radius: 0.5em; 82 | border-top-left-radius: $radius; 83 | border-top-right-radius: $radius; 84 | 85 | border-color: transparent; 86 | border-bottom: none; 87 | 88 | &.active { 89 | cursor: initial; 90 | background-color: var(--background-white); 91 | } 92 | } 93 | 94 | .todo-listing { 95 | background-color: var(--background-white); 96 | 97 | ol { 98 | list-style-type: none; 99 | padding-left: 0.5em; 100 | > li { 101 | border-color: var(--border-colour); 102 | 103 | &:first-child { 104 | border: none; 105 | } 106 | 107 | .todo-message { 108 | > i { 109 | &::after { 110 | content: " "; 111 | display: inline-block; 112 | width: 0.5em; 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | background-color: var(--slight-grey); 121 | } 122 | } 123 | } 124 | 125 | button { 126 | background-color: unset; 127 | &:active, 128 | &:hover, 129 | &:focus { 130 | outline: none; 131 | } 132 | } 133 | 134 | ul { 135 | padding-left: 1.5em; 136 | } 137 | 138 | .lightly-bordered { 139 | border-style: solid; 140 | border-width: thin; 141 | border-radius: var(--border-corner); 142 | border-color: var(--border-colour); 143 | } 144 | 145 | .input-group { 146 | border-style: solid; 147 | border-width: thin; 148 | border-radius: 0.2em; 149 | border-color: var(--input-group-outline); 150 | > * { 151 | border: unset; 152 | } 153 | > input { 154 | border-right-style: solid; 155 | border-right-width: thin; 156 | border-right-color: var(--input-group-outline); 157 | border-radius: 0; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /example/js/components/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { 3 | button, 4 | div, 5 | h2, 6 | input, 7 | label, 8 | output, 9 | section, 10 | } from "../../../src/noact-elements.js" 11 | import { MAX_TODOS, MIN_TODOS } from "../state.js" 12 | 13 | export type BenchmarkProps = {} 14 | 15 | export const Benchmark = ({}: BenchmarkProps) => 16 | output({ className: "benchmark-output" }) 17 | 18 | export type BenchmarkControlProps = { 19 | todo_sections: number 20 | on_new_bench: (_: number) => void 21 | onrandom: () => void 22 | } & BenchmarkProps 23 | 24 | export const BenchmarkControl = ({ 25 | on_new_bench, 26 | onrandom, 27 | todo_sections, 28 | }: BenchmarkControlProps) => { 29 | const input_id = "benchmark-input-input" 30 | return section( 31 | { 32 | id: "benchmark-control", 33 | className: cn( 34 | "d-grid", 35 | "ai-centre", 36 | "jc-space-between", 37 | "lightly-bordered", 38 | "px-6", 39 | "py-2", 40 | "row-gap-1", 41 | "col-gap-4", 42 | ), 43 | }, 44 | h2({ id: "benchmark-title", txt: "Benchmark" }), 45 | div( 46 | { 47 | id: "benchmark-input", 48 | className: cn("d-flex", "ai-baseline", "flex-wrap"), 49 | }, 50 | label({ 51 | htmlFor: input_id, 52 | txt: `Put in ${MIN_TODOS}-${MAX_TODOS}:`, 53 | }), 54 | div( 55 | { className: cn("input-group", "d-flex", "flex-grow-1") }, 56 | input({ 57 | id: input_id, 58 | type: "number", 59 | className: cn("flex-grow-1", "text-right"), 60 | min: String(MIN_TODOS), 61 | max: String(MAX_TODOS), 62 | value: String(todo_sections), 63 | onchange: ({ target }) => { 64 | const { value } = target as HTMLInputElement 65 | on_new_bench(parseInt(value)) 66 | }, 67 | }), 68 | button({ 69 | className: cn("clickable", "border-thin", "flex-shrink-1"), 70 | txt: "Random", 71 | onclick: onrandom, 72 | }), 73 | ), 74 | ), 75 | Benchmark({}), 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /example/js/components/readme.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { 3 | a, 4 | b, 5 | div, 6 | h1, 7 | h2, 8 | h4, 9 | hr, 10 | i, 11 | img, 12 | li, 13 | p, 14 | section, 15 | span, 16 | strike, 17 | ul, 18 | } from "../../../src/noact-elements.js" 19 | 20 | export type ReadmeProps = {} 21 | 22 | export const Readme = ({}: ReadmeProps) => 23 | section( 24 | { id: "readme", className: cn("lightly-bordered") }, 25 | div( 26 | { id: "readme-header", className: cn("px-6", "py-1") }, 27 | h4( 28 | { className: "mp-0" }, 29 | i({ className: "fas fa-book" }), 30 | span({ txt: " README.md" }), 31 | ), 32 | ), 33 | div( 34 | { id: "readme-body", className: cn("px-6", "pt-1") }, 35 | h1( 36 | {}, 37 | a( 38 | { href: "https://ms-jpq.github.io/noact" }, 39 | strike({ txt: "Re" }), 40 | span({ txt: "Noact" }), 41 | ), 42 | ), 43 | hr(), 44 | p({ txt: "Noact is a minimal self-rendering Virtual DOM library." }), 45 | ul( 46 | {}, 47 | li( 48 | {}, 49 | b({ txt: "Declarative:" }), 50 | span({ 51 | txt: "Pretty much like React, without the JSX compilation of course, hence the name.", 52 | }), 53 | ), 54 | li( 55 | {}, 56 | b({ txt: "Type safe:" }), 57 | span({ txt: "Noact is completely typesafe, which means you get" }), 58 | a({ 59 | txt: "static type checking", 60 | href: "https://github.com/ms-jpq/Noact/blob/noact/_assets/auto_complete.gif", 61 | }), 62 | span({ txt: "for free!" }), 63 | ), 64 | li( 65 | {}, 66 | b({ txt: "Simple:" }), 67 | a({ 68 | txt: "Only 60 lines", 69 | href: "https://github.com/ms-jpq/Noact/blob/noact/src/noact.ts", 70 | }), 71 | span({ 72 | txt: "of type declarations & rendering code. (and 10ish lines of code-gen code)", 73 | }), 74 | ), 75 | ), 76 | hr(), 77 | h2({ txt: "How it feels to write Noact" }), 78 | img({ 79 | className: "img-responsive", 80 | src: "https://raw.githubusercontent.com/ms-jpq/Noact/noact/_assets/demo.gif", 81 | }), 82 | p({}, b({ txt: "- Explosions -" })), 83 | p( 84 | {}, 85 | span({ txt: "Even has support for" }), 86 | b({ txt: "style auto complete" }), 87 | ), 88 | img({ 89 | className: "img-responsive", 90 | src: "https://raw.githubusercontent.com/ms-jpq/Noact/noact/_assets/type_demo.png", 91 | }), 92 | hr(), 93 | h2({ txt: "Source code" }), 94 | ul( 95 | {}, 96 | li( 97 | {}, 98 | a({ 99 | txt: "Rendering Engine", 100 | href: "https://github.com/ms-jpq/noact/tree/noact/src", 101 | }), 102 | ), 103 | li( 104 | {}, 105 | a({ 106 | txt: "This Page", 107 | href: "https://github.com/ms-jpq/noact/tree/noact/example", 108 | }), 109 | ), 110 | ), 111 | ), 112 | ) 113 | -------------------------------------------------------------------------------- /example/js/components/todo/00_input.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { non_empty } from "nda/iso/validation.js" 3 | import { button, div, i, input, label } from "../../../../src/noact-elements.js" 4 | 5 | export type TodoInputProps = { 6 | oninput: (_: string) => void 7 | idx: number 8 | } 9 | 10 | export const TodoInput = ({ oninput, idx }: TodoInputProps) => { 11 | const input_id = `todo-input-${idx}` 12 | return div( 13 | { 14 | className: cn("todo-input", "px-6", "lab-inp-btn"), 15 | }, 16 | label({ 17 | htmlFor: input_id, 18 | txt: "I need to:", 19 | }), 20 | div( 21 | { className: cn("input-group", "d-flex", "flex-row") }, 22 | input({ 23 | id: input_id, 24 | className: "flex-grow-1", 25 | placeholder: "...", 26 | onchange: ({ target }) => { 27 | const input = target as HTMLInputElement 28 | const { value } = input 29 | if (non_empty(value)) { 30 | oninput(value) 31 | } 32 | input.value = "" 33 | }, 34 | }), 35 | button( 36 | { className: cn("clickable", "flex-shrink-1") }, 37 | i({ className: "fas fa-reply" }), 38 | ), 39 | ), 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /example/js/components/todo/01_select.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { button, div, h4 } from "../../../../src/noact-elements.js" 3 | import { type View } from "../../state.js" 4 | 5 | export type TodoSelectProps = { 6 | onselect: (_: View) => void 7 | viewing: View 8 | } 9 | 10 | export const TodoSelect = ({ onselect, viewing }: TodoSelectProps) => 11 | div( 12 | { className: cn("todo-select", "px-6", "mt-4") }, 13 | button( 14 | { 15 | className: cn({ active: viewing === "todo" }), 16 | onclick: () => onselect("todo"), 17 | }, 18 | h4({ txt: "Remaining" }), 19 | ), 20 | button( 21 | { 22 | className: cn({ active: viewing === "done" }), 23 | onclick: () => onselect("done"), 24 | }, 25 | h4({ txt: "Done" }), 26 | ), 27 | button( 28 | { 29 | className: cn({ active: viewing === "all" }), 30 | onclick: () => onselect("all"), 31 | }, 32 | h4({ txt: "Showall" }), 33 | ), 34 | ) 35 | -------------------------------------------------------------------------------- /example/js/components/todo/02_listing.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { map } from "nda/iso/iterator.js" 3 | import { button, div, i, li, ol, span } from "../../../../src/noact-elements.js" 4 | import { type TodoItem } from "../../state.js" 5 | 6 | export type TodoListingProps = { 7 | ontoggle: (_: TodoItem) => void 8 | onremove: (_: TodoItem) => void 9 | items: TodoItem[] 10 | } 11 | 12 | export const TodoListing = ({ ontoggle, onremove, items }: TodoListingProps) => 13 | div( 14 | { className: cn("todo-listing", "px-6") }, 15 | ol( 16 | {}, 17 | ...map( 18 | items, 19 | (item) => 20 | li( 21 | { 22 | className: cn( 23 | "d-grid", 24 | "grid-col", 25 | "ac-baseline", 26 | "ji-start", 27 | "border-top-solid", 28 | "border-thin", 29 | "pt-4", 30 | "py-1", 31 | ), 32 | }, 33 | div( 34 | { 35 | className: cn("todo-message", "clickable"), 36 | onclick: () => ontoggle(item), 37 | }, 38 | i({ 39 | className: cn( 40 | "clickable", 41 | item.status === "todo" 42 | ? "far fa-check-square" 43 | : "fas fa-check-square", 44 | ), 45 | }), 46 | span({ txt: item.message }), 47 | ), 48 | button({ 49 | className: cn("clickable", "border-clear", "js-end", "font-w900"), 50 | txt: "×", 51 | onclick: () => onremove(item), 52 | }), 53 | ), 54 | ), 55 | ), 56 | ) 57 | -------------------------------------------------------------------------------- /example/js/components/todo/todo.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { div, h2, p, section } from "../../../../src/noact-elements.js" 3 | import { TodoInput, type TodoInputProps } from "./00_input.js" 4 | import { TodoSelect, type TodoSelectProps } from "./01_select.js" 5 | import { TodoListing, type TodoListingProps } from "./02_listing.js" 6 | 7 | export type TodoProps = { 8 | still_todo_count: number 9 | todo_sections: number 10 | } & TodoInputProps & 11 | TodoSelectProps & 12 | TodoListingProps 13 | 14 | export const Todo = ({ 15 | oninput, 16 | onselect, 17 | ontoggle, 18 | onremove, 19 | viewing, 20 | items, 21 | idx, 22 | todo_sections, 23 | still_todo_count, 24 | }: TodoProps) => 25 | section( 26 | { 27 | className: cn("todo", "lightly-bordered"), 28 | }, 29 | div( 30 | { 31 | className: cn( 32 | "todo-header", 33 | "d-grid", 34 | "grid-col", 35 | "jc-space-between", 36 | "ai-baseline", 37 | "px-6", 38 | ), 39 | }, 40 | h2({ className: cn("todo-title", "mb-0"), txt: "TODO" }), 41 | p({ 42 | className: cn("todo-meta", "text-right"), 43 | txt: `${idx} of ${todo_sections} synchronized`, 44 | }), 45 | ), 46 | TodoInput({ oninput, idx }), 47 | TodoSelect({ onselect, viewing }), 48 | TodoListing({ ontoggle, onremove, items }), 49 | p({ 50 | className: cn("todo-info", "px-6", "my-1"), 51 | txt: `${still_todo_count} items left`, 52 | }), 53 | ) 54 | -------------------------------------------------------------------------------- /example/js/entry.ts: -------------------------------------------------------------------------------- 1 | import { count_by, filter, map, sort_by_keys } from "nda/iso/iterator.js" 2 | import { counter, sleep, timer } from "nda/iso/prelude.js" 3 | import { int } from "nda/iso/rand.js" 4 | import { $$ } from "nda/web/dom.js" 5 | import { NewMountPoint } from "../../src/noact.js" 6 | import "../css/page.scss" 7 | import { type BodyProps } from "./layout/body.js" 8 | import { Page, type PageProps } from "./layout/page.js" 9 | import { 10 | MAX_TODOS, 11 | MIN_TODOS, 12 | type State, 13 | type TodoItem, 14 | type TodoStatus, 15 | type View, 16 | } from "./state.js" 17 | 18 | const inc = counter() 19 | const mount = NewMountPoint(document.body) 20 | 21 | // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle 22 | const shuffle = (pool: Iterable) => { 23 | const coll = [...pool] 24 | for (let i = coll.length - 1; i > 0; i--) { 25 | const j = Math.floor(Math.random() * (i + 1)) 26 | ;[coll[i], coll[j]] = [coll[j]!, coll[i]!] 27 | } 28 | return coll 29 | } 30 | 31 | const idx_by_status = (status: TodoStatus) => { 32 | switch (status) { 33 | case "todo": 34 | return 1 35 | case "done": 36 | return 2 37 | default: 38 | throw new Error("invalid status") 39 | } 40 | } 41 | 42 | const sort_todos = (items: TodoItem[]) => 43 | sort_by_keys(items, (i) => [idx_by_status(i.status), i.last_update]) 44 | 45 | const INIT_ITEMS = sort_todos([ 46 | ...map( 47 | shuffle([ 48 | { message: "Printer ran out of juice again", status: "todo" }, 49 | { message: "Something about neighbour's cat", status: "todo" }, 50 | { message: "Go to bed before 1AM", status: "todo" }, 51 | { message: "Craig owes me money?", status: "todo" }, 52 | { message: "👋Hire me👋", status: "todo" }, 53 | { message: "Draw a prefect circle", status: "todo" }, 54 | { message: "Take out trash", status: "done" }, 55 | { message: "Ask Jenny for penny", status: "done" }, 56 | { message: "Get groceries", status: "done" }, 57 | { message: "Download Mob Psycho", status: "done" }, 58 | ]), 59 | (i) => ({ ...i, id: inc(), last_update: inc() }), 60 | ), 61 | ]) 62 | 63 | const INIT_STATE: State = { 64 | todo_sections: 1, 65 | viewing: { 66 | view: "todo", 67 | last_update: Date.now(), 68 | }, 69 | items: INIT_ITEMS, 70 | } 71 | 72 | const invert_status = (status: TodoStatus) => { 73 | switch (status) { 74 | case "todo": 75 | return "done" 76 | case "done": 77 | return "todo" 78 | default: 79 | throw new Error("invalid status") 80 | } 81 | } 82 | 83 | const perf = async (draw: () => void) => { 84 | const t = timer() 85 | draw() 86 | await sleep(0) 87 | const elapsed = Math.round(t()) 88 | const count = $$("*").length 89 | const benchmarks = $$(".benchmark-output") 90 | for (const benchmark of benchmarks) { 91 | benchmark.value = `rendered ${count} elements in ${elapsed}ms` 92 | } 93 | } 94 | 95 | const update = ({ todo_sections, viewing, items }: State) => { 96 | const on_new_bench = (val: number) => { 97 | const todo_sections = Math.min(MAX_TODOS, Math.max(MIN_TODOS, val)) 98 | update({ todo_sections, items, viewing }) 99 | } 100 | 101 | const onrandom = () => 102 | update({ items, viewing, todo_sections: int(MIN_TODOS, MAX_TODOS) }) 103 | 104 | const oninput = (message: string) => { 105 | const new_item: TodoItem = { 106 | status: "todo", 107 | id: inc(), 108 | last_update: Date.now(), 109 | message, 110 | } 111 | const new_items = [...items, new_item] 112 | update({ todo_sections, items: new_items, viewing }) 113 | } 114 | 115 | const ontoggle = (item: TodoItem) => { 116 | const new_items = [ 117 | ...map(items, (i) => ({ 118 | ...i, 119 | status: i.id === item.id ? invert_status(i.status) : i.status, 120 | last_update: i.id === item.id ? Date.now() : i.last_update, 121 | })), 122 | ] 123 | update({ todo_sections, items: new_items, viewing }) 124 | } 125 | 126 | const onremove = (item: TodoItem) => { 127 | const new_items = [...filter(items, (i) => i.id !== item.id)] 128 | update({ todo_sections, items: new_items, viewing }) 129 | } 130 | 131 | const onselect = (view: View) => { 132 | if (view !== viewing.view) { 133 | update({ 134 | todo_sections, 135 | items: sort_todos(items), 136 | viewing: { view, last_update: Date.now() }, 137 | }) 138 | } 139 | } 140 | 141 | const still_todo_count = count_by(items, (i) => i.status === "todo") 142 | 143 | const body: BodyProps = { 144 | todo_sections, 145 | viewing: viewing.view, 146 | items, 147 | onrandom, 148 | on_new_bench, 149 | oninput, 150 | ontoggle, 151 | onremove, 152 | onselect, 153 | still_todo_count, 154 | } 155 | 156 | const page: PageProps = { 157 | last_view_update: viewing.last_update, 158 | header: {}, 159 | body: body, 160 | footer: {}, 161 | } 162 | 163 | perf(() => mount(Page(page))) 164 | } 165 | 166 | update(INIT_STATE) 167 | -------------------------------------------------------------------------------- /example/js/layout/body.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { map, range } from "nda/iso/iterator.js" 3 | import { main } from "../../../src/noact-elements.js" 4 | import { 5 | BenchmarkControl, 6 | type BenchmarkControlProps, 7 | } from "../components/benchmark.js" 8 | import { Readme } from "../components/readme.js" 9 | import { Todo, type TodoProps } from "../components/todo/todo.js" 10 | 11 | export type BodyProps = { 12 | todo_sections: number 13 | } & BenchmarkControlProps & 14 | Omit 15 | 16 | export const Body = ({ 17 | todo_sections, 18 | viewing, 19 | items, 20 | on_new_bench, 21 | onrandom, 22 | oninput, 23 | ontoggle, 24 | onremove, 25 | onselect, 26 | still_todo_count, 27 | }: BodyProps) => 28 | main( 29 | { className: cn("d-grid", "row-gap-8") }, 30 | Readme({}), 31 | BenchmarkControl({ todo_sections, on_new_bench, onrandom }), 32 | ...map(range(0, todo_sections), (idx) => 33 | Todo({ 34 | idx, 35 | viewing, 36 | items, 37 | oninput, 38 | onremove, 39 | onselect, 40 | ontoggle, 41 | todo_sections, 42 | still_todo_count, 43 | }), 44 | ), 45 | ) 46 | -------------------------------------------------------------------------------- /example/js/layout/footer.ts: -------------------------------------------------------------------------------- 1 | import { a, footer, p, span } from "../../../src/noact-elements.js" 2 | 3 | export type FooterProps = {} 4 | 5 | export const Footer = ({}: FooterProps) => 6 | footer( 7 | {}, 8 | p( 9 | { className: "text-centre" }, 10 | span({ txt: "© " }), 11 | a({ txt: "ms-jpq", href: "https://ms-jpq.github.io" }), 12 | ), 13 | ) 14 | -------------------------------------------------------------------------------- /example/js/layout/header.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { h1, header } from "../../../src/noact-elements.js" 3 | 4 | export type HeaderProps = {} 5 | 6 | export const Header = ({}: HeaderProps) => 7 | header( 8 | { 9 | className: cn( 10 | "d-grid", 11 | "text-centre", 12 | "ji-centre", 13 | "lightly-bordered", 14 | "py-8", 15 | ), 16 | }, 17 | h1({ className: "font-w500", txt: "This Page is Rendered Using Noact" }), 18 | ) 19 | -------------------------------------------------------------------------------- /example/js/layout/page.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "nda/iso/dom.js" 2 | import { filter } from "nda/iso/iterator.js" 3 | import { div } from "../../../src/noact-elements.js" 4 | import { type TodoItem, type View } from "../state.js" 5 | import { Body, type BodyProps } from "./body.js" 6 | import { Footer, type FooterProps } from "./footer.js" 7 | import { Header, type HeaderProps } from "./header.js" 8 | 9 | const item_visible = (view: View, last_view_update: number, item: TodoItem) => { 10 | switch (view) { 11 | case "all": 12 | return true 13 | case "todo": 14 | return item.last_update > last_view_update || item.status === "todo" 15 | case "done": 16 | return item.last_update > last_view_update || item.status === "done" 17 | } 18 | } 19 | 20 | export type PageProps = { 21 | last_view_update: number 22 | header: HeaderProps 23 | body: BodyProps 24 | footer: FooterProps 25 | } 26 | 27 | export const Page = ({ last_view_update, header, body, footer }: PageProps) => { 28 | const items = [ 29 | ...filter(body.items, (i) => 30 | item_visible(body.viewing, last_view_update, i), 31 | ), 32 | ] 33 | return div( 34 | { 35 | id: "container", 36 | className: cn("d-grid", "mx-auto", "mt-12", "row-gap-12"), 37 | }, 38 | Header(header), 39 | Body({ ...body, items }), 40 | Footer(footer), 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /example/js/state.ts: -------------------------------------------------------------------------------- 1 | export type View = "todo" | "done" | "all" 2 | 3 | export type TodoStatus = "done" | "todo" 4 | 5 | export type TodoItem = { 6 | id: number 7 | last_update: number 8 | status: TodoStatus 9 | message: string 10 | } 11 | 12 | export type State = { 13 | todo_sections: number 14 | viewing: { 15 | view: View 16 | last_update: number 17 | } 18 | items: TodoItem[] 19 | } 20 | 21 | export const [MIN_TODOS, MAX_TODOS] = [1, 100] 22 | -------------------------------------------------------------------------------- /excel/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/noact/03aa56e4bae7b970de5a131206888c2bc8f005da/excel/index.ts -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Fly Me to the Moon", 3 | "browserslist": [ 4 | "last 5 Chrome versions" 5 | ], 6 | "dependencies": { 7 | "@fortawesome/fontawesome-free": "^5", 8 | "bootstrap": "^4", 9 | "cda": "git+https://github.com/ms-jpq/cda.git#157f512149ebb01ef272b890edbda6b6ae5de785", 10 | "css-loader": "^6", 11 | "html-webpack-plugin": "^5", 12 | "mini-css-extract-plugin": "^2", 13 | "nda": "git+https://github.com/ms-jpq/nda.git#17b83e74ecec343b46581f4d5fc9569bfe537d3c", 14 | "sass": "^1", 15 | "sass-loader": "^13", 16 | "ts-loader": "^9", 17 | "webpack": "^5", 18 | "webpack-cli": "^4" 19 | }, 20 | "description": "_", 21 | "devDependencies": { 22 | "@types/node": "*", 23 | "prettier": "*", 24 | "typescript": "*" 25 | }, 26 | "license": "MIT", 27 | "scripts": { 28 | "build": "./build.mjs", 29 | "ci": "./ci/release.mjs", 30 | "pretty": "prettier --write **/**.tsx --write **/**.ts" 31 | }, 32 | "type": "module", 33 | "version": "1.0.0" 34 | } 35 | -------------------------------------------------------------------------------- /src/noact-elements.ts: -------------------------------------------------------------------------------- 1 | import { Render } from "./noact.js" 2 | 3 | /* 4 | * - GENERATED CODE - 5 | * 6 | * This list can be scraped from https://developer.mozilla.org/en-US/docs/Web/HTML/Element 7 | 8 | ;(() => { 9 | const selector = `li.toggle:nth-child(7) > details:nth-child(1) > ol > li code` // Update me if needed 10 | const stringify = (e) => { 11 | const tn = e.tagName.toLowerCase() 12 | return `export const ${tn} = Render('${tn}')` 13 | } 14 | const tags = $$(selector).map((a) => a.textContent.replace(/[<|>]/g, ``)) 15 | const elements = tags.sort().map((t) => document.createElement(t)) 16 | return elements.map(stringify).join(`\n`) 17 | })() 18 | 19 | * Minor fixups might be required, ie. missing h2 - h6 20 | * 21 | * - GENERATED CODE - 22 | */ 23 | 24 | export const a = Render("a") 25 | export const abbr = Render("abbr") 26 | export const acronym = Render("acronym") 27 | export const address = Render("address") 28 | export const applet = Render("applet") 29 | export const area = Render("area") 30 | export const article = Render("article") 31 | export const aside = Render("aside") 32 | export const audio = Render("audio") 33 | export const b = Render("b") 34 | export const base = Render("base") 35 | export const basefont = Render("basefont") 36 | export const bdi = Render("bdi") 37 | export const bdo = Render("bdo") 38 | export const bgsound = Render("bgsound") 39 | export const big = Render("big") 40 | export const blink = Render("blink") 41 | export const blockquote = Render("blockquote") 42 | export const body = Render("body") 43 | export const br = Render("br") 44 | export const button = Render("button") 45 | export const canvas = Render("canvas") 46 | export const caption = Render("caption") 47 | export const center = Render("center") 48 | export const cite = Render("cite") 49 | export const code = Render("code") 50 | export const col = Render("col") 51 | export const colgroup = Render("colgroup") 52 | export const command = Render("command") 53 | export const content = Render("content") 54 | export const data = Render("data") 55 | export const datalist = Render("datalist") 56 | export const dd = Render("dd") 57 | export const del = Render("del") 58 | export const details = Render("details") 59 | export const dfn = Render("dfn") 60 | export const dialog = Render("dialog") 61 | export const dir = Render("dir") 62 | export const div = Render("div") 63 | export const dl = Render("dl") 64 | export const dt = Render("dt") 65 | export const element = Render("element") 66 | export const em = Render("em") 67 | export const embed = Render("embed") 68 | export const fieldset = Render("fieldset") 69 | export const figcaption = Render("figcaption") 70 | export const figure = Render("figure") 71 | export const font = Render("font") 72 | export const footer = Render("footer") 73 | export const form = Render("form") 74 | export const frame = Render("frame") 75 | export const frameset = Render("frameset") 76 | export const h1 = Render("h1") 77 | export const h2 = Render("h2") 78 | export const h3 = Render("h3") 79 | export const h4 = Render("h4") 80 | export const h5 = Render("h5") 81 | export const h6 = Render("h6") 82 | export const head = Render("head") 83 | export const header = Render("header") 84 | export const hgroup = Render("hgroup") 85 | export const hr = Render("hr") 86 | export const html = Render("html") 87 | export const i = Render("i") 88 | export const iframe = Render("iframe") 89 | export const image = Render("image") 90 | export const img = Render("img") 91 | export const input = Render("input") 92 | export const ins = Render("ins") 93 | export const isindex = Render("isindex") 94 | export const kbd = Render("kbd") 95 | export const keygen = Render("keygen") 96 | export const label = Render("label") 97 | export const legend = Render("legend") 98 | export const li = Render("li") 99 | export const link = Render("link") 100 | export const listing = Render("listing") 101 | export const main = Render("main") 102 | export const map = Render("map") 103 | export const mark = Render("mark") 104 | export const marquee = Render("marquee") 105 | export const menu = Render("menu") 106 | export const menuitem = Render("menuitem") 107 | export const meta = Render("meta") 108 | export const meter = Render("meter") 109 | export const multicol = Render("multicol") 110 | export const nav = Render("nav") 111 | export const nextid = Render("nextid") 112 | export const nobr = Render("nobr") 113 | export const noembed = Render("noembed") 114 | export const noframes = Render("noframes") 115 | export const noscript = Render("noscript") 116 | export const object = Render("object") 117 | export const ol = Render("ol") 118 | export const optgroup = Render("optgroup") 119 | export const option = Render("option") 120 | export const output = Render("output") 121 | export const p = Render("p") 122 | export const param = Render("param") 123 | export const picture = Render("picture") 124 | export const plaintext = Render("plaintext") 125 | export const pre = Render("pre") 126 | export const progress = Render("progress") 127 | export const q = Render("q") 128 | export const rb = Render("rb") 129 | export const rp = Render("rp") 130 | export const rt = Render("rt") 131 | export const rtc = Render("rtc") 132 | export const ruby = Render("ruby") 133 | export const s = Render("s") 134 | export const samp = Render("samp") 135 | export const script = Render("script") 136 | export const section = Render("section") 137 | export const select = Render("select") 138 | export const shadow = Render("shadow") 139 | export const slot = Render("slot") 140 | export const small = Render("small") 141 | export const source = Render("source") 142 | export const spacer = Render("spacer") 143 | export const span = Render("span") 144 | export const strike = Render("strike") 145 | export const strong = Render("strong") 146 | export const style = Render("style") 147 | export const sub = Render("sub") 148 | export const summary = Render("summary") 149 | export const sup = Render("sup") 150 | export const table = Render("table") 151 | export const tbody = Render("tbody") 152 | export const td = Render("td") 153 | export const template = Render("template") 154 | export const textarea = Render("textarea") 155 | export const tfoot = Render("tfoot") 156 | export const th = Render("th") 157 | export const thead = Render("thead") 158 | export const time = Render("time") 159 | export const title = Render("title") 160 | export const tr = Render("tr") 161 | export const track = Render("track") 162 | export const tt = Render("tt") 163 | export const u = Render("u") 164 | export const ul = Render("ul") 165 | export const video = Render("video") 166 | export const wbr = Render("wbr") 167 | export const xmp = Render("xmp") 168 | -------------------------------------------------------------------------------- /src/noact.ts: -------------------------------------------------------------------------------- 1 | type Props = Partial> & { 2 | style?: Extract, string> 3 | dataset?: Record 4 | txt?: string 5 | } 6 | type E = HTMLElementTagNameMap & Record 7 | export type NNode = (() => T) & { tagName: keyof E; props: Props; children: NNode[] } 8 | export type MaybeNNode = NNode | undefined 9 | 10 | export const Render = (tagName: T) => (p: Props = {}, ...c: MaybeNNode[]): NNode => { 11 | const props = p.txt ? { ...p, textContent: p.txt } : p 12 | const { style = {}, dataset = {}, ..._p } = props 13 | const children = c.filter((c) => c) as NNode[] 14 | const render = ((element?: E[T]) => () => { 15 | if (element) return element 16 | const e = (element = document.createElement(tagName)) 17 | Object.entries(_p).forEach(([k, v]) => ((e)[k] = v)) 18 | Object.entries(style).forEach(([k, v]) => ((e.style)[k] = v)) 19 | Object.entries(dataset).forEach(([k, v]) => (e.dataset[k] = v as string)) 20 | children.forEach((child) => e.append(child())) 21 | return e 22 | })(undefined) 23 | return Object.assign(render, { tagName, props, children }) 24 | } 25 | 26 | const patchProps = (prev: NNode, next: NNode) => { 27 | const e = prev() 28 | const { style: pStyle = {}, dataset: pData = {}, ...pProps } = prev.props 29 | const { style: nStyle = {}, dataset: nData = {}, ...nProps } = next.props 30 | Object.entries(pProps).forEach(([k]) => (nProps)[k] === undefined && ((e)[k] = undefined)) 31 | Object.entries(nProps).forEach(([k, v]) => (pProps)[k] !== v && ((e)[k] = v)) 32 | Object.entries(pStyle).forEach(([k]) => (nStyle)[k] === undefined && e.style.removeProperty(k)) 33 | Object.entries(nStyle).forEach(([k, v]) => (pStyle)[k] !== v && ((e.style)[k] = v)) 34 | Object.entries(pData).forEach(([k]) => nData[k] === undefined && Reflect.deleteProperty(e.dataset, k)) 35 | Object.entries(nData).forEach(([k, v]) => pData[k] !== v && (e.dataset[k] = v as string)) 36 | } 37 | 38 | const longZip = (a1: T[], a2: T[]) => [...Array(Math.max(a1.length, a2.length)).keys()].map((_, i) => [a1[i], a2[i]]) 39 | 40 | const reconciliate = (prev: NNode, next: NNode): NNode => { 41 | patchProps(prev, next) 42 | const element = prev() 43 | const children = longZip(prev.children, next.children).map(([p, n]) => 44 | p && !n ? p().remove() 45 | : !p && n ? (element.append(n()), n) 46 | : p!.tagName !== n!.tagName ? (p!().replaceWith(n!()), n) 47 | : reconciliate(p!, n!) 48 | ).filter((c) => c) 49 | return Object.assign(prev, { props: next.props, children }) 50 | } 51 | 52 | export const NewRNode = (element: HTMLElement, props: Record = {}, ...children: MaybeNNode[]): NNode => 53 | Object.assign(() => element, { tagName: element.tagName, props, children: children.filter((c) => c) as NNode[] }) 54 | 55 | export const NewMountPoint = (root: HTMLElement) => { 56 | let prev = NewRNode(root) 57 | return (...children: MaybeNNode[]) => (prev = reconciliate(prev, NewRNode(root as any, {}, ...children))) 58 | } 59 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig.json", 3 | "compileOnSave": true, 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "checkJs": true, 7 | "composite": true, 8 | "module": "nodenext", 9 | "noErrorTruncation": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitOverride": true, 12 | "noImplicitReturns": true, 13 | "noUncheckedIndexedAccess": true, 14 | "outDir": "node_modules/.cache/ts_compiled", 15 | "resolveJsonModule": true, 16 | "rootDir": ".", 17 | "skipLibCheck": true, 18 | "sourceMap": true, 19 | "strict": true, 20 | "target": "esnext", 21 | "verbatimModuleSyntax": true 22 | }, 23 | "exclude": ["node_modules", "artifacts"], 24 | "include": ["."], 25 | "references": [] 26 | } 27 | -------------------------------------------------------------------------------- /webpack.config.mjs: -------------------------------------------------------------------------------- 1 | import HtmlWebpackPlugin from "html-webpack-plugin" 2 | import MiniCssExtractPlugin from "mini-css-extract-plugin" 3 | import { basename, dirname, join } from "node:path" 4 | import { fileURLToPath } from "node:url" 5 | 6 | export const top_level = dirname(fileURLToPath(new URL(import.meta.url))) 7 | 8 | export default { 9 | mode: "production", 10 | stats: { errorDetails: true }, 11 | entry: join(top_level, "example", "js", "entry.ts"), 12 | resolve: { 13 | extensions: [".ts", ".js"], 14 | extensionAlias: { ".js": [".js", ".ts"] }, 15 | }, 16 | module: { 17 | rules: [ 18 | { test: /\.ts$/i, loader: "ts-loader" }, 19 | { 20 | test: /\.s[ac]ss$/i, 21 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], 22 | }, 23 | ], 24 | }, 25 | plugins: [ 26 | new MiniCssExtractPlugin({ 27 | runtime: false, 28 | }), 29 | new HtmlWebpackPlugin({ 30 | publicPath: "/noact-page", 31 | title: basename(top_level), 32 | xhtml: true, 33 | }), 34 | ], 35 | } 36 | --------------------------------------------------------------------------------