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