Compare commits

...

43 commits
main ... 2.0

Author SHA1 Message Date
b1436e5194
Add attribute change event 2024-10-09 22:30:30 +02:00
22558caa9e
Prefix DOM events with skooma: for conflict-avoidance 2024-09-07 12:26:34 +02:00
be2cbee5e6
Add mini.js as minimalist node creation helper 2024-09-07 08:54:25 +02:00
d33b6fd177 Add code example and goals section in readme 2024-08-02 09:24:50 +02:00
8b67abf32f Update readme 2024-08-01 14:57:13 +02:00
e763312f06 Fix handling of document fragments in renderer 2024-07-31 09:30:17 +02:00
6bd29f05dc Refactor renderer class 2024-07-29 11:37:44 +02:00
e2ec8312af
Fix bug in special fields selection 2024-07-27 16:03:24 +02:00
fd10a49a43 Rewrite render module to use inheriting classes 2024-07-17 16:37:30 +02:00
6088b976d8 More code documentation 2024-06-24 13:04:42 +02:00
aa27cc0b34 Add basic editorconfig 2024-06-24 12:27:05 +02:00
a08d51e4db More code documentation 2024-06-24 12:26:41 +02:00
e9e8aeba4f Make property non-configurable 2024-06-24 12:26:36 +02:00
8de5303550 Minor refactor & documentation 2024-06-24 11:29:05 +02:00
74de53874b Rename skooma module to "render" 2024-03-18 11:49:38 +01:00
21e5bdbba4 Add "newer" map to track replaced reactive elements 2024-02-29 15:47:20 +01:00
12daec85e6 Extract Ref class into separate module 2024-02-29 15:33:26 +01:00
6d4e398336 Add Ref class to follow reactive elements 2024-02-29 15:29:36 +01:00
d53e6c7fd5 Add replace and replaced events to reactive elements 2024-02-29 15:29:20 +01:00
3ff99bee9b Add jsdoc class annotations to skooma module 2024-02-12 13:38:57 +01:00
8876dcfa68
Add meta-tag domProxy 2024-02-06 22:56:08 +01:00
0a80d860df
Rename domLens to domProxy / domArray 2024-02-06 22:02:29 +01:00
39012902e0
Full refactor of State (now Observable)
* Renamed to Observable
* Updated skooma.js to match the API
2024-02-06 22:00:48 +01:00
0a14283892
Remvoe stored state class
* The idea was good but the specifics were dumb
* Re-implement later with a better interface
2024-02-06 17:43:23 +01:00
5115de451f
Correct spelling of domLens 2024-02-06 17:43:23 +01:00
9b738bd589
Remove vestigial ChildObserver class from DOM lens 2024-02-06 17:43:23 +01:00
71d7c0ff4f
Rename "reactive" object to observable in code 2024-02-06 17:43:23 +01:00
784eb78f0a Refactor 2024-01-24 16:43:50 +01:00
6248593570 Fix and refactor skooma.js 2024-01-24 16:13:01 +01:00
5488f2a49a Fix bug in composed state 2024-01-24 16:12:22 +01:00
30f52a05e5 Refactor state module 2024-01-24 16:12:15 +01:00
1c03da8815 Make domLense error on non-element targets 2024-01-24 15:06:12 +01:00
44b6343de6 Remove event name cleanup in skooma.js 2024-01-24 14:51:45 +01:00
2445617e8b Switch to svelte store contract 2024-01-24 14:50:51 +01:00
688cbae9ba Refactor skooma.js 2024-01-24 13:14:06 +01:00
74e364e714 Add methods param to component generator 2024-01-24 10:21:59 +01:00
2f95afbcb7 Fix error handling arrays in skooma.js 2024-01-24 10:21:59 +01:00
24ea67bf81 Add get/set filters to forward state 2024-01-24 10:21:59 +01:00
dc29b10b1a [WIP] Tweak skooma state contract 2024-01-22 11:22:11 +01:00
dda6673f15 [WIP] Add child generators and refactor 2024-01-22 10:35:42 +01:00
f6e7c00944 [WIP] Refactor get/set with arguments 2024-01-22 10:32:35 +01:00
2b8ba6e7d6
Change domLense method semantics 2024-01-20 15:06:23 +01:00
7a789d407e [WIP] Major refactor and API change for version 2.0 2024-01-17 15:16:46 +01:00
11 changed files with 1100 additions and 671 deletions

2
.editorconfig Normal file
View file

@ -0,0 +1,2 @@
[*]
indent_style = tab

View file

