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,145 @@
1
+ /**
2
+ * src/dom/events.ts — DOM event binding.
3
+ *
4
+ * Responsibilities:
5
+ * - Bind `data-on="event:method"` listeners (once per element)
6
+ * - Bind `@event="method"` shorthand (scanned once per component root)
7
+ *
8
+ * LLM NOTE: Listeners are attached exactly once. The `__micraEvents` and
9
+ * `__micraAtScanned` flags prevent duplicate bindings on re-renders.
10
+ */
11
+
12
+ import type { InternalInstance, MicraElement, StateRecord } from '../types'
13
+ import { evalExpr, warn } from '../utils/expr'
14
+ import { queryOwn, queryAll } from './query'
15
+
16
+ // ── data-on ───────────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Bind `data-on="event:method[,event2:method2]"` listeners.
20
+ * Listeners are bound once — re-render calls are no-ops for already-bound elements.
21
+ *
22
+ * Supports modifiers: `click.prevent`, `click.stop`, `click.self`.
23
+ *
24
+ * @example
25
+ * <button data-on="click:save">Save</button>
26
+ * <form data-on="submit.prevent:handleSubmit">
27
+ */
28
+ export function bindDataOn<S extends StateRecord>(
29
+ root: Element,
30
+ instance: InternalInstance<S>,
31
+ ): void {
32
+ const isFragment = root.nodeType === 11
33
+ const els = isFragment
34
+ ? queryAll(root as unknown as ParentNode, '[data-on]')
35
+ : queryOwn(root, 'data-on')
36
+
37
+ // Include root itself if it carries data-on (e.g., the keyed item IS the button)
38
+ if (!isFragment && (root as HTMLElement).hasAttribute?.('data-on') && !els.includes(root))
39
+ els.unshift(root)
40
+
41
+ for (const el of els) {
42
+ const mEl = el as MicraElement
43
+ if (mEl.__micraEvents) continue
44
+ mEl.__micraEvents = true
45
+
46
+ const spec = mEl.dataset['on'] ?? ''
47
+ for (const part of spec.split(',')) {
48
+ const [evSpec, method] = part.trim().split(':') as [string, string]
49
+ if (!evSpec || !method) continue
50
+
51
+ const [evName, ...mods] = evSpec.split('.')
52
+
53
+ el.addEventListener(evName!, (e: Event) => {
54
+ if (mods.includes('prevent')) e.preventDefault()
55
+ if (mods.includes('stop')) e.stopPropagation()
56
+ if (mods.includes('self') && e.target !== el) return
57
+
58
+ const fn = instance[method.trim()]
59
+ if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
60
+ else warn(`method "${method.trim()}" not found`)
61
+ })
62
+ }
63
+ }
64
+ }
65
+
66
+ // ── @event shorthand ──────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Bind `@event="method"` shorthand attributes (Stimulus-style).
70
+ * Scanned once per component root (guarded by `__micraAtScanned`).
71
+ * Supports the same modifiers as data-on: `@click.prevent="submit"`.
72
+ *
73
+ * @example
74
+ * <button @click="increment">+</button>
75
+ * <form @submit.prevent="handleSubmit">
76
+ */
77
+ export function bindAtEvents<S extends StateRecord>(
78
+ root: Element,
79
+ instance: InternalInstance<S>,
80
+ ): void {
81
+ const mRoot = root as MicraElement
82
+ if (mRoot.__micraAtScanned) return
83
+ mRoot.__micraAtScanned = true
84
+
85
+ const all = queryAll(root, '*')
86
+ for (const el of all) {
87
+ for (const attr of Array.from(el.attributes)) {
88
+ if (!attr.name.startsWith('@')) continue
89
+ const [evSpec, ...rest] = attr.name.slice(1).split('.')
90
+ const method = attr.value.trim()
91
+
92
+ el.addEventListener(evSpec!, (e: Event) => {
93
+ if (rest.includes('prevent')) e.preventDefault()
94
+ if (rest.includes('stop')) e.stopPropagation()
95
+ if (rest.includes('self') && e.target !== el) return
96
+
97
+ const fn = instance[method]
98
+ if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
99
+ else warn(`method "${method}" not found`)
100
+ })
101
+ }
102
+ }
103
+ }
104
+
105
+ // ── data-model ────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Two-way binding: `data-model="key"` wires <input>/<select>/<textarea>
109
+ * to `state[key]`. Binding is attached once per element.
110
+ *
111
+ * @example
112
+ * <input data-model="search"> // updates state.search on every keystroke
113
+ * <select data-model="sortBy"> // updates state.sortBy on change
114
+ */
115
+ export function bindModels<S extends StateRecord>(
116
+ root: Element,
117
+ instance: InternalInstance<S>,
118
+ ): void {
119
+ const isFragment = root.nodeType === 11
120
+ const els = isFragment
121
+ ? queryAll(root as unknown as ParentNode, '[data-model]')
122
+ : queryOwn(root, 'data-model')
123
+
124
+ for (const el of els) {
125
+ const mEl = el as MicraElement
126
+ if (mEl.__micraModel) continue
127
+ mEl.__micraModel = true
128
+
129
+ const key = (el as HTMLInputElement).dataset['model'] ?? ''
130
+ const tag = el.tagName
131
+
132
+ const update = () => {
133
+ const val = tag === 'INPUT' && (el as HTMLInputElement).type === 'checkbox'
134
+ ? (el as HTMLInputElement).checked
135
+ : (el as HTMLInputElement).value
136
+ ;(instance.state as StateRecord)[key] = val
137
+ }
138
+
139
+ el.addEventListener(tag === 'SELECT' || (el as HTMLInputElement).type === 'radio'
140
+ ? 'change'
141
+ : 'input',
142
+ update,
143
+ )
144
+ }
145
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * src/dom/query.ts — DOM query helpers.
3
+ *
4
+ * LLM NOTE: These are utility functions with no side effects.
5
+ * queryOwn is the critical function that prevents a parent component from
6
+ * accidentally processing directives belonging to a nested child component.
7
+ */
8
+
9
+ /**
10
+ * querySelectorAll wrapper — returns a typed array.
11
+ */
12
+ export function queryAll(root: ParentNode, sel: string): Element[] {
13
+ return Array.from(root.querySelectorAll(sel))
14
+ }
15
+
16
+ /**
17
+ * Like querySelectorAll, but EXCLUDES elements that live inside a nested
18
+ * `[data-component]` subtree.
19
+ *
20
+ * This is what prevents a parent component's render() from clobbering
21
+ * the DOM managed by a child component.
22
+ *
23
+ * LLM NOTE: The walk goes up parentElement until it hits `root` or null.
24
+ * If any ancestor (between el and root) has data-component, the element is
25
+ * owned by that nested component, not by root's component — so we skip it.
26
+ */
27
+ export function queryOwn(root: Element, attr: string): Element[] {
28
+ return queryAll(root, `[${attr}]`).filter(el => {
29
+ let node: Element | null = el.parentElement
30
+ while (node && node !== root) {
31
+ if (node.hasAttribute('data-component')) return false
32
+ node = node.parentElement
33
+ }
34
+ return true
35
+ })
36
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * src/dom/refs.ts — data-ref collection.
3
+ *
4
+ * Responsibilities:
5
+ * - After each render, scan for `[data-ref]` elements (owned by this component)
6
+ * - Populate `instance.refs` so methods can do `this.refs.chart` etc.
7
+ *
8
+ * LLM NOTE: This module is PURE relative to state — it only reads DOM attributes
9
+ * and writes to instance.refs. It does NOT trigger renders.
10
+ */
11
+
12
+ import type { InternalInstance, MicraElement, StateRecord } from '../types'
13
+ import { queryOwn } from './query'
14
+
15
+ /**
16
+ * Collect all `[data-ref="name"]` elements owned by this component root into
17
+ * `instance.refs`.
18
+ *
19
+ * Called once after the initial render and again on every re-render (refs may
20
+ * point to newly created elements after an each-list update).
21
+ *
22
+ * @example
23
+ * // HTML: <canvas data-ref="chart">
24
+ * // JS: this.refs.chart → HTMLCanvasElement
25
+ */
26
+ export function collectRefs<S extends StateRecord>(
27
+ root: Element,
28
+ instance: InternalInstance<S>,
29
+ ): void {
30
+ instance.refs = {}
31
+ for (const el of queryOwn(root, 'data-ref')) {
32
+ const name = (el as MicraElement).dataset['ref']
33
+ if (name) instance.refs[name] = el as HTMLElement
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Micra.js — Lightweight reactive framework for small sites and simple SaaS.
3
+ *
4
+ * Public surface — re-exports only.
5
+ *
6
+ * Features:
7
+ * - JS expressions in directives (data-if="count > 0")
8
+ * - Keyed list diffing (data-each="items" data-key="id")
9
+ * - Auto-mount via data-component (Micra.define + Micra.start)
10
+ * - Props from SSR data-attributes (this.prop('page'))
11
+ * - Built-in fetch helper (this.fetch('/api/...'))
12
+ * - Global event bus (Micra.on / Micra.emit)
13
+ * - DOM refs (data-ref="chart" → this.refs.chart)
14
+ * - Additive class toggling (data-class="active:isActive")
15
+ * - @event shorthand (@click="increment")
16
+ * - Lifecycle: onCreate, onDestroy
17
+ * - SSR-friendly: Micra.start() is safe to call multiple times
18
+ * - Directive cache: O(1) re-renders after first mount
19
+ *
20
+ * Size target: < 5 KB minified+gzipped
21
+ *
22
+ * @module Micra
23
+ */
24
+
25
+ // ── Public types ──────────────────────────────────────────────────────────────
26
+ export type {
27
+ StateRecord,
28
+ UnsubFn,
29
+ EventHandler,
30
+ FetchOptions,
31
+ ComponentInstance,
32
+ ComponentDefinition,
33
+ } from './types'
34
+
35
+ // ── Errors ────────────────────────────────────────────────────────────────────
36
+ export { FetchError } from './utils/fetch'
37
+
38
+ // ── Public API ────────────────────────────────────────────────────────────────
39
+ export { define, defineComponent, instances, registry, debug } from './core/registry'
40
+ export { mount } from './core/mount'
41
+ export { start } from './core/start'
42
+ export { on, off, emit } from './core/bus'
package/src/types.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * src/types.ts — All Micra.js type definitions.
3
+ *
4
+ * Public types are re-exported from src/index.ts.
5
+ * Internal types (MicraElement, MicraTemplate, InternalInstance) are used by
6
+ * implementation modules but are NOT part of the public API.
7
+ */
8
+
9
+ // ── Public types ──────────────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Constraint for component state objects.
13
+ * Use any plain object: `{ count: 0, items: [] as User[] }`.
14
+ */
15
+ // LLM NOTE: intentionally wide — S is always inferred from the literal `state`
16
+ // property, so the actual type is precise (e.g. { count: number }), not Record.
17
+ export type StateRecord = Record<string, unknown>
18
+
19
+ /** Returns an unsubscribe function. */
20
+ export type UnsubFn = () => void
21
+
22
+ /** Event bus handler. Generic `T` types the payload. */
23
+ export type EventHandler<T = unknown> = (payload: T) => void
24
+
25
+ /** Options for `this.fetch()`. For GET/HEAD extra keys become query params. */
26
+ export interface FetchOptions {
27
+ method?: string
28
+ headers?: Record<string, string>
29
+ /** POST/PUT/PATCH body — serialized as JSON. */
30
+ body?: unknown
31
+ [key: string]: unknown
32
+ }
33
+
34
+ /**
35
+ * The `this` context inside component methods and lifecycle hooks.
36
+ * `S` is inferred from the component's `state` object.
37
+ *
38
+ * @example
39
+ * // state: { count: 0 } → S = { count: number }
40
+ * increment() { this.state.count++ } // count is number ✓
41
+ */
42
+ export interface ComponentInstance<S extends StateRecord = StateRecord> {
43
+ /** The root DOM element this component is mounted on. */
44
+ readonly $el: HTMLElement
45
+ /** Reactive state — any assignment triggers a batched re-render. */
46
+ state: S
47
+ /**
48
+ * DOM refs: collect elements with `data-ref="name"` → `this.refs.name`.
49
+ * @example <canvas data-ref="chart"> → this.refs.chart
50
+ */
51
+ refs: Record<string, HTMLElement>
52
+ /** Force a synchronous re-render. Normally not needed — state mutations batch automatically. */
53
+ render(): void
54
+ /** Unmount: clean up event bus subscriptions and call onDestroy. */
55
+ destroy(): void
56
+ /**
57
+ * Read a `data-*` attribute from the root element with auto-cast.
58
+ * Casts "true"/"false" → boolean, numeric strings → number.
59
+ * @example this.prop('perPage', 10) // data-per-page="20" → 20
60
+ */
61
+ prop(name: string): string | undefined
62
+ prop<T>(name: string, defaultVal: T): T
63
+ /** Fetch helper: CSRF header, JSON body, query params, typed errors. */
64
+ fetch(url: string, options?: FetchOptions): Promise<unknown>
65
+ /** Publish an event on the global bus. */
66
+ emit(event: string, payload?: unknown): void
67
+ /** Subscribe to the global bus. Subscription is auto-removed on destroy(). */
68
+ on<T = unknown>(event: string, handler: EventHandler<T>): UnsubFn
69
+ }
70
+
71
+ /**
72
+ * Component definition passed to `Micra.define` or `Micra.mount`.
73
+ *
74
+ * `S` is inferred from the `state` property — all methods receive
75
+ * `this: ComponentInstance<S>` automatically via `ThisType<>`.
76
+ *
77
+ * @example
78
+ * Micra.define('counter', {
79
+ * state: { count: 0 },
80
+ * inc() { this.state.count++ }, // this.state.count: number ✓
81
+ * })
82
+ */
83
+ export type ComponentDefinition<S extends StateRecord = StateRecord> = {
84
+ /** Initial flat state. Becomes reactive on mount. */
85
+ state?: S
86
+ /**
87
+ * Called once after mount in a microtask — safe for async data fetching.
88
+ * @example async onCreate() { this.state.data = await this.fetch('/api/data') }
89
+ */
90
+ onCreate?: () => void | Promise<void>
91
+ /**
92
+ * Called on destroy — clean up DOM listeners, timers, etc.
93
+ * Event bus subscriptions added via `this.on()` are cleaned up automatically.
94
+ */
95
+ onDestroy?: () => void
96
+ [method: string]: unknown
97
+ } & ThisType<ComponentInstance<S>>
98
+
99
+ // ── Internal types ────────────────────────────────────────────────────────────
100
+ // These are NOT exported from src/index.ts.
101
+
102
+ /**
103
+ * @internal Extended HTMLElement with Micra bookkeeping slots.
104
+ */
105
+ export interface MicraElement extends HTMLElement {
106
+ __micraModel?: true // data-model listener bound
107
+ __micraEvents?: true // data-on listeners bound
108
+ __micraAtScanned?: true // @event shorthand scanned (set on component root)
109
+ __micraKey?: unknown // keyed-diff key
110
+ __micraEach?: true // belongs to a no-key each list
111
+ __micraCache?: DirectiveCache // cached directive scan result
112
+ }
113
+
114
+ /**
115
+ * @internal Extended HTMLTemplateElement with keyed-diff state.
116
+ */
117
+ export interface MicraTemplate extends HTMLTemplateElement {
118
+ __micraMarker?: Comment
119
+ __micraNodes: Map<unknown, MicraElement>
120
+ __micraList: ChildNode[]
121
+ }
122
+
123
+ /**
124
+ * @internal Per-element directive binding (element + expression string).
125
+ */
126
+ export interface CachedBinding {
127
+ el: Element
128
+ expr: string
129
+ }
130
+
131
+ /**
132
+ * @internal Directive scan result — built once per Element, reused every render.
133
+ * This is the core of the performance optimization.
134
+ *
135
+ * LLM NOTE: DirectiveCache is built lazily on first render and stored on the
136
+ * element. It avoids repeated querySelectorAll calls on every re-render.
137
+ */
138
+ export interface DirectiveCache {
139
+ text: CachedBinding[]
140
+ html: CachedBinding[]
141
+ if: CachedBinding[]
142
+ show: CachedBinding[]
143
+ bind: CachedBinding[]
144
+ model: CachedBinding[]
145
+ class: CachedBinding[]
146
+ }
147
+
148
+ /**
149
+ * @internal Full instance as seen inside the runtime — extends the public
150
+ * interface with private bookkeeping slots and an index signature for
151
+ * dynamic method dispatch.
152
+ */
153
+ export interface InternalInstance<S extends StateRecord = StateRecord>
154
+ extends ComponentInstance<S> {
155
+ __micraSubs?: UnsubFn[]
156
+ [key: string]: unknown
157
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * src/utils/expr.ts — JS expression evaluator.
3
+ *
4
+ * Responsibilities:
5
+ * - Compile expression strings into cached functions
6
+ * - Evaluate them against a state object
7
+ * - Fast-path for simple property lookups
8
+ *
9
+ * LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
10
+ * All side effects are isolated to console.warn on invalid expressions.
11
+ */
12
+
13
+ import type { StateRecord } from '../types'
14
+
15
+ // ── Expression cache ──────────────────────────────────────────────────────────
16
+ // Compiled functions are keyed by expression string — Function() is only called
17
+ // once per unique expression across the entire app lifetime.
18
+
19
+ // LLM NOTE: exprCache is module-level (shared across all components).
20
+ // This is intentional — most apps reuse the same expressions.
21
+ const exprCache = new Map<string, (state: StateRecord) => unknown>()
22
+
23
+ // Simple identifier or dot-path: "count", "user.name", "item.email"
24
+ // Matches: letter/$/_ followed by word chars, optionally with .property chains
25
+ const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
26
+
27
+ /**
28
+ * Evaluate a JS expression string against a state object.
29
+ *
30
+ * Results are cached by expression string — repeated evaluations hit the cache.
31
+ * Uses a fast-path for simple dot-paths (e.g. "count", "user.name") that avoids
32
+ * Function() overhead.
33
+ *
34
+ * @example
35
+ * evalExpr('count > 0', { count: 5 }) // → true
36
+ * evalExpr('user.name', { user: { name: 'Alice' } }) // → 'Alice'
37
+ * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
38
+ */
39
+ export function evalExpr(expr: string, state: StateRecord): unknown {
40
+ // Fast-path: simple property access — no Function() needed
41
+ if (SIMPLE_PATH.test(expr)) {
42
+ return expr.split('.').reduce<unknown>((obj, key) =>
43
+ obj != null ? (obj as StateRecord)[key] : undefined,
44
+ state,
45
+ )
46
+ }
47
+
48
+ if (!exprCache.has(expr)) {
49
+ try {
50
+ exprCache.set(
51
+ expr,
52
+ new Function('$s', `with($s){return (${expr})}`) as (s: StateRecord) => unknown,
53
+ )
54
+ } catch {
55
+ warn(`invalid expression "${expr}"`)
56
+ exprCache.set(expr, () => undefined)
57
+ }
58
+ }
59
+
60
+ try {
61
+ return exprCache.get(expr)!(state)
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
67
+ // ── Dev warnings ──────────────────────────────────────────────────────────────
68
+
69
+ /** @internal Consistent warning prefix. */
70
+ export function warn(msg: string): void {
71
+ console.warn(`[Micra] ${msg}`)
72
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * src/utils/fetch.ts — HTTP fetch helper.
3
+ *
4
+ * Responsibilities:
5
+ * - Auto-attach CSRF token from <meta name="csrf-token">
6
+ * - Serialize POST/PUT/PATCH body as JSON
7
+ * - Serialize GET/HEAD options as query params
8
+ * - Throw a typed FetchError on non-2xx responses
9
+ * - Return parsed JSON or text
10
+ *
11
+ * LLM NOTE: This module is PURE (no DOM side effects beyond reading a meta tag).
12
+ * It wraps the native fetch() API with SaaS-friendly defaults.
13
+ */
14
+
15
+ import type { FetchOptions } from '../types'
16
+
17
+ // ── CSRF ──────────────────────────────────────────────────────────────────────
18
+
19
+ /** Read CSRF token from <meta name="csrf-token"> (Rails, Laravel, Django…). */
20
+ function getCSRF(): string | null {
21
+ return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? null
22
+ }
23
+
24
+ // ── Typed error ───────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Thrown by `this.fetch()` when the server returns a non-2xx status.
28
+ *
29
+ * @example
30
+ * try {
31
+ * await this.fetch('/api/data')
32
+ * } catch (e) {
33
+ * if (e instanceof FetchError && e.status === 404) { ... }
34
+ * }
35
+ */
36
+ export class FetchError extends Error {
37
+ constructor(
38
+ message: string,
39
+ public readonly status: number,
40
+ public readonly response: Response,
41
+ ) {
42
+ super(message)
43
+ this.name = 'FetchError'
44
+ }
45
+ }
46
+
47
+ // ── Fetch helper ──────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Fetch wrapper with SaaS defaults.
51
+ *
52
+ * - GET/HEAD: extra `options` keys become URL query params
53
+ * - POST/PUT/PATCH/DELETE: `options.body` is JSON-serialized
54
+ * - Attaches X-CSRF-Token header automatically
55
+ * - Returns parsed JSON if Content-Type is application/json, else text
56
+ *
57
+ * @example
58
+ * // GET with params → /api/users?page=2&status=active
59
+ * const data = await this.fetch('/api/users', { page: 2, status: 'active' })
60
+ *
61
+ * // POST with JSON body
62
+ * await this.fetch('/api/invite', { method: 'POST', body: { email, role } })
63
+ */
64
+ export async function micraFetch(url: string, options: FetchOptions = {}): Promise<unknown> {
65
+ const method = ((options.method as string | undefined) ?? 'GET').toUpperCase()
66
+ const headers: Record<string, string> = {
67
+ Accept: 'application/json',
68
+ ...(options.headers as Record<string, string> | undefined),
69
+ }
70
+
71
+ const csrf = getCSRF()
72
+ if (csrf) headers['X-CSRF-Token'] = csrf
73
+
74
+ let finalUrl = url
75
+ let body: string | undefined
76
+
77
+ if (method === 'GET' || method === 'HEAD') {
78
+ const params: Record<string, string> = {}
79
+ for (const [k, v] of Object.entries(options)) {
80
+ if (k !== 'method' && k !== 'headers' && v != null) params[k] = String(v)
81
+ }
82
+ if (Object.keys(params).length)
83
+ finalUrl += (url.includes('?') ? '&' : '?') + new URLSearchParams(params)
84
+ } else {
85
+ headers['Content-Type'] = 'application/json'
86
+ body = JSON.stringify(options.body !== undefined ? options.body : options)
87
+ }
88
+
89
+ const res = await fetch(finalUrl, {
90
+ method,
91
+ headers,
92
+ ...(body !== undefined ? { body } : {}),
93
+ })
94
+
95
+ if (!res.ok)
96
+ throw new FetchError(`[Micra] fetch: ${method} ${url} → ${res.status}`, res.status, res)
97
+
98
+ const ct = res.headers.get('content-type') ?? ''
99
+ return ct.includes('application/json') ? res.json() : res.text()
100
+ }