fusee-framework 1.3.0 → 1.4.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.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # Fusée Framework 🚀
2
- **v1.3.0 — Signals-First JS Framework | Atomic Reactivity | Peak Performance**
2
+ **v1.4.0 — Signals-First JS Framework | Atomic Reactivity | Peak Performance**
3
3
 
4
4
  Fusée is a custom, high-performance fine-grained reactive JavaScript framework built for speed and simplicity. It features a recursive, non-greedy compiler, a signals-based reactivity engine, Dependency Injection for components and a comprehensive CLI for instant application scaffolding.
5
5
 
@@ -0,0 +1,29 @@
1
+ import { defineComposable, assertSetupContext, signal, computed, onMount, onUnmount } from '../../framework/index.js'
2
+
3
+ // ─── counterLogic ─────────────────────────────────────────────────────────────
4
+ export const counterLogic = defineComposable((initial = 0, step = 1) => {
5
+ const count = signal(initial)
6
+ const doubled = computed(() => count() * 2)
7
+ const isEven = computed(() => count() % 2 === 0)
8
+
9
+ const increment = () => count(count() + step)
10
+ const decrement = () => count(count() - step)
11
+ const reset = () => count(initial)
12
+
13
+ return { count, doubled, isEven, increment, decrement, reset }
14
+ })
15
+
16
+ // ─── mouseTracker ─────────────────────────────────────────────────────────────
17
+ export const mouseTracker = defineComposable(() => {
18
+ assertSetupContext('mouseTracker')
19
+
20
+ const x = signal(0)
21
+ const y = signal(0)
22
+
23
+ const onMove = (e) => { x(e.clientX); y(e.clientY) }
24
+
25
+ onMount(() => window.addEventListener('mousemove', onMove))
26
+ onUnmount(() => window.removeEventListener('mousemove', onMove))
27
+
28
+ return { x, y }
29
+ })
@@ -0,0 +1,30 @@
1
+ import { defineComposable, assertSetupContext, signal, computed, onMount, onUnmount } from '../../framework/index.js'
2
+
3
+ export const counterLogic = defineComposable((initial: number = 0, step: number = 1) => {
4
+ const count = signal(initial)
5
+ const doubled = computed(() => count() * 2)
6
+ const isEven = computed(() => count() % 2 === 0)
7
+
8
+ const increment = () => count(count() + step)
9
+ const decrement = () => count(count() - step)
10
+ const reset = () => count(initial)
11
+
12
+ return { count, doubled, isEven, increment, decrement, reset }
13
+ })
14
+
15
+ export const mouseTracker = defineComposable(() => {
16
+ assertSetupContext('mouseTracker')
17
+
18
+ const x = signal(0)
19
+ const y = signal(0)
20
+
21
+ const onMove = (e: MouseEvent) => {
22
+ x(e.clientX)
23
+ y(e.clientY)
24
+ }
25
+
26
+ onMount(() => window.addEventListener('mousemove', onMove))
27
+ onUnmount(() => window.removeEventListener('mousemove', onMove))
28
+
29
+ return { x, y }
30
+ })
@@ -0,0 +1,53 @@
1
+ import { defineComposable, signal, effect } from '../../framework/index.js'
2
+
3
+ /**
4
+ * @template T
5
+ * @param {string | (() => string)} urlOrSignal
6
+ * @param {{ immediate?: boolean, transform?: (raw: any) => T }} [options]
7
+ */
8
+ export const useFetch = defineComposable((urlOrSignal, options = {}) => {
9
+ const { immediate = true, transform = null } = options
10
+
11
+ const data = signal(null)
12
+ const loading = signal(false)
13
+ const error = signal(null)
14
+
15
+ let controller = null
16
+
17
+ const execute = () => {
18
+ const url = typeof urlOrSignal === 'function' ? urlOrSignal() : urlOrSignal
19
+ if (!url) return
20
+
21
+ if (controller) controller.abort()
22
+ controller = new AbortController()
23
+
24
+ loading(true)
25
+ error(null)
26
+
27
+ fetch(url, { signal: controller.signal })
28
+ .then(res => {
29
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
30
+ return res.json()
31
+ })
32
+ .then(raw => {
33
+ data(transform ? transform(raw) : raw)
34
+ loading(false)
35
+ })
36
+ .catch(err => {
37
+ if (err.name === 'AbortError') return
38
+ error(err.message ?? String(err))
39
+ loading(false)
40
+ })
41
+ }
42
+
43
+ if (typeof urlOrSignal === 'function') {
44
+ effect(() => {
45
+ urlOrSignal()
46
+ execute()
47
+ })
48
+ } else if (immediate) {
49
+ execute()
50
+ }
51
+
52
+ return { data, loading, error, refetch: execute }
53
+ })
@@ -0,0 +1,63 @@
1
+ import { defineComposable, signal, effect } from '../../framework/index.js'
2
+
3
+ export interface FetchOptions<T> {
4
+ immediate?: boolean
5
+ transform?: (raw: any) => T
6
+ }
7
+
8
+ export interface FetchResult<T> {
9
+ data: Signal<T | null>
10
+ loading: Signal<boolean>
11
+ error: Signal<string | null>
12
+ refetch: () => void
13
+ }
14
+
15
+ export const useFetch = defineComposable(<T = any>(
16
+ urlOrSignal: string | (() => string),
17
+ options: FetchOptions<T> = {}
18
+ ): FetchResult<T> => {
19
+ const { immediate = true, transform = null } = options
20
+
21
+ const data = signal<T | null>(null)
22
+ const loading = signal(false)
23
+ const error = signal<string | null>(null)
24
+
25
+ let controller: AbortController | null = null
26
+
27
+ const execute = () => {
28
+ const url = typeof urlOrSignal === 'function' ? urlOrSignal() : urlOrSignal
29
+ if (!url) return
30
+
31
+ if (controller) controller.abort()
32
+ controller = new AbortController()
33
+
34
+ loading(true)
35
+ error(null)
36
+
37
+ fetch(url, { signal: controller.signal })
38
+ .then(res => {
39
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
40
+ return res.json()
41
+ })
42
+ .then(raw => {
43
+ data(transform ? transform(raw) : raw)
44
+ loading(false)
45
+ })
46
+ .catch(err => {
47
+ if (err.name === 'AbortError') return
48
+ error(err.message ?? String(err))
49
+ loading(false)
50
+ })
51
+ }
52
+
53
+ if (typeof urlOrSignal === 'function') {
54
+ effect(() => {
55
+ urlOrSignal()
56
+ execute()
57
+ })
58
+ } else if (immediate) {
59
+ execute()
60
+ }
61
+
62
+ return { data, loading, error, refetch: execute }
63
+ })
@@ -0,0 +1,31 @@
1
+ import { defineComposable, signal, effect } from '../../framework/index.js'
2
+
3
+ /**
4
+ * @template T
5
+ * @param {string} key
6
+ * @param {T} defaultValue
7
+ */
8
+ export const useLocalStorage = defineComposable((key, defaultValue = null) => {
9
+ const stored = localStorage.getItem(key)
10
+ let initial
11
+
12
+ try {
13
+ initial = stored !== null ? JSON.parse(stored) : defaultValue
14
+ } catch {
15
+ initial = defaultValue
16
+ localStorage.removeItem(key)
17
+ }
18
+
19
+ const value = signal(initial)
20
+
21
+ effect(() => {
22
+ const current = value()
23
+ if (current === null || current === undefined) {
24
+ localStorage.removeItem(key)
25
+ } else {
26
+ localStorage.setItem(key, JSON.stringify(current))
27
+ }
28
+ })
29
+
30
+ return value
31
+ })
@@ -0,0 +1,26 @@
1
+ import { defineComposable, signal, effect } from '../../framework/index.js'
2
+
3
+ export const useLocalStorage = defineComposable(<T>(key: string, defaultValue: T | null = null): Signal<T | null> => {
4
+ const stored = localStorage.getItem(key)
5
+ let initial: T | null
6
+
7
+ try {
8
+ initial = stored !== null ? JSON.parse(stored) : defaultValue
9
+ } catch {
10
+ initial = defaultValue
11
+ localStorage.removeItem(key)
12
+ }
13
+
14
+ const value = signal<T | null>(initial)
15
+
16
+ effect(() => {
17
+ const current = value()
18
+ if (current === null || current === undefined) {
19
+ localStorage.removeItem(key)
20
+ } else {
21
+ localStorage.setItem(key, JSON.stringify(current))
22
+ }
23
+ })
24
+
25
+ return value
26
+ })
package/app/main.js CHANGED
@@ -1,6 +1,22 @@
1
1
  import { createRouter, mountOutlet, defineAsyncComponent } from '../framework/index.js'
