micra.js 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * src/core/reactive.ts — Reactive state proxy and batch scheduler.
3
+ *
4
+ * Responsibilities:
5
+ * - Wrap a plain state object in a Proxy that notifies on writes
6
+ * - Batch multiple synchronous mutations into a single microtask render
7
+ *
8
+ * LLM NOTE: Both functions are PURE constructors — they have no side effects
9
+ * beyond setting up a Proxy / Promise chain. No DOM access here.
10
+ */
11
+
12
+ import type { StateRecord } from '../types'
13
+
14
+ /**
15
+ * Wrap `obj` in a shallow Proxy. Any property write calls `schedule()`.
16
+ * Arrays: replace, don't mutate — `state.items = [...state.items, x]`.
17
+ *
18
+ * @example
19
+ * const raw = { count: 0 }
20
+ * const state = createReactiveState(raw, render)
21
+ * state.count = 5 // triggers render() in next microtask
22
+ */
23
+ export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
24
+ return new Proxy(obj, {
25
+ set(target, key: string, value: unknown) {
26
+ // Cast through StateRecord — TypeScript cannot write through a generic index
27
+ ;(target as StateRecord)[key] = value
28
+ schedule()
29
+ return true
30
+ },
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Return a debounce function that defers `render` to the next microtask.
36
+ * Multiple calls within the same tick collapse to a single render.
37
+ *
38
+ * @example
39
+ * const schedule = createScheduler(render)
40
+ * schedule() // defers render
41
+ * schedule() // no-op — already pending
42
+ */
43
+ export function createScheduler(render: () => void): () => void {
44
+ let pending = false
45
+ return function schedule() {
46
+ if (pending) return
47
+ pending = true
48
+ Promise.resolve().then(() => { pending = false; render() })
49
+ }
50
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * src/core/registry.ts — Component definition registry and instance store.
3
+ *
4
+ * Responsibilities:
5
+ * - Store named component definitions (define / registry)
6
+ * - Store live component instances keyed by root HTMLElement (instances)
7
+ *
8
+ * LLM NOTE: Both maps are module-level singletons (one per page load).
9
+ * They are intentionally mutable from mount.ts and start.ts.
10
+ */
11
+
12
+ import type {
13
+ ComponentDefinition,
14
+ ComponentInstance,
15
+ InternalInstance,
16
+ StateRecord,
17
+ } from '../types'
18
+
19
+ // Named definition map — populated by define()
20
+ export const _registry = new Map<string, ComponentDefinition>()
21
+
22
+ // Live instance map — populated by mount(), cleared by instance.destroy()
23
+ export const _instances = new Map<HTMLElement, InternalInstance>()
24
+
25
+ // ── Public accessors ──────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Register a component definition under `name`.
29
+ *
30
+ * @example
31
+ * define('counter', { state: { count: 0 }, inc() { this.state.count++ } })
32
+ */
33
+ export function define<S extends StateRecord>(
34
+ name: string,
35
+ definition: ComponentDefinition<S>,
36
+ ): void {
37
+ _registry.set(name, definition as ComponentDefinition)
38
+ }
39
+
40
+ /**
41
+ * Type-helper — returns `definition` unchanged but lets TypeScript infer `S`
42
+ * from the `state` literal so all methods are typed with the correct `this`.
43
+ *
44
+ * Use this when defining a component outside a `define()` call.
45
+ *
46
+ * @example
47
+ * const counter = defineComponent({
48
+ * state: { count: 0 },
49
+ * increment() { this.state.count++ }, // this.state: { count: number } ✓
50
+ * })
51
+ * Micra.define('counter', counter)
52
+ */
53
+ export function defineComponent<S extends StateRecord>(
54
+ definition: ComponentDefinition<S>,
55
+ ): ComponentDefinition<S> {
56
+ return definition
57
+ }
58
+
59
+ /**
60
+ * Returns a read-only view of all live instances (keyed by root element).
61
+ * Useful for DevTools / debugging.
62
+ */
63
+ export function instances(): ReadonlyMap<HTMLElement, ComponentInstance> {
64
+ return _instances
65
+ }
66
+
67
+ /**
68
+ * Returns a read-only view of all registered component definitions.
69
+ * Useful for DevTools / debugging.
70
+ */
71
+ export function registry(): ReadonlyMap<string, ComponentDefinition> {
72
+ return _registry
73
+ }
74
+
75
+ /**
76
+ * Print all live component instances to the browser console.
77
+ * Shows component name, root element, and current state for each instance.
78
+ *
79
+ * @example
80
+ * // In browser DevTools console:
81
+ * Micra.debug()
82
+ * // [Micra] 3 live component(s)
83
+ * // counter $el: <div> state: { count: 5 }
84
+ * // user-list $el: <div> state: { users: [...], loading: false }
85
+ */
86
+ export function debug(): void {
87
+ if (_instances.size === 0) {
88
+ console.log('[Micra] No live components.')
89
+ return
90
+ }
91
+ console.group(`[Micra] ${_instances.size} live component(s)`)
92
+ for (const [el, instance] of _instances) {
93
+ const name = el.getAttribute('data-component') ?? '(unnamed)'
94
+ console.group(`%c${name}`, 'font-weight:bold;color:#6366f1')
95
+ console.log('$el ', el)
96
+ console.log('state', { ...instance.state })
97
+ console.groupEnd()
98
+ }
99
+ console.groupEnd()
100
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * src/core/start.ts — Auto-mount via [data-component].
3
+ *
4
+ * Responsibilities:
5
+ * - Scan the DOM (or a subtree) for [data-component] elements
6
+ * - Mount each using the registered definition
7
+ * - Skip already-mounted elements (safe to call multiple times)
8
+ * - Warn clearly when a component name is not registered
9
+ *
10
+ * LLM NOTE: start() is SSR-friendly — calling it multiple times is safe
11
+ * because mount() checks _instances before re-mounting.
12
+ */
13
+
14
+ import { warn } from '../utils/expr'
15
+ import { _registry, _instances } from './registry'
16
+ import { mount } from './mount'
17
+
18
+ /**
19
+ * Scan for `[data-component]` elements and auto-mount registered definitions.
20
+ *
21
+ * Pass a subtree root to limit the scan (e.g., after a partial SSR update):
22
+ * `Micra.start(document.getElementById('panel'))`
23
+ *
24
+ * @example
25
+ * // Mount everything on the page (called once after DOM ready)
26
+ * Micra.start()
27
+ *
28
+ * // Re-mount after injecting new HTML
29
+ * Micra.start(document.querySelector('#dynamic-section'))
30
+ */
31
+ export function start(root: Document | HTMLElement = document): void {
32
+ root.querySelectorAll<HTMLElement>('[data-component]').forEach(el => {
33
+ if (_instances.has(el)) return // already mounted — skip
34
+ const name = el.getAttribute('data-component')!
35
+ const def = _registry.get(name)
36
+ if (!def) {
37
+ warn(`component "${name}" not defined. Call Micra.define('${name}', {...}) first.`)
38
+ return
39
+ }
40
+ mount(el, def)
41
+ })
42
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * src/dom/directives.ts — Apply DOM directives to a component subtree.
3
+ *
4
+ * Responsibilities:
5
+ * - data-text, data-html, data-if, data-show, data-bind, data-model
6
+ * - data-class (additive class toggling)
7
+ * - Directive result cache (built once per element, reused on re-renders)
8
+ *
9
+ * LLM NOTE: applyDirectives() is called on every render. The directive cache
10
+ * (DirectiveCache on el.__micraCache) avoids repeated querySelectorAll on
11
+ * re-renders — cache is built lazily on the first call for each root element.
12
+ *
13
+ * Important: this module does NOT handle data-each — see dom/each.ts.
14
+ */
15
+
16
+ import type {
17
+ CachedBinding,
18
+ DirectiveCache,
19
+ InternalInstance,
20
+ MicraElement,
21
+ StateRecord,
22
+ } from '../types'
23
+ import { evalExpr, warn } from '../utils/expr'
24
+ import { queryOwn, queryAll } from './query'
25
+
26
+ // ── Directive appliers ────────────────────────────────────────────────────────
27
+ // Each function is PURE relative to state — reads state, writes DOM.
28
+
29
+ function applyText(el: Element, expr: string, state: StateRecord): void {
30
+ const text = String(evalExpr(expr, state) ?? '')
31
+ if (el.textContent !== text) el.textContent = text
32
+ }
33
+
34
+ function applyHtml(el: Element, expr: string, state: StateRecord): void {
35
+ el.innerHTML = String(evalExpr(expr, state) ?? '')
36
+ }
37
+
38
+ function applyIf(el: Element, expr: string, state: StateRecord): void {
39
+ (el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
40
+ }
41
+
42
+ function applyBind(el: Element, expr: string, state: StateRecord): void {
43
+ for (const pair of expr.split(',')) {
44
+ const colonIdx = pair.indexOf(':')
45
+ if (colonIdx === -1) continue
46
+ const attr = pair.slice(0, colonIdx).trim()
47
+ const valExpr = pair.slice(colonIdx + 1).trim()
48
+ const val = evalExpr(valExpr, state)
49
+
50
+ if (attr === 'class') {
51
+ (el as HTMLElement).className = String(val ?? '')
52
+ } else if (attr === 'value') {
53
+ if (document.activeElement !== el)
54
+ (el as HTMLInputElement).value = String(val ?? '')
55
+ } else if (attr === 'style') {
56
+ if (typeof val === 'object' && val !== null) {
57
+ Object.assign((el as HTMLElement).style, val)
58
+ } else {
59
+ el.setAttribute('style', String(val ?? ''))
60
+ }
61
+ } else if (typeof val === 'boolean') {
62
+ val ? el.setAttribute(attr, '') : el.removeAttribute(attr)
63
+ } else {
64
+ val == null ? el.removeAttribute(attr) : el.setAttribute(attr, String(val))
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * data-class="active:isActive, disabled:count === 0"
71
+ * Parses comma-separated `className:expression` pairs and toggles classes additively.
72
+ * Unlike data-bind="class:expr" this does NOT replace the full className.
73
+ *
74
+ * Syntax mirrors data-bind — split by comma, then by first colon.
75
+ *
76
+ * @example
77
+ * <div data-class="active:tab === 'home', hidden:!loaded">
78
+ */
79
+ function applyClass(el: Element, expr: string, state: StateRecord): void {
80
+ for (const pair of expr.split(',')) {
81
+ const colonIdx = pair.indexOf(':')
82
+ if (colonIdx === -1) continue
83
+ const cls = pair.slice(0, colonIdx).trim()
84
+ const valExpr = pair.slice(colonIdx + 1).trim()
85
+ if (!cls) continue
86
+ el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)))
87
+ }
88
+ }
89
+
90
+ function applyModel(
91
+ el: Element,
92
+ key: string,
93
+ rawState: StateRecord,
94
+ ): void {
95
+ const html = el as HTMLInputElement
96
+ if (document.activeElement !== el) {
97
+ html.value = rawState[key] == null ? '' : String(rawState[key])
98
+ }
99
+ // listener is attached separately in events.ts — this only syncs the value
100
+ }
101
+
102
+ // ── Directive cache ───────────────────────────────────────────────────────────
103
+
104
+ /** @internal Collect all directive bindings for a root element. Built once. */
105
+ function buildCache(root: Element): DirectiveCache {
106
+ const pick = (attr: string): CachedBinding[] => {
107
+ const els = queryOwn(root, attr)
108
+ // Include root itself
109
+ if ((root as HTMLElement).hasAttribute?.(attr)) els.unshift(root)
110
+ return els
111
+ .filter(el => !el.closest('template'))
112
+ .map(el => ({ el, expr: el.getAttribute(attr)! }))
113
+ }
114
+ return {
115
+ text: pick('data-text'),
116
+ html: pick('data-html'),
117
+ if: pick('data-if'),
118
+ show: pick('data-show'),
119
+ bind: pick('data-bind'),
120
+ model: pick('data-model'),
121
+ class: pick('data-class'),
122
+ }
123
+ }
124
+
125
+ // ── Main entry point ──────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Apply all non-each directives to a component subtree.
129
+ *
130
+ * For regular Elements: directive bindings are cached in `el.__micraCache`
131
+ * after the first call — subsequent re-renders skip querySelectorAll entirely.
132
+ *
133
+ * For DocumentFragments (no-key each clones): always re-scan because these
134
+ * fragments are new clones on every render.
135
+ *
136
+ * @param root - Component root Element or DocumentFragment (no-key each clone)
137
+ * @param state - Expression state (may include item/index for each rows)
138
+ * @param rawState - Raw (non-proxy) state for model sync
139
+ * @param instance - Component instance (unused here, kept for future hooks)
140
+ */
141
+ export function applyDirectives<S extends StateRecord>(
142
+ root: Element | DocumentFragment,
143
+ state: StateRecord,
144
+ rawState: StateRecord,
145
+ _instance: InternalInstance<S>,
146
+ ): void {
147
+ // DocumentFragments are temporary clones — always scan, never cache
148
+ if (root.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
149
+ applyFromList(buildFragmentList(root as DocumentFragment), state, rawState)
150
+ return
151
+ }
152
+
153
+ const el = root as MicraElement
154
+ if (!el.__micraCache) el.__micraCache = buildCache(el)
155
+ applyFromList(el.__micraCache, state, rawState)
156
+ }
157
+
158
+ /** @internal Apply a pre-built cache / binding list to current state. */
159
+ function applyFromList(
160
+ cache: DirectiveCache,
161
+ state: StateRecord,
162
+ rawState: StateRecord,
163
+ ): void {
164
+ cache.text.forEach(b => applyText(b.el, b.expr, state))
165
+ cache.html.forEach(b => applyHtml(b.el, b.expr, state))
166
+ cache.if.forEach(b => applyIf(b.el, b.expr, state))
167
+ cache.show.forEach(b => applyIf(b.el, b.expr, state))
168
+ cache.bind.forEach(b => applyBind(b.el, b.expr, state))
169
+ cache.model.forEach(b => applyModel(b.el, b.expr.trim(), rawState))
170
+ cache.class.forEach(b => applyClass(b.el, b.expr, state))
171
+ }
172
+
173
+ /** @internal Scan a DocumentFragment (no-key each clone) — returns a DirectiveCache. */
174
+ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
175
+ const pick = (attr: string): CachedBinding[] =>
176
+ queryAll(frag, `[${attr}]`)
177
+ .filter(el => !el.closest('template'))
178
+ .map(el => ({ el, expr: el.getAttribute(attr)! }))
179
+ return {
180
+ text: pick('data-text'),
181
+ html: pick('data-html'),
182
+ if: pick('data-if'),
183
+ show: pick('data-show'),
184
+ bind: pick('data-bind'),
185
+ model: pick('data-model'),
186
+ class: pick('data-class'),
187
+ }
188
+ }
189
+
190
+ // ── Dev warning helper ────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Validate directive usage and emit dev warnings.
194
+ * Called once after the initial render of a component.
195
+ *
196
+ * @internal
197
+ */
198
+ export function validateDirectives(root: Element): void {
199
+ queryOwn(root, 'data-each').forEach(el => {
200
+ if (!el.hasAttribute('data-key')) {
201
+ warn(`data-each="${el.getAttribute('data-each')}" has no data-key — keyed diff disabled. Add data-key="id" for better performance.`)
202
+ }
203
+ })
204
+ }
205
+
206
+ // Re-export warn for use in other modules
207
+ export { warn }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * src/dom/each.ts — Keyed and non-keyed list rendering (data-each).
3
+ *
4
+ * Responsibilities:
5
+ * - Process `<template data-each="items" data-key="id">` elements
6
+ * - Keyed diff: reuse/reorder DOM nodes by key — O(n) with a Map
7
+ * - Non-keyed fallback: full replace (no key → warn in dev, full re-render)
8
+ * - Apply directives to each row with a scoped itemState
9
+ *
10
+ * LLM NOTE: renderList() is called on every render cycle AFTER applyDirectives().
11
+ * Only <template> elements with data-each are processed.
12
+ * Keyed mode (data-key present) mutates the DOM in-place — nodes are
13
+ * created once and reused. Non-keyed mode removes all nodes and re-clones.
14
+ */
15
+
16
+ import type { InternalInstance, MicraElement, MicraTemplate, StateRecord } from '../types'
17
+ import { evalExpr, warn } from '../utils/expr'
18
+ import { applyDirectives } from './directives'
19
+ import { bindDataOn, bindAtEvents } from './events'
20
+ import { queryOwn, queryAll } from './query'
21
+
22
+ /**
23
+ * Process all `<template data-each>` elements owned by `root`.
24
+ * Scoped itemState makes `item`, `index`, `$index` available in row expressions.
25
+ *
26
+ * @param root - Component root Element
27
+ * @param state - Expression state (proxy merging rawState + instance)
28
+ * @param rawState - Raw (non-proxy) state — used for model binding
29
+ * @param instance - Component instance (for event binding)
30
+ */
31
+ export function renderList<S extends StateRecord>(
32
+ root: Element,
33
+ state: StateRecord,
34
+ rawState: StateRecord,
35
+ instance: InternalInstance<S>,
36
+ ): void {
37
+ queryOwn(root, 'data-each').forEach(tmplEl => {
38
+ if (tmplEl.tagName !== 'TEMPLATE') return
39
+ const tmpl = tmplEl as MicraTemplate
40
+
41
+ const itemsExpr = tmpl.getAttribute('data-each')!
42
+ const keyAttr = tmpl.getAttribute('data-key') ?? null
43
+ const items = evalExpr(itemsExpr, state)
44
+
45
+ // Ensure marker comment + internal state are initialized
46
+ if (!tmpl.__micraMarker) {
47
+ const m = document.createComment(`each:${itemsExpr}`)
48
+ tmpl.after(m)
49
+ tmpl.__micraMarker = m
50
+ tmpl.__micraNodes = new Map()
51
+ tmpl.__micraList = []
52
+ }
53
+
54
+ const marker = tmpl.__micraMarker
55
+ const keyMap = tmpl.__micraNodes
56
+ const parent = marker.parentNode!
57
+
58
+ // Empty / non-array: clear all rendered rows
59
+ if (!Array.isArray(items)) {
60
+ tmpl.__micraList.forEach(n => n.remove())
61
+ tmpl.__micraList = []
62
+ keyMap.clear()
63
+ return
64
+ }
65
+
66
+ if (keyAttr) {
67
+ renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, parent, state, rawState, instance)
68
+ } else {
69
+ renderNoKey(tmpl, items as StateRecord[], marker, parent, state, rawState, instance)
70
+ }
71
+ })
72
+ }
73
+
74
+ // ── Keyed diff ────────────────────────────────────────────────────────────────
75
+
76
+ function renderKeyed<S extends StateRecord>(
77
+ tmpl: MicraTemplate,
78
+ items: StateRecord[],
79
+ keyAttr: string,
80
+ marker: Comment,
81
+ keyMap: Map<unknown, MicraElement>,
82
+ parent: Node,
83
+ state: StateRecord,
84
+ rawState: StateRecord,
85
+ instance: InternalInstance<S>,
86
+ ): void {
87
+ const nextKeys = new Set<unknown>()
88
+ const nextNodes: MicraElement[] = []
89
+
90
+ for (const [index, item] of items.entries()) {
91
+ const key = item[keyAttr]
92
+ if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
93
+ nextKeys.add(key)
94
+
95
+ let node = keyMap.get(key) as MicraElement | undefined
96
+
97
+ if (!node) {
98
+ // Clone template and wrap multi-root fragments in a display:contents element
99
+ const frag = tmpl.content.cloneNode(true) as DocumentFragment
100
+ if (frag.childNodes.length === 1) {
101
+ node = frag.firstElementChild as MicraElement
102
+ } else {
103
+ node = document.createElement('micra-each-item') as MicraElement
104
+ node.style.display = 'contents'
105
+ node.append(frag)
106
+ }
107
+ node.__micraKey = key
108
+ keyMap.set(key, node)
109
+ // Bind data-on and @event handlers on the freshly created node (once)
110
+ bindDataOn(node, instance)
111
+ bindAtEvents(node, instance)
112
+ }
113
+
114
+ const itemState = Object.assign(
115
+ Object.create(state) as StateRecord,
116
+ { item, index, $index: index },
117
+ )
118
+ applyDirectives(node, itemState, rawState, instance)
119
+ nextNodes.push(node)
120
+ }
121
+
122
+ // Remove stale nodes
123
+ for (const [key, node] of keyMap) {
124
+ if (!nextKeys.has(key)) { node.remove(); keyMap.delete(key) }
125
+ }
126
+
127
+ // Insert / reorder nodes after marker (insertBefore is no-op if already in place)
128
+ let cursor: Node = marker
129
+ for (const node of nextNodes) {
130
+ if (cursor.nextSibling !== node) parent.insertBefore(node, cursor.nextSibling)
131
+ cursor = node
132
+ }
133
+
134
+ tmpl.__micraList = nextNodes
135
+ }
136
+
137
+ // ── Non-keyed (full re-render) ─────────────────────────────────────────────────
138
+
139
+ function renderNoKey<S extends StateRecord>(
140
+ tmpl: MicraTemplate,
141
+ items: StateRecord[],
142
+ marker: Comment,
143
+ parent: Node,
144
+ state: StateRecord,
145
+ rawState: StateRecord,
146
+ instance: InternalInstance<S>,
147
+ ): void {
148
+ tmpl.__micraList.forEach(n => n.remove())
149
+ tmpl.__micraList = []
150
+
151
+ const frag = document.createDocumentFragment()
152
+ for (const [index, item] of items.entries()) {
153
+ const clone = tmpl.content.cloneNode(true) as DocumentFragment
154
+ const itemState = Object.assign(
155
+ Object.create(state) as StateRecord,
156
+ { item, index, $index: index },
157
+ )
158
+ applyDirectives(clone, itemState, rawState, instance)
159
+ bindDataOn(clone as unknown as Element, instance)
160
+ bindAtEvents(clone as unknown as Element, instance)
161
+
162
+ const nodes = Array.from(clone.childNodes) as MicraElement[]
163
+ nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
164
+ tmpl.__micraList.push(...nodes)
165
+ }
166
+ parent.insertBefore(frag, marker.nextSibling)
167
+ }