Changes
119 changed files (+7847/-0)
-
.editorconfig (new)
-
@@ -0,0 +1,13 @@root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true # Prettier default indent_size = 2 indent_style = space [*.md] trim_trailing_whitespace = false
-
-
.gitignore (new)
-
@@ -0,0 +1,10 @@node_modules /esm /dist *.tgz *.zip /ef.api.json /docs/api
-
-
.prettierignore (new)
-
@@ -0,0 +1,4 @@/dist /esm /examples/dist /website/dist
-
-
.prettierrc (new)
-
@@ -0,0 +1,1 @@{}
-
-
.tool-versions (new)
-
@@ -0,0 +1,2 @@nodejs 20.6 bun 1.0
-
-
HEADER.txt (new)
-
@@ -0,0 +1,14 @@//! @pocka/ef - DOM helper library based on signals //! Copyright 2023 Shota FUJI //! //! Licensed under the Apache License, Version 2.0 (the "License"); //! you may not use this file except in compliance with the License. //! You may obtain a copy of the License at //! //! http://www.apache.org/licenses/LICENSE-2.0 //! //! Unless required by applicable law or agreed to in writing, software //! distributed under the License is distributed on an "AS IS" BASIS, //! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. //! See the License for the specific language governing permissions and //! limitations under the License.
-
-
LICENSE (new)
-
@@ -0,0 +1,201 @@Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
-
-
README.md (new)
-
@@ -0,0 +1,11 @@# `@pocka/ef` Declarative DOM helpers. - `@pocka/ef` ... Main entrypoint, exports everything. Recommended. - `@pocka/ef/signals` ... Exports Signals functions and helpers. - `@pocka/ef/dom` ... Exports DOM related functions. - `@pocka/ef/async` ... Exports async (Promise) helpers. - `@pocka/ef/setups` ... Exports additional element setup functions to work with `@pocka/ef/dom`. See `docs/` for documentations.
-
-
-
@@ -0,0 +1,119 @@--- status: accepted --- # Use Signals for reactive API ## Context and Problem Statement Development of ef.js has started as Signals + DOM library. However, I noticed Signals does not work well outside of synchronous function call stack, during implementing the library. Are there any tricks covers this drawback? Can other API design style work well with `el` function? ## Decision Drivers - Less unexpected behaviours on usage inside async and/or generator function. - Sophisticated and specialized API for the web platform. ## Considered Options - Signals - Observer pattern - Signals-like API with manual scope management ## Decision Outcome Chosen option "Signals", because of the simplicity and ease of use. This option also is least error-prone compared to the other two. ## Pros and Cons of the Options ### Signals The original plan. API design pattern implemented in S.js, Solid.js, Preact, etc. ```ts // Create a reactive value. const $age = signal(10); // Create a derived reactive value, which is read-only. const $isLegalToDrink = derived(() => { // NOTE: This is the drinking age in Japan. return $age.get() >= 20; }); // Runs a function everytime the reactive values inside the function got changed. effect(() => { console.log($isLegalToDrink.get()); }); el("div", [attr("data-legal", $isLegalToDrink)]); setTimeout(() => { $age.set(21); }, 1_000); ``` - Good, because dependency is built implicitly by value getter - Good, because the consumer of reactive values does not need a reference for the reactive value wrappers - Bad, because it heavily relies on mutable global variable, which is the reason this does not work in async/generator function - Bad, because it needs to explicitly pass around computation scope in order to work-around the async/generator function problem ### Observer pattern Simple Observer class and Derived class (equivalent for `derived` function). ```ts const $age = new Observable(10); // `as const` is required const $isLegalToDrink = new Derived([$age] as const, (age) => { return age >= 20; }); $isLegalToDrink.subscribe((isLegalToDrink) => { console.log(isLegalToDrink); }); el("div", [attr("data-legal", $isLegalToDrink)]); setTimeout(() => { $age.write(21); }, 1_000); ``` - Good, because it's simple and dependencies are clear - Good, because it's an mature and established pattern - Bad, because it too needs mutable global variable for ownership detection - Bad, because manually passing dependency is verbose, especially with TypeScript ### Signals-like API with manual scope management Eliminating the need of mutable global variable by exposing the internal computation scope. ```ts const { effect, signal, derived, write } = scope(); const $age = signal(10); const $isLegalToDrink = derived(({ read }) => { return read($age) >= 20; }); effect(({ read }) => { console.log(read($isLegalToDrink)); }); el("div", [attr("data-legal", $isLegalToDrink)]); setTimeout(() => { write($age, 21); }, 1_000); ``` - Good, because dependencies and ownership is extreamly clear and visible - Bad, because users can't just import functions - Bad, because resulting code would be shadowing-hell or too noisy due to property access especially when nesting, which is so error-prone
-
-
-
@@ -0,0 +1,83 @@--- status: accepted --- # Use global context for building dependency graph ## Context and Problem Statement The Signals architecture heavily relies on mutable global context variable to store computation scope for building dependency graph and ownership tree. This is problematic in async and/or generator function, which run in other loops so the global variable had been changed at the time of a line inside the function body runs. ## Decision Drivers - Need for work-around when using async, generator, or callback function makes API more complex. ## Considered Options - Keep relying on mutable global context - Algebraic Effect using generator function Manual scope management is excluded as it is already considered in [Use Signals for reactive API](./0001-use-signals-for-reactive-api.md). ## Decision Outcome Chosen option: "Keep relying on mutable global context", because one can't write useable and performant code using Algebraic Effect in the current state of JavaScript/TypeScript. This decision may be re-considered once JavaScript introduces an Algebraic Effect into the language or both JavaScript and TypeScript improves generator function syntax. ## Pros and Cons of the Options ### Keep relying on mutable global context This option does not change traditional Signals style with work-around API. ```ts const $age = signal(10); effect((ctx) => { (async () => { const isLegalToDrink = derived((ctx) => $age.get(ctx) >= 20); await somePromise; console.log($isLegalToDrink.get(ctx)); })(); }); ``` - Good, because it's simple function - Bad, because it's error-prone (forget to pass `ctx`, using parent `ctx`, etc.) - Bad, because this requires users to understand the concept of context ### Algebraic Effect using generator function This option defines "lookup for dependant scope for the value" and "lookup for a scope owning this value or scope" as effects. Effect handling is archived using generator function. This can be easily adopted to async code. ```ts const $age = signal(10); effect(async function* () { const $isLegalToDrink = yield derived(function* () { // `.get()` or `.value` is still necessary in order to distinguish value retrieval from // ownership lookup. return (yield $age.get()) >= 20; }); await somePromise; console.log(yield $isLegalToDrink.get()); }); ``` - Good, because everything is explictly stated and visible - Good, because it uses standard JavaScript syntax - Good, because effects can be type-checked at compile time - Bad, because Generator Function language feature is nearly abondand such as lack of usage on arrow function - Bad, because TypeScript does not correctly model generator function (`yield`, `next()`) - Bad, because using `yield*` to work-around TypeScript's `yield = any` bug introduces performance overhead on **runtime** - Bad, because TypeScript is extreamly buggy on generator function even with `yield*` work-around - Bad, because this is not a strictly effect function because the function is colored - Bad, because every function that use `effect` or `derived` must be a generator function
-
-
-
@@ -0,0 +1,62 @@// The purpose of this benchmark is measuring ef.js runtime performance in real // browsers. However, due to JIT/GC and test procedures being lightweight, most of // run duration would be < 1ms. Because of this, most likely p75 and p90 would be // 0ms on dev machine. import { group, bench } from "./framework.js"; import { attr, el, effect, signal } from "../../esm/es2019/ef.js"; await group("Element creation", [ bench("Manual (DOM)", () => { const div = document.createElement("div"); document.body.appendChild(div); const button = document.createElement("button"); button.textContent = "Button"; button.setAttribute("aria-disabled", "false"); div.appendChild(button); document.body.removeChild(div); }), bench("Manual (innerHTML)", () => { const div = document.createElement("div"); document.body.appendChild(div); div.innerHTML = `<button aria-disabled="false">Button</button>`; document.body.removeChild(div); }), bench("Without Signal", () => { const div = el("div"); document.body.appendChild(div); div.appendChild(el("button", [attr("aria-disabled", "false")], ["Button"])); document.body.removeChild(div); }), bench("With Signal", () => { const div = el("div"); document.body.appendChild(div); const $e = effect(() => { div.appendChild( el("button", [attr("aria-disabled", signal("false"))], ["Button"]), ); }); $e.dispose(); document.body.removeChild(div); }), bench("With Signal (without dispose)", () => { const div = el("div"); document.body.appendChild(div); div.appendChild( el("button", [attr("aria-disabled", signal("false"))], ["Button"]), ); document.body.removeChild(div); }), ]).run();
-
-
-
@@ -0,0 +1,105 @@class Group { /** * @param name {string} * @param bench {readonly Bench[]} * @param samples {number} */ constructor(name, bench, samples = 10_000) { /** * @type {string} * @public */ this.name = name; /** * @type {readonly Bench[]} * @public */ this.bench = bench; /** * @type {number} * @public */ this.samples = samples; } async run() { const { samples } = this; console.group(`${this.name} (n=${samples})`); for (const bench of this.bench) { const ds = []; for (let i = 0; i < samples; i++) { ds.push(await bench.run()); } const ordered = ds.sort((a, b) => a - b); const total = ds.reduce((a, b) => a + b, 0); console.group(bench.name); console.log("total: ", total); console.log("avg: ", total / samples); console.log("p75: ", ordered[(ordered.length * 0.75) | 0]); console.log("p90: ", ordered[(ordered.length * 0.9) | 0]); console.groupEnd(); } console.groupEnd(); } } /** * @param name {string} * @param benches {readonly Bench[]} */ export function group(name, benches) { return new Group(name, benches); } class Bench { /** * @param name {string} * @param f {() => (void | Promise<void>)} */ constructor(name, f) { /** * @type {string} * @public */ this.name = name; /** * @type {() => (void | Promise<void>)} * @private */ this.f = f; } /** * @returns {number | Promise<number>} */ run() { const start = performance.now(); const ret = this.f(); if (ret instanceof Promise) { return ret.then(() => { return performance.now() - start; }); } return performance.now() - start; } } /** * @param name {string} * @param f {() => (void | Promise<void>)} */ export function bench(name, f) { return new Bench(name, f); }
-
-
bench/browser/index.html (new)
-
@@ -0,0 +1,9 @@<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>ef.js in-browser benchmark</title> <script defer type="module" src="./main.js"></script> </head> </html>
-
-
bench/browser/main.js (new)
-
@@ -0,0 +1,1 @@await import("./element-creation.js");
-
-
-
@@ -0,0 +1,9 @@{ "extends": "../../tsconfig.json", "compilerOptions": { "module": "Node16", "moduleResolution": "Node16", "checkJs": true }, "include": ["./**/*.js"] }
-
-
-
@@ -0,0 +1,3 @@import { defineConfig } from "vite"; export default defineConfig({});
-
-
bench/headless/README.md (new)
-
@@ -0,0 +1,6 @@# bench/headless These are micro benchmark comparing ef.js API to manual JS/DOM operation. Please be aware that these benchmarks using Happy DOM, which is headless JavaScript implementation of DOM APIs. Those should be used only for performance overhead measurements.
-
-
-
@@ -0,0 +1,41 @@/// <reference lib="dom" /> import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { baseline, bench, run, group } from "mitata"; import { attr, effect, el, signal } from "../../esm/es2019/ef.js"; GlobalRegistrator.register(); group("[DOM] Element creation", () => { baseline("Manual DOM methods", () => { const button = document.createElement("button"); button.textContent = "Button"; button.setAttribute("aria-disabled", "false"); const div = document.createElement("div"); div.appendChild(button); }); bench("Manual DOM (innerHTML)", () => { const div = document.createElement("div"); div.innerHTML = `<button aria-disabled="false">Button</button>`; }); bench("Without Signal", () => { el("div", [], [el("button", [attr("aria-disabled", "false")], ["Button"])]); }); bench("With Signal", () => { effect(() => { el( "div", [], [el("button", [attr("aria-disabled", signal("false"))], ["Button"])], ); }).dispose(); }); }); await run(); GlobalRegistrator.unregister();
-
-
bench/js/README.md (new)
-
@@ -0,0 +1,19 @@# bench/js These are micro benchmark mainly for decision making. While those provides some level of performance metrics, the final decision or change should be based on integrated measurement. JS engines, especially JavaScriptCore, add performance penalty to later `bench` calls. See <https://github.com/evanwashere/mitata?tab=readme-ov-file#jit-bias> for more info. ## Running micro benchmark Replace `all.js` for specific filename for isolated benchmark. ```sh # JSC bun bench/micro/all.js # V8 node bench/micro/all.js ```
-
-
bench/js/all.js (new)
-
@@ -0,0 +1,6 @@// Needs to be serial because mitata heavily relies on global scope await import("./clone-set.js"); await import("./empty-set.js"); await import("./for-of.js"); await import("./getter.js"); await import("./setter.js");
-
-
bench/js/clone-set.js (new)
-
@@ -0,0 +1,52 @@import { bench, run, group } from "mitata"; const SET_SIZE = 100_000; const ITER_COUNT = 100_000; /** * @param size {number} * @returns {Set<{ n: number }>} */ function sampleSet(size) { return new Set(Array.from({ length: size }, (_, i) => ({ n: i }))); } group(`a.size = ${SET_SIZE}, 1 time`, () => { const a = sampleSet(SET_SIZE); bench("[...a]", () => { [...a]; }); bench("Array.from(a)", () => { Array.from(a); }); bench("new Set(a)", () => { new Set(a); }); }); group(`a.size = 1, ${ITER_COUNT} times`, () => { const a = sampleSet(1); bench("[...a]", () => { for (let i = 0; i < ITER_COUNT; i++) { [...a]; } }); bench("Array.from(a)", () => { for (let i = 0; i < ITER_COUNT; i++) { Array.from(a); } }); bench("new Set(a)", () => { for (let i = 0; i < ITER_COUNT; i++) { new Set(a); } }); }); await run();
-
-
bench/js/empty-set.js (new)
-
@@ -0,0 +1,29 @@import { bench, run, group } from "mitata"; const SET_SIZE = 100_000; /** * @param size {number} * @returns {Set<{ n: number }>} */ function sampleSet(size) { return new Set(Array.from({ length: size }, (_, i) => ({ n: i }))); } group(`a.size = ${SET_SIZE}`, () => { bench("for (const x of a) { a.delete(x) }", () => { const a = sampleSet(SET_SIZE); for (const x of a) { a.delete(x); } }); bench("a.clear()", () => { const a = sampleSet(SET_SIZE); a.clear(); }); }); await run();
-
-
bench/js/for-of.js (new)
-
@@ -0,0 +1,23 @@import { bench, run, group } from "mitata"; const ITER_COUNT = 100_000; group(`for-of (iteration count = ${ITER_COUNT})`, () => { const arr = Array.from({ length: ITER_COUNT }, (_, i) => ({ n: i })); bench("Array", () => { for (const x of arr) { x.n; } }); const set = new Set(Array.from({ length: ITER_COUNT }, (_, i) => ({ n: i }))); bench("Set", () => { for (const x of set) { x.n + x.n; } }); }); await run();
-
-
bench/js/getter.js (new)
-
@@ -0,0 +1,36 @@import { baseline, bench, group, run } from "mitata"; group("Get a number property", () => { class Box { /** * @param value {number} */ constructor(value) { this._value = value; } getValue() { return this._value; } get value() { return this._value; } } const box = new Box(1); baseline("property", () => { box._value; }); bench("getter", () => { box.value; }); bench("method", () => { box.getValue(); }); }); await run();
-
-
bench/js/setter.js (new)
-
@@ -0,0 +1,42 @@import { baseline, bench, group, run } from "mitata"; group("Set a boolean property", () => { class Box { /** * @param value {boolean} */ constructor(value) { this._value = value; } /** * @param value {boolean} */ setValue(value) { this._value = value; } /** * @param v {boolean} */ set value(v) { this._value = v; } } const box = new Box(true); baseline("property", () => { box._value = !box._value; }); bench("setter", () => { box.value = !box._value; }); bench("method", () => { box.setValue(!box._value); }); }); await run();
-
-
bench/tsconfig.json (new)
-
@@ -0,0 +1,10 @@{ "extends": "../tsconfig.json", "compilerOptions": { "types": ["bun-types"], "checkJs": true, "moduleResolution": "Node16", "module": "Node16" }, "include": ["./**/*.js"] }
-
-
bun.lockb (new)
-
bunfig.toml (new)
-
@@ -0,0 +1,2 @@preload = ["./website/plugins/css.ts", "./website/plugins/markdown.ts"] telemetry = false
-
-
docs/api.md (new)
-
@@ -0,0 +1,226 @@# API ## Interface types for Signals ```ts export interface Disposable { // Stop reactivity on the object dispose(): void; // ...internal members } export interface Computation { // ...internal members } export interface ReactiveValue<T> { // Get the containing value and associate a surrounding or specified Computation // to the ReactiveValue. get(computation?: Computation): T; // Alias for `get()` get value(): T; // Get the containing value without associating to any Computations. once(): T; // ...internal members } ``` ## `Signal` class ```ts export class Signal<T> implements Disposable, ReactiveValue<T> { constructor(initialValue: T); get(computation?: Computation): T; // Set a new value and triggers updates. set(newValue: T): void; // Update a containing value based on the previous value. update(fn: (prevValue: T) => T): void; // Get or Set a containing value. value: T; once(): T; dispose(): void; } // Alias for `new Signal(initialValue)` export function signal<T>(initialValue: T): Signal<T>; ``` ## `Derived` class ```ts export class Derived<T> implements Disposable, Computation, ReactiveValue<T> { constructor(compute: DerivedValueComputeFunction<T>); get(computation?: Computation): T; readonly value: T; once(): T; dispose(): void; } // Alias for `new Derived(compute)` export function derived<T>(compute: DerivedValueComputeFunction<T>): Derived<T>; ``` <details> <summary>Supplimental types</summary> ```ts type DerivedValueComputeFunction<T> = (computation: Computation) => T; ``` </details> ## `Effect` class ```ts export class Effect implements Disposable, Computation { constructor(fn: EffectFunction); dispose(): void; } // Alias for `new Effect(fn)` export function effect(fn: EffectFunction): Effect; ``` <details> <summary>Supplimental types</summary> ```ts type EffectCleanupCallback = () => void; type EffectFunction = ( computation: Computation, ) => void | EffectCleanupCallback; ``` </details> ## Signals helpers ```ts // This helper is especially useful for writing reusable UIs (or components). export type ReactiveOrStatic<T> = T | ReactiveValue<T>; // Returns true if `x` is ReactiveValue, false otherwise. export function isReactive<T>(x: ReactiveOrStatic<T>): x is ReactiveValue<T>; // Creates a ReactiveValue that tracks Promise state changes. export function asyncDerived<T, E = unknown>( fn: (ctx: Computation, signal: AbortSignal) => Promise<T>, ): ReactiveValue<PromiseSnapshot<T, E>>; ``` <details> <summary>Supplimental types</summary> ```ts // The promise is not settled. export interface Pending { isSettled: false; isRejected: false; isResolved: false; } // The promise is resolved. export interface Resolved<T> { isSettled: true; isRejected: false; isResolved: true; data: T; } // The promise is rejected. export interface Rejected<E> { isSettled: true; isRejected: true; isResolved: false; error: E; } // State of a Promise at a particular point. export type PromiseSnapshot<T, E> = Pending | Resolved<T> | Rejected<E>; ``` </details> ## DOM node creation ```ts export type NodeChild = Node | string | null | undefined; export type ElementSetup<T extends Element> = (el: T) => void; // Creates an HTMLElement export function el( tagName: string, setups?: ElementSetup<HTMLElement>[], children?: ReactiveOrStatic<NodeChild>[], ): HTMLElement; // Creates a SVGElement export function svg( tagName: string, setups?: ElementSetup<SVGElement>[], children?: ReactiveOrStatic<NodeChild>[], ): SVGElement; // Creates a MathMLElement export function mathml( tagName: string, setups?: ElementSetup<MathMLElement>[], children?: ReactiveOrStatic<NodeChild>[], ): MathMLElement; // Creates DocumentFragment export function fragment( children: ReactiveOrStatic<NodeChild>[], ): DocumentFragment; ``` ## Element setups ```ts // Set or Remove attribute from an element. export function attr( name: string, value: ReactiveOrStatic<string | boolean>, ): ElementSetup<Element>; // Set value to a property of an element. export function prop<T extends Element, K extends keyof T = keyof T>( name: T, value: ReactiveOrStatic<T[K]>, ): ElementSetup<T>; // Add an event listener to an element. export function on( eventName: string, listener: ReactiveOrStatic<(event: Event) => void>, options?: AddEventListenerOptions, ): ElementSetup<Element>; // Set a list of class to an element. export function classList( ...classes: ReactiveOrStatic<string | null>[] ): ElementSetup<Element>; // Set value to a stylesheet property of an element. export function style( name: string, value: ReactiveOrStatic<string | null>, ): ElementSetup<Element>; ```
-
-
docs/development.md (new)
-
@@ -0,0 +1,113 @@# Development Guide This document describes how to develop the ef library itself. ## Requirements You need Bun v1, and Node.js v20 (optional). Node.js is required for publishing the package and running benchmark on V8. This project has `.tool-versions` file. Use of a tool that supports reading `.tool-versions` is recommended. ## Commands ### Generate bundle files ```sh bun scripts/bundle.ts ``` This command bundles `src/ef.js` then writes to `dist/` and `bundle.zip`. ### Check module imports ```sh bun scripts/check-impots.ts ``` This command checks every `import`s in `src/**/*.ts`. This command exit with non-zero code when any of import rule violations found. ### Build the website ```sh bun scripts/website/build.ts ``` This command builds the website files then writes them to `website/dist/`. ### Build and Serve the website ```sh bun scripts/website/serve.ts # Rebuild when the source files are changed. Currently does not work for non ".tsx?" files due # to a bug in Bun. Also changes to client-only file does not trigger rebuild too. bun --hot scripts/website/serve.ts # Specify listen port and hostname HOST=my.domain PORT=8080 bun scripts/website/serve.ts ``` This command starts a web server and builds the website files. ### Build npm distribution files ```sh bun run build ``` This command starts TypeScript Compiler then creates `esm/es2019/*.js` and `esm/es2019/*.d.ts`. ### Run unit tests ```sh bun test ``` This command runs unit tests. Add `--watch` option in order to start in watch mode. For more options, please run the command with `--help` option. Test files are under `tests/` directory, and have `.test.ts` extension. Do not put test files under `src/` because that makes build configuration way more complex. ### Run example pages ```sh bun examples # or bun run examples ``` This starts Vite dev server. ### Run benchmarks ```sh # JavaScriptCore bun bench/[path/to/suite.js] # V8 node bench/[path/to/suite.js] ``` These command runs benchmarks. Benchmark suites are at `bench/`, written in **plain JavaScript** because Node.js can't run TypeScript files directly. ### Run in-browser benchmark ```sh bun bench # then open displayed URL in your browser ``` This command starts a Vite dev server, which serves a simple HTML page that runs in-browser benchmark JS. The script automatically starts once the page is loaded, and the result is displayed in browser console. ### Publish package to NPM ```sh npm publish ```
-
-
docs/installation.md (new)
-
@@ -0,0 +1,27 @@# Installation This package is available as ESM via npm. This is a recommended way for bundler usages. You can also download a bundled ESM file directly. This is the recommended way for non-bundler usages. ## npm Add `@pocka/ef` to your `package.json` by using a package manager of your choice. ```sh # Example: using official npm CLI npm i @pocka/ef ``` ## Bundled ESM Download the `ef-<version><modifier>.js` then load it from your script. If the filename has `h` modifier, you don't need to distribute the `LICENSE` file as the source code already includes a license header. ```js // example: version 1.0.0, minified, with license header import { signal } from "./path/fo/downloaded/ef-1.0.0mh.js"; ```
-
-
docs/introduction.md (new)
-
@@ -0,0 +1,74 @@# Introduction ef.js is a JavaScript library that provides state management functionality and declarative DOM functions based on that. This library is designed and built only for my personal needs. Unlike the majority of UI library, ef.js does not abstract DOM. It is designed to be simple and thin so it can be applied to anywhere standard DOM is expected and can cooperate with other code and libraries. Therefore, this is not for whom just trying to build a web app without understanding the web platform. ## State management The state management feature is designed based on Signals, a design approach for reactivity, popularised by [S.js](https://github.com/adamhaile/S) and [Solid.js](https://www.solidjs.com/). The API surface is kept bare minimum, so it is recommended to define helper function for each project if needed. ```ts import { derived, effect, signal } from "@pocka/ef"; // Create a reactive value const $number = signal(1); // Define a derived value const $isEven = derived(() => { return $number.get() % 2 === 0; }); // Run a function and re-run when `$isEven` was changed effect(() => { console.log($isEven.get()); }); // [LOG]: false // Update a reactive value $number.set(2); // [LOG]: true ``` ## Declarative DOM The DOM part is quite simple ― it just creates DOM Nodes. "Declarative" kicks in only when it takes Signals value, as it subscribes to value changes then update necessary part on changes. This is very similar to what Solid.js or similar libraries do, but without a special compiler or Virtual DOM. Of course, lack of those also means inferior DX and/or missing optimisation opportunities. Be advised to choose with consideration. ```ts import { el, prop, signal } from "@pocka/ef"; const $sent = signal(false); // `myButton` is HTMLButtonElement const myButton = el("button", [prop("disabled", $sent)], ["Send data"]); document.appendChild(myButton); $sent.set(true); // myButton is now disabled // ...internal of `prop` is something like this function prop(name, value) { return (element) => { if (isReactive(value)) { effect(() => { element[name] = value.get(); }); return; } element[name] = value; }; } ```
-
-
docs/usage.md (new)
-
@@ -0,0 +1,438 @@# Usage ## Key concepts ef.js provides two fundamental concept for state management: _Reactive Value_ and _Computation_. _Reactive Value_ is an object that holds a value and notifies its changes to dependant _Computations_. You can think this as a Subject in Observer Pattern, `EventEmitter` in Node.js, or `EventTarget` in DOM. _Computation_ is an object that holds a callback function and reruns it everytime dependent _Reactive Values_ has changed. In other words, _Computation_ subscribes to _Reactive Values_' changes. You can think this as an Observer in Observer Pattern or an event listener in Node.js and DOM. ## Work with Signals ### Create a Reactive Value You can create a _Reactive Value_ via `signal` and `derived` function. Difference between the two is you can directly change `signal`'s containing value while `derived` uses a value returned by its callback as its containing value. `derived` is also a _Computation_ and hence the `derived` marks every _Reactive Values_ accessed in its callback as its dependency. ```ts import { derived, signal } from "@pocka/ef"; // The starting dollar sign ($) in variable names is // just a convention in my source code. // You can freely omit and use regular variable name. const $age = signal(18); // The value is a result of the callback function. const $isLegalToDrink = derived(() => $age.get() >= 20); $age.get(); // 18 $isLegalToDrink.get(); // false // This updates $age's value, and $age notifies its change // to $isLegalToDrink, which reruns the callback function. $age.set(20); // 20 $age.get(); // 18 $isLegalToDrink.get(); // true ``` You can also use `Signal` and `Derived` class directly. Both `signal` and `derived` are thin wrapper around corresponding class constructor. ```ts import { Derived, Signal } from "@pocka/ef"; const $age = new Signal(18); const $isLegalToDrink = new Derived(() => $age.get() >= 20); // Returned object is no different const $foo = signal(0); const $bar = new Signal(0); $foo instanceof Signal; // true $bar instanceof Signal; // true ``` ### Retrieving value from a Reactive Value _Reactive Value_ has three way to access its containing value. - `.get(computation?)` - `.value` - `.once()` `.get(computation?)` returns the containing value and associates the _Reactive Value_ to a surrounding _Computation_ if exists. When the optional first parameter is specified, the _Reactive Value_ associates itself to the specified _Computation_ instead of a surrounding one. `.value` getter is an alias for `.get()`. `.once()` just returns the containing value and does nothing else. This is suitable for retrieving a value outside of _Computations_ or isolated callback functions. ```ts import { signal } from "@pocka/ef"; const $count = signal(0); $count.get(); // 0 $count.value; // 0 $count.once(); // 0 ``` ### Perform action on changes To perform something that has side effects everytime _Reactive Values_ changed, use `effect(callback)`. ```ts import { signal, effect } from "@pocka/ef"; const $count = signal(0); effect(() => { console.log($count.get()); }); // logs 0 $count.set(1); // logs 1 ``` If you don't want a _Reactive Value_ to be a dependency of the _Computation_, retrieve a value using `.once()`. ```ts import { signal, effect } from "@pocka/ef"; const $count = signal(0); effect(() => { console.log($count.once()); }); // logs 0 $count.set(1); // nothing happens ``` If the `callback` returns a function, it'll be invoked before every rerun and disposal. ```ts import { signal, effect } from "@pocka/ef"; const $count = signal(0); const log = effect(() => { console.log($count.get()); return () => { console.log("cleanup"); }; }); // logs 0 $count.set(1); // logs "cleanup" // logs 1 log.dispose(); // logs "cleanup" ``` ### Explicitly passing dependant Computation Normally, when `.get()` is called on a _Reactive Value_, ef.js can find a correct _Computation_ to associate. However, as ef.js relies on a global variable due to language and tooling limitations, this mechanism does not work in callback function. This restriction also applies to async function (`Promise`), which is essentially a set of callbacks. ```ts import { signal, effect } from "@pocka/ef"; const $count = signal(0); effect(function effectCallback() { const id = setTimeout(() => { // At this point, the execution of `effectCallback` is already completed. // Therefore, ef.js can't figure out which Computation to associate to. console.log($count.get()); }, 100); return () => { clearTimeout(id); }; }); setTimeout(() => { $count.set(1); }, 200); // wait 100ms // logs 0 // wait 100ms // -- script terminates -- ``` As a workaround, a callback function of a _Computation_ pass the _Computation_ itself as the first argument and you can explicitly pass it to `get()` method. ```ts import { signal, effect } from "@pocka/ef"; const $count = signal(0); effect((ctx) => { const id = setTimeout(() => { console.log($count.get(ctx)); }, 100); return () => { clearTimeout(id); }; }); setTimeout(() => { $count.set(1); }, 200); // wait 100ms // logs 0 // wait 200ms // logs 1 // -- script terminates -- ``` However, most of the time, you should avoid this due to increase of complexity. Eager binding of the value is recommended. ```ts import { signal, effect } from "@pocka/ef"; const $count = signal(0); effect(() => { const count = $count.get(); const id = setTimeout(() => { console.log(count); }, 100); return () => { clearTimeout(id); }; }); setTimeout(() => { $count.set(1); }, 200); // wait 100ms // logs 0 // wait 200ms // logs 1 ``` ### Resource disposal `Signal`, `Derived` and `Effect` has `dispose` method, which stops reactivity and make them collectable. ```ts import { signal, effect } from "@pocka/ef"; const $count = signal(0); const log = effect(() => { console.log($count.get()); }); // logs 0 $count.set(1); // logs 1 log.dispose(); $count.set(2); // nothing happens ``` ### Async helper Due to async (Promise) is frequently used in JavaScript ecosystem and Web API, ef.js provides a helper function named `asyncDerived`. ```ts import { signal, effect, asyncDerived } from "@pocka/ef"; const $name = signal("Alice"); const $profile = asyncDerived(async function (ctx, abortSignal) { const resp = await fetch(`/my-api/profiles/${$name.get(ctx)}`, { signal: abortSignal, }); if (resp.status !== 200) { throw new Error(`Unexpected API status: ${resp.status}`); } return resp.json(); }); effect(() => { if (!$profile.value.isSettled) { console.log("fetching..."); return; } if ($profile.value.isRejected) { console.error("failed to fetch", $profile.value.error); return; } // `.data` is a resolved data of `resp.json()` console.log($profile.value.data); }); ``` ## Working with DOM ### Element creation The most important function in ef.js to work with DOM is `el`. It creates and returns an `HTMLElement` with specified tag name. ```ts import { el } from "@pocka/ef"; const div = el("div"); // div is HTMLDivElement ``` You can also use `svg` for SVG elements and `mathml` for MathML elements. ```ts import { svg, mathml } from "@pocka/ef"; const path = svg("path"); // path is SVGPathElement const mn = mathml("mn"); // mn is MathMLElement ``` ### Element setup Second parameter of `el` (also `svg` and `mathml`) is an array of element setup function. Element setup function is a function takes an element in the first argument and do something to/with the element. ```ts import { el } from "@pocka/ef"; el("input", [ (el) => { // el is a current element (HTMLInputElement) el.setAttribute("type", "checkbox"); el.checked = true; el.addEventListener("input", (ev) => { console.log(ev.currentTarget.checked); }); }, ]); ``` ef.js provides three core and two extra helper functions for this task. Those helper functions are higer-ordered functions (return element setup function). ```ts import { attr, el, on, prop } from "@pocka/ef"; el("input", [ attr("type", "checkbox"), prop("checked", true), on("input", (ev) => { console.log(ev.currentTarget.checked); }), ]); ``` These helper functions accept _Reactive Value_ and update the only necessary parts on changes. ```ts import { attr, derived, el, on, prop, signal } from "@pocka/ef"; const $disabled = signal(false); const $name = signal("Alice"); function noop() {} el("button", [ attr( "title", derived(() => `Invite ${$name.get()}`), ), prop("disabled", $disabled), on( "click", derived(() => { if ($disabled.get()) { return noop; } return () => { console.log($name.once()); }; }), ), ]); $name.set("Bob"); // Update "title" attribute $disabled.set(true); // Update "disabled" property // Remove "click" event listener // Add "click" event listener ``` ### Element children Third parameter of `el` (`svg` and `mathml`) function is children. You can pass `Node` (e.g. `Element`, `Text`), string, `null` and `undefined`. String value is converted to `Text` (text node) and `null` and `undefined` are skipped. _Reactive Value_ containing these value is also accepted. ```ts import { el, signal } from "@pocka/ef"; const $span = signal(el("span")); const $name = signal("Alice"); el( "div", [], [document.createElement("i"), el("p"), $span, "Foo", $name, null, undefined], ); ``` ### `DocumentFragment` `fragment` function creates `DocumentFragment` with reactivity. Available child type is same as `el`. ```ts import { el, fragment, signal } from "@pocka/ef"; const $name = signal("Alice"); const profile = fragment(["Name is ", $name]); el("p", [], [profile]); ```
-
-
examples/.gitignore (new)
-
@@ -0,0 +1,1 @@/dist
-
-
-
@@ -0,0 +1,25 @@# Elm-like API sample This page is a one approach of creating API layer top of ef.js. ## Considerations ### Bundle size or runtime performance If you going to use this API style, you'll face a trade-off between increased bundle size and function call overhead. By defining everything as separated functions, there is virtually zero runtime overhead but bundle size would definitely increases. ```ts import { el } from "@pocka/ef"; export function div(setups = [], children = []) { return el("div", setups, children); } export const p = el.bind(null, "p"); // Enumerate every HTML/SVG/MathML tag... ``` On the other hand, using `Proxy` object like in this page, would bring performance penalty on every function call, although you can avoid this cost by declaring each tag as a function eagerly (see `main.ts`). You need to choose based on which approach is best suitable for your project.
-
-
-
@@ -0,0 +1,22 @@// This module "extends" ef.js with a helper function named `map` import { derived, isReactive, type ReactiveOrStatic } from "../../src/ef.js"; export * from "../../src/ef.js"; /** * Applies function `f` for `x` when `x` is not `ReactiveValue`, otherwise * Returns a Derived that value is a result of `f(x.get())`. * * Convenient helper to dealing with `ReactiveOrStatic`. * Useful for composing. */ export function map<T, P>( x: ReactiveOrStatic<T>, f: (x: T) => P, ): ReactiveOrStatic<P> { if (isReactive(x)) { return derived(() => f(x.get())); } return f(x); }
-
-
-
@@ -0,0 +1,56 @@<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <title>Elm-like API sample | ef.js</title> <script type="module" defer src="./main.ts"></script> <style> :root { font-family: sans-serif; } .root { display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; gap: 4px; } .items { margin: 0; margin-top: 16px; display: flex; flex-direction: column; justify-content: flex-start; align-items: stretch; gap: 4px; padding: 0; list-style: none; } .item { display: grid; grid-template-columns: 1em minmax(0, 1fr); gap: 2px; min-height: 1.5em; color: gray; } .item[data-checked] { color: black; } .item-check { align-self: center; justify-self: center; } .item-label { align-self: center; justify-self: start; } </style> </head> <body></body> </html>
-
-
-
@@ -0,0 +1,22 @@import { el, type ElementSetup } from "../../../src/ef.js"; const html = new Proxy( {}, { get<K extends keyof HTMLElementTagNameMap>(_: unknown, name: K) { return ( setups?: readonly ElementSetup<HTMLElementTagNameMap[K]>[], children?: Parameters<typeof el>[2], ) => { return el(name, setups, children); }; }, }, ) as { [K in keyof HTMLElementTagNameMap]: ( setups?: readonly ElementSetup<HTMLElementTagNameMap[K]>[], children?: Parameters<typeof el>[2], ) => HTMLElementTagNameMap[K]; }; export default html;
-
-
-
@@ -0,0 +1,40 @@import { attr, type ElementSetup, prop, classList, style, type ReactiveOrStatic, } from "../../ef.js"; export { style, classList as class_, classList }; export function id<T extends HTMLElement & { id: string }>( value: ReactiveOrStatic<string>, ): ElementSetup<T> { return prop("id", value); } export function for_<T extends HTMLElement>( value: ReactiveOrStatic<string>, ): ElementSetup<T> { return attr("for", value); } export function type<T extends HTMLElement & { type: string }>( value: ReactiveOrStatic<string>, ): ElementSetup<T> { return prop("type", value); } export function placeholder<T extends HTMLElement & { placeholder: string }>( value: ReactiveOrStatic<string>, ): ElementSetup<T> { return prop("placeholder", value); } export function autofocus<T extends HTMLElement & { autofocus: boolean }>( value: ReactiveOrStatic<boolean>, ): ElementSetup<T> { return prop("autofocus", value); }
-
-
-
@@ -0,0 +1,19 @@import { type ElementSetup, map, on, type ReactiveOrStatic } from "../../ef.js"; export function onInput<T extends HTMLElement & { value: string }>( listener: ReactiveOrStatic<(value: string) => void>, ): ElementSetup<T> { return on<T, InputEvent>( "input", map(listener, (f) => (ev) => { if ( ev.currentTarget instanceof HTMLElement && "value" in ev.currentTarget && typeof ev.currentTarget.value === "string" ) { ev.stopPropagation(); f(ev.currentTarget.value); } }), ); }
-
-
-
@@ -0,0 +1,70 @@import { attr, derived, map, signal, type ReactiveOrStatic } from "./ef.js"; import html from "./lib/html.js"; import { id, class_, for_, type, autofocus, placeholder, } from "./lib/html/attributes.js"; import { onInput } from "./lib/html/events.js"; const $inputText = signal(""); const { li, span, div, label, input, ul } = html; function item(labelText: string, $checked: ReactiveOrStatic<boolean>) { return li( [class_("item"), attr("data-checked", $checked)], [ span( [class_("item-check")], [map($checked, (checked) => (checked ? "✓" : null))], ), span([class_("item-label")], [labelText]), ], ); } document.body.appendChild( div( [class_("root")], [ label([for_("test_input")], ["Input text"]), input([ id("test_input"), type("text"), autofocus(true), placeholder("abcABC123!?"), onInput((value) => $inputText.set(value)), ]), ul( [class_("items")], [ item( "Lowercase", derived(() => /[a-z]/.test($inputText.get())), ), item( "Uppercase", derived(() => /[A-Z]/.test($inputText.get())), ), item( "Number", derived(() => /[0-9]/.test($inputText.get())), ), item( "Symbol", derived(() => /[!?,.*^()~_=-]/.test($inputText.get())), ), item( "Space", derived(() => /\s/.test($inputText.get())), ), ], ), ], ), );
-
-
-
@@ -0,0 +1,71 @@# JSX sample This page demonstrates simplicity and composability of ef.js using sample JSX implementation. Thanks to simple design choices, you can create your own JSX library which directly returns a DOM element. Sample application is a simple counter (increase decrease number) using component and Signal reactivity. ## Limitation Due to design constraint of both JSX and ef.js, several pitfalls exist. Those are workaround-able, but resulting implementation would be opinionated and different to other "normal" JSX implementations (such as React). For this reason, ef.js does not provide first-party JSX support. You need to implement your own. ### No way to create namespaced elements SVG and MathML elements requires `document.createElementNS` with namespace URI for creating each elements. However, JSX does not have a concept of namespace URI: everything is HTML. JSX does have XML namespace (`<foo:bar baz:qux="" />`), but this is different from namespace URI, which has important role in DOM. Therefore, in order to support those non-HTML elements, you need to add a hack on top of JSX or check the tag name (requires full list of SVG/MathML tag names = increased JS filesize). If you care about correctness, the latter is not an option because both HTML and SVG have overlapping tag names (e.g. `<a>`, `<title>`, `<style>`). ```jsx // For example... // By abusing XML namespace, probably compatibility issue on some tool const nsLikeHack = ( <svg:svg> <svg:path d="M0,0 L1,0 L0,1 Z" /> <svg:circle cx="3" cy="3" r="2" /> </svg:svg> ); // Simple and straightforward, but unintuitive (totally not HTML) const attrHack = ( <svg svg> <rect svg width="10" height="10" /> </svg> ); // Explicit and straightforward, but verbose // More DOM/HTML-ish than the above `svg` boolean attribute, though. const svgNS = "http://www.w3.org/2000/svg"; const verboseAttr = ( <svg ns={svgNS}> <path ns={svgNS} d="M1,0 L0,1" /> </svg> ); ``` Preact avoids this problem by turning `isSvg` on when it encounter `<svg>` (`SVGSVGElement`) so descendant elements are treated as SVG elements (probably same for MathML elements). This approach is not appliable to ef.js because Element creation is performed on inverse order (child first). ### No distinction between attributes and properties By design, JSX does not distinguish attributes and properties. Hence, a hack such as "set as attribute if the element does not have this property" is required. You can avoid that by adapting to identifier modifier approach like Vue.js, but it would spoil JSX's merit due to code would be less HTML-ish (and SVG-ish, MathML-ish). ```jsx // As an explanation, better avoid this // prefixed with "$" ... attr, everything else ... props // Maybe works better with TypeScript thanks to template string literal type? const clearProps = <div $tabindex="0" tabIndex={0} />; // Wrap with marker object (detect it using Symbol or Class) // Typing complexity and possible runtime performance penalty? const markValues = <div tabindex={attr("0")} tabIndex={0} />; ```
-
-
-
@@ -0,0 +1,45 @@<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <title>JSX helper sample | ef.js</title> <script type="module" defer src="./main.tsx"></script> <style> :root { font-family: sans-serif; } .root { display: flex; gap: 20px; } .counter { display: inline-grid; grid-template-columns: 2em 3em 2em; grid-template-areas: "label label label" "decr value incr"; justify-items: center; align-items: center; gap: 10px; border: 1px solid #ccc; padding: 10px 20px; border-radius: 3px; } .counter-label { grid-area: label; font-weight: bold; margin: 0; } .counter-button { justify-self: stretch; align-self: stretch; } </style> </head> <body></body> </html>
-
-
-
@@ -0,0 +1,1 @@export { jsx as jsxDEV, type JSX } from "./jsx-runtime.js";
-
-
-
@@ -0,0 +1,101 @@import { effect, el, type ElementSetup, isReactive, on, } from "../../../src/ef.js"; export { type JSX } from "./jsx.js"; const EVENT_HANDLER = /^on(?=[A-Z])/; function isAttributeValue(x: unknown): x is string | boolean { return typeof x === "string" || typeof x === "boolean"; } function setAttribute(el: Element, name: string, value: string | boolean) { switch (value) { case true: el.setAttribute(name, ""); return; case false: el.removeAttribute(name); return; default: el.setAttribute(name, value); } } function setPropertyOrAttribute( element: Element, name: string, value: unknown, ) { if (name in element) { try { // @ts-expect-error: TS is stupid still. element[name] = value; } catch (error) { if (error instanceof TypeError && isAttributeValue(value)) { // Probably element[name] was read-only setAttribute(element, name, value); return; } console.warn(`Failed to set property "${name}"`, { element, name, value, }); } return; } if (!isAttributeValue(value)) { console.warn( `The property you specified does not present in the element and the value is not available as an attribute value.`, { element, name, value }, ); return; } setAttribute(element, name, value); } export function jsx(type: string | ((props: any) => Node), props: any): Node { if (typeof type === "function") { return type(props); } const { children, ...rest } = props; const setups = Object.entries(rest).map<ElementSetup<Element>>( ([key, value]) => { if (EVENT_HANDLER.test(key) && typeof value === "function") { return on( key.replace(EVENT_HANDLER, "").toLowerCase(), value as (ev: Event) => unknown, ); } if (isReactive(value)) { return (el) => { effect(() => { setPropertyOrAttribute(el, key, value.get()); }); }; } return (el) => { setPropertyOrAttribute(el, key, value); }; }, ); const normalizedChildren = Array.isArray(children) ? children : [children]; return el(type, setups, normalizedChildren); } export { jsx as jsxs };
-
-
-
@@ -0,0 +1,25 @@import { type NodeChild, type ReactiveOrStatic } from "../../../src/ef.js"; export namespace JSX { export type IntrinsicElements = { [K in keyof HTMLElementTagNameMap]: { [key: string]: any; }; } & { [K in keyof SVGElementTagNameMap as `svg:${K}`]: { [key: string]: any; }; } & { [key: string]: any; }; export interface ElementChildrenAttribute { children?: JSXChildren; } export type Element = Node; } export type JSXChildren = | ReactiveOrStatic<NodeChild> | readonly ReactiveOrStatic<NodeChild>[];
-
-
-
@@ -0,0 +1,42 @@import { derived, signal } from "../../src/ef.js"; import { type JSXChildren } from "./jsx/jsx.js"; interface CounterProps { initialValue?: number; children: JSXChildren; } function Counter({ initialValue = 0, children }: CounterProps) { const $count = signal(initialValue); // `class` is attribute, `disabled` is property return ( <div class="counter"> <p class="counter-label">{children}</p> <button class="counter-button" disabled={derived(() => $count.get() <= 0)} onClick={() => $count.set($count.once() - 1)} > - </button> <span>{derived(() => $count.get().toString(10))}</span> <button class="counter-button" disabled={derived(() => $count.get() >= 10)} onClick={() => $count.set($count.once() + 1)} > + </button> </div> ); } document.body.appendChild( <div class="root"> <Counter>Counter 1</Counter> <Counter initialValue={5}>Counter 2</Counter> </div>, );
-
-
-
@@ -0,0 +1,8 @@{ "extends": "../tsconfig.json", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "./jsx" }, "include": ["./*.tsx"] }
-
-
examples/async/README.md (new)
-
@@ -0,0 +1,118 @@# Async sample This page demonstrates an example approach declarative UI to cooperate with async operation naturally. The example uses a help function named `series`, which leverages Async Generator Function to allow users write multi-step UI smoothly. Sample application loads specified user info asynchronously then display that as `<dl>`. ## Explicit resource association is required Signals and Deriveds/Effects are associated using function call stack by design. This design choice has one big drawback: everything must be a synchronous normal function call. For example, this code does not work as intended due to the changes to `$name` will not re-run `e1`. ```ts const $name = signal("Alice"); // 1. global stack = [] const e1 = effect(() => { // 2. global stack = [e1] const tid = setTimeout(() => { // 4. global stack = [] // next loop = outside of `e1` // When this line is executed, `e1` is popped from global stack. // Therefore, `$name.get()` would be orphan and changes to `$name` does not // re-run `e1`. console.log($name.get()); }, 500); return () => { clearTimeout(tid); }; }); // 3. global stack = [] ``` There are two way to tackle this problem. ### Retrieve value early If you call `.get()` eagerly (when the call stack is not emptied yet), there is nothing to care about. ```ts // ... const e1 = effect(() => { // global stack = [e1] // Hence `$name` is associated to `e1` const name = $name.get(); const tid = setTimeout(() => { console.log(name); }, 500); return () => { clearTimeout(tid); }; }); ``` ### Manually specify dependant effect `effect` function passes itself as a first argument to the callback. When `Signal.prototype.get()` and `Derived.prototype.get()` saw the first parameter, they treat the first parameter as a dependant instead of the global stack. By using those API, you can explicitly specify which value re-runs which effect on value changes. ```ts const e1 = effect((ctx) => { const tid = setTimeout(() => { // global stack is empty, but `.get(ctx)` refers the first argument // instead of global stack so `$name` is associated to `e1`. console.log($name.get(ctx)); }, 500); return () => { clearTimeout(tid); }; }); ``` While this approach is more flexible as it allows values to associate themselves to an effect lazily, this could be more error-prone because it's easy to forget put the context to the first parameter. I recommend you to carefully evaluate all three approaches before integrating into your app. The helper function in this example page uses this approach, mainly for demonstrating lazy association. ## Alternative Use `asyncDerived`. It's the most straightforward way to represent async procedure. ```ts import { asyncDerived } from "@pocka/ef"; const $myApiResult = asyncDerived(async () => { const resp = await fetch("/my/api"); if (resp.status !== 200) { throw new Error(`Unexpected status code (HTTP ${resp.status})`); } return resp.json(); }); const myUI = derived(() => { const myApiResult = $myApiResult.get(); if (!myApiResult.isSettled) { return el("p", [], ["Fetching"]); } if (myApiResult.isRejected) { return el("p", [], ["ERROR: " + String(myApiResult.error)]); } return el("div", [], [doSomethingWithApiData(myApiResult.data)]); }); ```
-
-
examples/async/api.ts (new)
-
@@ -0,0 +1,49 @@function sleep(ms: number): Promise<void> { return new Promise((resolve) => { setTimeout(() => { resolve(); }, ms); }); } type UserId = string & { __userId: never }; export interface User { id: UserId; displayName: string; } const usersData = new Map<UserId, Omit<User, "id">>([ ["alice" as UserId, { displayName: "Alice" }], ["bob" as UserId, { displayName: "Bob" }], ["carol" as UserId, { displayName: "Carol" }], ]); export async function fetchUserInfo( req: Request, abortSignal: AbortSignal, ): Promise<User> { await sleep(Math.random() * 500 + 1000); if (abortSignal.aborted) { throw new Error("Operation was aborted."); } const url = new URL(req.url); const id = url.searchParams.get("id") as UserId | null; if (!id) { throw new Error("UserId is required."); } const user = usersData.get(id); if (!user) { throw new Error(`User with UserId("${id}") does not exist.`); } return { id, ...user, }; }
-
-
examples/async/ef.ts (new)
-
@@ -0,0 +1,69 @@// This module "extends" ef.js with a helper function named `series` import { derived, type Computation, effect, signal, type ReactiveValue, } from "../../src/ef.js"; export * from "../../src/ef.js"; function abortPromise(signal: AbortSignal) { return new Promise<never>((_, reject) => { signal.addEventListener( "abort", (e) => { reject(e); }, { once: true }, ); }); } /** * Helper function to write async value with async generator function disguising * pure async + algebraic effect function. This improves code readability by reducing * code jumps (you can think in stream) */ export function series<T>( initialValue: T, f: ( ctx: Computation, abortSignal: AbortSignal, ) => AsyncGenerator<T, void, undefined>, ): ReactiveValue<T> { const $s = signal<T>(initialValue); effect((ctx) => { $s.set(initialValue); const abortController = new AbortController(); const i = f(ctx, abortController.signal); const consume = async () => { try { const iter = await Promise.race([ i.next(), abortPromise(abortController.signal), ]); if (iter.done) { return; } $s.set(iter.value); consume(); } catch {} }; consume(); return () => { abortController.abort(); }; }); return derived(() => $s.get()); }
-
-
-
@@ -0,0 +1,14 @@<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <title>Async operation sample | ef.js</title> <script type="module" defer src="./main.ts"></script> <style> :root { font-family: sans-serif; } </style> </head> <body></body> </html>
-
-
examples/async/main.ts (new)
-
@@ -0,0 +1,101 @@import { attr, el, on, series, signal } from "./ef.js"; import { fetchUserInfo } from "./api.js"; const $id = signal("alice"); // Changing this Signal value does not trigger re-render until the body of `series` // callback evaluates `$retry.get()`. const $retry = signal(0); // This element would be reused // You can test on browser devtool const retryButton = el( "button", [ on("click", () => { $retry.update((prev) => prev + 1); }), ], ["Retry (works only for error state)"], ); document.body.appendChild( el( "div", [], [ el( "select", [ on("change", (ev) => { if (!(ev.currentTarget instanceof HTMLSelectElement)) { return; } $id.set(ev.currentTarget.value); }), ], [ el("option", [attr("value", "alice")], ["Alice"]), el("option", [attr("value", "bob")], ["Bob"]), el("option", [attr("value", "carol")], ["Carol"]), el("option", [attr("value", "dave")], ["Dave (does not exist)"]), ], ), series(el("p", [], ["Loading..."]), async function* (ctx, s) { try { const url = new URL("https://example.com"); // This `$id.get(ctx)` associates `$id` to the containing `series`. // Everytime `$id` updated, the `series` re-runs thanks to this. url.searchParams.set("id", $id.get(ctx)); const user = await fetchUserInfo(new Request(url), s); yield el( "div", [], [ el( "dl", [], [ el("dt", [], ["ID"]), el("dd", [], [user.id]), el("dt", [], ["Display Name"]), el("dd", [], [user.displayName]), ], ), // This retry button do nothing because `$retry` is not associated to this `series`. retryButton, ], ); $retry.set(0); } catch (error) { yield el( "div", [], [ el( "p", [], [ "Failed to fetch users (retry=", // This association occurs only control enters this `catch` branch. // `ctx` is required here, otherwise calling `$retry.set` does not let // this effect (`series`) to be re-run. $retry.get(ctx).toString(10), "): ", String(error), ], ), retryButton, ], ); } }), ], ), );
-
-
examples/clock/README.md (new)
-
@@ -0,0 +1,38 @@# Clock sample This page demonstrates a single state / single loop application architecture using ef.js. ## Query parameters | Name | Available values | Description | | --------- | -------------------------------------------------- | --------------------------------------------------------- | | `feature` | Feature flag name, see below | Feature flag to activate. Can be multiple. | | `locale` | Valid locale string (see [`Intl.Locale`] for more) | Locale to use for formatting datetime for screen readers. | | `fg` | Predefined color, see below | Foreground color. | | `bg` | Predefined color, see below | Background color. | [`Intl.Locale`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale `fg` and `bg` are only available on a browser that supports `oklch()` CSS function (added in CSS Color Module Level 4). ### Feature flags | Name | Description | | --------------------- | ------------------------------------------------------------ | | `dropshadow` | Render shadows on texts. | | `show-visuallyhidden` | Render visually hidden texts (for screen reader) on screeen. | ### List of predefined colors - `red` - `dark-red` - `blue` - `dark-blue` - `green` - `dark-green` - `yellow` - `dark-yellow` - `black` - `mostly-black` - `white` - `mostly-white`
-
-
-
@@ -0,0 +1,83 @@<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <title>Clock sample | ef.js</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script type="module" defer src="./main.ts"></script> <!-- For third party licenses, see ./third-party.txt --> <style> :root { --fg-default: 0deg 0% 5%; --bg-default: 0deg 0% 100%; --fg: hsl(var(--theme-fg, var(--fg-default))); --fg-dim: hsl(var(--theme-fg, var(--fg-default)) / 0.65); --bg: hsl(var(--theme-bg, var(--bg-default))); font-family: "JetBrains Mono", monospace; } @media (prefers-color-scheme: dark) { :root { --fg-default: hsl(0deg 0% 100%); --bg-default: hsl(0deg 0% 5%); } } @supports (color: oklch(0% 0 0deg)) { :root { --color-red: 35% 0.7 0deg; --color-dark-red: 20% 0.2 0deg; --color-blue: 43% 0.7 220deg; --color-dark-blue: 8% 0.15 220deg; --color-green: 40% 0.75 150deg; --color-dark-green: 20% 0.15 150deg; --color-yellow: 80% 0.7 110deg; --color-dark-yellow: 40% 0.2 110deg; --color-black: 0% 0 0deg; --color-mostly-black: 10% 0 0deg; --color-white: 100% 0 0deg; --color-mostly-white: 95% 0 0deg; --fg-default: var(--color-mostly-black); --bg-default: var(--color-mostly-white); --fg: oklch(var(--theme-fg, var(--fg-default))); --fg-dim: oklch(var(--theme-fg, var(--fg-default)) / 0.65); --bg: oklch(var(--theme-bg, var(--bg-default))); } @media (prefers-color-scheme: dark) { :root { --fg-default: var(--color-white); --bg-default: var(--color-mostly-black); } } } * { font: inherit; margin: 0; padding: 0; } body { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; justify-content: stretch; align-items: stretch; background-color: var(--bg); color: var(--fg); overflow: hidden; } </style> <meta name="theme-color" content="var(--bg)" /> </head> <body></body> </html>
-
-
-
@@ -0,0 +1,48 @@.root { width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.3em; font-size: min(4em, 15vw, 30vh); } .label { display: inline-flex; align-items: center; gap: 0.2em; } body[data-features~="dropshadow"] .label { text-shadow: 0 2px 0.15em rgb(0 0 0 / 0.5); } .label > [data-single]::first-letter { color: var(--fg-dim); } .date { font-size: 0.75em; gap: 0; } .separator { font-size: 0.6em; } .visuallyHidden { position: absolute; top: 0; left: 0; width: 100%; height: 100%; user-select: none; pointer-events: none; overflow: hidden; opacity: 0; } body[data-features~="show-visuallyhidden"] .visuallyHidden { opacity: 0.5; }
-
-
examples/clock/main.ts (new)
-
@@ -0,0 +1,207 @@import "@fontsource/jetbrains-mono"; import { attr, classList, derived, effect, el, fragment, signal, type ReactiveValue, } from "../../src/ef.js"; import css from "./main.module.css"; const query = new URL(location.href).searchParams; // Set "feature" query params to body if (query.has("feature")) { document.body.dataset.features = query.getAll("feature").join(" "); } // User specified locale (undefined = UA provided value) let locale: string | undefined = undefined; if (query.has("locale")) { locale = query.get("locale") || undefined; } const colors = [ "red", "dark-red", "blue", "dark-blue", "green", "dark-green", "yellow", "dark-yellow", "black", "mostly-black", "white", "mostly-white", ]; if (query.has("fg")) { const value = query.get("fg") || ""; if (!colors.includes(value)) { console.warn( 'The value of `fg` query parameter is invalid: unknown color "' + value + '"', ); } else { document.documentElement.style.setProperty( "--theme-fg", `var(--color-${value})`, ); } } if (query.has("bg")) { const value = query.get("bg") || ""; if (!colors.includes(value)) { console.warn( 'The value of `bg` query parameter is invalid: unknown color "' + value + '"', ); } else { document.documentElement.style.setProperty( "--theme-bg", `var(--color-${value})`, ); } } /** * Format a number into zero-padded two digit string. */ function digit2(v: number): string { return v.toString(10).padStart(2, "0"); } // Current time (now) const initialDateTime = new Date(); const $now = signal(initialDateTime); const $year = derived(() => $now.get().getFullYear()); const $month = derived(() => $now.get().getMonth() + 1); const $date = derived(() => $now.get().getDate()); const $hour = derived(() => $now.get().getHours()); const $minute = derived(() => $now.get().getMinutes()); const $second = derived(() => $now.get().getSeconds()); // Update datetime Signal values effect(() => { const iid = setInterval(() => { const now = new Date(); $now.update((prev) => { if (prev.getSeconds() === now.getSeconds()) { // This clock only cares up to seconds, ignore milliseconds changes return prev; } return now; }); }, 100); return () => { clearInterval(iid); }; }); const dateFormatter = new Intl.DateTimeFormat(locale, { year: "numeric", month: "numeric", day: "numeric", }); const timeFormatter = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", }); function isSingle($n: ReactiveValue<number>) { return attr( "data-single", derived(() => $n.get() < 10), ); } document.body.appendChild( fragment([ el( "div", [attr("aria-hidden", "true"), classList(css.root)], [ el( "p", [classList(css.label, css.date)], [ el("span", [], [derived(() => $year.get().toString(10))]), el("span", [classList(css.separator)], ["-"]), el( "span", [isSingle($month)], [derived(() => digit2($month.get()))], ), el("span", [classList(css.separator)], ["-"]), el("span", [isSingle($date)], [derived(() => digit2($date.get()))]), ], ), el( "p", [classList(css.label)], [ el("span", [isSingle($hour)], [derived(() => digit2($hour.get()))]), el("span", [classList(css.separator)], [":"]), el( "span", [isSingle($minute)], [derived(() => digit2($minute.get()))], ), el("span", [classList(css.separator)], [":"]), el( "span", [isSingle($second)], [derived(() => digit2($second.get()))], ), ], ), ], ), // Since it's hard to let screen readers read the fragmented texts as a single text (with or without `role=text`), // this page uses visually hidden live region for screen readers. el( "p", [ attr("aria-live", "polite"), attr("aria-atomic", "false"), classList(css.visuallyHidden), ], [ // Recreating a whole TextNode with concatenated string indeed is inefficient. // However, this is necessary due to "aria-live" & "aria-atomic" being // so buggy (at least on VoiceOver) that one cannot rely on the spec-ed // behaviour. Overhead is acceptable considering the update is performed // once per minute. derived(() => { // Update when a day of the month has changed $date.get(); return "Today is " + dateFormatter.format(new Date()) + "."; }), el("br"), derived(() => { // Update when minute has changed $minute.get(); return "Current time is " + timeFormatter.format(new Date()) + "."; }), ], ), ]), );
-
-
examples/index.html (new)
-
@@ -0,0 +1,23 @@<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <title>Examples | ef.js</title> </head> <body> <ul> <li> <a href="async/">AsyncGenerator</a> </li> <li> <a href="clock/">Clock</a> </li> <li> <a href="advanced-elm-like/">Elm-like API</a> </li> <li> <a href="advanced-jsx/">JSX</a> </li> </ul> </body> </html>
-
-
examples/tsconfig.json (new)
-
@@ -0,0 +1,11 @@{ "extends": "../tsconfig.json", "compilerOptions": { "types": ["vite/client"], "moduleResolution": "Bundler", "noEmit": true, "declaration": false, "skipLibCheck": true }, "include": ["./**/*.ts"] }
-
-
examples/vite.config.ts (new)
-
@@ -0,0 +1,16 @@import { resolve } from "node:path"; import { defineConfig } from "vite"; export default defineConfig({ build: { rollupOptions: { input: { index: resolve(__dirname, "index.html"), async: resolve(__dirname, "async/index.html"), clock: resolve(__dirname, "clock/index.html"), advancedElmLike: resolve(__dirname, "advanced-elm-like/index.html"), advancedJSX: resolve(__dirname, "advanced-jsx/index.html"), }, }, }, });
-
-
package.json (new)
-
@@ -0,0 +1,62 @@{ "name": "@pocka/ef", "description": "Declarative DOM helpers.", "version": "1.0.0", "license": "Apache-2.0", "author": { "name": "Shota FUJI", "email": "pockawoooh@gmail.com" }, "type": "module", "main": "esm/es2019/ef.js", "types": "esm/es2019/ef.d.ts", "files": [ "esm", "docs/api.md", "docs/introduction.md", "docs/usage.md" ], "exports": { ".": "./esm/es2019/ef.js", "./async": "./esm/es2019/async.js", "./dom": "./esm/es2019/dom.js", "./setups": "./esm/es2019/setups.js", "./signals": "./esm/es2019/signals.js" }, "scripts": { "build": "tsc", "fmt": "prettier --write .", "examples": "vite examples", "examples:build": "vite build examples", "bench": "vite bench/browser", "prepack": "bun run build" }, "devDependencies": { "@fontsource/inter-tight": "^5.0.12", "@fontsource/jetbrains-mono": "^5.0.12", "@happy-dom/global-registrator": "^11.0.2", "@types/dom-navigation": "^1.0.1", "@types/hast": "^3.0.1", "@types/mdast": "^4.0.0", "@wooorm/starry-night": "^3.0.0", "brotli-wasm": "^2.0.0", "bun-types": "^1.0.1", "chalk": "^5.3.0", "commander": "^11.0.0", "esbuild": "^0.19.3", "hast-util-raw": "^9.0.1", "hast-util-to-html": "^9.0.0", "hast-util-to-string": "^3.0.0", "jszip": "^3.10.1", "lightningcss": "^1.22.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", "mdast-util-to-hast": "^13.0.2", "mdast-util-to-string": "^4.0.0", "mitata": "^0.1.6", "prettier": "^3.0.3", "typescript": "^5.2.2", "unist-util-find": "^3.0.0", "vite": "^4.4.9" } }
-
-
scripts/README.md (new)
-
@@ -0,0 +1,1 @@Files starting with underscore (e.g. `_filesize.ts`) are meant for internal helper/utility.
-
-
scripts/_filesize.ts (new)
-
@@ -0,0 +1,23 @@import { gzipSync } from "bun"; import chalk from "chalk"; export function bytesToString(bytes: number, color: boolean = false): string { // I hate common trend of using SI for byte sizes. It's not bit-shiftable. const kB = 1_000; const num = bytes >= kB ? (bytes / kB).toFixed(2) : bytes.toString(10); const unit = bytes >= kB ? "kB" : "B"; if (!color) { return num + unit; } return chalk.yellow(num) + chalk.cyan(unit); } export async function getGzipedSize( buf: ArrayBuffer | Uint8Array, ): Promise<number> { return gzipSync(buf instanceof Uint8Array ? buf : Buffer.from(buf)) .byteLength; }
-
-
scripts/bundle.ts (new)
-
@@ -0,0 +1,158 @@// This script bundles `esm/es2019/ef.js`, with variants. // The resulted files can be found at `dist/`. This command also copies // top-level `LICENSE` file into `dist/` for minified bundles. The result // files are archived and saved as `bundle.zip`. // // This script reads JS files tsc emits, as Bun bundler does not perform // any transform. import chalk from "chalk"; import { mkdir } from "node:fs/promises"; import { basename, relative } from "node:path"; import JsZip from "jszip"; import { bytesToString } from "./_filesize.js"; import { getNetworkFilesize } from "./print-filesize.js"; import { version } from "../package.json"; import HEADER from "../HEADER.txt"; const rootDir = new URL("../", import.meta.url); const outputDir = new URL("dist/", rootDir); const jsDir = new URL("esm/es2019/", rootDir); interface BundleOptions { minify?: boolean; header?: boolean; } export async function bundle({ minify = false, header = false, }: BundleOptions): Promise<Uint8Array> { // ESBuild can both transform and bundle in single step but unnecessarily changes // syntax too much. (e.g. `class Foo {}` -> `var Foo = class {}`) const result = await Bun.build({ entrypoints: [new URL("ef.js", jsDir).pathname], format: "esm", minify, }); const [file] = result.outputs; if (!file) { throw new Error("`Bun.build` returned zero-length array for `outputs`"); } if (result.outputs.length > 1) { throw new Error( "Expected a single JS file built, but recieved " + result.outputs.length + " files", ); } if (!header) { return new Uint8Array(await file.arrayBuffer()); } const enc = new TextEncoder(); return enc.encode(HEADER + (await file.text())); } function humanBytes(bytes: number): string { const label = bytesToString(bytes); if (bytes >= 10_000) { return chalk.bold(chalk.red(label)); } if (bytes >= 5_000) { return chalk.yellow(label); } return chalk.blue(label); } interface BundledFile { path: string; content: Uint8Array; } async function bundleAndWrite( outName: string, opts: BundleOptions, ): Promise<BundledFile> { console.error(chalk.gray(`Bundling "%s"...`), outName); const content = await bundle(opts); const outPath = relative(process.cwd(), new URL(outName, outputDir).pathname); console.error(chalk.gray(`Writing to "%s"...`), outPath); await Bun.write(outPath, content); const size = getNetworkFilesize(content); console.error( chalk.white(`%s: written %s (gzip: %s)`), outName, humanBytes(size.raw), humanBytes(size.gzip), ); return { path: outPath, content, }; } if (import.meta.main) { await mkdir(outputDir, { recursive: true }); const files: BundledFile[] = []; for (const minify of [true, false]) { for (const header of [true, false]) { const modifier = [minify ? "m" : "", header ? "h" : ""].join(""); files.push( await bundleAndWrite(`ef-${version}${modifier}.js`, { header, minify, }), ); } } const licenseSrc = new URL("LICENSE", rootDir); const licenseDst = new URL("LICENSE", outputDir); console.error( chalk.gray(`Copying "%s" to "%s"...`), relative(process.cwd(), licenseSrc.pathname), relative(process.cwd(), licenseDst.pathname), ); const licenseText = await Bun.file(licenseSrc); await Bun.write(licenseDst, licenseText); console.error(chalk.white("LICENSE: copied")); const zip = new JsZip(); const folder = zip.folder("ef")!; folder.file("LICENSE", await licenseText.arrayBuffer()); for (const file of files) { folder.file(basename(file.path), file.content); } const zipFile = await zip.generateAsync({ type: "uint8array" }); await Bun.write(new URL("bundle.zip", rootDir), zipFile); }
-
-
scripts/check-imports.ts (new)
-
@@ -0,0 +1,175 @@// This script checks `import`s in TypeScript files under `src/` have ".js" // file extension in its import path. In Node.js ESM, import path must have // a file extension, while TypeScript has been using extension-less import // even with Node.js ESM module setting. That means, runtime error happens // when importing an ESM module TypeScript deliberately emitted. // // (OK) import "./foo.js"; // (NG) import "./foo"; ... Runtime error, both in Node.js and basic file server. // (NG) import "./foo.ts"; ... ".ts" remains in emitted JS files. // (NG) require("./foo"); ... Enough. // (NG) import("./foo.js"); ... This package should be minimal set of functions. import { file, Transpiler } from "bun"; import chalk from "chalk"; import { readdirSync } from "node:fs"; import { relative } from "node:path"; export enum RuleViolationKind { ExtensionLessImport, CJS, DynamicImport, } export interface ImportRuleViolation { kind: RuleViolationKind; importPath: string; } export interface OffendingFile { url: URL; violations: readonly ImportRuleViolation[]; } export interface CheckResult { checkedFiles: number; offendingFiles: readonly OffendingFile[]; } async function checkFile( t: Transpiler, url: URL, ): Promise<readonly ImportRuleViolation[]> { const contents = await file(url).arrayBuffer(); const imports = t.scanImports(contents); return imports .map<ImportRuleViolation | null>((i) => { switch (i.kind) { case "dynamic-import": return { kind: RuleViolationKind.DynamicImport, importPath: i.path, }; case "require-call": case "require-resolve": return { kind: RuleViolationKind.CJS, importPath: i.path, }; case "import-statement": { if (!i.path.endsWith(".js")) { return { kind: RuleViolationKind.ExtensionLessImport, importPath: i.path, }; } return null; } default: { return null; } } }) .filter((v): v is ImportRuleViolation => v !== null); } async function checkDir(t: Transpiler, dir: URL): Promise<CheckResult> { let checkedFiles = 0; const offendingFiles: OffendingFile[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { const result = await checkDir(t, new URL(entry.name + "/", dir)); checkedFiles += result.checkedFiles; offendingFiles.push(...result.offendingFiles); continue; } if (!entry.isFile() || !entry.name.endsWith(".ts")) { continue; } checkedFiles += 1; const fileUrl = new URL(entry.name, dir); const violations = await checkFile(t, fileUrl); if (violations.length === 0) { continue; } offendingFiles.push({ url: fileUrl, violations, }); } return { checkedFiles, offendingFiles, }; } export async function checkImports(srcDir: URL): Promise<CheckResult> { const transpiler = new Transpiler({ loader: "ts", }); return checkDir(transpiler, srcDir); } function kindToLabel(kind: RuleViolationKind): string { switch (kind) { case RuleViolationKind.CJS: return "CommonJS import is not allowed."; case RuleViolationKind.DynamicImport: return "Dynamic import is not allowed."; case RuleViolationKind.ExtensionLessImport: return `Import path must end with ".js".`; } } if (import.meta.main) { const result = await checkImports(new URL("../src/", import.meta.url)); console.error( chalk.white(`Checked ${chalk.bold("%s")} files.`), result.checkedFiles, ); if (result.offendingFiles.length === 0) { console.error(chalk.white("No import rule violations found.")); process.exit(0); } console.error( chalk.red("%s have import rule violations."), result.offendingFiles.length > 1 ? result.offendingFiles.length + " files" : "1 file", ); for (const file of result.offendingFiles) { console.error( chalk.white(`[%s]`), relative(process.cwd(), file.url.pathname), ); for (const violation of file.violations) { console.error( chalk.white(` %s: %s`), chalk.yellow(`"${violation.importPath}"`), chalk.red(kindToLabel(violation.kind)), ); } } process.exit(1); }
-
-
-
@@ -0,0 +1,75 @@// This script reads given file and reports its filesize in raw bytes and gziped bytes. import { argv, file, inspect, gzipSync } from "bun"; import * as commander from "commander"; import { bytesToString } from "./_filesize.js"; // --- MODULE interface NetworkFilesize { raw: number; gzip: number; } export function getNetworkFilesize( input: ArrayBuffer | Uint8Array | string, ): NetworkFilesize { let buf: Uint8Array | Buffer; if (input instanceof Uint8Array) { buf = input; } else if (input instanceof ArrayBuffer) { buf = Buffer.from(input); } else if (typeof input === "string") { const enc = new TextEncoder(); buf = enc.encode(input); } else { throw new Error( `First argument of the getNetworkFilesize() must be one of ArrayBuffer, Uint8Array, or string. Got "` + inspect(input) + `".`, ); } return { raw: buf.byteLength, gzip: gzipSync(buf instanceof Uint8Array ? buf : Buffer.from(buf)) .byteLength, }; } export function printFilesize({ raw, gzip }: NetworkFilesize): void { console.log( "raw = %s, gzip = %s", bytesToString(raw, true), bytesToString(gzip, true), ); } // --- CLI interface Options { json?: boolean; } async function run(filepath: string, { json = false }: Options) { const target = file(filepath); const size = getNetworkFilesize(await target.arrayBuffer()); if (json) { console.log(JSON.stringify(size)); return; } printFilesize(size); } if (import.meta.main) { const program = commander.program .option("-J, --json") .argument("<path>", "File to analyze") .action(run); await program.parseAsync(argv); }
-
-
scripts/tsconfig.json (new)
-
@@ -0,0 +1,17 @@{ "extends": "../tsconfig.json", "compilerOptions": { "types": ["bun-types"], "noEmit": true, "declaration": false, "skipLibCheck": true, "moduleDetection": "force", "lib": ["ESNext"], "jsx": "react-jsx", "module": "ESNext", "target": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true }, "include": ["./**/*.ts"] }
-
-
scripts/website/_docs.ts (new)
-
@@ -0,0 +1,40 @@import { readdir } from "node:fs/promises"; interface Entry { url: URL; name: string; } export async function listEntries( dir: URL, root: URL = dir, ): Promise<readonly Entry[]> { const entries = await readdir(dir, { withFileTypes: true, }); return Promise.all( entries.map(async (entry) => { if (entry.isDirectory()) { return listEntries(new URL(entry.name + "/", dir), root); } if (!entry.isFile()) { return []; } const url = new URL(entry.name, dir); if (url.pathname.indexOf(root.pathname) !== 0) { throw new Error("Document outside docs root dir: " + url.pathname); } return [ { url, name: url.pathname.slice(root.pathname.length).replace(/\.md$/, ""), }, ]; }), ).then((arr) => arr.flat()); }
-
-
-
@@ -0,0 +1,80 @@import { file } from "bun"; import * as esbuild from "esbuild"; import { readdir } from "node:fs/promises"; export interface ThirdPartyInfo { licenseText: string; thirdPartyCounts: number; includedLicenseFileCounts: number; } export async function buildThirdPartyLicense( meta: esbuild.Metafile, ): Promise<ThirdPartyInfo | null> { const nmPat = /^node_modules\/([^/]+)\/([^/]+)/; const libs = Object.values(meta.inputs) .flatMap((output) => output.imports) .map((mod) => { const match = mod.path.match(nmPat); if (!match) { return null; } const [, s1, s2] = match; if (s1.startsWith("@")) { return { scope: s1, name: s2, }; } return { scope: null, name: s1, }; }) .filter( (mod, i, mods): mod is NonNullable<typeof mod> => !!mod && mods.findIndex((x) => x?.name === mod.name && x.scope === mod.scope) === i, ); const nmPath = new URL("../../node_modules/", import.meta.url); const licensePat = /^license(\.txt)?$/i; // TODO: Also check for NOTICE file const licenseFiles = await Promise.all( libs.map(async (pkg) => { const packagePath = new URL( pkg.scope ? `${pkg.scope}/${pkg.name}/` : pkg.name + "/", nmPath, ); for (const entry of await readdir(packagePath, { withFileTypes: true })) { if (entry.isFile() && licensePat.test(entry.name)) { return file(new URL(entry.name, packagePath)).text(); } } return null; }), ); const deduped = licenseFiles.filter( (text, i, texts) => text && texts.indexOf(text) === i, ); if (!deduped.length) { return null; } return { licenseText: deduped.join("\n\n===\n\n"), thirdPartyCounts: libs.length, includedLicenseFileCounts: deduped.length, }; }
-
-
scripts/website/build.ts (new)
-
@@ -0,0 +1,252 @@// This script builds ef.js document website and outputs to `website/dist`. import { cssPlugin } from "../../website/plugins/css.js"; import { markdownPlugin } from "../../website/plugins/markdown.js"; // @ts-expect-error: brotli-wasm ships with broken manifest import brotliPromise from "brotli-wasm"; import { write, gzipSync } from "bun"; import chalk from "chalk"; import * as esbuild from "esbuild"; import { copyFile, mkdir, readdir, rm } from "node:fs/promises"; import { dirname, relative } from "node:path"; import { bytesToString } from "../_filesize.js"; import { bundle } from "../bundle.js"; import { getNetworkFilesize } from "../print-filesize.js"; import { listEntries } from "./_docs.js"; import { buildThirdPartyLicense, type ThirdPartyInfo } from "./_license.js"; import { page, styles } from "../../website/html.js"; import { type LibBundleEntry } from "../../website/route.js"; import { version } from "../../package.json"; const brotli = await brotliPromise; const websiteDir = new URL("../../website/", import.meta.url); const destDir = new URL("dist/", websiteDir); const docsDir = new URL("../../docs/", import.meta.url); // --- Prep await rm(destDir, { force: true, recursive: true, }); await mkdir(destDir, { recursive: true }); const cwd = process.cwd(); // --- Copy assets const assetsDir = new URL("assets/", websiteDir); const files = await readdir(assetsDir, { withFileTypes: true, }); for (const file of files) { if (!file.isFile()) { continue; } const srcPath = new URL(file.name, assetsDir); const outPath = new URL(file.name, destDir); console.error( chalk.gray(`Copying asset "%s" to "%s"...`), relative(cwd, srcPath.pathname), relative(cwd, outPath.pathname), ); await copyFile(srcPath, outPath); console.error(chalk.white(`Copied to "%s"`), relative(cwd, outPath.pathname)); } // --- Bundle lib declare global { var libBundles: LibBundleEntry[]; } if (!globalThis.libBundles) { const matrix: readonly Required<Parameters<typeof bundle>[0]>[] = [ { header: true, minify: true }, { header: true, minify: false }, { header: false, minify: true }, { header: false, minify: false }, ]; globalThis.libBundles = await Promise.all( matrix.map(async ({ header, minify }) => { const content = await bundle({ header, minify }); return { header, minified: minify, content, size: { raw: content.byteLength, gzip: gzipSync(content).byteLength, brotli: brotli.compress(content).byteLength, }, }; }), ); } // Needs to write everytime because this script deletes /dist on every run. await copyFile( new URL("../../bundle.zip", import.meta.url), new URL("ef.zip", destDir), ); // --- Bundle main JS declare global { var bundler: esbuild.BuildContext; } const entrypoint = new URL("client.ts", websiteDir); globalThis.bundler ??= await esbuild.context({ entryPoints: [entrypoint.pathname], bundle: true, format: "esm", target: "es2019", outdir: destDir.pathname, loader: { ".woff": "file", ".woff2": "file", }, plugins: [cssPlugin, markdownPlugin], tsconfig: new URL("tsconfig.json", websiteDir).pathname, splitting: true, write: false, minify: true, legalComments: "none", metafile: true, define: { __VERSION__: JSON.stringify(version), __BUNDLE_INFO__: JSON.stringify(globalThis.libBundles), }, }); console.error( chalk.gray(`Bundling client JS code from "%s"...`), relative(cwd, entrypoint.pathname), ); const result = await globalThis.bundler.rebuild(); if (result.errors.length > 0) { console.error(chalk.red(`Build aborted due to bundle error.`)); process.exit(1); } for (const output of result.outputFiles || []) { await write(output.path, output.contents); const size = getNetworkFilesize(output.contents); console.error( chalk.white(`Built "%s" (raw: %s, gzip: %s)`), relative(cwd, output.path), bytesToString(size.raw, true), bytesToString(size.gzip, true), ); } // --- Generate third-party license file declare global { var thirdPartyInfo: ThirdPartyInfo | undefined | null; } // Skip generating third-party-license.txt on hot-reloading, as its purpose is // for distribution. The file is essentially meaningless in development. if (!globalThis.thirdPartyInfo && result.metafile) { console.error(chalk.gray(`Generating third-party licenses file...`)); globalThis.thirdPartyInfo = await buildThirdPartyLicense(result.metafile); } if (globalThis.thirdPartyInfo) { const filepath = new URL("third-party-license.txt", destDir); await write(filepath, globalThis.thirdPartyInfo.licenseText); console.error( chalk.white( `Wrote "%s" (%s external packages found, %s license files included)`, ), relative(cwd, filepath.pathname), chalk.yellow(globalThis.thirdPartyInfo.thirdPartyCounts), chalk.yellow(globalThis.thirdPartyInfo.includedLicenseFileCounts), ); } // --- Bundle main CSS const globalCssDest = new URL("styles.css", destDir); console.error( chalk.gray(`Writing "%s" to "%s"...`), relative(cwd, new URL("styles.css", websiteDir).pathname), relative(cwd, globalCssDest.pathname), ); await write(globalCssDest, styles); const globalCssSize = getNetworkFilesize(styles); console.error( chalk.white(`Wrote "%s" (raw: %s, gzip: %s)`), relative(cwd, globalCssDest.pathname), bytesToString(globalCssSize.raw, true), bytesToString(globalCssSize.gzip, true), ); // --- Build HTML files const docs = await listEntries(docsDir); const paths: readonly (readonly [string, string])[] = [ ["/", "index.html"], ["/download/", "download/index.html"], ["/404", "404.html"], ...docs.map((entry) => { return [`/docs/${entry.name}/`, `docs/${entry.name}/index.html`] as const; }), ]; for (const [path, filename] of paths) { const outPath = relative(cwd, new URL(filename, destDir).pathname); console.error(chalk.gray(`Generating route "%s" at "%s"...`), path, outPath); const html = await page(path, { version, bundle: globalThis.libBundles, }); await mkdir(dirname(outPath), { recursive: true }); await write(outPath, html); const size = getNetworkFilesize(html); console.error( chalk.white(`Built "%s" (raw: %s, gzip: %s)`), outPath, bytesToString(size.raw, true), bytesToString(size.gzip, true), ); } // --- fin. // Bun currently does not provide a way to detect `--hot`. // Assuming this script is running without `--hot`. if (import.meta.main) { globalThis.bundler.dispose(); }
-
-
scripts/website/serve.ts (new)
-
@@ -0,0 +1,64 @@// This script builds ef.js document website and serve it. import { serve, type Server, file } from "bun"; import chalk from "chalk"; import "./build"; const serveDir = new URL("../../website/dist/", import.meta.url); function convertReqPathToFsPath(path: string): string { const rel = path.replace(/^\//, ""); if (!rel || /\/$/.test(rel)) { return rel + "index.html"; } return rel; } function startServer() { const server = serve({ async fetch(req) { if (req.method !== "GET") { return new Response(null, { status: 405 }); } const url = new URL(req.url); const path = new URL(convertReqPathToFsPath(url.pathname), serveDir); if (path.pathname.indexOf(serveDir.pathname) !== 0) { return new Response(null, { status: 403 }); } const f = file(path); if (!(await f.exists())) { return new Response(file(new URL("404.html", serveDir)), { status: 404, }); } return new Response(f); }, port: process.env.PORT || 3000, hostname: process.env.HOST || undefined, }); const displayUrl = new URL("http://example.com"); displayUrl.port = server.port.toString(10); displayUrl.hostname = server.hostname; console.error( chalk.white(`Started a web server at %s`), chalk.blue(displayUrl.href), ); return server; } declare global { var server: Server; } globalThis.server ??= startServer();
-
-
src/async.ts (new)
-
@@ -0,0 +1,78 @@import { type Computation, effect, type ReactiveValue, signal, } from "./signals.js"; export interface Pending { isSettled: false; isRejected: false; isResolved: false; } const pending: Pending = { isRejected: false, isResolved: false, isSettled: false, }; export interface Resolved<T> { isSettled: true; isRejected: false; isResolved: true; data: T; } export interface Rejected<E> { isSettled: true; isRejected: true; isResolved: false; error: E; } export type PromiseSnapshot<T, E> = Pending | Resolved<T> | Rejected<E>; export function asyncDerived<T, E = unknown>( f: (ctx: Computation, abort: AbortSignal) => Promise<T>, ): ReactiveValue<PromiseSnapshot<T, E>> { const $s = signal<PromiseSnapshot<T, E>>(pending); effect((ctx) => { const abortController = new AbortController(); $s.set(pending); f(ctx, abortController.signal) .then((data) => { if (abortController.signal.aborted) { return; } $s.set({ isRejected: false, isResolved: true, isSettled: true, data, }); }) .catch((error) => { if (abortController.signal.aborted) { return; } $s.set({ isRejected: true, isResolved: false, isSettled: true, error, }); }); return () => { abortController.abort(); }; }); return $s; }
-
-
src/dom.ts (new)
-
@@ -0,0 +1,279 @@// This module contains bare minimum DOM helper functions with Signal support. // This module should only contain fundamental building block. // This module should not have convenient helpers or opinionated ones. import { effect, isReactive, type ReactiveOrStatic } from "./signals.js"; export type ElementSetup<T extends Element> = (el: T) => void; // (internal) For code deduplication (candidate for inlining) function setAttribute( el: Element, name: string, value: string | boolean, ): void { if (typeof value === "string") { el.setAttribute(name, value); return; } if (value) { el.setAttribute(name, ""); return; } el.removeAttribute(name); } /** * Sets an attribute. * When the value is `false`, removes the attribute. */ export function attr<T extends Element>( name: string, value: ReactiveOrStatic<string | boolean>, ): ElementSetup<T> { return (el) => { if (isReactive(value)) { effect(() => { setAttribute(el, name, value.get()); }); return; } setAttribute(el, name, value); }; } /** * Sets an element property. */ export function prop<T extends Element, K extends keyof T = keyof T>( name: K, value: ReactiveOrStatic<T[K]>, ): ElementSetup<T> { return (el) => { if (isReactive(value)) { effect(() => { el[name] = value.get(); }); return; } el[name] = value; }; } /** * Adds an event listener for the element. */ export function on<T extends Element, E extends Event>( eventName: string, listener: ReactiveOrStatic<(event: E) => void>, options?: AddEventListenerOptions, ): ElementSetup<T> { return (el) => { if (isReactive(listener)) { effect(() => { const f = listener.get(); // @ts-expect-error: TypeScript incorrectly models event target things. el.addEventListener(eventName, f, options); return () => { // @ts-expect-error: TypeScript incorrectly models event target things. el.removeEventListener(eventName, f, options); }; }); return; } // @ts-expect-error: TypeScript incorrectly models event target things. el.addEventListener(eventName, listener, options); }; } /** * A set of types ef accept for node children. * * - `Node` ... Appended as-is. * - `string` ... Appended as `Text` (DOM text node). * - `null` ... Do nothing (skip). * - `undefined` ... Do nothing (skip). */ export type NodeChild = Node | string | null | undefined; // (internal) Append children to a Node (parent). function adapt( node: Node, children: readonly ReactiveOrStatic<NodeChild>[], ): void { for (const child of children) { if (isReactive(child)) { const start = document.createTextNode(""); const end = document.createTextNode(""); node.appendChild(start); node.appendChild(end); effect(() => { // Due to the DocumentFragment disappears from Node Tree once appended, // we need to use `.parentNode` because `node` does not exist in the tree. // What a mess. const parent = start.parentNode || node; let oldNode: Node | null = null; if (start.nextSibling && start.nextSibling !== end) { oldNode = start.nextSibling; // Remove excess (= nodes between `oldNode` and `end`). // Cleanup approach cannot be used because how DocumentFragment works: // its children is appended to the parent and the node itself disappears. while (oldNode.nextSibling && oldNode.nextSibling !== end) { parent.removeChild(oldNode.nextSibling); } } const c = child.get(); if (c === null || typeof c === "undefined") { if (oldNode) { parent.removeChild(oldNode); } return; } // TextNode value rewrite shortcut if (oldNode && oldNode instanceof Text && typeof c === "string") { oldNode.nodeValue = c; return; } const newNode = typeof c === "string" ? document.createTextNode(c) : c; if (oldNode) { // Use `replaceChild` for easy performance boost parent.replaceChild(newNode, oldNode); } else { parent.insertBefore(newNode, end); } }); continue; } if (child === null || typeof child === "undefined") { continue; } node.appendChild( typeof child === "string" ? document.createTextNode(child) : child, ); } } /** * Create a DocumentFragment node. */ export function fragment( children: readonly ReactiveOrStatic<NodeChild>[], ): DocumentFragment { const f = document.createDocumentFragment(); adapt(f, children); return f; } /** * Create an HTMLElement element. */ export function el<TagName extends keyof HTMLElementTagNameMap>( tagName: TagName, setups?: readonly ElementSetup<HTMLElementTagNameMap[TagName]>[], children?: readonly ReactiveOrStatic<NodeChild>[], ): HTMLElementTagNameMap[TagName]; export function el<T extends Element>( tagName: string, setups?: readonly ElementSetup<T>[], children?: readonly ReactiveOrStatic<NodeChild>[], ): T; export function el<TagName extends keyof HTMLElementTagNameMap>( tagName: TagName, setups: readonly ElementSetup<HTMLElementTagNameMap[TagName]>[] = [], children: readonly ReactiveOrStatic<NodeChild>[] = [], ): HTMLElementTagNameMap[TagName] { const e = document.createElement(tagName); for (const f of setups) { f(e); } adapt(e, children); return e; } /** * Create a SVGElement element. * * You don't need to set `xmlns` attribute for elements created by this function. * Namespace is configured on element creation. */ export function svg<TagName extends keyof SVGElementTagNameMap>( tagName: TagName, setups?: readonly ElementSetup<SVGElementTagNameMap[TagName]>[], children?: readonly ReactiveOrStatic<NodeChild>[], ): SVGElementTagNameMap[TagName]; export function svg<T extends SVGElement>( tagName: string, setups?: readonly ElementSetup<T>[], children?: readonly ReactiveOrStatic<NodeChild>[], ): T; export function svg<TagName extends keyof SVGElementTagNameMap>( tagName: TagName, setups: readonly ElementSetup<SVGElementTagNameMap[TagName]>[] = [], children: readonly ReactiveOrStatic<NodeChild>[] = [], ): SVGElementTagNameMap[TagName] { const e = document.createElementNS("http://www.w3.org/2000/svg", tagName); for (const f of setups) { f(e); } adapt(e, children); return e; } /** * Create a MathMLElement element. */ export function mathml<TagName extends keyof MathMLElementTagNameMap>( tagName: TagName, setups?: readonly ElementSetup<MathMLElementTagNameMap[TagName]>[], children?: readonly ReactiveOrStatic<NodeChild>[], ): MathMLElementTagNameMap[TagName]; export function mathml<T extends MathMLElement>( tagName: string, setups?: readonly ElementSetup<T>[], children?: readonly ReactiveOrStatic<NodeChild>[], ): T; export function mathml<TagName extends keyof MathMLElementTagNameMap>( tagName: TagName, setups: readonly ElementSetup<MathMLElementTagNameMap[TagName]>[] = [], children: readonly ReactiveOrStatic<NodeChild>[] = [], ): MathMLElementTagNameMap[TagName] { const e = document.createElementNS( "http://www.w3.org/1998/Math/MathML", tagName, ); for (const f of setups) { f(e); } adapt(e, children); return e; }
-
-
src/ef.ts (new)
-
@@ -0,0 +1,4 @@export * from "./signals.js"; export * from "./dom.js"; export * from "./setups.js"; export * from "./async.js";
-
-
src/setups.ts (new)
-
@@ -0,0 +1,57 @@// This module contains convenient helper functions return ElementSetup. import { type ElementSetup } from "./dom.js"; import { effect, isReactive, type ReactiveOrStatic } from "./signals.js"; export function classList<T extends Element>( ...values: readonly ReactiveOrStatic<string | null>[] ): ElementSetup<T> { return (el) => { for (const value of values) { if (isReactive(value)) { effect(() => { const v = value.get(); if (v) { el.classList.add(v); return () => { el.classList.remove(v); }; } }); continue; } if (value) { el.classList.add(value); } } }; } export function style<T extends HTMLElement | SVGElement>( name: string, value: ReactiveOrStatic<string | null>, ): ElementSetup<T> { return (el) => { if (isReactive(value)) { effect(() => { const v = value.get(); if (v === null) { el.style.removeProperty(name); return; } el.style.setProperty(name, v); }); return; } if (value === null) { el.style.removeProperty(name); return; } el.style.setProperty(name, value); }; }
-
-
src/signals.ts (new)
-
@@ -0,0 +1,475 @@// This module contains Signal, Derived, and Effect implementation. // This module should not contain any DOM API or Web API. // This module should work on any JS engine that supports ES2019 or later. // -- SYMBOLS // This file uses Symbol as a method and property names in order // to archive module-level private methods/properties. Symbol-as-a-private-prop // also has an additional benefit over ES Private Class Fields: JS code emitted // by tsc does not include ugly polyfill using `WeakMap` and helper functions. const CHILDREN = Symbol(); const RECIEVE_UPDATE = Symbol(); const DEPENDENCIES = Symbol(); const DEPENDANTS = Symbol(); const IS_DISPOSED = Symbol(); const VALUE = Symbol(); const COMPUTE_FN = Symbol(); const COMPUTE = Symbol(); const EFFECT_FN = Symbol(); const RUN_EFFECT = Symbol(); const CLEANUP_FN = Symbol(); // -- INTERFACES /** * Object with manual lifetime management, in order to clear up references * so GC can collect as many. */ export interface Disposable { /** * Computation skip flag. */ [IS_DISPOSED]: boolean; /** * Dispose (destroy) this object. * * Although you can still access this object's properties after disposal, * updates to this object does not have any effect. */ dispose(): void; } /** * Computation is a computation function associated to >= 0 Reactives. * A computation function of a Computation got re-invoked everytime */ export interface Computation extends Disposable { /** * A set of child Signal/Derived/Effect created inside the computation * function of this Computation. This is different to dependency graph used for * reactive update notification. The main purpose of this data is to dispose * those children when the computation function of this Computation is about to * be invoked. */ [CHILDREN]: Set<Disposable>; [DEPENDENCIES]: Set<ReactiveValue<any>>; /** * Re-run a computation function of this Computation. */ [RECIEVE_UPDATE](effectQueue: Set<Effect>): void; } /** * ReactiveValue is an object that holds a value and be able to notify the changes * to the value to the ReactiveValue's dependants. */ export interface ReactiveValue<T> { /** * A set of Disposable Computation that subscribing to this ReactiveValue's changes. */ [DEPENDANTS]: Set<Computation>; /** * Get the current value of this ReactiveValue. * * This method associates this ReactiveValue (Signal or Derived) to * surrounding Computation (Effect or Derived) in order to re-run Computation * when this ReactiveValue changed. * * @param context - Optinal Computation object. If this presents, the ReactiveValue * associates to this Computation instead of the surrounding one. */ get(context?: Computation): T; /** * (as a getter) An alias for `get` method. */ get value(): T; /** * Get the current value of this ReactiveValue. * * In contrast to `.get()`, this method does not associate to any Computation. */ once(): T; } // -- GLOBALS // In order to archieve implicit dependency management, some amount of // global reference is unavoidable, unfortunately. // Global stack of Computation. // The purpose of this reference is to automatically track Computation -> ReactiveValue // dependencies. When ReactiveValue's value gets accessed, the current Computation // registers the ReactiveValue as a dependency. (technically, a ReactiveValue // registers the Computation as the ReactiveValue's dependant, but they're basically same) let currentComputation: Computation | null = null; // -- CLASSES // This module extensivly uses classes. While I prefer FP, class is suitable // for this usecase than plain object or black magic function. // * JS's language construct // * Familiar to JS devs // * Runtime check using `instanceof` // * Java/C# guys are happy also (idk) export class Signal<T> implements Disposable, ReactiveValue<T> { /** * Current value of the Signal. */ [VALUE]: T; [DEPENDANTS]: ReactiveValue<T>[typeof DEPENDANTS]; [IS_DISPOSED]: boolean; constructor(value: T) { this[VALUE] = value; this[DEPENDANTS] = new Set(); this[IS_DISPOSED] = false; if (currentComputation) { currentComputation[CHILDREN].add(this); } } once() { return this[VALUE]; } get(context?: Computation) { const ctx = context || currentComputation; if (ctx && !ctx[IS_DISPOSED]) { this[DEPENDANTS].add(ctx); ctx[DEPENDENCIES].add(this); } return this[VALUE]; } /** * (as a getter) An alias for `get` method. */ get value() { return this.get(); } /** * Change a value of the Signal to `value`. * This triggers re-run of every associated Deriveds and Effects. */ set(value: T) { if (this[IS_DISPOSED] || value === this[VALUE]) { return; } this[VALUE] = value; // Without this queue, same effect runs multiple times due to computations // also cause effect update. For example, // // ```ts // const $s = signal(1); // const $c = derived(() => $s.get()); // effect(() => { // $s.get(); // $c.get(); // }); // $s.set(2); // ``` // // runs the effect three times: initial, update for $s, and update for $c. const effectQueue = new Set<Effect>(); // Needs to clone the DEPENDANTS, because RECIEVE_UPDATEs modifies the Set. // Without cloning, infinite recursion happens. for (const d of Array.from(this[DEPENDANTS])) { d[RECIEVE_UPDATE](effectQueue); } for (const effect of effectQueue) { effect[RUN_EFFECT](); } effectQueue.clear(); } /** * (as a setter) An alias for `set` method. */ set value(value: T) { this.set(value); } /** * Change a value of the Signal based on the current value. * This triggers re-run of every associated Deriveds and Effects. * * The first argument of the function is a current value of the Signal. */ update(f: (currentValue: T) => T) { this.set(f(this[VALUE])); } dispose() { if (this[IS_DISPOSED]) { return; } this[IS_DISPOSED] = true; for (const d of this[DEPENDANTS]) { d[DEPENDENCIES].delete(this); } this[DEPENDANTS].clear(); } } export class Derived<T> implements Disposable, Computation, ReactiveValue<T> { [COMPUTE_FN]: () => T; [DEPENDANTS]: ReactiveValue<T>[typeof DEPENDANTS]; [DEPENDENCIES]: Computation[typeof DEPENDENCIES]; [CHILDREN]: Computation[typeof CHILDREN]; [IS_DISPOSED]: boolean; [VALUE]: T; constructor(fn: () => T) { this[COMPUTE_FN] = fn; this[DEPENDANTS] = new Set(); this[CHILDREN] = new Set(); this[DEPENDENCIES] = new Set(); this[IS_DISPOSED] = false; this[VALUE] = this[COMPUTE](); if (currentComputation) { currentComputation[CHILDREN].add(this); } } [RECIEVE_UPDATE](effectQueue: Set<Effect>) { if (this[IS_DISPOSED]) { return; } for (const d of this[DEPENDENCIES]) { d[DEPENDANTS].delete(this); } this[DEPENDENCIES].clear(); for (const s of this[CHILDREN]) { s.dispose(); } this[CHILDREN].clear(); const value = this[COMPUTE](); if (value === this[VALUE]) { return; } this[VALUE] = value; // Needs to clone the DEPENDANTS, because RECIEVE_UPDATEs modifies the Set. // Without cloning, infinite recursion happens. for (const d of Array.from(this[DEPENDANTS])) { d[RECIEVE_UPDATE](effectQueue); } } [COMPUTE](): T { const savedComputation = currentComputation; currentComputation = this; try { return this[COMPUTE_FN](); } finally { currentComputation = savedComputation; } } once() { return this[VALUE]; } get(context?: Computation) { const ctx = context || currentComputation; if (ctx) { this[DEPENDANTS].add(ctx); ctx[DEPENDENCIES].add(this); } return this[VALUE]; } get value() { return this.get(); } dispose() { if (this[IS_DISPOSED]) { return; } this[IS_DISPOSED] = true; this[DEPENDANTS].clear(); for (const d of this[DEPENDENCIES]) { d[DEPENDANTS].delete(this); } this[DEPENDENCIES].clear(); for (const s of this[CHILDREN]) { s.dispose(); } this[CHILDREN].clear(); } } export type EffectFunction = (context: Computation) => void | (() => void); export class Effect implements Disposable, Computation { [EFFECT_FN]: EffectFunction; [CHILDREN]: Computation[typeof CHILDREN]; [DEPENDENCIES]: Computation[typeof DEPENDENCIES]; [IS_DISPOSED]: boolean; [CLEANUP_FN]: (() => void) | null; constructor(f: EffectFunction) { this[CHILDREN] = new Set(); this[DEPENDENCIES] = new Set(); this[EFFECT_FN] = f; this[IS_DISPOSED] = false; this[CLEANUP_FN] = null; if (currentComputation) { currentComputation[CHILDREN].add(this); } // For initial run, it's safe to run the Effect without scheduling // because it's impossible to run the same Effect twice from user code. this[RUN_EFFECT](); } [RECIEVE_UPDATE](effectQueue: Set<Effect>) { if (this[IS_DISPOSED]) { return; } for (const d of this[DEPENDENCIES]) { d[DEPENDANTS].delete(this); } this[DEPENDENCIES].clear(); for (const d of this[CHILDREN]) { d.dispose(); } this[CHILDREN].clear(); effectQueue.add(this); } [RUN_EFFECT]() { if (this[IS_DISPOSED]) { return; } // Run cleanup functions before updating if (this[CLEANUP_FN]) { try { this[CLEANUP_FN](); } finally { this[CLEANUP_FN] = null; } } const savedComputation = currentComputation; try { currentComputation = this; this[CLEANUP_FN] = this[EFFECT_FN](this) || null; } finally { currentComputation = savedComputation; } } dispose() { if (this[IS_DISPOSED]) { return; } this[IS_DISPOSED] = true; if (this[CLEANUP_FN]) { try { this[CLEANUP_FN](); } finally { this[CLEANUP_FN] = null; } } for (const d of this[DEPENDENCIES]) { d[DEPENDANTS].delete(this); } this[DEPENDENCIES].clear(); for (const s of this[CHILDREN]) { s.dispose(); } this[CHILDREN].clear(); } } // -- PUBLIC FUNCTIONS // These are dumb functions just call class constructor. // It's up to the user which style (class or function) to use. /** * Creates a Signal. * @param value - Initial value of the Signal. */ export function signal<T>(value: T): Signal<T> { return new Signal(value); } /** * Creates a Derived based on zero or more other Signals. * You can think of Derived as a read-only Signal, whose * value is derived from other Signals. * @param f - Callback function that returns a value of the Derived. * Everytime one of the Signal inside the callback gets updated, * this callback function would be invoked too. */ export function derived<T>(f: () => T): Derived<T> { return new Derived(f); } /** * Creates an Effect. */ export function effect(f: EffectFunction): Effect { return new Effect(f); } // -- HELPERS /** * Convenient helper type for creating "A, or Signal/Derived whose value is A". */ export type ReactiveOrStatic<T> = T | ReactiveValue<T>; /** * Narrows down `ReactiveOrStatic` to `ReactiveValue` (Signal or Derived). */ export function isReactive<T>(x: ReactiveOrStatic<T>): x is ReactiveValue<T> { return x instanceof Signal || x instanceof Derived; }
-
-
tests/async.test.ts (new)
-
@@ -0,0 +1,96 @@import { describe, expect, mock, test } from "bun:test"; import { asyncDerived } from "../src/async.js"; import { signal } from "../src/signals.js"; function sleep(ms: number): Promise<void> { return new Promise((resolve) => { setTimeout(() => { resolve(); }, ms); }); } describe("asyncDerived", () => { test("should return pending state as an initial state", () => { const $c = asyncDerived(() => { return new Promise(() => {}); }); expect($c.get().isSettled).toBe(false); }); test("should change to resolved state when the promise is resolved", async () => { const p = new Promise<void>((resolve) => { resolve(); }); const $c = asyncDerived(() => { return p; }); await p; expect($c.get().isResolved).toBe(true); }); test("should change to rejected state when the promise is rejected", async () => { const p = new Promise<void>((_, reject) => { reject(new Error("Sample Error")); }); const $c = asyncDerived(() => { return p; }); await p.catch(() => {}); expect($c.get().isRejected).toBe(true); }); test("should re-run when the inner dependencies got update", async () => { const $s = signal(1); const $c = asyncDerived(async (ctx) => { return $s.get(ctx) + 1; }); await sleep(1); expect($c.get().isResolved).toBe(true); expect(($c.get() as any).data).toBe(2); $s.set(2); await sleep(1); expect($c.get().isResolved).toBe(true); expect(($c.get() as any).data).toBe(3); }); test("should notify abort via AbortSignal", async () => { const fn = mock((_n: number) => {}); const $s = signal(1); asyncDerived(async (ctx, abortSignal) => { const n = $s.get(ctx); abortSignal.addEventListener( "abort", () => { fn(n); }, { once: true }, ); return n + 1; }); $s.set(2); $s.set(3); await sleep(1); expect(fn.mock.calls).toEqual([[1], [2]]); }); });
-
-
tests/dom.test.ts (new)
-
@@ -0,0 +1,249 @@/// <reference lib="dom" /> import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { CustomEvent } from "happy-dom"; import { el, attr, fragment, mathml, on, prop, svg } from "../src/dom.js"; import { derived, signal } from "../src/signals.js"; beforeAll(() => { GlobalRegistrator.register(); }); afterAll(() => { GlobalRegistrator.unregister(); }); describe("el", () => { test("should create an HTMLElement element", () => { const p = el("p"); expect(p.tagName).toBe("P"); }); test("should run ElementSetups against the to-be created element", () => { const fn = mock((_n: number) => {}); el("div", [ () => { fn(0); }, () => { fn(1); }, ]); expect(fn.mock.calls).toEqual([[0], [1]]); }); test("should append child nodes", () => { const div = el("div", [], [el("p")]); const p = div.querySelector("p"); expect(p).toBeTruthy(); }); test("should convert strings to Text", () => { const p = el("p", [], ["Foo"]); expect(p.textContent).toBe("Foo"); }); test("should skip null or undefined", () => { const p = el("p", [], [el("span"), null, undefined, el("span")]); expect(p.childNodes.length).toBe(2); }); test("should react to Signal value changes", () => { const span = el("span"); const $child = signal<HTMLElement | null>(span); const p = el("p", [], [$child]); expect(p.querySelector("span")).toBe(span); $child.set(null); expect(p.querySelector("span")).toBe(null); $child.set(span); expect(p.querySelector("span")).toBe(span); }); }); describe("attr", () => { test("should set attribute", () => { const div = el("div", [attr("aria-hidden", "true")]); expect(div.getAttribute("aria-hidden")).toBe("true"); }); test("should react to change of Signal values", () => { const $s = signal("foo"); const button = el("button", [attr("data-name", $s)]); expect(button.dataset.name).toBe("foo"); $s.set("bar"); expect(button.dataset.name).toBe("bar"); }); test("should handle boolean value", () => { const $s = signal(true); const input = el("input", [attr("disabled", $s)]); expect(input.getAttribute("disabled")).toBe(""); $s.set(false); expect(input.getAttribute("disabled")).toBe(null); }); }); describe("prop", () => { test("should set property", () => { const input = el("input", [prop("readOnly", true)]); expect(input.readOnly).toBe(true); }); test("should react to Signal value changes", () => { const $disabled = signal(false); const button = el("button", [prop("disabled", $disabled)]); expect(button.disabled).toBe(false); $disabled.set(true); expect(button.disabled).toBe(true); }); }); describe("on", () => { test("should set event listener", () => { const fn = mock(() => {}); const div = el("div", [ on("click", () => { fn(); }), ]); // @ts-expect-error: Using native event cause runtime error (https://github.com/capricorn86/happy-dom/issues/1049) div.dispatchEvent(new CustomEvent("click")); expect(fn.mock.calls.length).toBe(1); }); test("should dispose stale listener", () => { const fn = mock(() => {}); const $s = signal(0); const div = el("div", [ on( "click", derived(() => { // Associate this Derived to $s, so every time $s gets updated, // listener function will be renewed too. $s.get(); return () => { fn(); }; }), ), ]); $s.set(1); // @ts-expect-error: Using native event cause runtime error (https://github.com/capricorn86/happy-dom/issues/1049) div.dispatchEvent(new CustomEvent("click")); expect(fn.mock.calls.length).toBe(1); }); }); describe("fragment", () => { test("children update mechanism correctly handles DocumentFragment quirk", () => { const $visible = signal(true); const div = el( "div", [], [ derived(() => $visible.get() ? fragment([el("span"), el("span")]) : null, ), ], ); expect(div.querySelectorAll("> span").length).toBe(2); $visible.set(false); expect(div.querySelectorAll("> span").length).toBe(0); $visible.set(true); expect(div.querySelectorAll("> span").length).toBe(2); }); test("should react to value changes", () => { const $name = signal("Alice"); const div = el("p", [], [fragment(["Hello, ", $name, "!"])]); expect(div.textContent).toBe("Hello, Alice!"); $name.set("Bob"); expect(div.textContent).toBe("Hello, Bob!"); }); }); describe("svg", () => { test("should create a SVGElement", () => { const path = svg("path", [attr("d", "M0,0 L1,0 L0,1 Z")]); // NOTE: happy-dom does not implement SVGPathElement expect(path).toBeInstanceOf(SVGElement); }); }); describe("mathml", () => { test("should create a MathMLElement", () => { const fomula = mathml( "math", [attr("display", "inline")], [ mathml( "mfrac", [], [ mathml( "msup", [], [mathml("mi", [], ["Ï€"]), mathml("mn", [], ["2"])], ), mathml("mn", [], ["6"]), ], ), ], ); // NOTE: happy-dom does not implement MathML at all. expect(fomula).toBeInstanceOf(Element); expect( (fomula as Element).querySelector("> mfrac > msup > mn")?.textContent, ).toBe("2"); }); });
-
-
tests/setups.test.ts (new)
-
@@ -0,0 +1,80 @@import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { el } from "../src/dom.js"; import { classList, style } from "../src/setups.js"; import { signal } from "../src/signals.js"; beforeAll(() => { GlobalRegistrator.register(); }); afterAll(() => { GlobalRegistrator.unregister(); }); describe("classList", () => { test("should add a list of class to an element", () => { const div = el("div", [classList("foo", "bar", "baz")]); expect(div.getAttribute("class")).toBe("foo bar baz"); }); test("should cleanup previous class", () => { const $class = signal<string | null>("foo"); const div = el("div", [classList($class)]); expect(div.getAttribute("class")).toBe("foo"); $class.set("bar"); expect(div.getAttribute("class")).toBe("bar"); $class.set(null); expect(div.getAttribute("class")).toBeEmpty(); }); }); describe("style", () => { test("should set styles", () => { const div = el("div", [ style("display", "none"), style("background-color", "tomato"), ]); expect(div.style.display).toBe("none"); expect(div.style.backgroundColor).toBe("tomato"); }); test("should set custom properties", () => { const div = el("div", [ style("--foo", "color(display-p3 0.8 1 0.9 / 0.5)"), style("--_bar", "t, o, k, e, n"), style("--baz", null), ]); expect(div.style.getPropertyValue("--foo")).toBe( "color(display-p3 0.8 1 0.9 / 0.5)", ); expect(div.style.getPropertyValue("--_bar")).toBe("t, o, k, e, n"); expect(div.style.getPropertyValue("--baz")).toBeEmpty(); }); test("should react to Signal changes", () => { const $display = signal<string | null>("block"); const div = el("div", [style("display", $display)]); expect(div.style.display).toBe("block"); $display.set("none"); expect(div.style.display).toBe("none"); $display.set(null); expect(div.style.display).toBeEmpty(); }); });
-
-
tests/signals.test.ts (new)
-
@@ -0,0 +1,578 @@import { heapStats } from "bun:jsc"; import { describe, expect, mock, test } from "bun:test"; import { derived, effect, signal } from "../src/signals.js"; function sleep(ms: number): Promise<void> { return new Promise((resolve) => { setTimeout(() => { resolve(); }, ms); }); } describe("signal", () => { test("should be able to return containing value via `get`", () => { const $s = signal(1); expect($s.get()).toBe(1); }); test("`.value` alias should work", () => { const $s = signal(1); expect($s.value).toBe(1); $s.value = 2; expect($s.get()).toBe(2); }); test("should update containing value via `set`", () => { const $s = signal(1); $s.set(2); expect($s.get()).toBe(2); }); test("should skip updates when the value is same", () => { const fn = mock(() => {}); const $s = signal(1); effect(() => { $s.get(); fn(); }); $s.set(1); $s.set(1); expect(fn).toHaveBeenCalledTimes(1); }); test("`once` should not trigger re-run", () => { const fn = mock((_n: number) => {}); const $s = signal(1); effect(() => { const $c = derived(() => $s.once()); fn($c.get()); }); $s.set(2); expect(fn.mock.calls).toEqual([[1]]); }); test("`update` pass the current value as an argument", () => { const $s = signal(1); $s.update((n) => n + 1); expect($s.once()).toBe(2); }); test("should stop updating once disposed", () => { const fn = mock((_n: number) => {}); const $s = signal(1); effect(() => { fn($s.get()); }); $s.dispose(); $s.set(2); expect(fn.mock.calls).toEqual([[1]]); }); test("should be able to manually specify which Computation to associate", async () => { const fn = mock((_n: number) => {}); const $s = signal(0); effect((ctx) => { sleep(5).then(() => { // At the time this line, the Effect (ctx) is popped from global stack. fn($s.get(ctx)); }); }); $s.set(1); await sleep(6); $s.set(2); await sleep(6); expect(fn.mock.calls).toEqual([[1], [2]]); }); test("should work with immediate multiple writes", () => { const $s = signal(0); $s.set(1); $s.set(2); $s.set(3); expect($s.once()).toBe(3); $s.set(2); $s.set(1); $s.set(0); expect($s.once()).toBe(0); }); }); describe("derived", () => { test("should create a derived signal based on another signal", () => { const $s = signal(1); const $c = derived(() => $s.get() + 1); expect($c.get()).toBe(2); }); test("`.value` alias should work", () => { const $s = signal(1); const $c = derived(() => $s.get() + 1); expect($c.value).toBe(2); }); test("should be updated when dependent signal was updated", () => { const $s = signal(1); const $c = derived(() => $s.get() + 1); expect($c.get()).toBe(2); $s.set(2); expect($c.get()).toBe(3); }); test("should be updated when one of dependent signal got updated", () => { const $s1 = signal(1); const $s2 = signal(2); const $c = derived(() => $s1.get() + $s2.get()); expect($c.get()).toBe(3); $s1.set(10); expect($c.get()).toBe(12); }); test("should trigger effect re-run", () => { const fn = mock((_n: number) => {}); const $s = signal(1); const $c = derived(() => $s.get() + 1); effect(() => { fn($c.get()); }); $s.set(2); expect(fn.mock.calls).toEqual([[2], [3]]); }); test("should not react to unnecessary change", () => { const fn = mock((_v: unknown) => {}); const $s1 = signal(true); const $s2 = signal(1); effect(() => { const $c = derived(() => { return $s1.get() ? $s2.get() : null; }); effect(() => { fn($c.get()); }); }); $s1.set(false); $s2.set(2); expect(fn.mock.calls).toEqual([[1], [null]]); }); test("should update nested computation", () => { const fn = mock((_v: number) => {}); const $s = signal(1); const $c1 = derived(() => $s.get() + 1); const $c2 = derived(() => $c1.get() + 1); const $c3 = derived(() => $c2.get() + 1); effect(() => { fn($c3.get()); }); $s.set(2); expect(fn.mock.calls).toEqual([[4], [5]]); }); test("should stop reacting to dependants' changes once disposed", () => { const fn = mock((_n: number) => {}); const $s = signal(1); const $c = derived(() => { fn($s.get()); return 0; }); $c.get(); $c.dispose(); $s.set(2); expect(fn.mock.calls).toEqual([[1]]); }); test("should de-associate Signals", () => { const fn = mock(() => {}); const $s1 = signal(1); const $s2 = signal(10); const $s3 = signal(100); const $c = derived(() => { fn(); if ($s1.get() > 0) { return $s2.get(); } else { return $s3.get(); } }); expect($c.get()).toBe(10); $s1.set(0); expect($c.get()).toBe(100); $s2.set(20); expect($c.get()).toBe(100); expect(fn.mock.calls.length).toBe(2); }); test("should manually specify which Computation to associate", async () => { const fn = mock((_n: number) => {}); const $s = signal(0); const $c = derived(() => $s.get()); effect((ctx) => { sleep(5).then(() => { // At the time this line, the Effect (ctx) is popped from global stack. fn($c.get(ctx)); }); }); $s.set(1); await sleep(6); $s.set(2); await sleep(6); expect(fn.mock.calls).toEqual([[1], [2]]); }); test("should dispose child setups on update", () => { const fn = mock(() => {}); const $s = signal(0); const $c = derived(() => { effect(() => { return () => { fn(); }; }); return $s.get(); }); $s.set(1); expect(fn.mock.calls.length).toBe(1); $c.dispose(); expect(fn.mock.calls.length).toBe(2); }); }); describe("effect", () => { test("should run the callback function immediately", () => { const fn = mock(() => {}); effect(() => { fn(); }); expect(fn).toHaveBeenCalledTimes(1); }); test("should re-run the callback function when inner signal got updated", () => { const fn = mock((_n: number) => {}); const $s = signal(1); effect(() => { fn($s.get()); }); $s.set(2); $s.set(3); expect(fn.mock.calls).toEqual([[1], [2], [3]]); }); test("should not run same effect multiple times for same signal update", () => { const fn = mock((_n: number) => {}); const $s = signal(1); const $c = derived(() => $s.get()); effect(() => { $s.get(); fn($c.get()); }); $s.set(2); expect(fn.mock.calls).toEqual([[1], [2]]); }); test("should run effect without dupe", () => { const fn = mock((_n: number) => {}); const $s = signal(1); effect(() => { fn($s.get()); }); $s.set(2); $s.set(2); $s.set(1); $s.set(1); $s.set(2); $s.set(2); expect(fn.mock.calls).toEqual([[1], [2], [1], [2]]); }); test("should destroy signals and computations created inside the callback function", () => { const fn1 = mock((_n: number) => {}); const fn2 = mock((_n: number) => {}); const $s1 = signal(1); effect(() => { const $s2 = signal($s1.get() * 10); effect(() => { fn1($s2.get()); }); const $c = derived(() => { return $s1.get(); }); effect(() => { fn2($c.get()); }); }); $s1.set(2); expect(fn1.mock.calls).toEqual([[10], [20]]); expect(fn2.mock.calls).toEqual([[1], [2]]); }); test("should re-run callback only when scope item was updated", () => { const fn1 = mock(() => {}); const fn2 = mock(() => {}); const fn3 = mock((_v: unknown) => {}); const $s = signal(1); const $c = derived(() => $s.get()); effect(() => { fn1(); effect(() => { fn2(); $s.get(); effect(() => { fn3($c.get()); }); }); }); $s.set(2); expect(fn1).toHaveBeenCalledTimes(1); expect(fn2).toHaveBeenCalledTimes(2); expect(fn3.mock.calls).toEqual([[1], [2]]); }); test("should run cleanup function on dispose", () => { const fn = mock(() => {}); const e = effect(() => { return () => { fn(); }; }); expect(fn.mock.calls.length).toBe(0); e.dispose(); expect(fn.mock.calls.length).toBe(1); }); test("should run cleanup function on re-run", () => { const fn = mock(() => {}); const $s = signal(1); effect(() => { $s.get(); return () => { fn(); }; }); $s.set(2); expect(fn.mock.calls.length).toBe(1); }); test("should de-asocciate Signals", () => { const fn = mock(() => {}); const $s1 = signal(1); const $s2 = signal(10); const $s3 = signal(100); effect(() => { fn(); if ($s1.get() > 0) { $s2.get(); } else { $s3.get(); } }); $s1.set(0); $s2.set(20); expect(fn.mock.calls.length).toBe(2); }); }); // These test measures object counts instead of heap size, because it's hard to // define test thresholds (acceptable margin). describe("GC", () => { // Error margin for object counts const ERROR_MARGIN = 100; test("Signals should not retain references to stale computation closures", () => { Bun.gc(true); let p1: number; let p2: number; { const $s = signal(1); const $c = derived(() => $s.get().toString(16)); const eff = effect(() => { $c.get(); }); for (let i = 0; i < 10_000; i++) { $s.set(i); } Bun.gc(true); p1 = heapStats().objectCount; for (let i = 0; i < 10_000; i++) { $s.set(i); } Bun.gc(true); p2 = heapStats().objectCount; eff.dispose(); } Bun.gc(true); const p3 = heapStats().objectCount; // If stale references are held, memory usage would radically inrease. expect(p2).toBeLessThanOrEqual(p1 + ERROR_MARGIN); // If stale references are held, counts go over 10_000. expect(p3).toBeLessThanOrEqual(p1 + ERROR_MARGIN); expect(p3).toBeLessThanOrEqual(p2 + ERROR_MARGIN); }); test("Deriveds and Effects should free descendants", () => { Bun.gc(true); let p1: number; let p2: number; { const $s = signal(1); const $c = derived(() => { return signal($s.get().toString(16)).get(); }); const eff = effect(() => { derived(() => { return $c.get(); }).get(); }); for (let i = 0; i < 10_000; i++) { $s.set(i); } Bun.gc(true); p1 = heapStats().objectCount; for (let i = 0; i < 10_000; i++) { $s.set(i); } Bun.gc(true); p2 = heapStats().objectCount; eff.dispose(); } Bun.gc(true); const p3 = heapStats().objectCount; // If stale references are held, memory usage would radically inrease. expect(p2).toBeLessThanOrEqual(p1 + ERROR_MARGIN); // If stale references are held, counts go over 10_000. expect(p3).toBeLessThanOrEqual(p1 + ERROR_MARGIN); expect(p3).toBeLessThanOrEqual(p2 + ERROR_MARGIN); }); });
-
-
tests/tsconfig.json (new)
-
@@ -0,0 +1,11 @@{ "extends": "../tsconfig.json", "compilerOptions": { "types": ["bun-types"], "moduleResolution": "Bundler", "noEmit": true, "declaration": false, "skipLibCheck": true }, "include": ["./**/*.ts"] }
-
-
tsconfig.json (new)
-
@@ -0,0 +1,15 @@{ "compilerOptions": { "strict": true, "noUnusedLocals": true, "allowUnreachableCode": false, "skipLibCheck": true, "target": "ES2019", "lib": ["ES2019", "DOM", "ESNext"], "module": "ES2020", "moduleResolution": "Classic", "outDir": "esm/es2019", "declaration": true }, "include": ["src/**/*.ts"] }
-
-
website/.gitignore (new)
-
@@ -0,0 +1,1 @@/dist
-
-
website/app.module.css (new)
-
@@ -0,0 +1,265 @@.body { flex-grow: 1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, max-content) minmax(0, 1fr); grid-template-rows: minmax(max-content, 1fr) max-content; grid-template-areas: "nav main outline" "footer footer footer"; gap: var(--spacing-15); min-height: 0; max-width: 100%; } .sitenav { justify-self: end; grid-area: nav; } .outline { grid-area: outline; } .navButton, .navButtonWrapper { display: none; } .navInner { position: sticky; margin-top: var(--spacing-5); top: calc(var(--v-rhythm) * 1); } .main { grid-area: main; width: 100vw; max-width: calc(70ch + 2em); padding: var(--spacing-12) 1em; line-height: var(--v-rhythm); } .footer { grid-area: footer; } :where(.main) h1, :where(.main) h2, :where(.main) h3 { font-family: var(--font-family-heading); font-weight: bold; margin: var(--v-rhythm) 0; line-height: calc(var(--v-rhythm) * 2); } :where(.main) h1 { font-size: var(--font-8); margin-top: calc(var(--v-rhythm) * 3); } :where(.main) h2 { font-size: var(--font-7); margin-top: calc(var(--v-rhythm) * 3); } :where(.main) h3 { font-size: var(--font-6); margin-top: calc(var(--v-rhythm) * 2); } :where(.main) h1 > a, :where(.main) h2 > a, :where(.main) h3 > a, :where(.main) h4 > a, :where(.main) h5 > a, :where(.main) h6 > a { text-decoration: none; } :where(.main) h1 > a:hover, :where(.main) h2 > a:hover, :where(.main) h3 > a:hover, :where(.main) h4 > a:hover, :where(.main) h5 > a:hover, :where(.main) h6 > a:hover { text-decoration: underline; } :where(.main) p { font-size: var(--font-4); margin: var(--v-rhythm) 0; } :where(.main) em { font-style: italic; } :where(.main p) code, :where(.main ul) code, :where(.main pre) code { font-size: var(--font-3); padding: 0 0.5em; border: 1px solid var(--color-border); background-color: var(--color-panel-bg); border-radius: var(--radii); } :where(.main) pre { font-size: var(--font-3); padding: calc(var(--v-rhythm) * 0.5) 1em; margin: calc(var(--v-rhythm) * 0.5) -1em; border: 1px solid var(--color-border); background-color: var(--color-panel-bg); border-radius: var(--radii); overflow: auto; } :where(.main) pre > code { padding: 0; border: none; background-color: transparent; border-radius: 0; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .navOverlay { display: none; animation: 0.2s ease both fadeIn; } /* 600px = 16px * 37.5 */ @media (min-height: 37.5rem) and (min-width: 74.9376rem) { .navInner { margin-top: calc(var(--v-rhythm) * 3 + var(--spacing-12)); top: calc(var(--v-rhythm) * 2); } } /* 1199px = 16px * 74.9375 */ @media (max-width: 74.9375rem) { .body { grid-template-columns: 100vw; grid-template-rows: minmax(max-content, 1fr) max-content; grid-template-areas: "single" "footer"; gap: 0; } .sitenav, .main, .navButtonWrapper, .outline, .navOverlay { grid-area: single; } .main { justify-self: center; } .sitenav, .outline { justify-self: start; padding: var(--spacing-5) var(--spacing-10); grid-row: 1 / 3; border-right: 1px solid var(--color-border); background-color: var(--color-bg); box-shadow: 0 0 8px rgb(0 0 0 / 0.2); z-index: 5; transform: translateX(-100%); transition: 0.2s ease transform; } .sitenav:focus-within { transform: translateX(0); } .outline { justify-self: end; border-right: none; border-left: 1px solid var(--color-border); transform: translateX(100%); } .outline:focus-within { transform: translateX(0); } .navOverlay { grid-row: 1 / 3; background-color: var(--color-overlay); backdrop-filter: blur(2px); z-index: 3; } .sitenav:focus-within + .navOverlay, .outline:focus-within + .navOverlay { display: block; } .navButtonWrapper { display: flex; justify-content: space-between; align-items: flex-start; } .navButton { display: block; position: sticky; top: calc(-1 * var(--radii)); left: 0; margin-top: var(--spacing-5); padding: var(--action-padding); font-family: var(--font-family-heading); font-weight: 600; border: 1px solid var(--color-border); border-left: none; background-color: var(--color-panel-bg); border-radius: var(--radii); border-top-left-radius: 0; border-bottom-left-radius: 0; box-shadow: 0 0 4px rgb(0 0 0 / 0.3); cursor: pointer; } .navButton:nth-child(even) { border-right: none; border-left: 1px solid var(--color-border); border-radius: var(--radii); border-top-right-radius: 0; border-bottom-right-radius: 0; } .navButton:hover { text-decoration: underline; } .navButton:focus-visible { border-color: var(--color-focus); box-shadow: 0 0 0 var(--focus-shadow-radius) var(--color-focus-shadow); outline: none; } @media (prefers-reduced-motion: reduce) { .sitenav, .outline { opacity: 0; transition: 0.2s ease opacity; } .sitenav:focus-within, .outline:focus-within { opacity: 1; } } }
-
-
website/app.tsx (new)
-
@@ -0,0 +1,145 @@/** @jsxImportSource ./jsx */ import { derived, fragment, isReactive, type ReactiveOrStatic, type NodeChild, } from "./ef.js"; import { Footer } from "./components/footer.js"; import { Header } from "./components/header.js"; import { Nav, NavItem } from "./components/nav.js"; import { type Route } from "./route.js"; import { notFound } from "./pages/not-found.js"; import { loadDocFile } from "./load-docs.js"; import { cx } from "./app.module.css"; const DOCS_PATTERN = /^\/docs\/(\S+)\/$/; export function title(text?: string): string { return text ? `${text} | ef.js` : "ef.js"; } export async function route(path: string): Promise<Route> { if (DOCS_PATTERN.test(path)) { const [, slug] = path.match(DOCS_PATTERN)!; const { default: doc, title, outline } = await loadDocFile(slug); const docs = await import("./pages/docs.js").then((mod) => mod.docs); return docs({ html: doc, title, outline }); } switch (path) { case "/": return import("./pages/top.js").then((mod) => mod.top); case "/download/": return import("./pages/downloads.js").then((mod) => mod.downloads); default: return notFound; } } export function app( $main: ReactiveOrStatic<NodeChild>, $outline: ReactiveOrStatic<NodeChild> = null, ): Node { const sitenavButton = ( <button class={cx.navButton} onClick={() => { sitenav.querySelector("a")?.focus(); }} > Menu </button> ) as HTMLButtonElement; const sitenav = ( <Nav class={cx.navInner} onESC={() => { sitenavButton.focus(); }} > <NavItem href="/">Top</NavItem> <NavItem href="/docs/introduction/">Introduction</NavItem> <NavItem href="/download/">Downloads</NavItem> <NavItem href="/docs/installation/">Installation</NavItem> <NavItem href="/docs/usage/">Usage</NavItem> <NavItem href="/docs/api/">API</NavItem> <NavItem href="/docs/development/">Development Guide</NavItem> </Nav> ) as HTMLElement; const $hasOutline = derived(() => { return !!(isReactive($outline) ? $outline.get() : $outline); }); const $outlineButton = derived(() => { if (!$hasOutline.get()) { return null; } return ( <button class={cx.navButton} onClick={() => { $outlineEl.once()?.querySelector("a")?.focus({ // Without this, browser try to scroll to offscreen element, // which cause sudden viewport scroll preventScroll: true, }); }} > Outline </button> ) as HTMLButtonElement; }); const $outlineEl = derived(() => { if (!$hasOutline.get()) { return null; } return ( <Nav class={cx.navInner} onESC={() => { $outlineButton.once()?.focus(); }} > {$outline} </Nav> ) as HTMLElement; }); return fragment([ <Header />, <div class={cx.body}> <div class={cx.sitenav}>{sitenav}</div> <div class={cx.navOverlay} onClick={() => { sitenavButton.focus(); }} /> <div class={cx.outline}>{$outlineEl}</div> <div class={cx.navOverlay} onClick={() => { $outlineButton.once()?.focus(); }} /> <div class={cx.navButtonWrapper}> {sitenavButton} {$outlineButton} </div> <main class={cx.main}>{$main}</main> <Footer class={cx.footer} /> </div>, ]); }
-
-
-
-
-
-
@@ -0,0 +1,5 @@<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" rx="2" fill="#09AA59"/> <path d="M20.6685 31.0284C18.856 31.0284 17.2423 30.7813 15.8276 30.2869C14.4128 29.7983 13.2168 29.0795 12.2395 28.1307C11.2622 27.1875 10.5179 26.0341 10.0065 24.6705C9.49517 23.3068 9.23949 21.7557 9.23949 20.017C9.23949 18.3011 9.49517 16.7443 10.0065 15.3466C10.5236 13.9489 11.2764 12.75 12.2651 11.75C13.2537 10.7443 14.4611 9.97159 15.8872 9.43182C17.319 8.89204 18.9497 8.62216 20.7793 8.62216C22.4724 8.62216 23.9838 8.86932 25.3134 9.36364C26.6486 9.85795 27.7793 10.5625 28.7054 11.4773C29.6372 12.3864 30.3446 13.4687 30.8276 14.7244C31.3162 15.9744 31.5577 17.358 31.552 18.875C31.5577 19.9205 31.4668 20.8807 31.2793 21.7557C31.0918 22.6307 30.7963 23.3949 30.3929 24.0483C29.9952 24.696 29.4724 25.2102 28.8247 25.5909C28.177 25.9659 27.3929 26.179 26.4724 26.2301C25.8134 26.2813 25.2793 26.233 24.8702 26.0852C24.4611 25.9375 24.1486 25.7187 23.9327 25.429C23.7224 25.1335 23.5861 24.7898 23.5236 24.3977H23.4213C23.2849 24.7386 23.0179 25.0483 22.6202 25.3267C22.2224 25.5994 21.7366 25.8125 21.1628 25.9659C20.5946 26.1136 19.9895 26.1676 19.3474 26.1278C18.677 26.0881 18.0406 25.9318 17.4384 25.6591C16.8418 25.3864 16.3105 24.9915 15.8446 24.4744C15.3844 23.9574 15.0207 23.3182 14.7537 22.5568C14.4923 21.7955 14.3588 20.9148 14.3531 19.9148C14.3588 18.9261 14.498 18.0653 14.7707 17.3324C15.0491 16.5994 15.4156 15.983 15.8702 15.483C16.3304 14.983 16.8418 14.5909 17.4043 14.3068C17.9668 14.0227 18.5321 13.8409 19.1003 13.7614C19.7423 13.6648 20.3503 13.6648 20.9241 13.7614C21.498 13.858 21.981 14.0142 22.373 14.2301C22.7707 14.446 23.0179 14.679 23.1145 14.929H23.2338V13.9744H25.9014V22.2926C25.9071 22.6847 25.9952 22.9886 26.1656 23.2045C26.3361 23.4205 26.5662 23.5284 26.856 23.5284C27.248 23.5284 27.5747 23.3551 27.8361 23.0085C28.1031 22.6619 28.302 22.1307 28.4327 21.4148C28.569 20.6989 28.6372 19.7869 28.6372 18.679C28.6372 17.6108 28.4952 16.6733 28.2111 15.8665C27.9327 15.054 27.5406 14.3636 27.0349 13.7955C26.5349 13.2216 25.9526 12.7557 25.2878 12.3977C24.623 12.0398 23.9014 11.7784 23.123 11.6136C22.3503 11.4489 21.5548 11.3665 20.7366 11.3665C19.3219 11.3665 18.0861 11.5824 17.0293 12.0142C15.9724 12.4403 15.0918 13.0398 14.3872 13.8125C13.6827 14.5852 13.1543 15.4915 12.802 16.5312C12.4554 17.5653 12.2793 18.6932 12.2736 19.9148C12.2793 21.2614 12.4668 22.4545 12.8361 23.4943C13.2111 24.5284 13.7622 25.3977 14.4895 26.1023C15.2168 26.8068 16.1145 27.3409 17.1827 27.7045C18.2509 28.0682 19.4781 28.25 20.8645 28.25C21.5179 28.25 22.1571 28.2017 22.7821 28.1051C23.4071 28.0142 23.9724 27.9006 24.4781 27.7642C24.9838 27.6335 25.3901 27.5057 25.6969 27.3807L26.5406 29.8523C26.1827 30.0568 25.6969 30.2472 25.0832 30.4233C24.4753 30.6051 23.7878 30.75 23.0207 30.858C22.2594 30.9716 21.4753 31.0284 20.6685 31.0284ZM20.2764 23.3239C20.9696 23.3239 21.5207 23.1903 21.9298 22.9233C22.3446 22.6562 22.6401 22.2614 22.8162 21.7386C22.998 21.2102 23.0832 20.5597 23.0719 19.7869C23.0662 19.1051 22.9781 18.5284 22.8077 18.0568C22.6429 17.5795 22.356 17.2187 21.9469 16.9744C21.5435 16.7244 20.981 16.5994 20.2594 16.5994C19.6287 16.5994 19.0918 16.733 18.6486 17C18.2111 17.267 17.8759 17.642 17.6429 18.125C17.4156 18.6023 17.2991 19.1648 17.2935 19.8125C17.2991 20.4148 17.3957 20.9858 17.5832 21.5256C17.7707 22.0597 18.0804 22.4943 18.5122 22.8295C18.944 23.1591 19.5321 23.3239 20.2764 23.3239ZM32.2861 30.9091V12.9091H35.8656V15.108H36.0275C36.1866 14.7557 36.4167 14.3977 36.7179 14.0341C37.0247 13.6648 37.4224 13.358 37.9111 13.1136C38.4054 12.8636 39.019 12.7386 39.752 12.7386C40.7065 12.7386 41.5872 12.9886 42.394 13.4886C43.2008 13.983 43.8457 14.7301 44.3287 15.7301C44.8116 16.7244 45.0531 17.9716 45.0531 19.4716C45.0531 20.9318 44.8173 22.1648 44.3457 23.1705C43.8798 24.1705 43.2434 24.929 42.4366 25.446C41.6355 25.9574 40.7378 26.2131 39.7434 26.2131C39.0389 26.2131 38.4395 26.0966 37.9451 25.8636C37.4565 25.6307 37.0559 25.3381 36.7434 24.9858C36.4309 24.6278 36.1923 24.267 36.0275 23.9034H35.9167V30.9091H32.2861ZM35.84 19.4545C35.84 20.233 35.948 20.9119 36.1639 21.4915C36.3798 22.071 36.6923 22.5227 37.1014 22.8466C37.5105 23.1648 38.0076 23.3239 38.5929 23.3239C39.1838 23.3239 39.6838 23.1619 40.0929 22.8381C40.502 22.5085 40.8116 22.054 41.0218 21.4744C41.2378 20.8892 41.3457 20.2159 41.3457 19.4545C41.3457 18.6989 41.2406 18.0341 41.0304 17.4602C40.8201 16.8864 40.5105 16.4375 40.1014 16.1136C39.6923 15.7898 39.1895 15.6278 38.5929 15.6278C38.002 15.6278 37.502 15.7841 37.0929 16.0966C36.6895 16.4091 36.3798 16.8523 36.1639 17.4261C35.948 18 35.84 18.6761 35.84 19.4545ZM51.7083 26.2557C50.3845 26.2557 49.2396 25.9744 48.2737 25.4119C47.3134 24.8437 46.572 24.054 46.0492 23.0426C45.5265 22.0256 45.2651 20.8466 45.2651 19.5057C45.2651 18.1534 45.5265 16.9716 46.0492 15.9602C46.572 14.9432 47.3134 14.1534 48.2737 13.5909C49.2396 13.0227 50.3845 12.7386 51.7083 12.7386C53.0322 12.7386 54.1742 13.0227 55.1345 13.5909C56.1004 14.1534 56.8447 14.9432 57.3674 15.9602C57.8901 16.9716 58.1515 18.1534 58.1515 19.5057C58.1515 20.8466 57.8901 22.0256 57.3674 23.0426C56.8447 24.054 56.1004 24.8437 55.1345 25.4119C54.1742 25.9744 53.0322 26.2557 51.7083 26.2557ZM51.7254 23.4432C52.3276 23.4432 52.8305 23.2727 53.2339 22.9318C53.6373 22.5852 53.9413 22.1136 54.1458 21.517C54.3561 20.9205 54.4612 20.2415 54.4612 19.4801C54.4612 18.7188 54.3561 18.0398 54.1458 17.4432C53.9413 16.8466 53.6373 16.375 53.2339 16.0284C52.8305 15.6818 52.3276 15.5085 51.7254 15.5085C51.1174 15.5085 50.6061 15.6818 50.1913 16.0284C49.7822 16.375 49.4725 16.8466 49.2623 17.4432C49.0578 18.0398 48.9555 18.7188 48.9555 19.4801C48.9555 20.2415 49.0578 20.9205 49.2623 21.517C49.4725 22.1136 49.7822 22.5852 50.1913 22.9318C50.6061 23.2727 51.1174 23.4432 51.7254 23.4432ZM64.7471 26.2557C63.4062 26.2557 62.2528 25.9716 61.2868 25.4034C60.3266 24.8295 59.588 24.0341 59.0709 23.017C58.5596 22 58.3039 20.8295 58.3039 19.5057C58.3039 18.1648 58.5624 16.9886 59.0795 15.9773C59.6022 14.9602 60.3437 14.1676 61.3039 13.5994C62.2641 13.0256 63.4062 12.7386 64.73 12.7386C65.8721 12.7386 66.8721 12.946 67.73 13.3608C68.588 13.7756 69.267 14.358 69.767 15.108C70.267 15.858 70.5425 16.7386 70.5937 17.75H67.1675C67.0709 17.0966 66.8153 16.571 66.4005 16.1733C65.9914 15.7699 65.4545 15.5682 64.7897 15.5682C64.2272 15.5682 63.7357 15.7216 63.3153 16.0284C62.9005 16.3295 62.5766 16.7699 62.3437 17.3494C62.1107 17.929 61.9942 18.6307 61.9942 19.4545C61.9942 20.2898 62.1079 21 62.3351 21.5852C62.5681 22.1705 62.8948 22.6165 63.3153 22.9233C63.7357 23.2301 64.2272 23.3835 64.7897 23.3835C65.2045 23.3835 65.5766 23.2983 65.9062 23.1278C66.2414 22.9574 66.517 22.7102 66.7329 22.3864C66.9545 22.0568 67.0993 21.6619 67.1675 21.2017H70.5937C70.5368 22.2017 70.2641 23.0824 69.7755 23.8438C69.2925 24.5994 68.6249 25.1903 67.7726 25.6165C66.9204 26.0426 65.9118 26.2557 64.7471 26.2557ZM74.528 22.233L74.5365 17.8778H75.0649L79.2581 12.9091H83.4257L77.7922 19.4886H76.9314L74.528 22.233ZM71.2382 26V8.54545H74.8689V26H71.2382ZM79.4201 26L75.5678 20.2983L77.9882 17.733L83.6729 26H79.4201ZM87.2599 26.2472C86.4247 26.2472 85.6804 26.1023 85.027 25.8125C84.3736 25.517 83.8565 25.0824 83.4759 24.5085C83.1009 23.929 82.9134 23.2074 82.9134 22.3438C82.9134 21.6165 83.0469 21.0057 83.3139 20.5114C83.581 20.017 83.9446 19.6193 84.4048 19.3182C84.8651 19.017 85.3878 18.7898 85.973 18.6364C86.5639 18.483 87.1832 18.375 87.831 18.3125C88.5923 18.233 89.206 18.1591 89.6719 18.0909C90.1378 18.017 90.4759 17.9091 90.6861 17.767C90.8963 17.625 91.0014 17.4148 91.0014 17.1364V17.0852C91.0014 16.5455 90.831 16.1278 90.4901 15.8324C90.1548 15.5369 89.6776 15.3892 89.0582 15.3892C88.4048 15.3892 87.8849 15.5341 87.4986 15.8239C87.1122 16.108 86.8565 16.4659 86.7315 16.8977L83.3736 16.625C83.544 15.8295 83.8793 15.142 84.3793 14.5625C84.8793 13.9773 85.5241 13.5284 86.3139 13.2159C87.1094 12.8977 88.0298 12.7386 89.0753 12.7386C89.8026 12.7386 90.4986 12.8239 91.1634 12.9943C91.8338 13.1648 92.4276 13.429 92.9446 13.7869C93.4673 14.1449 93.8793 14.6051 94.1804 15.1676C94.4815 15.7244 94.6321 16.392 94.6321 17.1705V26H91.1889V24.1847H91.0866C90.8764 24.5938 90.5952 24.9545 90.2429 25.267C89.8906 25.5739 89.4673 25.8153 88.973 25.9915C88.4787 26.1619 87.9077 26.2472 87.2599 26.2472ZM88.2997 23.7415C88.8338 23.7415 89.3054 23.6364 89.7145 23.4261C90.1236 23.2102 90.4446 22.9205 90.6776 22.5568C90.9105 22.1932 91.027 21.7812 91.027 21.321V19.9318C90.9134 20.0057 90.7571 20.0739 90.5582 20.1364C90.3651 20.1932 90.1463 20.2472 89.902 20.2983C89.6577 20.3437 89.4134 20.3864 89.169 20.4261C88.9247 20.4602 88.7031 20.4915 88.5043 20.5199C88.0781 20.5824 87.706 20.6818 87.3878 20.8182C87.0696 20.9545 86.8224 21.1392 86.6463 21.3722C86.4702 21.5994 86.3821 21.8835 86.3821 22.2244C86.3821 22.7188 86.5611 23.0966 86.919 23.358C87.2827 23.6136 87.7429 23.7415 88.2997 23.7415Z" fill="white"/> <path d="M44.645 42.3182L30.5825 94.5625H22.7629L36.8254 42.3182H44.645ZM59.733 88.6392C56.3665 88.6392 53.4688 87.9574 51.0398 86.5938C48.6251 85.2159 46.7643 83.2699 45.4575 80.7557C44.1506 78.2273 43.4972 75.2372 43.4972 71.7855C43.4972 68.419 44.1506 65.4645 45.4575 62.9219C46.7643 60.3793 48.6038 58.3977 50.9759 56.9773C53.3623 55.5568 56.1606 54.8466 59.3708 54.8466C61.5299 54.8466 63.5398 55.1946 65.4006 55.8906C67.2756 56.5724 68.9092 57.6023 70.3012 58.9801C71.7075 60.358 72.8012 62.0909 73.5825 64.179C74.3637 66.2528 74.7543 68.6818 74.7543 71.4659V73.9588H47.1194V68.3338H66.2103C66.2103 67.027 65.9262 65.8693 65.358 64.8608C64.7898 63.8523 64.0015 63.0639 62.993 62.4957C61.9987 61.9134 60.841 61.6222 59.52 61.6222C58.1421 61.6222 56.9205 61.9418 55.8552 62.581C54.804 63.206 53.9802 64.0511 53.3836 65.1165C52.787 66.1676 52.4816 67.3395 52.4674 68.6321V73.9801C52.4674 75.5994 52.7657 76.9986 53.3623 78.1776C53.9731 79.3565 54.8325 80.2656 55.9404 80.9048C57.0484 81.544 58.3623 81.8636 59.8822 81.8636C60.8907 81.8636 61.814 81.7216 62.6521 81.4375C63.4901 81.1534 64.2075 80.7273 64.804 80.1591C65.4006 79.5909 65.8552 78.8949 66.1677 78.071L74.5626 78.625C74.1364 80.642 73.2629 82.4034 71.9418 83.9091C70.635 85.4006 68.9447 86.5653 66.8708 87.4034C64.8112 88.2273 62.4319 88.6392 59.733 88.6392ZM94.0771 55.2727V62.0909H73.8782V55.2727H94.0771ZM78.5018 88V52.9077C78.5018 50.5355 78.9634 48.5682 79.8867 47.0057C80.8242 45.4432 82.1026 44.2713 83.7219 43.4901C85.3413 42.7088 87.1808 42.3182 89.2404 42.3182C90.6325 42.3182 91.9038 42.4247 93.0543 42.6378C94.2191 42.8509 95.0856 43.0426 95.6538 43.2131L94.0344 50.0312C93.6793 49.9176 93.239 49.8111 92.7134 49.7116C92.2021 49.6122 91.6765 49.5625 91.1367 49.5625C89.8015 49.5625 88.8711 49.875 88.3455 50.5C87.82 51.1108 87.5572 51.9702 87.5572 53.0781V88H78.5018Z" fill="white"/> </svg>
-
-
website/client.ts (new)
-
@@ -0,0 +1,167 @@import "@fontsource/inter-tight/600.css"; import "@fontsource/jetbrains-mono/400.css"; import "@wooorm/starry-night/style/both"; import { derived, effect, el, fragment, signal, PromiseSnapshot, } from "./ef.js"; import { app, route, title } from "./app.js"; import { type LibBundleEntry, type Route } from "./route.js"; declare global { const __VERSION__: string; const __BUNDLE_INFO__: readonly LibBundleEntry[]; } function rafp(): Promise<void> { return new Promise((resolve) => { requestAnimationFrame(() => { resolve(); }); }); } const $route = signal<PromiseSnapshot<Route, unknown>>({ isSettled: false, isRejected: false, isResolved: false, }); route(location.pathname) .then(async (r) => { $route.set({ isSettled: true, isRejected: false, isResolved: true, data: r, }); const url = new URL(location.href); if (!url.hash) { return; } // Wait for the new element to be attached to document tree await rafp(); const match = document.querySelector(url.hash); if (match) { match.scrollIntoView(); } }) .catch((error) => { $route.set({ isSettled: true, isRejected: true, isResolved: false, error, }); }); if (window.navigation) { window.navigation.addEventListener("navigate", (ev) => { if ( !ev.canIntercept || ev.downloadRequest || ev.hashChange || ev.navigationType === "reload" || !ev.userInitiated ) { return; } ev.intercept({ async handler() { const r = await route(new URL(ev.destination.url).pathname); // NOTE: Chrome seems to have a bug that it can't scroll at all on Navigation API. // I don't know what causes this. if (ev.navigationType === "push") { document.body.scroll({ top: 0, behavior: "instant", }); } $route.set({ isSettled: true, isRejected: false, isResolved: true, data: r, }); // Wait for the new element to be attached to document tree await rafp(); }, }); }); } const $title = derived(() => { const route = $route.get(); if (!route.isResolved) { return null; } return route.data.title; }); effect(() => { if ($title.value) { document.title = title($title.value); } }); const $head = derived(() => { if (!$route.value.isResolved) { return null; } return fragment($route.value.data.head || []); }); const headMarker = document.head.querySelector( `script[type="application/json"][data-ef-head-mk]`, ); while (headMarker && headMarker.nextSibling) { document.head.removeChild(headMarker.nextSibling); } document.head.appendChild(fragment([$head])); const ctx = { version: __VERSION__, bundle: __BUNDLE_INFO__, }; const $main = derived(() => { if (!$route.value.isSettled) { return el("p", [], ["Loading"]); } if ($route.value.isRejected) { return el( "p", [], ["Failed to render the route: ", String($route.value.error)], ); } return $route.value.data.content(ctx); }); const $outline = derived(() => { if (!$route.value.isResolved) { return null; } return $route.value.data.outline?.(ctx); }); document.body.replaceChildren(app($main, $outline));
-
-
-
@@ -0,0 +1,15 @@.root { padding: var(--spacing-10); border-top: 1px solid var(--color-border); font-size: var(--font-3); line-height: var(--v-rhythm); background-color: var(--color-panel-bg); color: var(--color-fg-dim); } @media (min-width: 40rem) { .root { text-align: center; } }
-
-
-
@@ -0,0 +1,28 @@/** @jsxImportSource ../jsx */ import { cx } from "./footer.module.css"; interface FooterProps { class?: string; } export function Footer({ class: className }: FooterProps) { return ( <footer class={cx.root + " " + className}> <p> Copyright 2023 Shota FUJI, licensed under{" "} <a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener" > Apache License, Version 2.0 </a> . </p> <p> The licenses of third-party code used in this site are at{" "} <a href="/third-party-license.txt">third-party-license.txt</a>. </p> </footer> ); }
-
-
-
@@ -0,0 +1,40 @@.root { display: flex; justify-content: space-between; align-items: center; gap: var(--spacing-2); padding: calc(var(--spacing-2) + var(--focus-shadow-radius)) var(--spacing-6); background-color: var(--color-panel-bg); border-bottom: 1px solid var(--color-border); box-shadow: 0 0 8px rgb(0 0 0 / 0.07); } .root :global(.action):not(:focus-visible) { border-color: transparent; } .logo { padding: 0; flex-shrink: 0; } .logoImage { height: 32px; width: auto; } .menu { flex-shrink: 1; display: flex; align-items: stretch; gap: var(--spacing-4); padding: var(--spacing-6) var(--focus-shadow-radius); margin: calc(var(--spacing-6) * -1) 0; list-style: none; overflow-x: auto; } .menu > li { display: block; }
-
-
-
@@ -0,0 +1,28 @@/** @jsxImportSource ../jsx */ import { cx } from "./header.module.css"; interface HeaderProps { class?: string; } export function Header({ class: className }: HeaderProps) { return ( <header class={[cx.root, className].filter(Boolean).join(" ")}> <a class={`${cx.logo} action`} href="/"> <img class={cx.logoImage} alt="Logo of ef.js" src="/favicon.svg" /> </a> <ul class={cx.menu}> <li> <a class="action" href="/docs/usage/"> Usage </a> </li> <li> <a class="action" href="/docs/api/"> API </a> </li> </ul> </header> ); }
-
-
-
@@ -0,0 +1,38 @@.list { list-style: none; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; line-height: var(--v-rhythm); font-size: var(--font-4); color: var(--color-fg-dim); } .item > .list { padding-left: var(--spacing-10); } .link { border: 1px solid transparent; border-radius: var(--radii); text-decoration: none; } .link:hover { text-decoration: underline; } .link:focus { border-color: var(--color-focus); box-shadow: 0 0 0 var(--focus-shadow-radius) var(--color-focus-shadow); outline: none; } .link[data-current] { color: var(--color-fg); text-shadow: var(--color-fg) 0 0 1px; } .groupLabel::after { content: "/"; }
-
-
-
@@ -0,0 +1,164 @@/** @jsxImportSource ../jsx */ import { effect } from "../ef.js"; import { type JSXChildren } from "../jsx/jsx"; import { cx } from "./nav.module.css"; const EL_KIND = "nav_link"; const idToOffsetTopMap = new Map<string, number>(); function onScrollEnd() { const { scrollTop } = document.body; // This margin works like scroll-padding-top const topmostY = Math.min(...idToOffsetTopMap.values()); let closest: readonly [number, string] | null = null; for (const [id, top] of idToOffsetTopMap) { const dy = top - scrollTop - topmostY; if ( !closest || (closest[0] > 0 && dy < closest[0]) || (dy < 0 && dy > closest[0]) ) { closest = [dy, id]; continue; } } const currents = document.querySelectorAll( `[data-kind="${EL_KIND}"][data-current]`, ); currents.forEach((el) => { el.removeAttribute("data-current"); }); if (!closest) { return; } const [, id] = closest; const target = document.querySelector( `[data-kind="${EL_KIND}"][href="#${id}"]`, ); if (!target) { return; } target.setAttribute("data-current", ""); } interface NavProps { class?: string; ulClass?: string; children: JSXChildren; onESC?(): void; } export function Nav({ children, class: className, ulClass, onESC, ...rest }: NavProps) { effect(() => { requestAnimationFrame(() => { onScrollEnd(); }); document.body.addEventListener("scrollend", onScrollEnd, { passive: true }); return () => { document.body.removeEventListener("scrollend", onScrollEnd); }; }); return ( <nav {...rest} class={className ?? false} onKeyDown={(ev: Event) => { if (!(ev instanceof KeyboardEvent) || ev.key !== "Escape") { return; } onESC?.(); }} > <ul class={[cx.list, ulClass].filter(Boolean).join(" ")}>{children}</ul> </nav> ); } interface NavItemProps { href: string; children: JSXChildren; } const HASH_PATTERN = /^#(\S+)$/; function register(href: string) { effect(() => { if (HASH_PATTERN.test(href)) { const [, id] = href.match(HASH_PATTERN)!; requestAnimationFrame(() => { const target = document.getElementById(id); if (!target) { return; } idToOffsetTopMap.set(id, target.offsetTop); }); return () => { idToOffsetTopMap.delete(id); }; } }); } export function NavItem({ href, children }: NavItemProps) { register(href); return ( <li class={cx.item}> <a class={cx.link} href={href} data-kind={EL_KIND}> {children} </a> </li> ); } interface NavGroupProps { label: JSXChildren; href?: string; children: JSXChildren; } export function NavGroup({ label, children, href }: NavGroupProps) { if (href) { register(href); } return ( <li class={cx.item}> {href ? ( <a href={href} class={cx.link} data-kind={EL_KIND}> {label} </a> ) : ( <span class={cx.groupLabel}>{label}</span> )} <ul class={cx.list}>{children}</ul> </li> ); }
-
-
-
@@ -0,0 +1,5 @@import footer from "./footer.module.css"; import header from "./header.module.css"; import nav from "./nav.module.css"; export default footer + header + nav;
-
-
website/ef.ts (new)
-
@@ -0,0 +1,1 @@export * from "../src/ef.js";
-
-
website/happy-dom.ts (new)
-
@@ -0,0 +1,3 @@import { GlobalRegistrator } from "@happy-dom/global-registrator"; GlobalRegistrator.register();
-
-
website/html.tsx (new)
-
@@ -0,0 +1,73 @@/** @jsxImportSource ./jsx */ /// <reference types="bun-types" /> /// <reference lib="dom" /> import "./happy-dom.js"; import { derived, fragment } from "./ef.js"; import { app, route, title } from "./app.js"; import { type RouteContext } from "./route.js"; import globalStyles from "./styles.css"; import componentStyles from "./components/styles.js"; import appStyles from "./app.module.css"; const DOCTYPE = "<!doctype html>"; export async function page(path: string, ctx: RouteContext): Promise<string> { const { title: routTitle, content, outline, head = [] } = await route(path); const $html = derived( () => ( <html lang="en-US"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{title(routTitle)}</title> <meta name="description" content="Documentation for ef.js, a JavaScript library for declarative DOM manipulation." /> <link rel="stylesheet" href="/styles.css" /> <script type="module" defer src="/client.js" /> {/* ESBuild quirk (implicit CSS generation) */} <link rel="stylesheet" href="/client.css" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" sizes="100x100" href="/favicon.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <script type="application/json" data-ef-head-mk /> {fragment(head)} </head> <body>{app(content(ctx), outline?.(ctx))}</body> </html> ) as HTMLHtmlElement, ); const html = DOCTYPE + $html.get().outerHTML; $html.dispose(); return html; } export const styles = globalStyles + appStyles + componentStyles;
-
-
-
@@ -0,0 +1,1 @@export { jsx as jsxDEV, type JSX } from "./jsx-runtime.js";
-
-
-
@@ -0,0 +1,78 @@import { effect, el, type ElementSetup, isReactive, on, type ReactiveOrStatic, } from "../ef.js"; import { type JSXChildren } from "./jsx.js"; export { type JSX } from "./jsx.js"; function setAttribute(el: Element, name: string, value: string | boolean) { switch (value) { case true: el.setAttribute(name, ""); return; case false: el.removeAttribute(name); return; default: el.setAttribute(name, value); } } const EVENT_PATTERN = /^on([A-Z][a-zA-Z]*)$/; /** * This JSX runtime focuses on attributes, because this docs site will be statically HTML rendered. */ export function jsx( type: string | ((props: any) => Node), props: { [key: `on${string}`]: (evt: Event) => void; } & { [key: string]: ReactiveOrStatic<string | boolean> } & { children?: JSXChildren; }, ): Node { if (typeof type === "function") { return type(props); } const { children, ...rest } = props; const setups = Object.entries(rest).map<ElementSetup<Element>>( ([key, value]) => { if (typeof value === "function") { if (EVENT_PATTERN.test(key)) { const name = key.replace(EVENT_PATTERN, "$1").toLowerCase(); return on(name, value); } // ignore invalid return () => {}; } if (isReactive(value)) { return (el) => { effect(() => { setAttribute(el, key, value.get()); }); }; } return (el) => { setAttribute(el, key, value); }; }, ); const normalizedChildren = Array.isArray(children) ? children.flat() : [children]; return el(type, setups, normalizedChildren); } export { jsx as jsxs };
-
-
website/jsx/jsx.ts (new)
-
@@ -0,0 +1,21 @@import { type NodeChild, type ReactiveOrStatic } from "../ef.js"; export namespace JSX { export type IntrinsicElements = { [K in keyof HTMLElementTagNameMap]: { [key: string]: any; }; } & { [key: string]: any; }; export interface ElementChildrenAttribute { children?: JSXChildren; } export type Element = Node; } export type JSXChildren = | ReactiveOrStatic<NodeChild> | readonly ReactiveOrStatic<NodeChild>[];
-
-
website/load-docs.js (new)
-
@@ -0,0 +1,7 @@// ESBuild has a bug that it emits a code to call `globImport_public_<ext>` without // the helper function not defined. Using glob-import from plain JS file works as // a workaround. // https://github.com/evanw/esbuild/issues/3319 export function loadDocFile(slug) { return import(`../docs/${slug}.md`); }
-
-
website/loaders.d.ts (new)
-
@@ -0,0 +1,18 @@declare module "*.module.css" { const code: string; export default code; export const cx: { [ident: string]: string; }; } declare module "*.css" { const code: string; export default code; } declare module "*.md" { const html: string; export default html; }
-
-
website/pages/docs.tsx (new)
-
@@ -0,0 +1,42 @@/** @jsxImportSource ../jsx */ import { el, fragment, prop } from "../ef.js"; import { NavGroup, NavItem } from "../components/nav.js"; import { type Route } from "../route.js"; import type { OutlineItem } from "../plugins/markdown"; interface DocsProps { html: string; title: string; outline: readonly OutlineItem[]; } export function docs({ html, title: t, outline }: DocsProps): Route { return { title: t, content() { return <div>{el("div", [prop("innerHTML", html)])}</div>; }, outline() { return fragment(outline.map((item) => <OutlineEntry item={item} />)); }, }; } interface OutlineEntryProps { item: OutlineItem; } function OutlineEntry({ item }: OutlineEntryProps) { if (item.children.length > 0) { return ( <NavGroup label={item.content} href={`#${item.id}`}> {item.children.map((child) => ( <OutlineEntry item={child} /> ))} </NavGroup> ); } return <NavItem href={`#${item.id}`}>{item.content}</NavItem>; }
-
-
-
@@ -0,0 +1,29 @@.links { display: flex; flex-direction: column; align-items: stretch; gap: var(--spacing-10); } .info { display: flex; flex-direction: column; align-items: flex-start; gap: var(--spacing-1); } .sizes { display: flex; flex-wrap: wrap; gap: 0 var(--spacing-8); } .size { display: flex; gap: var(--spacing-4); font-size: var(--font-3); } .sizeLabel { color: var(--color-fg-dim); }
-
-
-
@@ -0,0 +1,92 @@/** @jsxImportSource ../jsx */ import { NavItem, NavGroup } from "../components/nav.js"; import { type Route } from "../route.js"; import css, { cx } from "./downloads.module.css"; function toKB(bytes: number): string { return `${(bytes / 1_000).toFixed(2)}kB`; } export const downloads: Route = { title: "Downloads", content({ version, bundle }) { return ( <div> <h1 id="downloads">Downloads</h1> <p> <a href="/ef.zip" download={`ef-${version}.zip`}> Download ef-{version}.zip </a> </p> <p> The JS files are ECMAScript Modules and are written in ES2019 syntax. The calls to DOM API is adheres to the WHATWG DOM standard at the time of 2023-10-01. </p> <ul class={cx.links}> {bundle.map((b) => { const modifier = [b.minified ? "m" : "", b.header ? "h" : ""].join( "", ); const name = `ef-${version}${modifier}.js`; return ( <li> <span class={cx.info}> <span>{name}</span> <span class={cx.sizes}> <span class={cx.size}> <span class={cx.sizeLabel}>raw</span> <span>{toKB(b.size.raw)}</span> </span> <span class={cx.size}> <span class={cx.sizeLabel}>gzip</span> <span>{toKB(b.size.gzip)}</span> </span> <span class={cx.size}> <span class={cx.sizeLabel}>brotli</span> <span>{toKB(b.size.brotli)}</span> </span> </span> </span> </li> ); })} </ul> <h2 id="filename_schema">Filename schema</h2> <p>The filename schema is:</p> <pre> <code>{"ef-<version><modifier>.js"}</code> </pre> <p> where <code>{"<version>"}</code> is a{" "} <code>{"major.minor.patch"}</code> semver string. A modifier{" "} <code>m</code> indicates the code is minified. A modifier{" "} <code>h</code> indicates the code has license header. </p> <h2 id="notes">Notes</h2> <p> If you use only a tiny subset of API and would like to reduce filesize as much as possible, consider bundling your file with a tool that supports Tree Shaking. </p> <p> The LICENSE file is unmodified text of Apache License, Version 2.0. If your distribution already includes the full license text for another Apache 2.0 licensed software, no additional work is needed. </p> </div> ); }, outline() { return ( <NavGroup label="Downloads" href="#downloads"> <NavItem href="#filename_schema">Filename schema</NavItem> <NavItem href="#notes">Notes</NavItem> </NavGroup> ); }, head: [(<style>{css}</style>) as HTMLStyleElement], };
-
-
-
@@ -0,0 +1,14 @@/** @jsxImportSource ../jsx */ import { type Route } from "../route.js"; export const notFound: Route = { title: "Page not found", content() { return ( <div> <h1>Page not found</h1> <p>The page you're looking for does not exist.</p> </div> ); }, };
-
-
website/pages/top.md (new)
-
@@ -0,0 +1,27 @@```js import { attr, derived, el, on, signal } from "/ef.js"; const $opened = signal(false); querySelector("#demo").appendChild( el( "label", [], [ el("input", [ attr("type", "checkbox"), on("input", (ev) => { $opened.set(ev.currentTarget.checked); }), ]), derived(() => ($opened.get() ? "Checked" : "Unchecked")), ], ), ); ``` ef.js (`@pocka/ef`) is a small JavaScript library that helps writing DOM manipulation in a declarative manner. - [Introduction](/docs/introduction/) - [Downloads](/download/) - [Usage](/docs/usage/)
-
-
-
@@ -0,0 +1,9 @@.description { color: var(--color-fg-dim); } .demoLabel { display: flex; align-items: center; gap: 0.5em; }
-
-
website/pages/top.tsx (new)
-
@@ -0,0 +1,45 @@/** @jsxImportSource ../jsx */ import { attr, derived, el, on, prop, signal } from "../ef.js"; import { type Route } from "../route.js"; import css, { cx } from "./top.module.css"; import md from "./top.md"; function demo() { const $opened = signal(false); return el( "label", [attr("class", cx.demoLabel)], [ el("input", [ attr("type", "checkbox"), on("input", (ev) => { if (!(ev.currentTarget instanceof HTMLInputElement)) { return; } $opened.set(ev.currentTarget.checked); }), ]), derived(() => ($opened.get() ? "Checked" : "Unchecked")), ], ); } export const top: Route = { content() { return ( <div> <h1>ef.js</h1> <p class={cx.description}> Simple DOM helper for efficient update powered by Signals. </p> {demo()} {el("div", [prop("innerHTML", md)])} </div> ); }, head: [el("style", [], [css])], };
-
-
-
@@ -0,0 +1,1 @@This directory contains ESBuild/Bun plugin files.
-
-
website/plugins/css.ts (new)
-
@@ -0,0 +1,58 @@import { type BunPlugin, plugin } from "bun"; import { Plugin } from "esbuild"; // A plugin compatible for both Bun and ESBuild export const cssPlugin = { name: "CSS loader", async setup(build) { const { bundle } = await import("lightningcss"); build.onLoad( { filter: /\/?website\/.+\.css$/, }, ({ path }) => { if (path.endsWith(".module.css")) { const { code, exports } = bundle({ filename: path, minify: true, cssModules: true, sourceMap: false, }); const simplied = Object.fromEntries( Object.entries(exports || {}).map(([key, value]) => [ key, value.name, ]), ); // Bun's bundler seems not to support "object" loader. return { loader: "js", contents: "export default " + JSON.stringify(code.toString()) + ";export const cx=" + JSON.stringify(simplied) + ";", }; } const { code } = bundle({ filename: path, minify: true, sourceMap: false, }); // Bun runtime seems not to support "text" loader. return { loader: "js", contents: "export default " + JSON.stringify(code.toString()), }; }, ); }, } satisfies BunPlugin & Plugin; await plugin(cssPlugin);
-
-
-
@@ -0,0 +1,176 @@import { type BunPlugin, plugin } from "bun"; import type * as esbuild from "esbuild"; import type * as Hast from "hast"; import type * as Mdast from "mdast"; import { common, createStarryNight } from "@wooorm/starry-night"; export interface OutlineItem { id: string; content: string; level: number; children: OutlineItem[]; } // StarryNight crashes when tries to create an instance other than top-level. // maybe its hack or WASM (Oniguruma) related bug. const starryNight = await createStarryNight(common); export const markdownPlugin = { name: "Markdown loader", async setup(build) { const { file } = await import("bun"); const { fromMarkdown } = await import("mdast-util-from-markdown"); const { toHast, defaultHandlers } = await import("mdast-util-to-hast"); const { toString: mdastToString } = await import("mdast-util-to-string"); const { raw } = await import("hast-util-raw"); const { toHtml } = await import("hast-util-to-html"); const { find } = await import("unist-util-find"); const { toString: hastToString } = await import("hast-util-to-string"); build.onLoad({ filter: /\.md$/ }, async ({ path }) => { const mdast = fromMarkdown(await file(path).text()); const outline: OutlineItem[] = []; const outlineStack: OutlineItem[] = []; const hast = raw( toHast(mdast, { // The function cannot properly handle CommonMark without this stupid option allowDangerousHtml: true, handlers: { heading(state, node: Mdast.Heading) { const text = mdastToString(node); const id = encodeURIComponent( text.replace(/[ ,.]/g, "_").toLowerCase(), ); while ( outlineStack[outlineStack.length - 1] && outlineStack[outlineStack.length - 1].level >= node.depth ) { const removed = outlineStack.pop()!; const parent = outlineStack[outlineStack.length - 1]; if (!parent) { outline.push(removed); break; } parent.children.push(removed); } outlineStack.push({ id, content: text, level: node.depth, children: [], }); const before = defaultHandlers.heading(state, node); const after: Hast.Element = { type: "element", tagName: before.tagName, properties: { id }, children: [ { type: "element", tagName: "a", properties: { href: `#${id}` }, children: before.children, }, ], }; state.patch(node, after); return after; }, code(state, node: Mdast.Code) { const value = node.value ? node.value + "\n" : ""; const scope = node.lang ? starryNight.flagToScope(node.lang) : null; const children: Hast.ElementContent[] = scope ? starryNight .highlight(value, scope) .children.filter( (child): child is Hast.ElementContent => child.type === "element" || child.type === "text" || child.type === "raw", ) : [ { type: "text", value, }, ]; const code: Hast.Element = { type: "element", tagName: "code", properties: {}, children, data: node.meta ? { meta: node.meta } : {}, }; state.patch(node, code); const pre: Hast.Element = { type: "element", tagName: "pre", properties: {}, children: [state.applyData(node, code)], }; state.patch(node, pre); return pre; }, }, }), ); while (outlineStack.length > 0) { const removed = outlineStack.pop()!; const parent = outlineStack[outlineStack.length - 1]; if (!parent) { outline.push(removed); break; } parent.children.push(removed); } const h1 = find( hast, // @ts-expect-error: unist-util-find has broken type definition (node) => node.type === "element" && node.tagName === "h1", ); // @ts-expect-error: unist-util-find has broken type definition const title = h1 ? hastToString(h1) : null; const html = toHtml(hast); return { loader: "js", contents: "export default " + JSON.stringify(html) + "; export const title = " + JSON.stringify(title) + "; export const outline = " + JSON.stringify(outline), }; }); }, } satisfies BunPlugin & esbuild.Plugin; await plugin(markdownPlugin);
-
-
-
@@ -0,0 +1,10 @@{ "extends": "../tsconfig.json", "compilerOptions": { "types": ["bun-types"], "module": "ESNext", "target": "ESNext", "moduleResolution": "Bundler" }, "include": ["./*.ts"] }
-
-
website/route.ts (new)
-
@@ -0,0 +1,26 @@export interface LibBundleEntry { header: boolean; minified: boolean; size: { raw: number; gzip: number; brotli: number }; } export interface RouteContext { version: string; bundle: readonly LibBundleEntry[]; } export interface Route { title?: string; content(ctx: RouteContext): Node; outline?(ctx: RouteContext): Node head?: readonly HTMLElement[]; }
-
-
website/styles.css (new)
-
@@ -0,0 +1,219 @@* { font: inherit; font-variant-ligatures: none; box-sizing: border-box; margin: 0; padding: 0; border: none; background: transparent; color: inherit; } :root { --spacing-unit: 2px; --scaling-factor: 1.25; --spacing-1: var(--spacing-unit); --spacing-2: calc(var(--spacing-1) * var(--scaling-factor)); --spacing-3: calc(var(--spacing-2) * var(--scaling-factor)); --spacing-4: calc(var(--spacing-3) * var(--scaling-factor)); --spacing-5: calc(var(--spacing-4) * var(--scaling-factor)); --spacing-6: calc(var(--spacing-5) * var(--scaling-factor)); --spacing-7: calc(var(--spacing-6) * var(--scaling-factor)); --spacing-8: calc(var(--spacing-7) * var(--scaling-factor)); --spacing-9: calc(var(--spacing-8) * var(--scaling-factor)); --spacing-10: calc(var(--spacing-9) * var(--scaling-factor)); --spacing-11: calc(var(--spacing-10) * var(--scaling-factor)); --spacing-12: calc(var(--spacing-11) * var(--scaling-factor)); --spacing-13: calc(var(--spacing-12) * var(--scaling-factor)); --spacing-14: calc(var(--spacing-13) * var(--scaling-factor)); --spacing-15: calc(var(--spacing-14) * var(--scaling-factor)); --spacing-16: calc(var(--spacing-15) * var(--scaling-factor)); --spacing-17: calc(var(--spacing-16) * var(--scaling-factor)); --spacing-18: calc(var(--spacing-17) * var(--scaling-factor)); --spacing-19: calc(var(--spacing-18) * var(--scaling-factor)); --spacing-20: calc(var(--spacing-19) * var(--scaling-factor)); --font-family-body: Arial, sans-serif; --font-family-heading: "Inter Tight", sans-serif; --font-family-mono: "JetBrains Mono", monospace; --font-4: 1rem; --font-3: calc(var(--font-4) / var(--scaling-factor)); --font-2: calc(var(--font-3) / var(--scaling-factor)); --font-1: calc(var(--font-2) / var(--scaling-factor)); --font-5: calc(var(--font-4) * var(--scaling-factor)); --font-6: calc(var(--font-5) * var(--scaling-factor)); --font-7: calc(var(--font-6) * var(--scaling-factor)); --font-8: calc(var(--font-7) * var(--scaling-factor)); --v-rhythm: calc(var(--font-4) * 1.4); --action-padding-v: var(--spacing-5); --action-padding-h: var(--spacing-6); --action-padding: var(--action-padding-v) var(--action-padding-h); --palette-theme: 150deg 90% 35%; --palette-theme-dark: 150deg 90% 20%; --palette-focus: 220deg 90% 80%; --palette-black: 0deg 0% 0%; --palette-mostly-black: 0deg 0% 15%; --palette-white: 0deg 0% 100%; --palette-mostly-white: 0deg 0% 97%; --radii: 3px; --focus-shadow-radius: 2px; --alpha-dim: 0.7; --alpha-border: 0.2; --alpha-action-overlay: 0.15; --alpha-overlay: 0.85; --color-fg: hsl(var(--palette-black)); --color-fg-dim: hsl(var(--palette-black) / var(--alpha-dim)); --color-bg: hsl(var(--palette-mostly-white)); --color-panel-bg: hsl(var(--palette-white)); --color-border: hsl(var(--palette-black) / var(--alpha-border)); --color-focus: hsl(var(--palette-focus)); --color-focus-shadow: hsl(var(--palette-focus) / 0.3); --color-action-overlay: hsl( var(--palette-black) / var(--alpha-action-overlay) ); --color-overlay: hsl(var(--palette-white) / var(--alpha-overlay)); font-family: var(--font-family-body); } @media (prefers-color-scheme: dark) { :root { --color-fg: hsl(var(--palette-white)); --color-fg-dim: hsl(var(--palette-white) / var(--alpha-dim)); --color-bg: hsl(var(--palette-mostly-black)); --color-panel-bg: hsl(var(--palette-black)); --color-border: hsl(var(--palette-white) / var(--alpha-border)); --color-action-overlay: hsl( var(--palette-white) / var(--alpha-action-overlay) ); --color-overlay: hsl(var(--palette-black) / var(--alpha-overlay)); } } @media (pointer: coarse) { :root { --action-padding-v: var(--spacing-6); --action-padding-h: var(--spacing-7); } } @supports (color: oklch(0% 0 0deg)) { :root { --palette-theme: 40% 0.75 150deg; --palette-theme-dark: 20% 0.15 150deg; --palette-focus: 80% 0.5 220deg; --palette-black: 0% 0 0deg; --palette-mostly-black: 15% 0 0deg; --palette-white: 100% 0 0deg; --palette-mostly-white: 97% 0 0deg; --color-fg: oklch(var(--palette-black)); --color-fg-dim: oklch(var(--palette-black) / var(--alpha-dim)); --color-bg: oklch(var(--palette-mostly-white)); --color-panel-bg: oklch(var(--palette-white)); --color-border: oklch(var(--palette-black) / var(--alpha-border)); --color-focus: oklch(var(--palette-focus)); --color-focus-shadow: oklch(var(--palette-focus) / 0.3); --color-action-overlay: oklch( var(--palette-black) / var(--alpha-action-overlay) ); --color-overlay: oklch(var(--palette-white) / var(--alpha-overlay)); } @media (prefers-color-scheme: dark) { :root { --color-fg: oklch(var(--palette-white)); --color-fg-dim: oklch(var(--palette-white) / var(--alpha-dim)); --color-bg: oklch(var(--palette-mostly-black)); --color-panel-bg: oklch(var(--palette-black)); --color-border: oklch(var(--palette-white) / var(--alpha-border)); --color-action-overlay: oklch( var(--palette-white) / var(--alpha-action-overlay) ); --color-overlay: oklch(var(--palette-black) / var(--alpha-overlay)); } } } @media (prefers-contrast: more) { :root { --color-fg-dim: var(--color-fg); --color-border: var(--color-fg); --color-focus-shadow: var(--color-focus); } } html { overflow: hidden; } body { display: flex; flex-direction: column; font-size: var(--font-4); width: 100vw; height: 100vh; background-color: var(--color-bg); color: var(--color-fg); overflow: hidden auto; scroll-padding-top: calc(var(--v-rhythm) * 2); scroll-behavior: smooth; } @media (prefers-reduced-motion: reduce) { body { scroll-behavior: auto; } } /* This is necessary as LightningCSS incorrectly deletes fallback values */ @supports (height: 100dvh) { body { width: 100dvw; height: 100dvh; } } pre, code { font-family: var(--font-family-mono); } :where(a) { border: 1px solid transparent; border-radius: var(--radii); } .action { display: inline-flex; font-family: var(--font-family-heading); font-size: var(--font-4); font-weight: bold; border: 1px solid var(--color-border); padding: var(--action-padding); border-radius: var(--radii); cursor: pointer; text-decoration: none; } .action:hover { background-color: var(--color-action-overlay); } .action:focus-visible, a:focus-visible { border-color: var(--color-focus); box-shadow: 0 0 0 var(--focus-shadow-radius) var(--color-focus-shadow); outline: none; }
-
-
website/tsconfig.json (new)
-
@@ -0,0 +1,11 @@{ "extends": "../tsconfig.json", "compilerOptions": { "allowJs": true, "module": "ES2020", "moduleResolution": "Bundler", "noEmit": true, "jsx": "react-jsx", }, "include": ["./**/*.ts", "./**/*.tsx"] }
-