2
2
  import { Loading } from './components/Loading.js'
3
3
 
4
+ directive('focus', {
5
+ mounted(el) {
6
+ el.focus()
7
+ }
8
+ })
9
+
10
+ directive('highlight', {
11
+ mounted(el, binding) {
12
+ el.style.backgroundColor = binding.value || '#ffff0033'
13
+ el.style.transition = 'background-color 0.3s'
14
+ },
15
+ updated(el, binding) {
16
+ el.style.backgroundColor = binding.value || '#ffff0033'
17
+ }
18
+ })
19
+
4
20
  const routes = [
5
21
  {
6
22
  path: '/',
@@ -30,6 +46,13 @@ const routes = [
30
46
  loadingComponent: Loading
31
47
  })
32
48
  },
49
+ {
50
+ path: '/composables',
51
+ component: defineAsyncComponent({
52
+ loader: () => import('./routes/TestComposables.js'),
53
+ loadingComponent: Loading
54
+ })
55
+ },
33
56
  ]
34
57
 
35
58
  createRouter(routes)
package/app/main.ts CHANGED
@@ -2,35 +2,58 @@ import { createRouter, mountOutlet, defineAsyncComponent } from '../framework/in
2
2
  import type { Route } from '../framework/types/index.js'
3
3
  import { Loading } from './components/Loading.js'
4
4
 
5
+ directive('focus', {
6
+ mounted(el) {
7
+ el.focus()
8
+ }
9
+ })
10
+
11
+ directive('highlight', {
12
+ mounted(el, binding) {
13
+ el.style.backgroundColor = binding.value || '#ffff0033'
14
+ el.style.transition = 'background-color 0.3s'
15
+ },
16
+ updated(el, binding) {
17
+ el.style.backgroundColor = binding.value || '#ffff0033'
18
+ }
19
+ })
20
+
5
21
  const routes: Route[] = [
6
22
  {
7
23
  path: '/',
8
24
  component: defineAsyncComponent({
9
- loader: () => import('./routes/Home.js'),
25
+ loader: () => import('./routes/Home.ts'),
10
26
  loadingComponent: Loading
11
27
  })
12
28
  },
13
29
  {
14
30
  path: '/about',
15
31
  component: defineAsyncComponent({
16
- loader: () => import('./routes/About.js'),
32
+ loader: () => import('./routes/About.ts'),
17
33
  loadingComponent: Loading
18
34
  })
19
35
  },
20
36
  {
21
37
  path: '/test-for',
22
38
  component: defineAsyncComponent({
23
- loader: () => import('./routes/TestFor.js'),
39
+ loader: () => import('./routes/TestFor.ts'),
24
40
  loadingComponent: Loading
25
41
  })
26
42
  },
27
43
  {
28
44
  path: '/dynamic',
29
45
  component: defineAsyncComponent({
30
- loader: () => import('./routes/TestDynamic.js'),
46
+ loader: () => import('./routes/TestDynamic.ts'),
31
47
  loadingComponent: Loading
32
48
  })
33
49
  },
50
+ {
51
+ path: '/composables',
52
+ component: defineAsyncComponent({
53
+ loader: () => import('./routes/TestComposables.ts'),
54
+ loadingComponent: Loading
55
+ })
56
+ },
34
57
  ]
35
58
 
36
59
  createRouter(routes)
