├── www
├── static
│ ├── CNAME
│ ├── nomini.js
│ ├── jingle.m4a
│ ├── Jost-Regular.woff2
│ ├── favicon.svg
│ ├── logo.svg
│ └── main.css
├── config.toml
├── templates
│ ├── page.html
│ └── base.html
└── content
│ ├── comparison.md
│ ├── _index.md
│ └── docs.md
├── .gitignore
├── scripts
├── build.sh
└── devour.sh
├── .github
└── workflows
│ └── build-docs.yml
├── LICENSE
├── CONTRIBUTING.md
├── README.md
├── dist
├── nomini.core.min.js
└── nomini.min.js
└── nomini.js
/www/static/CNAME:
--------------------------------------------------------------------------------
1 | nomini.js.org
2 |
--------------------------------------------------------------------------------
/www/static/nomini.js:
--------------------------------------------------------------------------------
1 | ../../nomini.js
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | www/public
3 | test.html
4 | dist/*.br
5 | dist/*.gz
6 |
--------------------------------------------------------------------------------
/www/static/jingle.m4a:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nonnorm/nomini/HEAD/www/static/jingle.m4a
--------------------------------------------------------------------------------
/www/static/Jost-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nonnorm/nomini/HEAD/www/static/Jost-Regular.woff2
--------------------------------------------------------------------------------
/scripts/build.sh:
--------------------------------------------------------------------------------
1 | rm ./dist/*
2 |
3 | ./scripts/devour.sh > ./dist/nomini.min.js
4 |
5 | ./scripts/devour.sh template form fetch morph helpers events > ./dist/nomini.core.min.js
6 |
--------------------------------------------------------------------------------
/.github/workflows/build-docs.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches: main
4 | name: Build and deploy GH Pages
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Checkout
10 | uses: actions/checkout@v4
11 | - name: Build and Deploy Website
12 | uses: shalzz/zola-deploy-action@master
13 | env:
14 | BUILD_DIR: www
15 | PAGES_BRANCH: gh-pages
16 | TOKEN: ${{ secrets.GITHUB_TOKEN }}
17 |
--------------------------------------------------------------------------------
/scripts/devour.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Nomini Devourer: removes code you don't need
4 | # Current available blocks to remove (as of v0.3.0):
5 | # data, bind, ref, fetch, morph, form, template, evtmods, helpers
6 | # Ex: ./scripts/devour.sh template form > nomini-custom.js
7 |
8 | file="nomini.js"
9 | sed_script=""
10 |
11 | for block in "$@"; do
12 | sed_script="$sed_script;/--- BEGIN $block/,/--- END $block/d"
13 | done
14 |
15 | sed_script="$sed_script;/--- BEGIN/d;/--- END/d"
16 |
17 | sed "$sed_script" "$file" | (command -v minify >/dev/null && minify --type js || cat)
18 |
--------------------------------------------------------------------------------
/www/config.toml:
--------------------------------------------------------------------------------
1 | # The URL the site will be built for
2 | base_url = "https://nomini.js.org"
3 | title = "Nomini"
4 | description = "Nomini is the smallest reactive and server-driven hypermedia framework"
5 |
6 | # Whether to automatically compile all Sass files in the sass directory
7 | compile_sass = false
8 |
9 | # Whether to build a search index to be used later on by a JavaScript library
10 | build_search_index = false
11 |
12 | [markdown]
13 | # Whether to do syntax highlighting
14 | # Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola
15 | highlight_code = true
16 | highlight_theme = "monokai"
17 | external_links_target_blank = true
18 |
19 | [extra]
20 | # Put all your custom variables here
21 |
--------------------------------------------------------------------------------
/www/templates/page.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}
4 | {{page.title}} — Nomini
5 | {% endblock %}
6 |
7 | {% block content %}
8 | {% if page.toc %}
9 |
27 | {% endif %}
28 |
29 |
30 |
31 | {{ page.title }}
32 |
33 |
34 | {{ page.content | safe }}
35 |
36 | {% endblock content %}
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 nonnorm
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 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 | First off, thank you for taking the time to contribute to Nomini! Contributions are always appreciated, and they can range from spelling and grammar fixes to whole new features, or even just bug reporting.
3 |
4 | All in all, this is a pretty small project, with loosely defined contributing guidelines. If you're editing the source of Nomini, that can be found in `nomini.js`. Otherwise, the website is in `www/`.
5 |
6 | If you're proposing a major feature, please do it in Issues before opening a PR. This will give us time to review and consider it before any code needs to be written.
7 |
8 | ## AI Policy
9 | I have nothing against the use of Large Language Models (ChatGPT, Claude, Gemini, Qwen, etc.) to assist in open-source projects. However, there are some guidelines that I expect everyone (including maintainers) to abide by:
10 | 1. **Fully understand what the AI generates**
11 | 2. **Be able to defend your choices, even if they were AI assisted**
12 | 3. **Review AI code critically to ensure it meets project standards**
13 |
14 | TL;DR: AI is permitted for use in the writing of code and docs. Just please keep in mind that the term *AI slop* was coined for a reason.
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 
2 |
3 | Nomini is an extremely small (~2kb) library for reactive HTML and partial page updates. It blends the best ideas from Alpine.js, htmx, and Datastar while staying tiny and easy to understand.
4 |
5 | Nomini lets you:
6 | - Add lightweight reactive state directly in your HTML
7 | - Bind DOM properties and classes with automatic dependency tracking
8 | - Listen to any event with inline handlers
9 | - Make AJAX requests to replace any part of the page
10 | - Keep everything declarative and local
11 |
12 | If you want the power of Alpine + htmx with **an order of magnitude less code**, Nomini is the smallest tool that delivers both.
13 |
14 | Full documentation is available at [the website](https://nomini.js.org/docs)
15 |
16 | ## Why Nomini?
17 | - It's tiny! (8x smaller than Datastar)
18 | - Write plain JavaScript, no special DSLs to learn
19 | - Enhanced `onclick` with modifiers and access to reactive variables
20 | - Built-in AJAX helpers that grab all of the data from the current scope
21 |
22 | ## What Nomini is *not*
23 | Nomini is deliberately minimal. It **does not** try to be:
24 | - A template language
25 | - A performant virtual DOM
26 | - A full-featured set of helpers for every use case
27 | - A global state management system
28 |
29 | Nomini stays small by keeping the mental model simple: **Boring HTML + islands of reactivity + server-driven pages**
30 |
--------------------------------------------------------------------------------
/www/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | | | Nomini | HTMX v4 | Alpine | Datastar |
7 | |-|-|-|-|-|
8 | | **Bundle Size (.min.br)** | 🟢 ~1.8kb | 🟡 ~10.2kb | 🟡 ~14.7kb | 🟡 ~10.6kb |
9 | | **Main Purpose** | Lightweight reactivity and partial page updates | Easy partial page updates | Full-featured reactivity system | Full-featured streaming page updates and reactivity |
10 | | **Reactivity Model** | Proxy | 🔴 N/A | Proxy | Signals and Proxy |
11 | | **Data Scoping** | 🟡 `nm-data`, no inheritance | 🔴 N/A | 🟢 Global tree with overrides | 🟢 Global tree with overrides |
12 | | **Event Handling** | 🟢 `nm-on` with modifiers | 🟢 `hx-on`/`hx-trigger` with many modifiers | 🟢 `x-on/@` with modifiers | 🟢 `data-on` with modifiers |
13 | | **Templating** | 🟡 `template` + `nm-use` (simple) | 🔴 None | 🟢 `x-for`/`x-teleport` | 🟡 Rocket (pro only) |
14 | | **Transitions** | 🟡 ID-based settling | 🟡 ID-based settling | 🟢 `x-transition` | 🟢 Full morphing |
15 | | **Morphing** | 🔴 None | 🔴 Idiomorph WIP, with extension currently | 🔴 Only with `alpine-morph` | 🟢 Improved Idiomorph built-in |
16 | | **AJAX** | 🟢 `$fetch` | 🟢 `hx-get` | 🔴 Only with `alpine-ajax` | 🟢 `@get` |
17 | | **Streaming Support** | 🟢 By HTML Chunk | 🟢 By HTML Chunk or SSE | 🔴 N/A | 🟢 By custom SSE format |
18 | | **Server Requirements** | 🟢 Produce HTML | 🟢 Produce HTML | 🟡 Produce HTML and JSON | 🟡 Produce custom SSE format (or HTML) |
19 | | **Server Power** | 🟡 Swap in reactive HTML, trigger events, use templates | 🟡 Swap in HTML, trigger events | 🔴 Only with `alpine-ajax` | 🟢 Update signals, run scripts, morph HTML |
20 | | **Plugin Support** | 🔴 None | 🟢 Good plugin system | 🟢 Good plugin system | 🟢 Amazing plugin system (everything is a plugin) |
21 | | **Community Support** | 🔴 GitHub only | 🟢 HTMX Discord + social media | 🟢 Large community, unofficial Discord | 🟢 Datastar Discord |
22 | | **Docs Quality** | 🟡 Basic website and docs | 🟢 Comprehensive website with extensive docs and essays | 🟢 Comprehensive docs with many examples | 🟢 Comprehensive reference and good tutorial |
23 | | **Learning Curve** | 🟢 Low | 🟢 Very Low | 🟡 Medium | 🟡 Medium–High |
24 | | **Locality of Behavior** | 🟢 Excellent | 🟢 Excellent | 🟢 Excellent | 🟢 Excellent |
25 | | **CSP Compatability** | 🔴 None | 🟢 Good | 🟡 Possible | 🔴 None |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/www/static/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/www/content/_index.md:
--------------------------------------------------------------------------------
1 | +++
2 | template = "base.html"
3 | +++
4 |
5 |
6 |
7 | # Nomini
8 | *The tiny, reactive, server-driven framework*
9 |
10 |
11 |
12 |
13 |
14 | ## Uhh... what is Nomini?
15 | Nomini is *not* your average JavaScript framework. Honestly, it’s barely a framework at all—and that’s the point. Nomini is just a tiny (~2kb) collection of useful attributes and helper functions that allow you to embrace writing JavaScript like the good old days, with a few modern conveniences layered on top.
16 | ### Features:
17 | - `nm-data`: The heart of Nomini. Create a reactive data scope, similar to Alpine's `x-data`.
18 | - `nm-bind`: Take any property of any element, including classes and event listeners, and bind it to a reactive variable.
19 |
20 | ___(With these two attributes, you can build almost anything! Still, there are a few more that punch well above their weight.)___
21 |
22 | - `nm-ref`: Hold a reference to an element in your data scope.
23 | - `nm-form`: Automatically wire an entire form's inputs to reactive variables.
24 | - `nm-use`: A tiny templating system that takes advantage of `nm-data` scopes.
25 |
26 |
27 |
28 |
29 |
30 | ## Installation
31 | **Nomini is a single file** designed to be downloaded and vendored into your project (placed into a static directory).
32 |
33 | That means you never have to worry about dependency updates, you can add new features as you please, or you can remove filthy code that you don't use.
34 |
35 | If you prefer a CDN, just paste this line of code into your ``.
36 | ```html
37 |
38 | ```
39 |
40 | ### Flavors
41 | Nomini comes in 2 different flavors, both tiny but surprisingly powerful:
42 | - **Core (<800 bytes, 1.5kb uncompressed)**: Featherweight event and data-binding library, best for integrating with more advanced hypermedia libraries.
43 | - Includes `nm-data`, `nm-bind`, `nm-ref`, standard helpers (`$dataset`, `$watch`, `$dispatch`).
44 | - **Full (1.8kb, 4.5kb uncompressed)**: Syntactical sugar and AJAX support to bring Nomini closer to more advanced libraries.
45 | - Includes above, `nm-form`, event modifiers, fetch helpers, reactive fetch variables, CSS transition support for fetch, `nm-use`, and advanced helpers (`$persist`).
46 | - Your own?: Use the [custom bundler script](https://github.com/nonnorm/nomini/blob/main/scripts/devour.sh) to customize your own tiny copy of Nomini.
47 |
48 |
49 |
50 |
51 |
52 | ## Credits
53 | This project would not have existed without the inspiration of many other projects made by many talented developers. Thank you to:
54 | 1. [Carson Gross](https://github.com/1cg) for his work on [htmx](https://github.com/bigskysoftware/htmx) v4, by which the multipurpose fetch helper was inspired, and [fixi](https://github.com/bigskysoftware/fixi), which was a benchmark for this library's minimalism.
55 | 2. [Katrina Scialdone](https://github.com/kgscialdone) for their work on [Ajaxial](https://github.com/kgscialdone/ajaxial)—a spiritual precursor to fixi—and [Facet](https://github.com/kgscialdone/facet), which provided inspiration for the `nm-use` templating system.
56 | 3. [Delaney Gillilan](https://github.com/delaneyj) and the Datastar core team for their work on [Datastar](https://github.com/starfederation/datastar), which was a useful feature benchmark and inspiration for some of the helper functions.
57 | 4. [Aiden Bai](https://github.com/aidenybai) for his work on [dababy](https://github.com/aidenybai/dababy), from which this project was directly forked for its innovative idea of JS property binding.
58 |
59 |
60 |
--------------------------------------------------------------------------------
/www/static/main.css:
--------------------------------------------------------------------------------
1 | @import url("https://cdn.jsdelivr.net/npm/open-props@1.7.16/open-props.min.css");
2 |
3 | @font-face {
4 | font-family: 'Jost';
5 | src: url('Jost-Regular.woff2') format('woff2');
6 | font-weight: normal;
7 | font-style: normal;
8 | font-display: swap;
9 | }
10 |
11 | *, *::before, *::after {
12 | box-sizing: border-box;
13 | margin: 0;
14 | }
15 |
16 | :root {
17 | scroll-behavior: smooth;
18 | --nomini-blue: #112d85;
19 | --nomini-yellow: #f9dc70;
20 | }
21 |
22 | body {
23 | font-family: Jost, sans-serif;
24 | line-height: 1.6;
25 | background-image: radial-gradient(circle, var(--nomini-blue) 1px, var(--gray-0) 0);
26 | background-size: 35px 35px;
27 | }
28 |
29 | main {
30 | padding-block: var(--size-2);
31 | padding-inline: 6vw;
32 |
33 | display: grid;
34 | grid-template-columns: minmax(30ch, 1fr);
35 | gap: var(--size-5);
36 |
37 | @media (min-width: 800px) {
38 | grid-template-columns: minmax(max-content, 1fr) minmax(30ch, 6fr) 1fr;
39 |
40 | >* {
41 | grid-column: 2;
42 | }
43 | }
44 | }
45 |
46 | header {
47 | height: var(--size-8);
48 | background-color: var(--blue-6);
49 | display: flex;
50 | align-items: center;
51 | justify-content: space-evenly;
52 | gap: var(--size-3);
53 | padding-block: var(--size-2);
54 | border-bottom: var(--border-size-3) solid var(--blue-9);
55 | }
56 |
57 | .toc {
58 | background-color: var(--blue-0);
59 | grid-column: 1;
60 | padding: var(--size-2);
61 | border-radius: var(--radius-1);
62 | border: var(--border-size-2) var(--nomini-blue) dashed;
63 | height: max-content;
64 |
65 | @media (min-width: 800px) {
66 | position: sticky;
67 | top: var(--size-1);
68 | }
69 |
70 | a {
71 | color: var(--blue-10);
72 | font-size: var(--font-size-2);
73 | }
74 | }
75 |
76 | nav {
77 | color: white;
78 |
79 | >a {
80 | color: var(--nomini-yellow);
81 | display: inline-block;
82 | transition: transform 200ms;
83 |
84 | &:hover {
85 | transform: scale(1.12);
86 | }
87 | }
88 | }
89 |
90 | img {
91 | height: 100%;
92 | }
93 |
94 | section, article {
95 | background-color: var(--gray-1);
96 |
97 | padding: var(--size-4) var(--size-3);
98 |
99 | >pre+:not(:is(h2, h3, h4)) {
100 | margin-top: var(--size-2);
101 | }
102 |
103 | >:not(:is(h1, h2, h3, h4))+* {
104 | margin-top: var(--size-3);
105 | }
106 | }
107 |
108 | article {
109 | background-color: white;
110 | border: var(--border-size-2) solid var(--gray-6);
111 | }
112 |
113 | section {
114 | border-left: solid var(--blue-7) var(--border-size-3);
115 | border-radius: var(--radius-2);
116 | background-color: var(--gray-1);
117 | }
118 |
119 | .hero {
120 | text-align: center;
121 |
122 | background-color: var(--blue-1);
123 | color: var(--blue-11);
124 | border: solid var(--blue-7) var(--border-size-2);
125 |
126 | >h1 {
127 | font-size: var(--font-size-8);
128 | }
129 |
130 | >p {
131 | font-size: var(--font-size-4);
132 | color: var(--blue-10);
133 | }
134 | }
135 |
136 | .jingle {
137 | position: absolute;
138 | cursor: pointer;
139 | top: 401px;
140 | left: 83px;
141 | border: none;
142 | background: none;
143 | }
144 |
145 | pre {
146 | padding: var(--size-2);
147 | border-radius: var(--radius-2);
148 | overflow: auto;
149 | }
150 |
151 | .demo-red {
152 | background-color: var(--red-11);
153 | color: var(--yellow-4);
154 | }
155 |
156 | dialog {
157 | margin: auto;
158 | }
159 |
160 | .table-wrapper {
161 | overflow: auto;
162 | }
163 |
164 | table {
165 | width: 100%;
166 | border-collapse: collapse;
167 | border: solid var(--border-size-2) var(--gray-5);
168 |
169 | thead th {
170 | position: sticky;
171 | top: 0;
172 | background-color: white;
173 | background-clip: padding-box;
174 | }
175 |
176 | th, td {
177 | border-inline: solid var(--border-size-2) var(--gray-5);
178 | text-align: center;
179 | padding: var(--size-1);
180 | }
181 |
182 | tbody tr:nth-child(odd) {
183 | background-color: var(--gray-2);
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/dist/nomini.min.js:
--------------------------------------------------------------------------------
1 | (()=>{const s=(e,t,n)=>{n={bubbles:!0,detail:{},...n},e.dispatchEvent(new CustomEvent(t,n))},c=(e,t,n)=>{/^{.*}$/s.test(e)&&(e=e.slice(1,-1));try{return new Function("__data",`with(__data) {return {${e}}}`).call(n,t)}catch(t){return console.error("[Nomini] failed to parse obj:",e,`
2 | `,t),{}}},e=(e,t)=>{const n=e.matches(t)?[e]:[];return[...n,...e.querySelectorAll(t)].filter(e=>!e.closest("[nm-ignore]"))},r=e=>e.closest("[nm-data]")?.nmProxy||l(),o=e=>{t=e,t(),t=null},i=(e,t)=>{n=e,t(),n=null};let t=null,n=null;const l=()=>({$refs:{},_nmFetching:!1,_nmAbort:new AbortController,$get(e,t){this.$fetch(e,"GET",t)},$post(e,t){this.$fetch(e,"POST",t)},$fetch(e,t,o){const r=n;this._nmAbort.abort(),this._nmAbort=new AbortController,this._nmFetching=!0;const i={headers:{"nm-request":!0},method:t,signal:this._nmAbort.signal};o={...this.$nmData(),...this.$dataset(),...o};const a=new URLSearchParams(o);/GET|DELETE/.test(t)?e+=(e.includes("?")?"&":"?")+a:i.body=a,fetch(e,i).then(async e=>{if(!e.ok)throw new Error(`${e.statusText}: ${await e.text()}`);const s=e.body.pipeThrough(new TextDecoderStream).getReader();let t="",n;for(;!0;){const{done:e,value:o}=await s.read();if(e)break;t+=o,clearTimeout(n),n=setTimeout(()=>{d(t),t=""},20)}}).catch(t=>s(r,"fetcherr",{detail:{err:t,url:e}})).finally(()=>this._nmFetching=!1)},$nmData(){const e=e=>e!==Object(e);return Object.entries(this).reduce((t,[n,s])=>/^[a-z]+$/i.test(n)?(typeof s=="function"&&(s=s()),(e(s)||Array.isArray(s)&&s.every(e))&&(t[n]=s),t):t,{})},$dataset(){let t={},e=n;for(;e;){if(t={...e.dataset,...t},e.hasAttribute("nm-data"))break;e=e.parentElement}return t},$watch:o,$dispatch(e,t,o){s(n,e,{detail:t,...o})},$debounce(e,t,s=!0){const o=n,a=this._nmAbort.signal;clearTimeout(o.nmTimer),o.nmTimer=setTimeout(()=>{s&&a.aborted||i(o,e)},t)},$persist(e,t){t=t||`_nmProp-${e}`;const n=localStorage[t];n&&(this[e]=JSON.parse(n)),o(()=>{localStorage[t]=JSON.stringify(this[e])})}}),d=t=>{const n=document.createElement("template");n.innerHTML=t;for(const t of n.content.children){if(!t.id){console.warn("[Nomini] Fragment is missing an id: ",t);continue}const i=t.getAttribute("nm-swap")||"outer",o=document.getElementById(t.id);if(!o){console.warn("[Nomini] Swap target not found: #",t.id);continue}if(e(o,"[nm-bind]").forEach(e=>s(e,"destroy",{bubbles:!1})),u(t),i==="inner")o.replaceChildren(...t.childNodes),a(o);else if(i==="outer")o.replaceWith(t),a(t);else if(/(before|after|prepend|append)/.test(i)){const e=[...t.childNodes];o[i](...e),e.forEach(e=>e.nodeType===1&&a(e))}else console.error("[Nomini] Invalid swap strategy: ",i)}},u=t=>{const n=["style","class","height","width"],s=e(t,"[id]");s.forEach(e=>{const t=document.getElementById(e.id);if(t&&t.tagName===e.tagName){const o=e.cloneNode(),s=t=>n.forEach(n=>{const s=t.getAttribute(n);s?e.setAttribute(n,s):e.removeAttribute(n)});s(t),requestAnimationFrame(()=>s(o))}})},a=n=>{e(n,"[nm-use]").forEach(e=>{const t=e.getAttribute("nm-use"),n=document.getElementById(t);if(n){const t=n.content.cloneNode(!0),s=t.querySelector("slot:not([name])");s&&s.replaceWith(...e.childNodes),e.replaceChildren(t)}else console.error("[Nomini] No template with id: #",t)}),e(n,"[nm-data]").forEach(e=>{const s={...c(e.getAttribute("nm-data"),{},e),...l()},n={},o=new Proxy(s,{get(e,s){return t&&(n[s]||=new Set).add(t),e[s]},set(e,s,o){e[s]=o;const i=n[s];if(i){const e=t;t=null,i.forEach(e=>e()),t=e}return!0}});e.nmProxy=o}),e(n,"[nm-ref]").forEach(e=>{const t=r(e),n=e.getAttribute("nm-ref");t.$refs[n]=e}),e(n,"[nm-form]").forEach(t=>{const n=r(t);e(t,"[name]").forEach(e=>{const t=e.type,s=()=>{let s;t==="checkbox"?s=e.checked:t==="radio"&&e.checked?s=e.value:t==="file"?s=e.files:/number|range/.test(t)?s=+e.value:s=e.value,n[e.name]=s};s(),e.addEventListener("input",s),e.addEventListener("change",s)}),e(t,"button, input[type='submit']").forEach(e=>{o(()=>e.disabled=n._nmFetching)})}),e(n,"[nm-bind]").forEach(e=>{const t=r(e),n=c(e.getAttribute("nm-bind"),t,e);Object.entries(n).forEach(([n,s])=>{if(n.startsWith("on")){const[r,...o]=n.slice(2).split("."),c=o.find(e=>e.startsWith("debounce")),a=+c?.slice(8),l=n=>i(e,()=>{o.includes("prevent")&&n.preventDefault(),o.includes("stop")&&n.stopPropagation(),a?t.$debounce(()=>s(n),a):s(n)});(o.includes("window")?window:e).addEventListener(r,l,{once:o.includes("once")});return;e.addEventListener(n.slice(2),()=>i(e,s))}else{const[t,a]=n.split(".");i(e,()=>o(async()=>{const n=await s();a?t==="class"?e.classList.toggle(a,n):e[t][a]=n:e[t]=n}))}}),s(e,"init",{bubbles:!1})})};document.addEventListener("DOMContentLoaded",()=>a(document.body))})()
--------------------------------------------------------------------------------
/www/content/docs.md:
--------------------------------------------------------------------------------
1 | +++
2 | title = "Documentation"
3 | +++
4 |
5 | ## Introduction
6 | Nomini is a JavaScript framework that serves one very specific purpose: make it easier to write JavaScript in your HTML. It is not the first library to [consider](https://htmx.org/essays/locality-of-behavior) [these](https://unplannedobsolescence.com/blog/behavior-belongs-in-html/) [principles](https://daverupert.com/2021/10/html-with-superpowers/). However, it does have one core tenet that many other libraries don't: __minimalism__. Nomini does not provide a lot—and that's on purpose. It exists to fill in the small gaps where HTML falls short (but hopefully won't [some day](https://alexanderpetros.com/triptych/)): partial page swaps, custom event handling, and automatic state synchronization. And it fits all of this into an easy-to-understand 2kb package.
7 |
8 | ---
9 |
10 | ## Attributes
11 | Nomini attributes generally are **object-like**, meaning the value is a JavaScript object with key-value pairs, **optionally** including the curly braces:
12 |
13 | ```
14 | nm-bind="textContent: () => name"
15 | ```
16 | or:
17 | ```
18 | nm-bind="{ textContent: () => name }"
19 | ```
20 |
21 | Any object-like attribute will have `this` bound to the element with the attribute. Often, the keys of the object can have **modifiers**, which will be separated by the main key with dots. If a key has modifiers, it must be wrapped in quotes to be parsed correctly.
22 |
23 | ### nm-data
24 | `nm-data` allows you to declare a scope of reactive JavaScript data. It will be globally accessible from `nm-` attributes in this scope only. Scopes are not inherited. If your data is not reactive and it's a simple type, consider using `data-*` attributes and the [`$dataset`](#dataset) function instead.
25 |
26 | ```html
27 |
28 |
29 |
30 | ```
31 |
32 | #### Local Properties
33 | Any key in an `nm-data` that starts with an underscore will **never** be sent to the server automatically. This is only relevant if working with helpers like `$get` inside of a scope.
34 |
35 | ```html
36 |
37 |
38 |
39 | ```
40 |
41 | #### Computed Properties
42 | Nomini has no special syntax for computed properties, but because the data scope is a standard object, you can write a member function to compute the property. Note that in member functions `this` refers to the data scope, not the element.
43 |
44 | ```html
45 |
46 |
47 |
48 | ```
49 |
50 | ### nm-bind
51 | `nm-bind` binds any property of an element to a JavaScript expression, whether static or reactive from an `nm-data`. You can access reactive data declared in an [`nm-data`](#nm-data), along with any built-in [helpers](#helpers) at any point. Multiple binds can be toggled by providing multiple key/value pairs. All values in a bind are **required** to be an arrow function. If something isn't working, check that it's in an arrow function. The function will be called once upon initialization and again whenever any reactive data that it depends on changes.
52 |
53 | `nm-bind` does not allow binding to arbitrary attributes, but nearly every meaningful attribute has a JS property equivalent. If you want to retrieve custom data, that should be stored in `data-*` attributes and can be accessed through the [`$dataset` helper](#dataset).
54 |
55 | ```html
56 |
57 | ```
58 |
59 |
60 | Of course, its greatest strength is when combined with `nm-data`.
61 | ```html
62 |
63 |
64 |
65 |
66 | ```
67 |
68 |
69 |
70 |
71 |
72 | #### Event Listeners
73 | `nm-bind` additionally gives you access to inline event listeners, bound like they would be with the built-in `on*` properties. However, it supports all DOM events, including user-emitted ones or [library-emitted ones](#events). Multiple events can be listened to by providing multiple key/value pairs. The callback can take an event parameter, and it will not be called until the event is triggered.
74 |
75 | ```html
76 |
77 | ```
78 |
79 |
80 | ##### Inline Event Modifiers
81 | Nomini additionally supports some additional syntax sugar for common event handling patterns. Postfix the event name with one or more of these modifiers to change the behavior:
82 | - **`.prevent`**: Calls `e.preventDefault`.
83 | - **`.stop`**: Calls `e.stopPropagation`.
84 | - **`.debounce`**: Debounces the event by the number of milliseconds listed.
85 | - **`.once`**: Removes the event listener after it's called once.
86 | - **`.window`**: Adds the event listener to the window instead of the current element.
87 |
88 | ```html
89 |
92 | ```
93 |
96 |
97 | ```html
98 |
101 | ```
102 |
105 |
106 | ```html
107 |
111 | 1/3th scale window (resize me)
112 | (Yes, this could also be done with vw and vh properties)
113 |
114 | ```
115 |
119 | 1/3th scale window (resize me)
120 | (Yes, this could also be done with vw and vh properties)
121 |
122 |
123 | #### Nested Binds
124 | You can bind to properties one level deep by separating the levels by dots in your key. Remember to quote the key.
125 |
126 | ```html
127 |
128 | Words words words
129 |
130 | ```
131 |
132 | Words words words
133 |
134 |
135 | #### Class Support
136 | You can also bind to classes using the nested bind support. Bind to a class using the `class.` property, where the bind function is expected to return a boolean.
137 |
138 | ```html
139 |
145 | ```
146 |
152 |
153 | #### Async Support
154 | To support async functions, all binds are automatically awaited if required.
155 |
156 | **Caution**: Be careful when using async functions. Any property accesses after an `await` will very likely not be reactive. Async function support is provided for the cases where it's useful, but it can be the source of many hard-to-find bugs.
157 |
158 | ### nm-ref
159 | Grab a reference to the current element and put it into the `$refs` object.
160 | ```html
161 |
165 |
166 | ```
167 |
168 |
172 |
173 |
174 | ### nm-form
175 | Purely a convenience helper, it synchronizes all form inputs with the reactive scope by name. It works on a container containing inputs with a `name` or on inputs themselves. It's equivalent to putting an `nm-bind` event listener on all of the form elements. Additionally, it will disable any submit buttons if a request is in progress.
176 |
177 | ```html
178 |
179 |
180 |
181 |
182 | ```
183 |
184 |
185 |
186 |
187 |
188 | ### nm-use
189 | `nm-use` allows you to grab the contents of a `template` element with a specified `id` and swap it into the current element. It also includes `` support, so the current contents of the element will be moved into the slot. Props are passed through the inherited `nm-data` scope and its associated `data-*` attributes.
190 |
191 | ```html
192 |
193 |
194 |
195 |
196 |
197 |
198 | What is the answer to life, the universe, and everything?
199 |
200 | ```
201 |
202 |
203 |
204 |
205 |
206 |
207 | What is the answer to life, the universe, and everything?
208 |
209 |
210 | ---
211 |
212 | ## Helpers
213 | ### $dataset
214 | Walks up the element tree and collects all `data-*` attributes in the current `nm-data` scope.
215 |
216 | ### $fetch/$get/$post
217 | All AJAX in Nomini goes through `$fetch`. Inside any `nm-` attribute (but almost always `nm-on`):
218 |
219 | ```js
220 | $get("/user")
221 | $post("/save", { id: 1 })
222 | $fetch("/url", "PUT", { foo: 1 })
223 | ```
224 |
225 | Nomini will automatically encode all reactive data, `data-*` attributes from the scope, and provided data into the request. During the fetch, the reactive `_nmFetching` will be set to true. The [`fetcherr`](#fetcherr) event will automatically be dispatched on a non-2xx status code or a network error.
226 |
227 | #### Response Format
228 | Nomini supports two types of responses: oneshot and streaming. Every chunk sent to the server, whether as a oneshot or streaming response, is expected to contain one or more complete HTML fragments with a top-level `id`. Optionally, the `nm-swap` attribute is used to determine how the content gets swapped in. Options are: `outer` (default), `inner`, `before`, `prepend`, `append`, `after`. `outer` will replace the element with the corresponding ID with the new element, all others will discard the new wrapper element and replace the children of the existing element with the children of the wrapper.
229 |
230 | Example server response:
231 | ```html
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 | ```
241 |
242 | ### $nmData
243 | Used internally to collect all computed values, primitives, arrays, and other encodable user data for `$fetch`. Not likely to be needed by users.
244 |
245 | ### $dispatch
246 | Dispatch an event on the current element. Can be listened to higher up on the tree by [`nm-bind`](#nm-bind).
247 | ```js
248 | $dispatch("my-event")
249 | // Second attribute describes 'detail' property of the event
250 | $dispatch("scary", { run: "away" })
251 | // Third attribute is the general options property
252 | $dispatch("help-me", { so: "alone" }, { bubbles: false })
253 | ```
254 |
255 | ### $watch
256 | Takes a callback, will run it once on initialization and once whenever its dependent variables change.
257 | ```html
258 |
259 | ```
260 |
261 |
262 | ### $persist
263 | Hydrates a variable with data from localStorage and reactively links it to localStorage.
264 | ```js
265 | // Call this in 'oninit', assuming 'text' is a variable in the data
266 | $persist('text')
267 | // Can also provide a custom localStorage key
268 | $persist('text', 'myText101')
269 | ```
270 |
271 | ---
272 |
273 | ## Events
274 | ### init
275 | Dispatched on any element as it's initialized by Nomini. Does **not** bubble.
276 |
277 | ```html
278 |
279 | ```
280 |
281 | ### destroy
282 | Dispatched on any element when it's about to be swapped out by Nomini. Does **not** bubble.
283 |
284 | ```html
285 |
286 | ```
287 |
288 | ### fetcherr
289 | Dispatched by `$fetch` when a request fails or returns a non-2xx response code. Bubbles outside the scope so that it can be handled globally. The `detail` property contains an `err` message and a `url`.
290 |
291 | ---
292 |
293 | ## Magic Properties
294 | ### _nmFetching
295 | Reactive boolean value that is set to `true` whenever a [fetch](#fetch-get-post) is in progress. Can be used to disable buttons or show loading indicators.
296 |
297 | ```html
298 |
299 | ```
300 |
301 | ### _nmAbort
302 | An [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) that is attached to the current fetch. Call `abort` on it to abort an in-progress fetch.
303 | ```js
304 | _nmAbort.abort()
305 | ```
306 |
--------------------------------------------------------------------------------
/nomini.js:
--------------------------------------------------------------------------------
1 | // Nomini v0.3.1
2 | // Inspired by aidenybai/dababy
3 | // Copyright (c) 2025 nonnorm
4 | // Licensed under the MIT License.
5 |
6 | (() => {
7 | // Utility functions
8 | const dispatch = (el, name, opts) => {
9 | opts = { bubbles: true, detail: {}, ...opts };
10 | el.dispatchEvent(new CustomEvent(name, opts));
11 | };
12 |
13 | const evalExpression = (expression, data, thisArg) => {
14 | if (/^{.*}$/s.test(expression)) {
15 | expression = expression.slice(1, -1);
16 | }
17 |
18 | try {
19 | return new Function(
20 | "__data",
21 | `with(__data) {return {${expression}}}`,
22 | ).call(thisArg, data);
23 | } catch (err) {
24 | console.error("[Nomini] failed to parse obj:", expression, "\n", err);
25 | return {};
26 | }
27 | };
28 |
29 | const queryAttr = (el, selector) => {
30 | const elMatch = el.matches(selector) ? [el] : [];
31 | return [...elMatch, ...el.querySelectorAll(selector)].filter(
32 | (val) => !val.closest("[nm-ignore]"),
33 | );
34 | };
35 |
36 | const getClosestProxy = (el) =>
37 | el.closest("[nm-data]")?.nmProxy || helpers();
38 |
39 | const runTracked = (fn) => {
40 | currentBind = fn;
41 | currentBind();
42 | currentBind = null;
43 | };
44 |
45 | const runWithEl = (el, fn) => {
46 | currentEl = el;
47 | fn();
48 | currentEl = null;
49 | };
50 |
51 | // Important reactivity stuff
52 | let currentBind = null;
53 | let currentEl = null;
54 |
55 | // Helpers that are included with every data object
56 | const helpers = () => ({
57 | // --- BEGIN ref
58 | $refs: {},
59 | // --- END ref
60 | // --- BEGIN fetch
61 | _nmFetching: false,
62 | _nmAbort: new AbortController(),
63 | $get(url, data) {
64 | this.$fetch(url, "GET", data);
65 | },
66 | $post(url, data) {
67 | this.$fetch(url, "POST", data);
68 | },
69 | $fetch(url, method, data) {
70 | const el = currentEl;
71 |
72 | this._nmAbort.abort();
73 | this._nmAbort = new AbortController();
74 | this._nmFetching = true;
75 |
76 | const opts = {
77 | headers: { "nm-request": true },
78 | method,
79 | signal: this._nmAbort.signal,
80 | };
81 |
82 | data = { ...this.$nmData(), ...this.$dataset(), ...data };
83 |
84 | const encodedData = new URLSearchParams(data);
85 |
86 | if (/GET|DELETE/.test(method))
87 | url += (url.includes("?") ? "&" : "?") + encodedData;
88 | else opts.body = encodedData;
89 |
90 | fetch(url, opts)
91 | .then(async (res) => {
92 | if (!res.ok) {
93 | throw new Error(`${res.statusText}: ${await res.text()}`);
94 | }
95 |
96 | const stream = res.body.pipeThrough(new TextDecoderStream()).getReader();
97 | let buf = "";
98 | let timeout;
99 |
100 | while (true) {
101 | const { done, value } = await stream.read();
102 | if (done) break;
103 |
104 | buf += value;
105 |
106 | clearTimeout(timeout);
107 | timeout = setTimeout(() => {
108 | swap(buf);
109 | buf = "";
110 | }, 20);
111 | }
112 | })
113 | .catch((err) => dispatch(el, "fetcherr", { detail: { err, url } }))
114 | .finally(() => this._nmFetching = false);
115 | },
116 | $nmData() {
117 | const isPrimitive = (x) => x !== Object(x);
118 |
119 | return Object.entries(this).reduce((acc, [k, v]) => {
120 | if (!/^[a-z]+$/i.test(k)) return acc;
121 | if (typeof v === "function") v = v();
122 | if (isPrimitive(v) || (Array.isArray(v) && v.every(isPrimitive)))
123 | acc[k] = v;
124 | return acc;
125 | }, {});
126 | },
127 | // --- END fetch
128 | $dataset() {
129 | let datasets = {};
130 | let el = currentEl;
131 |
132 | while (el) {
133 | datasets = { ...el.dataset, ...datasets };
134 | if (el.hasAttribute("nm-data")) break;
135 | el = el.parentElement;
136 | }
137 |
138 | return datasets;
139 | },
140 | $watch: runTracked,
141 | $dispatch(evt, detail, opts) {
142 | dispatch(currentEl, evt, { detail, ...opts });
143 | },
144 | $debounce(fn, ms, abortable = true) {
145 | const el = currentEl;
146 | const signal = this._nmAbort.signal;
147 | clearTimeout(el.nmTimer);
148 | el.nmTimer = setTimeout(() => {
149 | if (!(abortable && signal.aborted)) runWithEl(el, fn)
150 | }, ms);
151 | },
152 | // --- BEGIN helpers
153 | $persist(prop, key) {
154 | key = key || `_nmProp-${prop}`;
155 |
156 | const stored = localStorage[key];
157 | if (stored) this[prop] = JSON.parse(stored);
158 |
159 | runTracked(() => {
160 | localStorage[key] = JSON.stringify(this[prop]);
161 | });
162 | },
163 | // --- END helpers
164 | });
165 |
166 | // --- BEGIN fetch
167 | const swap = (text) => {
168 | const template = document.createElement("template");
169 | template.innerHTML = text;
170 |
171 | for (const fragment of template.content.children) {
172 | if (!fragment.id) {
173 | console.warn("[Nomini] Fragment is missing an id: ", fragment);
174 | continue;
175 | }
176 |
177 | const strategy = fragment.getAttribute("nm-swap") || "outer";
178 | const target = document.getElementById(fragment.id);
179 |
180 | if (!target) {
181 | console.warn("[Nomini] Swap target not found: #", fragment.id);
182 | continue;
183 | }
184 |
185 | queryAttr(target, "[nm-bind]").forEach(
186 | (el) => dispatch(el, "destroy", { bubbles: false })
187 | );
188 |
189 | // --- BEGIN morph
190 | cssMorph(fragment);
191 | // --- END morph
192 |
193 | if (strategy === "inner") {
194 | target.replaceChildren(...fragment.childNodes);
195 | init(target);
196 | } else if (strategy === "outer") {
197 | target.replaceWith(fragment);
198 | init(fragment);
199 | } else if (/(before|after|prepend|append)/.test(strategy)) {
200 | const kids = [...fragment.childNodes];
201 | target[strategy](...kids);
202 | kids.forEach((n) => n.nodeType === 1 && init(n));
203 | } else console.error("[Nomini] Invalid swap strategy: ", strategy);
204 | }
205 | };
206 | // --- END fetch
207 |
208 | // --- BEGIN morph
209 | const cssMorph = (fragment) => {
210 | const attributesToSettle = ["style", "class", "height", "width"];
211 |
212 | const idSet = queryAttr(fragment, "[id]");
213 |
214 | idSet.forEach((newEl) => {
215 | const oldEl = document.getElementById(newEl.id);
216 |
217 | if (oldEl && oldEl.tagName === newEl.tagName) {
218 | const newElCopy = newEl.cloneNode();
219 |
220 | const morph = (src) =>
221 | attributesToSettle.forEach((attr) => {
222 | const attrVal = src.getAttribute(attr);
223 | if (attrVal) newEl.setAttribute(attr, attrVal);
224 | else newEl.removeAttribute(attr);
225 | });
226 |
227 | morph(oldEl);
228 | requestAnimationFrame(() => morph(newElCopy));
229 | }
230 | });
231 | };
232 | // --- END morph
233 |
234 | const init = (baseEl) => {
235 | // --- BEGIN template
236 | queryAttr(baseEl, "[nm-use]").forEach((useEl) => {
237 | const id = useEl.getAttribute("nm-use");
238 | const template = document.getElementById(id);
239 | if (template) {
240 | const templateFrag = template.content.cloneNode(true);
241 | const slot = templateFrag.querySelector("slot:not([name])");
242 | if (slot) slot.replaceWith(...useEl.childNodes);
243 | useEl.replaceChildren(templateFrag);
244 | } else console.error("[Nomini] No template with id: #", id);
245 | });
246 | // --- END template
247 |
248 | // --- BEGIN data
249 | queryAttr(baseEl, "[nm-data]").forEach((dataEl) => {
250 | const rawData = {
251 | ...evalExpression(dataEl.getAttribute("nm-data"), {}, dataEl),
252 | ...helpers(),
253 | };
254 |
255 | const trackedDeps = {};
256 |
257 | const proxyData = new Proxy(rawData, {
258 | get(obj, prop) {
259 | if (currentBind) (trackedDeps[prop] ||= new Set()).add(currentBind);
260 |
261 | return obj[prop];
262 | },
263 | set(obj, prop, val) {
264 | obj[prop] = val;
265 |
266 | const deps = trackedDeps[prop];
267 |
268 | if (deps) {
269 | // Required to prevent infinite loops (this took 3 hours to debug!)
270 | const thisBind = currentBind;
271 | currentBind = null;
272 |
273 | deps.forEach((fn) => fn());
274 |
275 | currentBind = thisBind;
276 | }
277 |
278 | return true;
279 | },
280 | });
281 |
282 | dataEl.nmProxy = proxyData;
283 | });
284 | // --- END data
285 |
286 | // --- BEGIN ref
287 | queryAttr(baseEl, "[nm-ref]").forEach((el) => {
288 | const proxyData = getClosestProxy(el);
289 | const refName = el.getAttribute("nm-ref");
290 |
291 | proxyData.$refs[refName] = el;
292 | });
293 | // --- END ref
294 |
295 | // --- BEGIN form
296 | queryAttr(baseEl, "[nm-form]").forEach((el) => {
297 | const proxyData = getClosestProxy(el);
298 |
299 | queryAttr(el, "[name]").forEach((inputEl) => {
300 | const inputType = inputEl.type;
301 |
302 | const setVal = () => {
303 | let res;
304 |
305 | if (inputType === "checkbox")
306 | res = inputEl.checked;
307 | else if (inputType === "radio" && inputEl.checked)
308 | res = inputEl.value;
309 | else if (inputType === "file")
310 | res = inputEl.files;
311 | else if (/number|range/.test(inputType))
312 | res = +inputEl.value;
313 | else res = inputEl.value;
314 |
315 | proxyData[inputEl.name] = res;
316 | };
317 |
318 | setVal();
319 |
320 | inputEl.addEventListener("input", setVal);
321 | inputEl.addEventListener("change", setVal);
322 | });
323 |
324 | queryAttr(el, "button, input[type='submit']").forEach((submitEl) => {
325 | runTracked(() => submitEl.disabled = proxyData._nmFetching);
326 | });
327 | });
328 | // --- END form
329 |
330 | // --- BEGIN bind
331 | queryAttr(baseEl, "[nm-bind]").forEach((bindEl) => {
332 | const proxyData = getClosestProxy(bindEl);
333 |
334 | const props = evalExpression(
335 | bindEl.getAttribute("nm-bind"),
336 | proxyData,
337 | bindEl,
338 | );
339 |
340 | Object.entries(props).forEach(([key, val]) => {
341 | if (key.startsWith("on")) {
342 | // --- BEGIN events
343 | const [eventName, ...mods] = key.slice(2).split(".");
344 |
345 | const debounceMod = mods.find((val) => val.startsWith("debounce"));
346 | const delay = +debounceMod?.slice(8);
347 |
348 | const listener = (e) => runWithEl(bindEl, () => {
349 |
350 | if (mods.includes("prevent")) e.preventDefault();
351 | if (mods.includes("stop")) e.stopPropagation();
352 |
353 | if (delay) proxyData.$debounce(() => val(e), delay);
354 | else val(e);
355 | });
356 |
357 | (mods.includes("window") ? window : bindEl).addEventListener(eventName, listener, {
358 | once: mods.includes("once"),
359 | });
360 |
361 | return;
362 | // --- END events
363 |
364 | // If special event handling isn't enabled just do a normal event listener
365 | bindEl.addEventListener(key.slice(2), () => runWithEl(bindEl, val));
366 | } else {
367 | const [main, sub] = key.split(".");
368 | runWithEl(bindEl,
369 | () => runTracked(async () => {
370 | const resolvedVal = await val();
371 |
372 | if (sub) {
373 | if (main === "class") bindEl.classList.toggle(sub, resolvedVal);
374 | else bindEl[main][sub] = resolvedVal;
375 | } else bindEl[main] = resolvedVal;
376 | })
377 | );
378 | }
379 | });
380 |
381 | dispatch(bindEl, "init", { bubbles: false });
382 | });
383 | // --- END bind
384 | };
385 |
386 | document.addEventListener("DOMContentLoaded", () => init(document.body));
387 | })();
388 |
--------------------------------------------------------------------------------