@ -1,21 +1,5 @@
class ChildObserver extends MutationObserver {
constructor() {
super(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new CustomEvent("change", {detail: mutation}))
}
})
}
observe(element) {
MutationObserver.prototype.observe.call(this, element, { childList: true })
}
}
const childObserver = new ChildObserver()
export const lense = (methods, extra) => {
if (extra) return lense(extra)(methods)
export const domArray = (methods, extra) => {
if (extra) return domArray(extra)(methods)
const traps = {
get(target, prop) {
@ -24,12 +8,12 @@ export const lense = (methods, extra) => {
} else if (prop === Symbol.iterator) {
return function*() {
for (const child of target.children) {
yield methods.get(child)
yield methods.get.call(child)
}
}
} else if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) return methods.get(child)
if (child) return methods.get.call(child)
return child
} else {
return Array.prototype[prop]
@ -39,7 +23,7 @@ export const lense = (methods, extra) => {
if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) {
methods.set(child, value)
methods.set.call(child, value)
return true
} else {
for (let i = target.children.length; i < Number(prop); i++) {
@ -47,8 +31,8 @@ export const lense = (methods, extra) => {
}
const element = methods.new(value)
target.appendChild(element)
if (methods.get(element) !== value)
methods.set(element, value)
if (methods.get.call(element) !== value)
methods.set.call(element, value)
return true
}
} else if (prop == "length") {
@ -71,15 +55,20 @@ export const lense = (methods, extra) => {
}
return element => {
const proxy = new Proxy(element, traps)
if (methods.event) childObserver.observe(element)
if (typeof methods.event === "function") element.addEventListener("change", event => {
methods.event(proxy, element, event.detail)
})
return proxy
if (!(element instanceof Element)) throw(new Error("Creating domArray on non-element"))
return new Proxy(element, traps)
}
}
export default lense
export const meta = (element=document.head) => new Proxy(element, {
get: (target, name) => target.querySelector(`meta[name="${name}"]`)?.content,
set: (target, name, value) => {
let meta = target.querySelector(`meta[name="${name}"]`)
if (!meta) {
meta = document.createElement("meta")
meta.name = name
target.append(meta)
}
meta.content = value
}
})

6
jsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"compilerOptions": {
"checkJs": true,
"target": "es2021"
}
}

7
mini.js Normal file
View file

@ -0,0 +1,7 @@
export default new Proxy(document, {
get: (_, tag) => content => {
let node = document.createElement(tag)
for (let key in content) node[key] = content[key]
return node
}
})

515
observable.js Normal file
View file

@ -0,0 +1,515 @@
/** @type FinalizationRegistry<AbortController> */
const abortRegistry = new FinalizationRegistry(controller => controller.abort())
/** @param {String} string */
const camelToKebab = string => string.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`)
/** @param {String} string */
const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a+b.toUpperCase())
const identity = object=>object
const target = Symbol("Proxy Target")
/* Custom Event Classes */
/**
* @typedef {Object} Change
* @property {string} property
* @property {any} from
* @property {any} to
* @property {boolean} mutation - The change happened inside the value without a new assignment
*/
/** Event fired for every change before the internal state has been updated that can be canceled. */
export class SynchronousChangeEvent extends Event {
/** @param {Change} change */
constructor(change) {
super('synchronous', {cancelable: true})
this.change = Object.freeze(change)
}
}
/** Event fired for one or more changed values after the internal state has been updated. */
export class MultiChangeEvent extends Event {
/** @type {any} */
#final
/** @type {any} */
#values
/** @param {Change[]} changes */
constructor(...changes) {
super('change')
this.changes = changes
}
get values() {
if (!this.#values) {
const values = new Map()
for (const {property, from, to} of this.changes) {
let list = values.get(property)
if (!list) {
list = [from]
values.set(property, list)
}
list.push(to)
}
this.#values = values
}
return this.#values
}
get final() {
if (!this.#final) {
this.#final = new Map()
for (const [property, list] of this.values) {
if (list[0] !== list[list.length-1]) {
this.#final.set(property, list[list.length-1])
}
}
}
return this.#final
}
}
export class ValueChangeEvent extends MultiChangeEvent {
get value() {
return this.final.value
}
}
/* Observable Classes */
export class Observable extends EventTarget {
#synchronous
/** @type Change[]> */
#queue
#abortController = new AbortController
#ref = new WeakRef(this)
get ref() { return this.#ref }
constructor({synchronous=false}={}) {
super()
Object.defineProperty(this, "observable", {value: true, configurable: false, writable: false})
if (this.constructor === Observable) {
throw new TypeError("Cannot instantiate abstract class")
}
this.#synchronous = !!synchronous
abortRegistry.register(this, this.#abortController)
this.proxy = new Proxy(this.constructor.prototype.proxy, {
get: (target, prop) => target.call(this, prop)
})
}
/**
* @param {string} prop
*/
proxy(prop, {get=undefined, set=undefined, ...options}={}) {
const proxy = new ProxiedObservableValue(this, prop, options)
if (get) proxy.get = get
if (set) proxy.set = set
return proxy
}
/**
* @param {string} prop
* @param {function(any):void} callback
*/
subscribe(prop, callback) {
const controller = new AbortController()
// @ts-ignore
this.addEventListener("change", ({final}) => {
if (final.has(prop)) return callback(final.get(prop))
}, {signal: controller.signal})
callback(this[prop])
return () => controller.abort()
}
/** Queues up a change event
* @param {string} property - Name of the changed property
* @param {any} from
* @param {any} to
* @param {boolean} mutation - whether a change was an assignment or a mutation (nested change)
*/
enqueue(property, from, to, mutation=false) {
const change = {property, from, to, mutation}
if (!this.dispatchEvent(new SynchronousChangeEvent(change))) return false
if (!this.synchronous) {
if (!this.#queue) {
this.#queue = []
queueMicrotask(() => {
this.emit(...this.#queue)
this.#queue = undefined
})
}
this.#queue.push(change)
} else {
this.emit(change)
}
return true
}
/** @param {any[]} _args */
emit(..._args) {
throw new TypeError(`${this.constructor.name} did not define an 'emit' method`)
}
get signal() { return this.#abortController.signal }
get synchronous() { return this.#synchronous }
}
export class ObservableObject extends Observable {
/**
* @param {Object} target
* @param {Object} options
*/
constructor(target={}, {shallow=true, ...options}={}) {
super(options)
Object.defineProperty(this, "target", target)
this.values = new Proxy(target, {
/**
* @param {Object} target
* @param {String} prop
* @param {any} value
*/
set: (target, prop, value) => {
const old = target[prop]
if (old === value) {
return true
} else {
if (this.enqueue(prop, old, value)) {
if (!shallow) {
if (old instanceof Observable) this.disown(prop, old)
if (value instanceof Observable) this.adopt(prop, value)
}
target[prop] = value
return true
} else {
return false
}
}
},
/**
* @param {Object} target
* @param {String} prop
*/
get: (target, prop) => target[prop],
})
}
/**
* @param {string} prop
* @param {Object} options
*/
proxy(prop, {get=undefined, set=undefined, ...options}={}) {
const proxy = new ProxiedObservableValue(this, prop, {values: this.values, ...options})
if (get) proxy.get = get
if (set) proxy.set = set
return proxy
}
/** @param {Change[]} changes */
emit(...changes) {
this.dispatchEvent(new MultiChangeEvent(...changes))
}
/** @type Map<Observable, Map<String, Function>> */
#nested = new Map()
/** Adopts an obsercable to be notified of its changes
* @param {string} prop
* @param {Observable} observable
*/
adopt(prop, observable) {
let handlers = this.#nested.get(observable)
if (!handlers) {
// Actual adoption
handlers = new Map()
this.#nested.set(observable, handlers)
}
const ref = this.ref
const handler = () => ref.deref()?.emit(prop, observable, observable, {observable: true})
handlers.set(prop, handler)
observable.addEventListener("change", handler, {signal: this.signal})
}
/** Undoes the adoption of a nested observable, cancelling the associated event hook
* @param {string} prop
* @param {Observable} observable
*/
disown(prop, observable) {
const handlers = this.#nested.get(observable)
const handler = handlers.get(prop)
observable.removeEventListener("change", handler)
handlers.delete(prop)
if (handlers.size == 0) {
this.#nested.delete(observable)
}
}
}
export class ObservableValue extends Observable {
#value
/**
* @param {any} value
* @param {Object} options
*/
constructor(value, options) {
super(options)
this.#value = value
}
get value() { return this.#value }
set value(value) {
if (this.enqueue("value", this.#value, value)) {
this.#value = value
}
}
/**
* @param {string} prop
* @param {function(any):void} callback
*/
subscribe(prop, callback) {
// @ts-ignore
if (typeof(prop) == "function") [prop, callback] = ["value", prop]
this.constructor.prototype.subscribe.call(this, prop, callback)
}
/** @param {Change[]} changes */
emit(...changes) {
this.dispatchEvent(new ValueChangeEvent(...changes))
}
}
class ProxiedObservableValue extends ObservableValue {
#values
#prop
/**
* @param {Observable} backend
* @param {string} prop
*/
constructor(backend, prop, {values=backend, ...options}={}) {
super(options)
this.#values = values
this.#prop = prop
const ref = this.ref
backend.addEventListener("synchronous", event => {
const {property, from, to, ...rest} = event.change
if (property == this.#prop) {
ref.deref()?.enqueue({
property,
from: this.get(from),
to: this.get(to),
...rest
})
}
}, { signal: this.signal })
}
get = identity
set = identity
get value() { return this.get(this.#values[this.#prop]) }
set value(value) { this.#values[this.#prop] = this.set(value) }
}
const attributeObserver = new MutationObserver(mutations => {
for (const {type, target, attributeName: name} of mutations) {
if (type == "attributes") {
const next = target.getAttribute(name)
const camelName = kebabToCamel(name)
if (String(target.state.values[camelName]) !== next)
target.state.values[camelName] = next
}
}
})
export const component = (name, generator, methods) => {
if (typeof name === "function") {
methods = generator
generator = name
name = camelToKebab(generator.name)
}
const Element = class extends HTMLElement{
constructor() {
super()
const target = Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value]))
this.state = new ObservableObject(target)
this.state.addEventListener("change", event => {
for (const {property, to: value} of event.changes) {
const kebabName = camelToKebab(property)
if (this.getAttribute(kebabName) !== String(value))
this.setAttribute(kebabName, value)
}
})
attributeObserver.observe(this, {attributes: true})
const content = generator.call(this, this.state)
if (content) this.replaceChildren(content)
}
}
if (methods) {
Object.defineProperties(Element.prototype, Object.getOwnPropertyDescriptors(methods))
}
customElements.define(name, Element)
return Element;
}
class ObservableComposition extends ObservableValue {
#func
#states
constructor(func, options, ...states) {
super(options)
this.#func = func
this.#states = states
const abortController = new AbortController()
abortRegistry.register(this, abortController)
const ref = new WeakRef(this)
states.forEach(state => {
state.addEventListener("change", () => {
ref.deref()?.scheduleUpdate()
}, {signal: abortController.signal})
})
this.update()
}
#microtaskQueued
scheduleUpdate() {
if (this.synchronous) {
this.update()
} else {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
this.update()
})
this.#microtaskQueued = true
}
}
}
update() {
const value = this.#func(...this.#states.map(state => state.value))
const change = {property: "value", from: this.value, to: value}
this.value = value
this.emit(change)
}
}
export const compose = func => (...states) => new ObservableComposition(func, {}, ...states)
class MutationEvent extends Event {
constructor() {
super("mutation", {bubbles: true})
}
}
const mutationObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new MutationEvent())
}
})
export class ObservableElement extends Observable {
#getValue
#equal
#value
#changedValue = false
/**
* @param {HTMLElement} target
*/
constructor(target, {get=undefined, equal=undefined, ...options}={}) {
// @ts-ignore
super(options)
Object.defineProperty(this, "target", {value: target, configurable: false, writable: false})
this.#getValue = get ?? (target => target.value)
this.#equal = equal ?? ((a, b) => a===b)
this.#value = this.#getValue(target)
const controller = new AbortController()
target.addEventListener("mutation", event => { this.update(event) }, {signal: controller.signal})
abortRegistry.register(this, controller)
mutationObserver.observe(target, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
}
get value() { return this.#value }
update() {
const current = this.#getValue(this.target)
if (this.#equal(this.#value, current)) return
this.#value = current
if (this.synchronous) {
this.dispatchEvent(new ValueChangeEvent(["value", current]))
} else {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
this.dispatchEvent(new ValueChangeEvent(["value", this.#changedValue]))
})
this.#changedValue = current
}
}
}
}
export class MapStorage extends Storage {
#map = new Map()
/**
* @param {number} index
* @return {string}
*/
key(index) {
return [...this.#map.keys()][index]
}
/**
* @param {string} keyName
* @return {any}
*/
getItem(keyName) {
if (this.#map.has(keyName))
return this.#map.get(keyName)
else
return null
}
/**
* @param {string} keyName
* @param {any} keyValue
*/
setItem(keyName, keyValue) {
this.#map.set(keyName, String(keyValue))
}
/** @param {string} keyName */
removeItem(keyName) {
this.#map.delete(keyName)
}
clear() {
this.#map.clear()
}
}

View file

@ -3,7 +3,7 @@
"version": "1.7.0",
"author": "darkwiiplayer",
"license": "Unlicense",
"main": "skooma.js",
"main": "render.js",
"type": "module",
"repository": {
"type": "git",

View file

@ -1,12 +1,40 @@
# Skooma
A functional-friendly helper library for procedural DOM generation and
templating.
```js
import {html} from "skooma.js"
import {html} from "skooma/render.js"
document.body.append(
html.p(
"This is a paragraph with some text ",
html.b("and some bold text "),
html.img({
alt: "And an image",
href: "http://picsum.photos/200/200"
})
)
)
```
## Goals
1. `skooma/render` should stay small enough to use it as just a helper library
to generate some dom nodes in any sort of web environment.
1. `skooma/observable` should likewise function as a standalone reactive state
management library to be used with or without a framework
1. A developer who doesn't use skooma should be able to read any code using it
and piece together what it does based on structure and function names
1. Skooma should be easy to gradually introduce into an application that uses
a different framework or no framework at all
1. Skooma should make it easy to gradually replace it with a different solution
should it prove unfit for a project it is being used in
1. The library should be hackable so that developers can tweak it for different
environments like SSR or frameworks
## Warning
**This branch is in the process of being aggressively refactored and improved.
This readme file may not reflect the latest state of the interface.**
## Overview
```js
@ -22,7 +50,7 @@ document.body.append(html.div(
## Interface / Examples
### Basic DOM generation
### Basic DOM generatio
Accessing the `html` proxy with any string key returns a new node generator
function:
@ -89,7 +117,7 @@ text`Hello, ${html.b(user)}!`
## handle
```js
import {handle} from 'skooma.js'
import {handle} from 'skooma/state.js'
```
Since it is common for event handlers to call `preventDefault()`, skooma

18
ref.js Normal file
View file

@ -0,0 +1,18 @@
/** A reference to a reactive element that follows it around through changes */
export class Ref {
#current
/** @param {Element|Text} target A reactive element to follow */
constructor(target) {
this.#current = target
this.follow(target)
}
follow(target) {
target.addEventListener("replaced", ({next}) => {
this.#current = next
this.follow(next)
})
}
deref() { return this.#current }
}

496
render.js Normal file
View file

@ -0,0 +1,496 @@
// Keep a referee alive until a referrer is collected
const weakReferences = new WeakMap()
/** Keeps the referenced value alive until the referrer is collected
* @param {Object} referrer
* @param {Object} reference
*/
const untilDeathDoThemPart = (referrer, reference) => {
if (!weakReferences.has(referrer)) weakReferences.set(referrer, new Set())
weakReferences.get(referrer).add(reference)
}
/** Like AbortController, but resets after each abort */
class MultiAbortController {
#controller = new AbortController()
/** @return {AbortSignal} */
get signal() { return this.#controller.signal }
abort() { this.#controller.abort(); this.#controller = new AbortController() }
}
/** A symbol representing nothing to be appended to an element */
export const empty = Symbol("Explicit empty argument for Skooma")
/** Converts a snake-case string to a CSS property name
* @param {string} key
* @return {string}
*/
const snakeToCSS = key => key.replace(/^[A-Z]/, a => "-" + a).replace(/[A-Z]/g, a => '-' + a.toLowerCase())
/** @typedef SpecialAttributeDescriptor
* @type {object}
* @property {function(Node):void} [get]
* @property {function(Node,any):void} [set]
* @property {function(Node,function(any):void):void} [subscribe]
* @property {function(Node):boolean} [filter]
*/
/**
* Returns a fallback if value is fallback
* @param {any} value
* @param {any} whenUndefined
*/
const fallback = (value, whenUndefined) => typeof value != "undefined" ? value : whenUndefined
/** @typedef {EventTarget & {value: any}} Observable */
/** Cancelable event triggered when a reactive element gets replaced with something else */
export class BeforeReplaceEvent extends Event {
/** @param {Element|Text} next */
constructor(next) {
super("skooma:beforereplace", { cancelable: true })
this.next = next
}
}
/** Event triggered when a reactive element was replaced */
export class AfterReplaceEvent extends Event {
/** @param {Element|Text} next */
constructor(next) {
super("skooma:afterreplace")
this.next = next
}
}
/** Event triggered when a new element replaces an old one */
export class ReplacedEvent extends Event {
/** @param {Element|Text} old */
constructor(old) {
super("skooma:replaced", { bubbles: true })
this.old = old
}
}
/** Event triggered when a reactive attribute changes on an element */
export class AttributeEvent extends Event {
/**
* @param {String} attribute
* @param {any} from
* @param {any} to
*/
constructor(attribute, from, to) {
super("skooma:attribute", { cancelable: true })
this.attribute = attribute
this.from = from
this.to = to
}
}
// Other utility exports
/** Wraps an event handler in a function that calls preventDefault on the event
* @param {function(event) : event} fn
* @return {function(event)}
*/
export const handle = fn => event => { event.preventDefault(); return fn(event) }
/** A reference to an element that follows it around through replacements */
export class Ref {
/** @type {WeakMap<Text|Element,Text|Element>} */
static #map = new WeakMap()
/** @type {Element|Text} */
#element
/** @param {Element|Text} element */
constructor(element) {
this.#element = element
}
/** @return {Element|Text} */
deref() {
const next = Ref.newer(this.#element)
if (next) {
this.#element = next
return this.deref()
} else {
return this.#element
}
}
/** @param {Element|Text} element */
static newer(element) {
return this.#map.get(element)
}
/**
* @param {Element|Text} previous
* @param {Element|Text} next
*/
static replace(previous, next) {
if (this.newer(previous))
throw "Element has already been replaced with newer one"
this.#map.set(previous, next)
}
}
/** Main class doing all the rendering */
export class Renderer {
static proxy() {
return new Proxy(new this(), {
/** @param {string} prop */
get: (renderer, prop) => /** @param {any[]} args */ (...args) => renderer.node(prop, args),
has: (renderer, prop) => renderer.nodeSupported(prop),
})
}
/** @param {string} name */
node(name, ...args) {
throw "Attempting to use an abstract Renderer"
}
/** @param {string|symbol} name */
nodeSupported(name) {
if (typeof(name) != "string") return false
return true
}
/** Turns an attribute value into a string */
/** @param {any} value */
static serialiseAttributeValue(value) {
if (typeof value == "string" || typeof value == "number")
return value
else if (value && "join" in value)
return value.join(" ")
else if (Object.getPrototypeOf(value) == Object.prototype)
return JSON.stringify(value)
else
return value.toString()
}
}
export class DomRenderer extends Renderer {
/** @type {Object<string,SpecialAttributeDescriptor>} */
static specialAttributes = Object.freeze({})
/** Processes a list of arguments for an HTML Node
* @param {Element|ShadowRoot} element
* @param {Array} args
*/
static apply(element, ...args) {
for (const arg of args) if (arg !== empty) {
if (Array.isArray(arg)) {
this.apply(element, ...arg)
} else {
const child = this.toElement(arg)
if (child)
element.append(child)
else if (typeof arg == "function")
this.apply(element, arg(element) || empty)
else if (arg instanceof DocumentFragment)
element.append(arg)
else if (arg && typeof(arg)=="object")
for (const key in arg)
if (element instanceof Element)
this.setAttribute(element, key, arg[key])
else
throw `Attempting to set attributes on a non-element (${element.constructor.name})`
else
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
}
}
}
/** Creates a new node
* @param {String} name
* @param {Array} args
*/
node(name, args) {
const element = this.createElement(name)
this.constructor.apply(element, args)
return element
}
/**
* @protected
* @param {String} name
* @param {Object} options
* @return {Node}
*/
createElement(name, options={}) {
return document.createElement(name, options)
}
/** Turns an argument into something that can be inserted as a child into a DOM node
* @protected
* @param {any} value
* @return {Element|Text}
*/
static toElement(value) {
if (typeof value == "string" || typeof value == "number")
return document.createTextNode(value.toString())
else if (value instanceof Element)
return value
else if (this.isObservable(value))
return this.toReactiveElement(value)
}
/**
* @protected
* @param {Observable} observable
* @return {Element|Text}
*/
static toReactiveElement(observable) {
if (observable.value instanceof DocumentFragment) {
throw "Failed to create reactive element: Document fragments cannot be replaced dynamically"
}
const element = this.toElement(observable.value)
untilDeathDoThemPart(element, observable)
let ref = new WeakRef(element)
const handleChange = () => {
const element = ref.deref()
if (!element) return
const next = this.toElement(observable.value)
if (element?.dispatchEvent(new BeforeReplaceEvent(next))) {
element.replaceWith(next)
Ref.replace(element, next)
next.dispatchEvent(new ReplacedEvent(element))
element.dispatchEvent(new AfterReplaceEvent(next))
ref = new WeakRef(next)
}
observable.addEventListener("change", handleChange, {once: true})
}
observable.addEventListener("change", handleChange, {once: true})
return element
}
/** Set an attribute on an element
* @protected
* @param {Element} element
* @param {string} attribute
* @param {any} value
* @param {AbortSignal} [cleanupSignal]
*/
static setAttribute(element, attribute, value, cleanupSignal) {
const special = this.getSpecialAttribute(element, attribute)
if (this.isObservable(value))
{ this.setReactiveAttribute(element, attribute, value) }
else if (typeof value === "function")
element.addEventListener(attribute, value, { signal: cleanupSignal })
else if (special?.set)
special.set(element, value)
else if (value === true)
{ if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '') }
else if (value === false)
element.removeAttribute(attribute)
else {
element.setAttribute(attribute, this.serialiseAttributeValue(value))
}
}
/** Set up a binding between an attribute and an observable
* @protected
* @param {Element} element
* @param {string} attribute
* @param {Observable} observable
*/
static setReactiveAttribute(element, attribute, observable) {
const multiAbort = new MultiAbortController()
observable.addEventListener("change", () => {
multiAbort.abort()
if (element.dispatchEvent(new AttributeEvent(attribute, element.getAttribute(attribute), observable.value)))
this.setAttribute(element, attribute, observable.value, multiAbort.signal)
})
this.setAttribute(element, attribute, observable.value, multiAbort.signal)
const special = this.getSpecialAttribute(element, attribute)
if (special?.subscribe) {
untilDeathDoThemPart(element, observable)
special.subscribe(element, value => {
if (value != observable.value) observable.value = value
})
}
}
/**
* @param {CSSStyleDeclaration} style The style property of a node
* @param {object} rules A map of snake case property names to css values
*/
static insertStyles(style, rules) {
for (const [key, value] of Object.entries(rules))
if (typeof value == "undefined")
style.removeProperty(snakeToCSS(key))
else
style.setProperty(snakeToCSS(key), value.toString())
}
/** Returns whether an object is an observable according to skooma's contract
* @param {any} object
* @return {object is Observable}
*/
static isObservable(object) {
return object && object.observable
}
/** Wraps a list of elements in a document fragment
* @param {Array<Element|String>} elements
*/
static documentFragment(...elements) {
const fragment = new DocumentFragment()
for (const element of elements)
fragment.append(this.toElement(element))
return fragment
}
/**
* @protected
* @param {Element} element
* @param {String} attribute
*/
static getSpecialAttribute(element, attribute) {
const special = this.specialAttributes[attribute]
if (special?.filter == undefined)
return special
if (special.filter(element))
return special
return undefined
}
/**
* @param {String|Array<String>} data
* @param {Array<String|Element>} items
*/
static createTextOrFragment(data = "", ...items) {
return Array.isArray(data)
? this.textFromTemplate(data, items)
: document.createTextNode(data)
}
/** Turns a template literal into document fragment.
* Strings are returned as text nodes.
* Elements are inserted in between.
* @param {Array<String>} literals
* @param {Array<any>} items
* @return {DocumentFragment}
*/
static textFromTemplate(literals, items) {
const fragment = new DocumentFragment()
for (const key in items) {
fragment.append(document.createTextNode(literals[key]))
fragment.append(this.toElement(items[key]))
}
fragment.append(document.createTextNode(literals[literals.length - 1]))
return fragment
}
}
/** Renderer for normal HTML nodes targetting a browser's DOM */
export class DomHtmlRenderer extends DomRenderer {
/**
* @param {String} name
* @param {Object} options
* @return {Node}
*/
createElement(name, options) {
return document.createElement(name.replace(/([a-z])([A-Z])/g, "$1-$2"), options)
}
/** Creates a new node and make it a custom element if necessary
* @param {String} name
* @param {Array} args
*/
node(name, args) {
const custom = this.getCustom(args)
const opts = custom && { is: String(custom) }
const element = this.createElement(name, opts)
this.constructor.apply(element, args)
return element
}
/** Recursively finds the last 'is' attribute in a list nested array of objects
* @param {Array} args
*/
getCustom(args) {
return args.reduce(
(current, argument) => Array.isArray(argument)
? fallback(this.getCustom(argument), current)
: (argument && typeof argument == "object")
? fallback(argument.is, current)
: current
, undefined
)
}
/** @type {Object<string,SpecialAttributeDescriptor>} */
static specialAttributes = {
value: {
/** @param {HTMLInputElement} element */
get(element) { return element.value },
/** @param {HTMLInputElement} element */
set(element, value) {
element.setAttribute("value", value)
element.value = value
},
/** @param {HTMLInputElement} element */
subscribe(element, callback) {
element.addEventListener("input", () => {
callback(this.get(element))
})
},
/** @param {HTMLElement} element */
filter(element) {
return element.nodeName.toLowerCase() == "input"
}
},
style: {
/** @param {HTMLElement} element */
set(element, value) { DomRenderer.insertStyles(element.style, value) }
},
dataset: {
/** @param {HTMLElement} element */
set(element, value) {
for (const [attribute2, value2] of Object.entries(value)) {
element.dataset[attribute2] = DomRenderer.serialiseAttributeValue(value2)
}
}
},
shadowRoot: {
/** @param {HTMLElement} element */
set(element, value) {
DomRenderer.apply(
(element.shadowRoot || element.attachShadow({ mode: "open" })),
value
)
}
}
}
}
/** Renderer for normal SVG nodes targetting a browser's DOM */
export class DomSvgRenderer extends DomRenderer {
/**
* @param {String} name
* @param {Object} options
* @return {Node}
*/
createElement(name, options) {
return document.createElementNS("http://www.w3.org/2000/svg", name, options)
}
}
export const html = DomHtmlRenderer.proxy()
export const svg = DomSvgRenderer.proxy()
export const fragment = DomRenderer.documentFragment.bind(DomRenderer)
export const text = DomRenderer.createTextOrFragment.bind(DomRenderer)
export default html

219
skooma.js
View file

@ -1,219 +0,0 @@
/*
A functional HTML generation library.
Example:
html.label(
html.span("Delete everything", {class: ["warning", "important"]}),
html.button("Click", {onClick: e => document.body.innerHTML=""}),
)
or
html.ul([1, 2, 3, 4, 5].map(x => html.li(x)), {class: "numbers"})
*/
// Keep a referee alive until a referrer is collected
const weakReferences = new WeakMap()
const untilDeathDoThemPart = (referrer, reference) => {
if (!weakReferences.has(referrer)) {
weakReferences.set(referrer, new Set())
}
weakReferences.get(referrer).add(reference)
}
export const empty = Symbol("Explicit empty argument for Skooma")
const keyToPropName = key => key.replace(/^[A-Z]/, a => "-"+a).replace(/[A-Z]/g, a => '-'+a.toLowerCase())
const insertStyles = (rule, styles) => {
for (const [key, value] of Object.entries(styles))
if (typeof value == "undefined")
rule.removeProperty(keyToPropName(key))
else
rule.setProperty(keyToPropName(key), value.toString())
}
const parseAttribute = (attribute) => {
if (typeof attribute == "string" || typeof attribute == "number")
return attribute
else if (attribute && "join" in attribute)
return attribute.join(" ")
else
return JSON.stringify(attribute)
}
const defined = (value, fallback) => typeof value != "undefined" ? value : fallback
const getCustom = args => args.reduce(
(current, argument) => Array.isArray(argument)
? defined(getCustom(argument), current)
: (argument && typeof argument == "object")
? defined(argument.is, current)
: current
,undefined
)
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
const isReactive = object => object
&& (typeof object == "object")
&& ("addEventListener" in object)
&& ("value" in object)
const toChild = arg => {
if (typeof arg == "string" || typeof arg == "number") {
return document.createTextNode(arg)
} else if (isElement(arg)) {
return arg
} else if (isReactive(arg)) {
return reactiveChild(arg)
}
}
const reactiveChild = reactive => {
const ref = new WeakRef(toChild(reactive.value))
reactive.addEventListener("change", () => {
const value = ref.deref()
if (value)
value.replaceWith(reactiveChild(reactive))
}, {once: true})
untilDeathDoThemPart(ref.deref(), reactive)
return ref.deref()
}
const specialAttributes = {
value: {
get: element => element.value,
set: (element, value) => {
element.setAttribute("value", value)
element.value = value
},
hook: (element, callback) => { element.addEventListener("input", callback) }
},
style: {
set: (element, value) => { insertStyles(element.style, value) }
},
dataset: {
set: (element, value) => {
for (const [attribute2, value2] of Object.entries(value)) {
element.dataset[attribute2] = parseAttribute(value2)
}
}
},
shadowRoot: {
set: (element, value) => {
parseArgs((element.shadowRoot || element.attachShadow({mode: "open"})), null, value)
}
}
}
const setAttribute = (element, attribute, value, cleanupSignal) => {
const special = specialAttributes[attribute]
if (isReactive(value))
setReactiveAttribute(element, attribute, value)
else if (typeof value === "function")
element.addEventListener(attribute.replace(/^on[A-Z]/, x => x.charAt(x.length-1).toLowerCase()), value, {signal: cleanupSignal})
else if (special) {
special.set(element, value)
}
else if (value === true)
{if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')}
else if (value === false)
element.removeAttribute(attribute)
else {
element.setAttribute(attribute, parseAttribute(value))
}
}
const setReactiveAttribute = (element, attribute, reactive, abortController) => {
untilDeathDoThemPart(element, reactive)
if (abortController) abortController.abort()
abortController = new AbortController()
const ref = new WeakRef(element)
setAttribute(element, attribute, reactive.value, abortController.signal)
reactive.addEventListener("change", () => {
const element = ref.deref()
if (element)
setReactiveAttribute(element, attribute, reactive, abortController)
}, {once: true})
const special = specialAttributes[attribute]
if (special?.hook) {
special.hook(element, () => {
const value = special.get(element, attribute)
if (value != reactive.value) reactive.value = value
})
}
}
const parseArgs = (element, before, ...args) => {
if (element.content) element = element.content
for (const arg of args) if (arg !== empty) {
const child = toChild(arg)
if (child)
element.insertBefore(child, before)
else if (arg === undefined || arg == null)
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
else if (typeof arg == "function")
arg(element)
else if ("length" in arg)
parseArgs(element, before, ...arg)
else
for (const key in arg)
setAttribute(element, key, arg[key])
}
}
const node = (name, args, options) => {
let element
const custom = getCustom(args)
const opts = custom && {is: String(custom)}
if ("nameFilter" in options) name = options.nameFilter(name)
if (options.xmlns)
element = document.createElementNS(options.xmlns, name, opts)
else
element = document.createElement(name, opts)
parseArgs(element, null, args)
return element
}
const nameSpacedProxy = (options={}) => new Proxy(Window, {
get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) },
has: (_target, _prop) => true,
})
export const html = nameSpacedProxy({nameFilter: name => name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()})
export const svg = nameSpacedProxy({xmlns: "http://www.w3.org/2000/svg"})
export default html
// Other utility exports
// Wraps an event handler in a function that calls preventDefault on the event
export const handle = fn => event => { event.preventDefault(); return fn(event) }
// Wraps a list of elements in a document fragment
export const fragment = (...elements) => {
const fragment = new DocumentFragment()
for (const element of elements)
fragment.append(element)
return fragment
}
// Turns a template literal into document fragment.
// Strings are returned as text nodes.
// Elements are inserted in between.
const textFromTemplate = (literals, items) => {
const fragment = new DocumentFragment()
for (const key in items) {
fragment.append(document.createTextNode(literals[key]))
fragment.append(items[key])
}
fragment.append(document.createTextNode(literals.at(-1)))
return fragment
}
export const text = (data="", ...items) =>
typeof data == "object" && "at" in data
? textFromTemplate(data, items)
: document.createTextNode(data)

413
state.js
View file

@ -1,413 +0,0 @@
const abortRegistry = new FinalizationRegistry(controller => controller.abort())
const camelToKebab = string => string.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`)
const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a+b.toUpperCase())
export class ChangeEvent extends Event {
#final
constructor(...changes) {
super('change')
this.changes = changes
}
get final() {
if (!this.#final) {
this.#final = new Map(this.changes)
}
return this.#final
}
}
export class SimpleState extends EventTarget {}
export class MapStorage extends Storage {
#map = new Map()
key(index) {
return [...this.#map.keys()][index]
}
getItem(keyName) {
if (this.#map.has(keyName))
return this.#map.get(keyName)
else
return null
}
setItem(keyName, keyValue) {
this.#map.set(keyName, String(keyValue))
}
removeItem(keyName) {
this.#map.delete(keyName)
}
clear() {
this.#map.clear()
}
}
export class State extends SimpleState {
#target
#options
#queue
#forwardCache
#abortController
#nested = new Map()
#weakRef = new WeakRef(this)
static isState(object) { return SimpleState.prototype.isPrototypeOf(object) }
constructor(target={}, options={}) {
super()
this.#abortController = new AbortController
abortRegistry.register(this, this.#abortController)
this.#options = options
this.#target = target
this.values = new Proxy(target, {
set: (_target, prop, value) => {
const old = this.get(prop)
if (old !== value) {
this.emit(prop, value)
if (this.#options.shallow) {
if (State.isState(old)) this.disown(prop, old)
if (State.isState(value)) this.adopt(prop, value)
}
this.set(prop, value)
}
return true
},
get: (_target, prop) => this.get(prop),
})
this.addEventListener
// Try running a "<name>Changed" method for every changed property
// Can be disabled to maybe squeeze out some performance
if (options.methods ?? true) {
this.addEventListener("change", ({final}) => {
final.forEach((value, prop) => {
if (`${prop}Changed` in this) this[`${prop}Changed`](value)
})
})
}
}
// When you only need one value, you can skip the proxy.
set value(value) { this.values.value = value }
get value() { return this.values.value }
adopt(prop, state) {
let handlers = this.#nested.get(state)
if (!handlers) {
// Actual adoption
handlers = new Map()
this.#nested.set(state, handlers)
}
const ref = this.#weakRef
const handler = () => ref.deref()?.emit(prop, state)
handlers.set(prop, handler)
state.addEventListener("change", handler, {signal: this.#abortController.signal})
}
disown(prop, state) {
const handlers = this.#nested.get(state)
const handler = handlers.get(prop)
state.removeEventListener("change", handler)
handlers.delete(prop)
if (handlers.size == 0) {
this.#nested.delete(state)
}
}
// Anounces that a prop has changed
emit(prop, value) {
if (this.#options.defer ?? true) {
if (!this.#queue) {
this.#queue = []
queueMicrotask(() => {
this.dispatchEvent(new ChangeEvent(...this.#queue))
this.#queue = undefined
})
}
this.#queue.push([prop, value])
} else {
this.dispatchEvent(new ChangeEvent([prop, value]))
}
}
forward(property="value", fallback) {
if (!this.#forwardCache) this.#forwardCache = new Map()
const cached = this.#forwardCache.get(property)?.deref()
if (cached) {
return cached
} else {
const forwarded = new ForwardState(this, property, fallback)
const ref = new WeakRef(forwarded)
this.#forwardCache.set(property, ref)
forwardFinalizationRegistry.register(forwarded, [this.#forwardCache, property])
return forwarded
}
}
set(...args) {
if (args.length === 1) return this.set("value", ...args)
const [prop, value] = args
this.#target[prop] = value
}
get(...args) {
if (args.length === 0) return this.get("value")
const prop = args[0]
return this.#target[prop]
}
subscribe(prop, callback) {
if (!callback) return this.subscribe("value", prop)
const controller = new AbortController()
this.addEventListener("change", ({final}) => {
if (final.has(prop)) return callback(final.get(prop))
}, {signal: controller.signal})
callback(this.value)
return () => controller.abort()
}
// Backwards compatibility
get proxy() { return this.values }
}
const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => {
cache.remove(name)
})
export class ForwardState extends SimpleState {
#backend
#property
#fallback
constructor(backend, property, fallback) {
super()
this.#backend = backend
this.#property = property
this.#fallback = fallback
const ref = new WeakRef(this)
const abortController = new AbortController()
backend.addEventListener("change", event => {
const state = ref.deref()
if (state) {
const relevantChanges = event.changes
.filter(([name]) => name === property)
.map(([_, value]) => ["value", value])
if (relevantChanges.length > 0)
state.dispatchEvent(new ChangeEvent(...relevantChanges))
} else {
abortController.abort()
}
}, {signal: abortController.signal})
}
get value() { return this.#backend.values[this.#property] ?? this.#fallback }
set value(value) { this.#backend.values[this.#property] = value }
}
class StorageChangeEvent extends Event {
constructor(storage, key, value, targetState) {
super("storagechange")
this.storageArea = storage
this.key = key
this.newValue = value
this.targetState = targetState
}
}
export class StoredState extends State {
#storage
constructor(init, options={}) {
super({}, options)
this.#storage = options.storage ?? localStorage ?? new MapStorage()
// Initialise storage from defaults
for (const [prop, value] of Object.entries(init)) {
if (this.#storage[prop] === undefined)
this.set(prop, value)
}
// Emit change events for any changed keys
for (let i=0; i<this.#storage.length; i++) {
const key = this.#storage.key(i)
const value = this.#storage[key]
if (value !== JSON.stringify(init[key]))
this.emit(key, value)
}
// Listen for changes from other windows
const handler = event => {
if (event.targetState !== this && event.storageArea == this.#storage) {
this.emit(event.key, JSON.parse(event.newValue))
}
}
addEventListener("storage", handler)
addEventListener("storagechange", handler)
}
set(prop, value) {
const json = JSON.stringify(value)
dispatchEvent(new StorageChangeEvent(this.#storage, prop, json, this))
this.#storage[prop] = json
}
get(prop) {
const value = this.#storage[prop]
return value && JSON.parse(value)
}
}
const attributeObserver = new MutationObserver(mutations => {
for (const {type, target, attributeName: name} of mutations) {
if (type == "attributes") {
const next = target.getAttribute(name)
const camelName = kebabToCamel(name)
if (String(target.state.values[camelName]) !== next)
target.state.values[camelName] = next
}
}
})
export const component = (generator, name) => {
name = name ?? camelToKebab(generator.name)
const Element = class extends HTMLElement{
constructor() {
super()
this.state = new State(Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value])))
this.state.addEventListener("change", event => {
for (const [name, value] of event.changes) {
const kebabName = camelToKebab(name)
if (this.getAttribute(kebabName) !== String(value))
this.setAttribute(kebabName, value)
}
})
attributeObserver.observe(this, {attributes: true})
this.replaceChildren(generator(this))
}
}
customElements.define(name, Element)
return Element;
}
class ComposedState extends SimpleState {
#func
#states
#options
constructor(func, options, ...states) {
super()
this.#func = func
this.#states = states
this.#options = options
const abortController = new AbortController()
abortRegistry.register(this, abortController)
const ref = new WeakRef(this)
states.forEach(state => {
state.addEventListener("change", event => {
const value = event.final.get("value")
if (value) ref.deref()?.scheduleUpdate()
}, {signal: abortController.signal})
})
this.update()
}
#microtaskQueued
scheduleUpdate() {
if (this.#options.defer) {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
this.update()
})
}
this.#microtaskQueued = true
} else {
this.update()
}
}
update() {
this.value = this.#func(...this.#states.map(state => state.value))
this.dispatchEvent(new ChangeEvent([["value", this.value]]))
}
}
export const compose = func => (...states) => new ComposedState(func, {defer: true}, ...states)
const eventName = "mutation"
class MutationEvent extends Event {
constructor() {
super(eventName, {bubbles: true})
}
}
const mutationObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new MutationEvent())
}
})
export class DOMState extends SimpleState {
#target
#defer
#getValue
#equal
#old
#changedValue = false
constructor(target, options) {
super()
this.#defer = options.defer ?? false
this.#target = target
this.#getValue = options.get ?? (target => target.value)
this.#equal = options.equal ?? ((a, b) => a===b)
this.#old = this.#getValue(target)
const controller = new AbortController()
target.addEventListener(eventName, event=>{this.update(event)}, {signal: controller.signal})
abortRegistry.register(this, controller)
mutationObserver.observe(target, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
}
get value() { return this.#old }
update() {
const current = this.#getValue(this.#target)
if (this.#equal(this.#old, current)) return
this.#old = current
if (this.#defer) {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
this.dispatchEvent(new ChangeEvent(["value", this.#changedValue]))
})
this.#changedValue = current
}
} else {
this.dispatchEvent(new ChangeEvent(["value", current]))
}
}
}
export default State