@@ -0,0 +1,158 @@
1
+ import { counterLogic, mouseTracker } from '../composables/demo.js'
2
+ import { useFetch } from '../composables/useFetch.js'
3
+ import { useLocalStorage } from '../composables/useLocalStorage.js'
4
+
5
+ export const TestComposables = defineComponent({
6
+ setup() {
7
+ // ── 1. counterLogic — pure signal composable, no context needed ──────
8
+ const counter = counterLogic(0, 1)
9
+ const bigCounter = counterLogic(100, 10)
10
+
11
+ // ── 2. mouseTracker — lifecycle composable (needs setup context) ─────
12
+ const mouse = mouseTracker()
13
+
14
+ // ── 3. useLocalStorage — persists across page reloads ────────────────
15
+ const theme = useLocalStorage('demo-theme', 'dark')
16
+ const nickname = useLocalStorage('demo-nickname', '')
17
+
18
+ const toggleTheme = () => theme(theme() === 'dark' ? 'light' : 'dark')
19
+
20
+ // ── 4. useFetch — reactive data fetching ─────────────────────────────
21
+ const { data: posts, loading, error } = useFetch(
22
+ 'https://jsonplaceholder.typicode.com/posts?_limit=3'
23
+ )
24
+ // ── 5. Custom Directives Test ────────────────────────────────────────
25
+ const hColor = signal('#8b8bff33')
26
+ const colors = ['#8b8bff33', '#10b98133', '#f59e0b33', '#ef444433']
27
+ let colorIdx = 0
28
+ const nextColor = () => hColor(colors[++colorIdx % colors.length])
29
+
30
+ return {
31
+ hColor,
32
+ nextColor,
33
+
34
+ // counter
35
+ count: counter.count,
36
+ doubled: counter.doubled,
37
+ isEven: counter.isEven,
38
+ increment: counter.increment,
39
+ decrement: counter.decrement,
40
+ reset: counter.reset,
41
+
42
+ // big counter
43
+ bigCount: bigCounter.count,
44
+ bigIncrement: bigCounter.increment,
45
+ bigDecrement: bigCounter.decrement,
46
+
47
+ // mouse
48
+ mouseX: mouse.x,
49
+ mouseY: mouse.y,
50
+
51
+ // localStorage
52
+ theme,
53
+ nickname,
54
+ toggleTheme,
55
+
56
+ // fetch
57
+ posts,
58
+ loading,
59
+ error,
60
+
61
+ template: `
62
+ <div class="page">
63
+ <h1>🧩 Composables Demo</h1>
64
+ <p style="color:#888; margin-bottom: 2rem;">
65
+ Each section below is powered by a <code style="color:#ff3333">defineComposable()</code> function.
66
+ No naming rules — just reactive logic.
67
+ </p>
68
+
69
+ <!-- ── 1. counterLogic ─────────────────────────────────────── -->
70
+ <section class="demo-card">
71
+ <h2>📦 counterLogic(0, step=1)</h2>
72
+ <p class="demo-desc">Pure signal composable — works without setup() context.</p>
73
+ <div class="counter-display">
74
+ <span class="big-num" :class="isEven ? 'even' : 'odd'">{{ count }}</span>
75
+ <span class="derived-label">× 2 = {{ doubled }}</span>
76
+ </div>
77
+ <div class="btn-row">
78
+ <button @click="decrement" class="cmp-btn minus">−</button>
79
+ <button @click="reset" class="cmp-btn reset">↺</button>
80
+ <button @click="increment" class="cmp-btn plus">+</button>
81
+ </div>
82
+ <p class="badge" :class="isEven ? 'badge-blue' : 'badge-red'">{{ isEven ? 'Even' : 'Odd' }}</p>
83
+ </section>
84
+
85
+ <!-- ── 1b. Second independent counter (step=10) ───────────── -->
86
+ <section class="demo-card">
87
+ <h2>📦 counterLogic(100, step=10)</h2>
88
+ <p class="demo-desc">Same composable, different instance. Fully isolated state.</p>
89
+ <div class="counter-display">
90
+ <span class="big-num">{{ bigCount }}</span>
91
+ </div>
92
+ <div class="btn-row">
93
+ <button @click="bigDecrement" class="cmp-btn minus">−10</button>
94
+ <button @click="bigIncrement" class="cmp-btn plus">+10</button>
95
+ </div>
96
+ </section>
97
+
98
+ <!-- ── 2. mouseTracker ────────────────────────────────────── -->
99
+ <section class="demo-card">
100
+ <h2>🖱️ mouseTracker()</h2>
101
+ <p class="demo-desc">Lifecycle composable — registers window listener on mount, cleans up on unmount.</p>
102
+ <div class="mouse-display">
103
+ <span>X: <strong>{{ mouseX }}</strong></span>
104
+ <span>Y: <strong>{{ mouseY }}</strong></span>
105
+ </div>
106
+ </section>
107
+
108
+ <!-- ── 3. useLocalStorage ─────────────────────────────────── -->
109
+ <section class="demo-card">
110
+ <h2>💾 useLocalStorage()</h2>
111
+ <p class="demo-desc">State persists across page reloads. Try changing the nickname, then refresh.</p>
112
+ <div style="margin-bottom: 12px;">
113
+ <input f-model="nickname"
114
+ class="cmp-input"
115
+ placeholder="Your nickname…" />
116
+ <p style="margin-top: 8px; color: #888; font-size: 0.85rem;">
117
+ Stored: <strong style="color:#ff3333">{{ nickname || '(empty)' }}</strong>
118
+ </p>
119
+ </div>
120
+ <button @click="toggleTheme" class="cmp-btn reset">
121
+ Theme: {{ theme }}
122
+ </button>
123
+ </section>
124
+
125
+ <!-- ── 4. useFetch ────────────────────────────────────────── -->
126
+ <section class="demo-card">
127
+ <h2>🌐 useFetch()</h2>
128
+ <p class="demo-desc">Fetches from JSONPlaceholder. Loading state handled reactively.</p>
129
+ <div f-if="loading">
130
+ <p style="color: #888;">⏳ Loading posts…</p>
131
+ </div>
132
+ <div f-elif="error">
133
+ <p style="color: #ff6b6b;">❌ {{ error }}</p>
134
+ </div>
135
+ <div f-else>
136
+ <div f-for="post in posts" style="margin-bottom: 10px; padding: 10px; background:#1a1a24; border-radius:6px; border-left: 3px solid #ff3333;">
137
+ <strong style="color:#ff9999">{{ post.title }}</strong>
138
+ <p style="margin-top: 4px; font-size: 0.8rem; color: #666;">{{ post.body }}</p>
139
+ </div>
140
+ </div>
141
+ </section>
142
+
143
+ <!-- ── 6. Custom Directives ────────────────────────────────────── -->
144
+ <section class="demo-card">
145
+ <h2>🛠️ Custom Directives</h2>
146
+ <p class="demo-desc">Testing <code>f-focus</code> and <code>f-highlight</code>.</p>
147
+
148
+ <div f-highlight="hColor" style="padding: 15px; border-radius: 8px; margin-bottom: 10px;">
149
+ <input f-focus class="cmp-input" placeholder="I have f-focus!" />
150
+ </div>
151
+
152
+ <button @click="nextColor" class="cmp-btn plus">Change Highlight Color</button>
153
+ </section>
154
+ </div>
155
+ `
156
+ }
157
+ }
158
+ })
@@ -0,0 +1,158 @@
1
+ import { defineComponent } from '../../framework/index.js'
2
+ import { counterLogic, mouseTracker } from '../composables/demo.js'
3
+ import { useFetch } from '../composables/useFetch.js'
4
+ import { useLocalStorage } from '../composables/useLocalStorage.js'
5
+
6
+ export const TestComposables = defineComponent({
7
+ setup() {
8
+ // ── 1. counterLogic — pure signal composable, no context needed ──────
9
+ const counter = counterLogic(0, 1)
10
+ const bigCounter = counterLogic(100, 10)
11
+
12
+ // ── 2. mouseTracker — lifecycle composable (needs setup context) ─────
13
+ const mouse = mouseTracker()
14
+
15
+ // ── 3. useLocalStorage — persists across page reloads ────────────────
16
+ const theme = useLocalStorage<string>('demo-theme', 'dark')
17
+ const nickname = useLocalStorage<string>('demo-nickname', '')
18
+
19
+ const toggleTheme = () => theme(theme() === 'dark' ? 'light' : 'dark')
20
+
21
+ // ── 4. useFetch — reactive data fetching ─────────────────────────────
22
+ const { data: posts, loading, error } = useFetch(
23
+ 'https://jsonplaceholder.typicode.com/posts?_limit=3'
24
+ )
25
+ // ── 5. Custom Directives Test ────────────────────────────────────────
26
+ const hColor = signal('#8b8bff33')
27
+ const colors = ['#8b8bff33', '#10b98133', '#f59e0b33', '#ef444433']
28
+ let colorIdx = 0
29
+ const nextColor = () => hColor(colors[++colorIdx % colors.length])
30
+
31
+ return {
32
+ hColor,
33
+ nextColor,
34
+
35
+ // counter
36
+ count: counter.count,
37
+ doubled: counter.doubled,
38
+ isEven: counter.isEven,
39
+ increment: counter.increment,
40
+ decrement: counter.decrement,
41
+ reset: counter.reset,
42
+
43
+ // big counter
44
+ bigCount: bigCounter.count,
45
+ bigIncrement: bigCounter.increment,
46
+ bigDecrement: bigCounter.decrement,
47
+
48
+ // mouse
49
+ mouseX: mouse.x,
50
+ mouseY: mouse.y,
51
+
52
+ // localStorage
53
+ theme,
54
+ nickname,
55
+ toggleTheme,
56
+
57
+ // fetch
58
+ posts,
59
+ loading,
60
+ error,
61
+
62
+ template: `
63
+ <div class="page">
64
+ <h1>🧩 Composables Demo</h1>
65
+ <p style="color:#888; margin-bottom: 2rem;">
66
+ Each section below is powered by a <code style="color:#ff3333">defineComposable()</code> function.
67
+ No naming rules — just reactive logic.
68
+ </p>
69
+
70
+ <!-- ── 1. counterLogic ─────────────────────────────────────── -->
71
+ <section class="demo-card">
72
+ <h2>📦 counterLogic(0, step=1)</h2>
73
+ <p class="demo-desc">Pure signal composable — works without setup() context.</p>
74
+ <div class="counter-display">
75
+ <span class="big-num" :class="isEven ? 'even' : 'odd'">{{ count }}</span>
76
+ <span class="derived-label">× 2 = {{ doubled }}</span>
77
+ </div>
78
+ <div class="btn-row">
79
+ <button @click="decrement" class="cmp-btn minus">−</button>
80
+ <button @click="reset" class="cmp-btn reset">↺</button>
81
+ <button @click="increment" class="cmp-btn plus">+</button>
82
+ </div>
83
+ <p class="badge" :class="isEven ? 'badge-blue' : 'badge-red'">{{ isEven ? 'Even' : 'Odd' }}</p>
84
+ </section>
85
+
86
+ <!-- ── 1b. Second independent counter (step=10) ───────────── -->
87
+ <section class="demo-card">
88
+ <h2>📦 counterLogic(100, step=10)</h2>
89
+ <p class="demo-desc">Same composable, different instance. Fully isolated state.</p>
90
+ <div class="counter-display">
91
+ <span class="big-num">{{ bigCount }}</span>
92
+ </div>
93
+ <div class="btn-row">
94
+ <button @click="bigDecrement" class="cmp-btn minus">−10</button>
95
+ <button @click="bigIncrement" class="cmp-btn plus">+10</button>
96
+ </div>
97
+ </section>
98
+
99
+ <!-- ── 2. mouseTracker ────────────────────────────────────── -->
100
+ <section class="demo-card">
101
+ <h2>🖱️ mouseTracker()</h2>
102
+ <p class="demo-desc">Lifecycle composable — registers window listener on mount, cleans up on unmount.</p>
103
+ <div class="mouse-display">
104
+ <span>X: <strong>{{ mouseX }}</strong></span>
105
+ <span>Y: <strong>{{ mouseY }}</strong></span>
106
+ </div>
107
+ </section>
108
+
109
+ <!-- ── 3. useLocalStorage ─────────────────────────────────── -->
110
+ <section class="demo-card">
111
+ <h2>💾 useLocalStorage()</h2>
112
+ <p class="demo-desc">State persists across page reloads. Try changing the nickname, then refresh.</p>
113
+ <div style="margin-bottom: 12px;">
114
+ <input f-model="nickname"
115
+ class="cmp-input"
116
+ placeholder="Your nickname…" />
117
+ <p style="margin-top: 8px; color: #888; font-size: 0.85rem;">
118
+ Stored: <strong style="color:#ff3333">{{ nickname || '(empty)' }}</strong>
119
+ </p>
120
+ </div>
121
+ <button @click="toggleTheme" class="cmp-btn reset">
122
+ Theme: {{ theme }}
123
+ </button>
124
+ </section>
125
+
126
+ <!-- ── 4. useFetch ────────────────────────────────────────── -->
127
+ <section class="demo-card">
128
+ <h2>🌐 useFetch()</h2>
129
+ <p class="demo-desc">Fetches from JSONPlaceholder. Loading state handled reactively.</p>
130
+ <div f-if="loading">
131
+ <p style="color: #888;">⏳ Loading posts…</p>
132
+ </div>
133
+ <div f-elif="error">
134
+ <p style="color: #ff6b6b;">❌ {{ error }}</p>
135
+ </div>
136
+ <div f-else>
137
+ <div f-for="post in posts" style="margin-bottom: 10px; padding: 10px; background:#1a1a24; border-radius:6px; border-left: 3px solid #ff3333;">
138
+ <strong style="color:#ff9999">{{ post.title }}</strong>
139
+ <p style="margin-top: 4px; font-size: 0.8rem; color: #666;">{{ post.body }}</p>
140
+ </div>
141
+ </div>
142
+ </section>
143
+ <!-- ── 6. Custom Directives ────────────────────────────────────── -->
144
+ <section class="demo-card">
145
+ <h2>🛠️ Custom Directives</h2>
146
+ <p class="demo-desc">Testing <code>f-focus</code> and <code>f-highlight</code>.</p>
147
+
148
+ <div f-highlight="hColor" style="padding: 15px; border-radius: 8px; margin-bottom: 10px;">
149
+ <input f-focus class="cmp-input" placeholder="I have f-focus!" />
150
+ </div>
151
+
152
+ <button @click="nextColor" class="cmp-btn plus">Change Highlight Color</button>
153
+ </section>
154
+ </div>
155
+ `
156
+ }
157
+ }
158
+ })
@@ -11,11 +11,10 @@ export const TestFor = defineComponent({
11
11
  items,
12
12
  addItem: () => {
13
13
  const nextId = Math.max(0, ...items().map(i => i.id)) + 1
14
- items([...items(), { id: nextId, name: `Item ${nextId}` }])
14
+ items.push({ id: nextId, name: `Item ${nextId}` })
15
15
  },
16
16
  shuffleItems: () => {
17
- const shuffled = [...items()].sort(() => Math.random() - 0.5)
18
- items(shuffled)
17
+ items.sort(() => Math.random() - 0.5)
19
18
  },
20
19
  template: `
21
20
  <div class="page">
@@ -23,11 +23,10 @@ export const TestFor = defineComponent({
23
23
  addItem: () => {
24
24
  const nextIds = items().map(i => i.id)
25
25
  const nextId = nextIds.length > 0 ? Math.max(...nextIds) + 1 : 1
26
- items([...items(), { id: nextId, name: `Item ${nextId}` }])
26
+ items.push({ id: nextId, name: `Item ${nextId}` })
27
27
  },
28
28
  shuffleItems: () => {
29
- const shuffled = [...items()].sort(() => Math.random() - 0.5)
30
- items(shuffled)
29
+ items.sort(() => Math.random() - 0.5)
31
30
  },
32
31
  template: `
33
32
  <div class="page">
package/auto-imports.d.ts CHANGED
@@ -9,6 +9,7 @@ declare global {
9
9
  const batch: typeof import('fusee-framework').batch
10
10
  const computed: typeof import('fusee-framework').computed
11
11
  const defineComponent: typeof import('fusee-framework').defineComponent
12
+ const directive: typeof import('fusee-framework').directive
12
13
  const effect: typeof import('fusee-framework').effect
13
14
  const emit: typeof import('fusee-framework').emit
14
15
  const inject: typeof import('fusee-framework').inject
@@ -3,6 +3,7 @@ export const FuseePreset = {
3
3
  'signal', 'computed', 'effect',
4
4
  'batch', 'untrack', 'inspect',
5
5
  'defineComponent', 'onMount', 'onUnmount',
6
+ 'directive',
6
7
  'mountOutlet', 'mountTemplate', 'emit'
7
8
  ]
8
9
  }
@@ -7,6 +7,10 @@ setEffectHook(eff => {
7
7
  if (currentInstance) currentInstance._effects.push(eff)
8
8
  })
9
9
 
10
+ export function getCurrentInstance() {
11
+ return currentInstance
12
+ }
13
+
10
14
  function resolveProps(schema, received) {
11
15
  const isArray = Array.isArray(schema)
12
16
  const resolved = {}
@@ -68,9 +72,6 @@ function resolveProps(schema, received) {
68
72
  return resolved
69
73
  }
70
74
 
71
- // ── Emit ──────────────────────────────────────────────────────────────────────
72
- // listeners: { change: fn, submit: fn, ... }
73
- // emit('change', 42) → calls listeners.change(42)
74
75
  function createEmit(listeners) {
75
76
  return function emit(eventName, ...args) {
76
77
  const handler = listeners[eventName]
@@ -82,10 +83,6 @@ function createEmit(listeners) {
82
83
  }
83
84
  }
84
85
 
85
- // ── Slots ─────────────────────────────────────────────────────────────────────
86
- // Parses slot content from parent HTML string.
87
- // Named slots: <template slot="header">...</template>
88
- // Default slot: everything else
89
86
  export function parseSlots(slotHTML) {
90
87
  const slots = { default: '' }
91
88
  if (!slotHTML || !slotHTML.trim()) return slots
@@ -105,19 +102,15 @@ export function parseSlots(slotHTML) {
105
102
  return slots
106
103
  }
107
104
 
108
- // Replaces <slot> and <slot name="x"> in template with actual slot content
109
105
  function resolveSlots(template, slots) {
110
- // Handle named slots with any content between tags (including default content)
111
106
  let result = template.replace(/<slot\s+name="([^"]+)"\s*>[\s\S]*?<\/slot>/gi, (match, name) => {
112
107
  return slots[name] ?? ''
113
108
  })
114
- // Handle default slots with any content between tags
115
109
  result = result.replace(/<slot\s*>[\s\S]*?<\/slot>/gi, () => {
116
110
  return slots.default ?? ''
117
111
  })
118
- // Handle self-closing named slots
112
+
119
113
  result = result.replace(/<slot\s+name="([^"]+)"\s*\/>/gi, (_, name) => slots[name] ?? '')
120
- // Handle self-closing default slots
121
114
  result = result.replace(/<slot\s*\/>/gi, () => slots.default ?? '')
122
115
  return result
123
116
  }
@@ -131,10 +124,6 @@ export function onUnmount(fn) {
131
124
  }
132
125
 
133
126
  export function defineComponent(options) {
134
- // ComponentFactory now accepts props + optional { listeners, slots, parent }
135
- // listeners: event handlers from parent @eventName="handler"
136
- // slots: parsed slot HTML from parent
137
- // parent: parent component instance for provide/inject
138
127
  return function ComponentFactory(props = {}, { listeners = {}, slots = {}, parent = null } = {}) {
139
128
  const instance = {
140
129
  props: options.props ? resolveProps(options.props, props) : props,
@@ -149,16 +138,13 @@ export function defineComponent(options) {
149
138
  const emit = createEmit(listeners)
150
139
 
151
140
  currentInstance = instance
152
- // setup() receives (props, { emit, slots })
153
141
  const result = options.setup(instance.props, { emit, slots })
154
- // Store instance reference on result for child component access
155
142
  result._instance = instance
156
143
  currentInstance = null
157
144
 
158
145
  function render(container) {
159
146
  instance._element = container
160
147
 
161
- // Resolve slot placeholders before mounting
162
148
  const resolvedTemplate = resolveSlots(result.template, slots)
163
149
 
164
150
  const { effects } = mountTemplate(
@@ -201,8 +187,8 @@ export function inject(key) {
201
187
  }
202
188
 
203
189
  export function defineAsyncComponent(loaderOrOptions) {
204
- const options = typeof loaderOrOptions === 'function'
205
- ? { loader: loaderOrOptions }
190
+ const options = typeof loaderOrOptions === 'function'
191
+ ? { loader: loaderOrOptions }
206
192
  : loaderOrOptions
207
193
 
208
194
  return function AsyncComponentFactory(props = {}, { listeners = {}, slots = {}, parent = null } = {}) {
@@ -0,0 +1,27 @@
1
+ import { getCurrentInstance } from './component.js'
2
+
3
+ export function defineComposable(fn) {
4
+ const composable = function (...args) {
5
+ return fn(...args)
6
+ }
7
+
8
+ composable._isComposable = true
9
+
10
+ Object.defineProperty(composable, 'name', {
11
+ value: fn.name || 'composable',
12
+ configurable: true
13
+ })
14
+
15
+ return composable
16
+ }
17
+
18
+ export function assertSetupContext(name) {
19
+ const instance = getCurrentInstance()
20
+ if (!instance) {
21
+ console.warn(
22
+ `[framework] ${name ? name + ' ' : ''}composable was called outside of setup(). ` +
23
+ `Lifecycle hooks (onMount, onUnmount) and injections will not work.`
24
+ )
25
+ }
26
+ return instance
27
+ }
@@ -2,6 +2,73 @@ import { signal, effect, batch } from './signal.js'
2
2
  import { evaluateExpression } from './evaluator.js'
3
3
  import { compileNode } from './compiler.js'
4
4
 
5
+ const customDirectives = new Map()
6
+
7
+ export function directive(name, definition) {
8
+ if (typeof name !== 'string' || !name.trim()) {
9
+ console.error('[framework] directive() requires a non-empty name')
10
+ return
11
+ }
12
+ if (!definition || typeof definition !== 'object') {
13
+ console.error(`[framework] directive("${name}") requires a definition object`)
14
+ return
15
+ }
16
+ customDirectives.set(name, definition)
17
+ }
18
+
19
+ export function processCustomDirectives(el, context, effects) {
20
+ if (el.nodeType !== 1 || !el.hasAttribute || !el.attributes) return
21
+
22
+ for (const attr of [...el.attributes]) {
23
+ const match = attr.name.match(/^f-([a-zA-Z0-9-]+)(?::([a-zA-Z0-9-]+))?(?:\.(.+))?$/)
24
+
25
+ if (!match) continue
26
+
27
+ const directiveName = match[1]
28
+ const arg = match[2] || null
29
+ const modifiersStr = match[3]
30
+ const modifiers = modifiersStr ? modifiersStr.split('.') : []
31
+
32
+ const definition = customDirectives.get(directiveName)
33
+ if (!definition) {
34
+ continue
35
+ }
36
+
37
+ el.removeAttribute(attr.name)
38
+
39
+ const value = evaluateExpression(attr.value, context)
40
+ const expression = attr.value
41
+
42
+ const binding = {
43
+ value,
44
+ expression,
45
+ arg,
46
+ modifiers
47
+ }
48
+
49
+ if (typeof definition.mounted === 'function') {
50
+ definition.mounted(el, binding)
51
+ }
52
+
53
+ if (typeof definition.updated === 'function') {
54
+ effects.push(effect(() => {
55
+ const newValue = evaluateExpression(expression, context)
56
+
57
+ if (!Object.is(binding.value, newValue)) {
58
+ binding.value = newValue
59
+ definition.updated(el, binding)
60
+ }
61
+ }))
62
+ }
63
+
64
+ if (typeof definition.unmounted === 'function') {
65
+ effects.push(() => {
66
+ definition.unmounted(el, binding)
67
+ })
68
+ }
69
+ }
70
+ }
71
+
5
72
  export function processDirectives(root, context, components, effects) {
6
73
  if (processFor(root, context, components, effects)) return true
7
74
  if (processIf(root, context, components, effects)) return true
@@ -13,6 +80,7 @@ export function processDirectives(root, context, components, effects) {
13
80
  processHtml(root, context, effects)
14
81
  processText(root, context, effects)
15
82
  processEvents(root, context, effects)
83
+ processCustomDirectives(root, context, effects)
16
84
  processCloak(root)
17
85
 
18
86
  return false
@@ -282,7 +350,7 @@ export function processIs(el, context, components, effects) {
282
350
  // 1. Prepare directive state
283
351
  const expr = el.getAttribute('f-is')
284
352
  const keepAlive = processKeepAlive(el)
285
-
353
+
286
354
  el.removeAttribute('f-is')
287
355
 
288
356
  const anchor = document.createComment('f-keepAlive-anchor')
@@ -51,6 +51,64 @@ export function signal(initialValue) {
51
51
  }
52
52
 
53
53
  accessor.isSignal = true
54
+
55
+ if (Array.isArray(initialValue)) {
56
+ accessor.push = (...items) => {
57
+ const next = [...accessor(), ...items]
58
+ accessor(next)
59
+ return next.length
60
+ }
61
+
62
+ accessor.pop = () => {
63
+ const arr = accessor()
64
+ if (arr.length === 0) return undefined
65
+ const item = arr[arr.length - 1]
66
+ accessor(arr.slice(0, -1))
67
+ return item
68
+ }
69
+
70
+ accessor.shift = () => {
71
+ const arr = accessor()
72
+ if (arr.length === 0) return undefined
73
+ const item = arr[0]
74
+ accessor(arr.slice(1))
75
+ return item
76
+ }
77
+
78
+ accessor.unshift = (...items) => {
79
+ const next = [...items, ...accessor()]
80
+ accessor(next)
81
+ return next.length
82
+ }
83
+
84
+ accessor.splice = (start, deleteCount, ...items) => {
85
+ const arr = [...accessor()]
86
+ const removed = arr.splice(start, deleteCount, ...items)
87
+ accessor(arr)
88
+ return removed
89
+ }
90
+
91
+ accessor.remove = (predicate) => {
92
+ accessor(accessor().filter((item, i) => !predicate(item, i)))
93
+ }
94
+
95
+ accessor.clear = () => {
96
+ accessor([])
97
+ }
98
+
99
+ accessor.sort = (compareFn) => {
100
+ const next = [...accessor()].sort(compareFn)
101
+ accessor(next)
102
+ return accessor
103
+ }
104
+
105
+ accessor.reverse = () => {
106
+ const next = [...accessor()].reverse()
107
+ accessor(next)
108
+ return accessor
109
+ }
110
+ }
111
+
54
112
  return accessor
55
113
  }
56
114
 
@@ -1,8 +1,10 @@
1
1
  // ─── Framework Public API ─────────────────────────────────────────────────────
2
2
  export { signal, computed, effect, batch, untrack, inspect } from './core/signal.js'
3
- export { defineComponent, defineAsyncComponent, onMount, onUnmount, parseSlots, provide, inject } from './core/component.js'
3
+ export { defineComponent, defineAsyncComponent, onMount, onUnmount, parseSlots, provide, inject, getCurrentInstance } from './core/component.js'
4
4
  export { createRouter, navigate, mountOutlet } from './router/router.js'
5
5
  export { mountTemplate } from './core/compiler.js'
6
6
 
7
- // Emit is a placeholder - actual emit function is created per component
7
+ export { defineComposable, assertSetupContext } from './core/composable.js'
8
+ export { directive } from './core/directives.js'
9
+
8
10
  export const emit = () => console.warn('[framework] emit() can only be used inside component setup()')
@@ -0,0 +1,14 @@
1
+ export interface Composable<T = any> {
2
+ (...args: any[]): T
3
+ readonly _isComposable: true
4
+ }
5
+
6
+ declare global {
7
+ interface Composable<T = any> {
8
+ (...args: any[]): T
9
+ readonly _isComposable: true
10
+ }
11
+ }
12
+
13
+ export function defineComposable<T>(fn: (...args: any[]) => T): Composable<T>
14
+ export function assertSetupContext(name?: string): any
@@ -37,3 +37,18 @@ export function processHtml(root: HTMLElement, context: ComponentResult, effects
37
37
  export function processText(root: HTMLElement, context: ComponentResult, effects: (() => void)[]): void
38
38
  export function processEvents(root: HTMLElement, context: ComponentResult): void
39
39
  export function processCloak(root: HTMLElement): void
40
+
41
+ export interface DirectiveBinding<V = any> {
42
+ value: V
43
+ expression: string
44
+ arg: string | null
45
+ modifiers: string[]
46
+ }
47
+
48
+ export interface DirectiveHooks<V = any> {
49
+ mounted?: (el: HTMLElement, binding: DirectiveBinding<V>) => void
50
+ updated?: (el: HTMLElement, binding: DirectiveBinding<V>) => void
51
+ unmounted?: (el: HTMLElement, binding: DirectiveBinding<V>) => void
52
+ }
53
+
54
+ export function directive<V = any>(name: string, hooks: DirectiveHooks<V>): void
@@ -1,16 +1,17 @@
1
- // ─── Fusée Framework Types ───────────────────────────────────────────────────
2
-
3
- // Public API
4
1
  export * from './signal'
5
2
  export * from './component'
3
+ export * from './composable'
6
4
  export * from './router'
7
5
  export * from './compiler'
6
+ export * from './directives'
8
7
 
9
8
  declare global {
10
9
  type Signal<T = any> = import('./signal').Signal<T>
11
10
  type Computed<T = any> = import('./signal').Computed<T>
12
11
  type SignalAccessor<T> = import('./signal').SignalAccessor<T>
12
+ type Composable<T extends (...args: any[]) => any> = import('./composable').Composable<T>
13
+ type DirectiveBinding<V = any> = import('./directives').DirectiveBinding<V>
14
+ type DirectiveHooks<V = any> = import('./directives').DirectiveHooks<V>
13
15
  }
14
16
 
15
- // Re-export as namespace for convenience
16
17
  export as namespace Fusee
@@ -6,13 +6,25 @@ export type SignalAccessor<T> = {
6
6
  isSignal: boolean;
7
7
  }
8
8
 
9
+ export type ArraySignalAccessor<T extends any[]> = SignalAccessor<T> & {
10
+ push(...items: T[number][]): number;
11
+ pop(): T[number] | undefined;
12
+ shift(): T[number] | undefined;
13
+ unshift(...items: T[number][]): number;
14
+ splice(start: number, deleteCount: number, ...items: T[number][]): T[number][];
15
+ remove(predicate: (item: T[number], index: number) => boolean): void;
16
+ clear(): void;
17
+ sort(compareFn?: (a: T[number], b: T[number]) => number): ArraySignalAccessor<T>;
18
+ reverse(): ArraySignalAccessor<T>;
19
+ }
20
+
9
21
  export type ComputedAccessor<T> = {
10
22
  (): T;
11
23
  isSignal: boolean;
12
24
  readonly: true;
13
25
  }
14
26
 
15
- export type Signal<T = any> = SignalAccessor<T>
27
+ export type Signal<T = any> = T extends any[] ? ArraySignalAccessor<T> : SignalAccessor<T>
16
28
  export type Computed<T = any> = ComputedAccessor<T>
17
29
 
18
30
  type EffectRunner = {
@@ -20,7 +32,8 @@ type EffectRunner = {
20
32
  deps: Set<Set<EffectRunner>>;
21
33
  }
22
34
 
23
- export declare function signal<T>(initialValue: T): Signal<T>
35
+ export declare function signal<T extends any[]>(initialValue: T): ArraySignalAccessor<T>
36
+ export declare function signal<T>(initialValue: T): SignalAccessor<T>
24
37
  export declare function effect(fn: () => void): () => void
25
38
  export declare function computed<T>(fn: () => T): Computed<T>
26
39
  export declare function batch<T>(fn: () => T): T
package/index.html CHANGED
@@ -160,6 +160,139 @@
160
160
  opacity: 0.3;
161
161
  cursor: not-allowed;
162
162
  }
163
+
164
+ /* ── Composables demo ──────────────────────────────────────────── */
165
+ .demo-card {
166
+ background: #13131d;
167
+ border: 1px solid #2a2a3a;
168
+ border-radius: 12px;
169
+ padding: 1.5rem;
170
+ margin-bottom: 1.5rem;
171
+ }
172
+
173
+ .demo-card h2 {
174
+ font-size: 1rem;
175
+ font-weight: 600;
176
+ color: #e8e8f0;
177
+ margin-bottom: 0.35rem;
178
+ }
179
+
180
+ .demo-desc {
181
+ font-size: 0.8rem;
182
+ color: #555 !important;
183
+ margin-bottom: 1rem !important;
184
+ }
185
+
186
+ .counter-display {
187
+ display: flex;
188
+ flex-direction: column;
189
+ align-items: center;
190
+ margin-bottom: 1rem;
191
+ }
192
+
193
+ .big-num {
194
+ font-size: 3.5rem;
195
+ font-weight: 700;
196
+ line-height: 1;
197
+ transition: color 0.2s;
198
+ }
199
+
200
+ .big-num.even {
201
+ color: #8b8bff;
202
+ }
203
+
204
+ .big-num.odd {
205
+ color: #ff3333;
206
+ }
207
+
208
+ .derived-label {
209
+ font-size: 0.8rem;
210
+ color: #555;
211
+ margin-top: 0.25rem;
212
+ }
213
+
214
+ .btn-row {
215
+ display: flex;
216
+ gap: 0.5rem;
217
+ justify-content: center;
218
+ margin-bottom: 0.75rem;
219
+ }
220
+
221
+ .cmp-btn {
222
+ padding: 0.45rem 1.1rem;
223
+ border-radius: 6px;
224
+ border: 1px solid #2a2a3a;
225
+ background: #0f0f1a;
226
+ color: #e8e8f0;
227
+ cursor: pointer;
228
+ font-size: 1rem;
229
+ transition: background 0.15s;
230
+ }
231
+
232
+ .cmp-btn:hover {
233
+ background: #1f1f30;
234
+ }
235
+
236
+ .cmp-btn.plus {
237
+ border-color: #8b8bff;
238
+ color: #8b8bff;
239
+ }
240
+
241
+ .cmp-btn.minus {
242
+ border-color: #ff3333;
243
+ color: #ff3333;
244
+ }
245
+
246
+ .cmp-btn.reset {
247
+ border-color: #555;
248
+ color: #888;
249
+ }
250
+
251
+ .badge {
252
+ display: inline-block;
253
+ padding: 0.2rem 0.65rem;
254
+ border-radius: 999px;
255
+ font-size: 0.75rem;
256
+ font-weight: 600;
257
+ }
258
+
259
+ .badge-blue {
260
+ background: rgba(139, 139, 255, 0.15);
261
+ color: #8b8bff;
262
+ }
263
+
264
+ .badge-red {
265
+ background: rgba(255, 51, 51, 0.12);
266
+ color: #ff3333;
267
+ }
268
+
269
+ .mouse-display {
270
+ display: flex;
271
+ gap: 2rem;
272
+ font-size: 1.1rem;
273
+ color: #aaa;
274
+ margin-bottom: 0.5rem;
275
+ }
276
+
277
+ .mouse-display strong {
278
+ color: #e8e8f0;
279
+ }
280
+
281
+ .cmp-input {
282
+ width: 100%;
283
+ padding: 0.5rem 0.75rem;
284
+ background: #0f0f1a;
285
+ border: 1px solid #2a2a3a;
286
+ border-radius: 6px;
287
+ color: #e8e8f0;
288
+ font-size: 0.95rem;
289
+ outline: none;
290
+ transition: border-color 0.15s;
291
+ }
292
+
293
+ .cmp-input:focus {
294
+ border-color: #8b8bff;
295
+ }
163
296
  </style>
164
297
  </head>
165
298
 
@@ -169,9 +302,10 @@
169
302
  <a href="#/about">About</a>
170
303
  <a href="#/test-for">Loops Test</a>
171
304
  <a href="#/dynamic" style="color: #ec4899;">Dynamic Components</a>
305
+ <a href="#/composables" style="color: #34d399;">Composables</a>
172
306
  </nav>
173
307
  <div id="app"></div>
174
- <script type="module" src="./app/main.js"></script>
308
+ <script type="module" src="./app/main.ts"></script>
175
309
  </body>
176
310
 
177
311
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusee-framework",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Fusée - Signals-first JS Framework | Peak Performance",
5
5
  "main": "framework/index.js",
6
6
  "types": "framework/types/index.d.ts",