fusee-framework 0.1.0-alpha.1 → 1.0.1

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,369 @@
1
+ import { effect, batch } from './signal.js'
2
+ import { evaluateExpression } from './evaluator.js'
3
+ import { compileNode } from './compiler.js'
4
+
5
+ export function processDirectives(root, context, components, effects) {
6
+ processFor(root, context, components, effects)
7
+ processIf(root, context, components, effects)
8
+ processShow(root, context, effects)
9
+ processModel(root, context, effects)
10
+ processRefs(root, context)
11
+ processHtml(root, context, effects)
12
+ processText(root, context, effects)
13
+ processEvents(root, context)
14
+ processCloak(root)
15
+ }
16
+
17
+ export function processRefs(root, context) {
18
+ const refEls = root.querySelectorAll('[f-ref]')
19
+ for (const el of refEls) {
20
+ if (!el.parentNode) continue
21
+
22
+ const refName = el.getAttribute('f-ref')
23
+ el.removeAttribute('f-ref')
24
+
25
+ const refVar = context[refName]
26
+ if (typeof refVar === 'function' && refVar.isSignal && !refVar.readonly) {
27
+ refVar(el)
28
+ } else {
29
+ const reason = refVar?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
30
+ console.warn(`[framework] f-ref requires a writable signal. Target "${refName}" is invalid: ${reason}`)
31
+ }
32
+ }
33
+ }
34
+
35
+ export function processModel(root, context, effects) {
36
+ const modelEls = root.querySelectorAll('[f-model]')
37
+ for (const el of modelEls) {
38
+ if (!el.parentNode) continue
39
+
40
+ const expr = el.getAttribute('f-model')
41
+ el.removeAttribute('f-model')
42
+
43
+ const signalMethod = context[expr]
44
+ if (typeof signalMethod === 'function' && signalMethod.isSignal && !signalMethod.readonly) {
45
+ effects.push(effect(() => {
46
+ const val = signalMethod()
47
+ if (el.type === 'checkbox') {
48
+ el.checked = !!val
49
+ } else {
50
+ el.value = val ?? ''
51
+ }
52
+ }))
53
+
54
+ const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio') ? 'change' : 'input'
55
+ el.addEventListener(eventName, () => {
56
+ const newVal = el.type === 'checkbox' ? el.checked : el.value
57
+ batch(() => signalMethod(newVal))
58
+ })
59
+ } else {
60
+ const reason = signalMethod?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
61
+ console.warn(`[framework] f-model requires a writable signal. Expression "${expr}" is invalid: ${reason}`)
62
+ }
63
+ }
64
+ }
65
+
66
+ export function processFor(root, context, components, effects) {
67
+ const forEls = root.querySelectorAll('[f-for]')
68
+ for (const el of forEls) {
69
+ if (!el.parentNode) continue
70
+
71
+ const expr = el.getAttribute('f-for')
72
+ el.removeAttribute('f-for')
73
+
74
+ const match = expr.match(/^\s*(?:(?:\(\s*([a-zA-Z_$][0-9a-zA-Z_$]*)(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?(?:\s*,\s*([a-zA-Z_$][0-9a-zA-Z_$]*))?\s*\))|([a-zA-Z_$][0-9a-zA-Z_$]*))\s+in\s+(.+)\s*$/)
75
+ if (!match) {
76
+ console.warn(`[framework] Invalid f-for expression: "${expr}"`)
77
+ continue
78
+ }
79
+
80
+ const itemName = match[4] || match[1]
81
+ const keyName = match[2]
82
+ const indexName = match[3]
83
+ const arrayExpr = match[5]
84
+
85
+ const keyExpr = el.getAttribute('data-bind-key') || el.getAttribute(':key')
86
+ if (keyExpr) {
87
+ el.removeAttribute('data-bind-key')
88
+ el.removeAttribute(':key')
89
+ }
90
+
91
+ const anchor = document.createComment('f-for')
92
+ const parent = el.parentNode
93
+ parent.insertBefore(anchor, el)
94
+
95
+ const templateNode = el.cloneNode(true)
96
+ parent.removeChild(el)
97
+
98
+ // Cache of currently rendered items: Map<key, { node, effects, context }>
99
+ let cache = new Map()
100
+ let previousOrder = []
101
+
102
+ effects.push(effect(() => {
103
+ const rawItems = evaluateExpression(arrayExpr, context)
104
+ let items = []
105
+
106
+ if (Array.isArray(rawItems)) {
107
+ items = rawItems.map((item, index) => ({ value: item, rawIndex: index }))
108
+ } else if (typeof rawItems === 'number' && !isNaN(rawItems)) {
109
+ items = Array.from({ length: Math.max(0, Math.floor(rawItems)) }, (_, i) => ({ value: i + 1, rawIndex: i }))
110
+ } else if (rawItems && typeof rawItems === 'object') {
111
+ items = Object.entries(rawItems).map(([key, value], index) => ({ value, key, rawIndex: index }))
112
+ }
113
+
114
+ const nextCache = new Map()
115
+ const nextOrder = []
116
+
117
+ const newItemsWithKeys = items.map((item, index) => {
118
+ const itemContext = Object.create(context)
119
+ itemContext[itemName] = item.value
120
+ if (keyName) itemContext[keyName] = item.hasOwnProperty('key') ? item.key : item.rawIndex
121
+ if (indexName) itemContext[indexName] = index
122
+
123
+ const key = keyExpr ? evaluateExpression(keyExpr, itemContext) : item.rawIndex
124
+ return { item, key, itemContext }
125
+ })
126
+
127
+ const parentNode = anchor.parentNode
128
+ let currentCursor = anchor.nextSibling
129
+
130
+ newItemsWithKeys.forEach(({ item, key, itemContext }, index) => {
131
+ let entry = cache.get(key)
132
+
133
+ if (entry) {
134
+ entry.context[itemName] = item.value
135
+ if (keyName) entry.context[keyName] = item.hasOwnProperty('key') ? item.key : item.rawIndex
136
+ if (indexName) entry.context[indexName] = index
137
+
138
+ if (entry.node !== currentCursor) {
139
+ parentNode.insertBefore(entry.node, currentCursor)
140
+ } else {
141
+ currentCursor = currentCursor.nextSibling
142
+ }
143
+ cache.delete(key)
144
+ } else {
145
+ // Create new node
146
+ const clone = templateNode.cloneNode(true)
147
+ const childEffects = []
148
+ compileNode(clone, itemContext, components, childEffects)
149
+
150
+ parentNode.insertBefore(clone, currentCursor)
151
+ entry = { node: clone, effects: childEffects, context: itemContext }
152
+ }
153
+
154
+ nextCache.set(key, entry)
155
+ nextOrder.push(entry.node)
156
+ })
157
+
158
+ for (const [key, entry] of cache.entries()) {
159
+ for (const e of entry.effects) if (typeof e === 'function') e()
160
+ entry.node.parentNode?.removeChild(entry.node)
161
+ }
162
+
163
+ cache = nextCache
164
+ previousOrder = nextOrder
165
+ }))
166
+ }
167
+ }
168
+
169
+ export function processIf(root, context, components, effects) {
170
+ const ifEls = Array.from(root.querySelectorAll('[f-if]'))
171
+ const processedEls = new Set()
172
+
173
+ for (const ifEl of ifEls) {
174
+ if (processedEls.has(ifEl)) continue
175
+
176
+ const parent = ifEl.parentNode
177
+ if (!parent) continue
178
+
179
+ const group = []
180
+ let curr = ifEl
181
+
182
+ while (curr) {
183
+ if (curr.nodeType === 1) {
184
+ const type = curr.hasAttribute('f-if') ? 'if' :
185
+ curr.hasAttribute('f-elif') ? 'elif' :
186
+ curr.hasAttribute('f-else-if') ? 'elif' :
187
+ curr.hasAttribute('f-else') ? 'else' : null
188
+
189
+ if (type) {
190
+ const expr = type === 'else' ? 'true' : curr.getAttribute(`f-${type === 'elif' && curr.hasAttribute('f-else-if') ? 'else-if' : type}`)
191
+ group.push({ node: curr, expr, type, isElement: true })
192
+ processedEls.add(curr)
193
+
194
+ curr.removeAttribute('f-if')
195
+ curr.removeAttribute('f-elif')
196
+ curr.removeAttribute('f-else-if')
197
+ curr.removeAttribute('f-else')
198
+
199
+ if (type === 'else') break
200
+ } else {
201
+ break
202
+ }
203
+ } else if (curr.nodeType === 3 && curr.textContent.trim() === '' || curr.nodeType === 8) {
204
+ group.push({ node: curr, isElement: false })
205
+ } else {
206
+ break
207
+ }
208
+ curr = curr.nextSibling
209
+ }
210
+
211
+ const templates = group
212
+ .filter(item => item.isElement)
213
+ .map(item => ({
214
+ template: item.node.cloneNode(true),
215
+ expr: item.expr,
216
+ type: item.type
217
+ }))
218
+
219
+ const anchor = document.createComment('f-condition-anchor')
220
+ parent.insertBefore(anchor, ifEl)
221
+
222
+ for (const item of group) item.node.remove()
223
+
224
+ let activeIndex = -1
225
+ let activeNode = null
226
+ let activeCleanup = []
227
+
228
+ effects.push(effect(() => {
229
+ let nextIndex = -1
230
+ for (let i = 0; i < templates.length; i++) {
231
+ const isMatch = templates[i].type === 'else' ? true : evaluateExpression(templates[i].expr, context)
232
+ if (isMatch) {
233
+ nextIndex = i
234
+ break
235
+ }
236
+ }
237
+
238
+ if (nextIndex !== activeIndex) {
239
+ for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
240
+ activeCleanup = []
241
+ if (activeNode) {
242
+ activeNode.remove()
243
+ activeNode = null
244
+ }
245
+
246
+ if (nextIndex !== -1) {
247
+ activeNode = templates[nextIndex].template.cloneNode(true)
248
+ compileNode(activeNode, context, components, activeCleanup)
249
+ anchor.parentNode.insertBefore(activeNode, anchor.nextSibling)
250
+ }
251
+ activeIndex = nextIndex
252
+ }
253
+ }))
254
+ }
255
+ }
256
+
257
+ export function processShow(root, context, effects) {
258
+ const showEls = root.querySelectorAll('[f-show]')
259
+ for (const el of showEls) {
260
+ if (!el.parentNode) continue
261
+
262
+ const expr = el.getAttribute('f-show')
263
+ el.removeAttribute('f-show')
264
+ const originalDisplay = el.style.display === 'none' ? '' : el.style.display
265
+
266
+ effects.push(effect(() => {
267
+ const val = evaluateExpression(expr, context)
268
+ el.style.display = val ? originalDisplay : 'none'
269
+ }))
270
+ }
271
+ }
272
+
273
+ export function processHtml(root, context, effects) {
274
+ const els = root.querySelectorAll('[f-html]')
275
+ for (const el of els) {
276
+ if (!el.parentNode) continue
277
+
278
+ const expr = el.getAttribute('f-html')
279
+ el.removeAttribute('f-html')
280
+
281
+ const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
282
+ if (isDev) {
283
+ console.warn(`[framework] f-html can expose the site to XSS. Use only with trusted content.`)
284
+ }
285
+
286
+ effects.push(effect(() => {
287
+ const val = evaluateExpression(expr, context)
288
+ el.innerHTML = String(val ?? '')
289
+ }))
290
+ }
291
+ }
292
+ export function processEvents(root, context) {
293
+ const els = root.querySelectorAll('*')
294
+ const targets = root.attributes ? [root, ...els] : els
295
+
296
+ for (const el of targets) {
297
+ if (!el.attributes) continue
298
+
299
+ for (const attr of [...el.attributes]) {
300
+ if (attr.name.startsWith('@')) {
301
+ const fullEventName = attr.name.slice(1)
302
+ const parts = fullEventName.split('.')
303
+ const eventName = parts[0]
304
+ const modifiers = parts.slice(1)
305
+ const expr = attr.value
306
+
307
+ el.removeAttribute(attr.name)
308
+
309
+ const handler = (e) => {
310
+ if (modifiers.includes('prevent')) e.preventDefault()
311
+ if (modifiers.includes('stop')) e.stopPropagation()
312
+ if (modifiers.includes('self') && e.target !== el) return
313
+
314
+ // Specific handling for common modifiers like .enter, .escape etc on keyboard events
315
+ if (e instanceof KeyboardEvent) {
316
+ const key = e.key.toLowerCase()
317
+ const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
318
+ if (keyModifiers.length > 0) {
319
+ const match = keyModifiers.some(m => {
320
+ if (m === 'escape') return key === 'escape' || key === 'esc'
321
+ if (m === 'space') return key === ' ' || key === 'spacebar'
322
+ return key === m
323
+ })
324
+ if (!match) return
325
+ }
326
+ }
327
+
328
+ batch(() => {
329
+ const potentialFn = context[expr]
330
+ if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
331
+ potentialFn(e)
332
+ } else {
333
+ evaluateExpression(expr, context, { $event: e }, false)
334
+ }
335
+ })
336
+ }
337
+
338
+ const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
339
+ const options = {
340
+ once: modifiers.includes('once'),
341
+ capture: modifiers.includes('capture') || modifiers.includes('away')
342
+ }
343
+
344
+ targetNode.addEventListener(eventName, handler, options)
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ export function processText(root, context, effects) {
351
+ const els = root.querySelectorAll('[f-text]')
352
+ for (const el of els) {
353
+ if (!el.parentNode) continue
354
+ const expr = el.getAttribute('f-text')
355
+ el.removeAttribute('f-text')
356
+ effects.push(effect(() => {
357
+ const val = evaluateExpression(expr, context)
358
+ el.textContent = String(val ?? '')
359
+ }))
360
+ }
361
+ }
362
+
363
+ export function processCloak(root) {
364
+ const els = root.querySelectorAll('[f-cloak]')
365
+ const targets = root.hasAttribute && root.hasAttribute('f-cloak') ? [root, ...els] : els
366
+ for (const el of targets) {
367
+ el.removeAttribute('f-cloak')
368
+ }
369
+ }
@@ -0,0 +1,70 @@
1
+ export const MUSTACHE_RE = /\{\{\s*(.+?)\s*\}\}/
2
+
3
+ const SENSITIVE_ATTRS = ['href', 'src', 'srcset', 'formaction', 'xlink:href', 'data']
4
+ const DANGEROUS_SCHEMES = /^(javascript|data|vbscript|file):/i
5
+
6
+ export function evaluateExpression(expr, context, extraContext = {}, unwrapSignals = true) {
7
+ const keys = []
8
+ const values = []
9
+ const identifiers = new Set(expr.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) || [])
10
+
11
+ // Map global context (only what is used in expression)
12
+ for (const k in context) {
13
+ if (identifiers.has(k)) {
14
+ keys.push(k)
15
+ const val = context[k]
16
+ values.push(unwrapSignals && typeof val === 'function' && val.isSignal ? val() : val)
17
+ }
18
+ }
19
+
20
+ // Map extra context (like $event)
21
+ for (const k in extraContext) {
22
+ if (!keys.includes(k)) {
23
+ keys.push(k)
24
+ values.push(extraContext[k])
25
+ }
26
+ }
27
+
28
+ try {
29
+ const isMultiStatement = expr.includes(';') || expr.includes('console.')
30
+ const body = isMultiStatement ? expr : `return ${expr}`
31
+ const fn = new Function(...keys, body)
32
+ return fn(...values)
33
+ } catch (e) {
34
+ console.warn(`[framework] Error evaluating "${expr}":`, e)
35
+ return ''
36
+ }
37
+ }
38
+
39
+ export function parseInterpolation(str) {
40
+ const parts = []
41
+ const regex = /\{\{\s*(.+?)\s*\}\}/g
42
+ let lastIndex = 0
43
+ let match
44
+
45
+ while ((match = regex.exec(str)) !== null) {
46
+ if (match.index > lastIndex) {
47
+ parts.push({ type: 'static', value: str.slice(lastIndex, match.index) })
48
+ }
49
+ parts.push({ type: 'dynamic', key: match[1] })
50
+ lastIndex = regex.lastIndex
51
+ }
52
+
53
+ if (lastIndex < str.length) {
54
+ parts.push({ type: 'static', value: str.slice(lastIndex) })
55
+ }
56
+
57
+ return parts
58
+ }
59
+
60
+ export function sanitizeAttr(name, value) {
61
+ const attrName = name.toLowerCase()
62
+ if (SENSITIVE_ATTRS.includes(attrName)) {
63
+ const trimmed = value.trim()
64
+ if (DANGEROUS_SCHEMES.test(trimmed)) {
65
+ console.warn(`[framework] Blocked potential XSS on ${name}: "${value}"`)
66
+ return 'about:blank'
67
+ }
68
+ }
69
+ return value
70
+ }
@@ -1,52 +1,137 @@
1
- // ─── Reactivity Engine ───────────────────────────────────────────────────────
2
- // Solid-style signals: count() to read, count(val) to write.
3
- // An "effect" is any function that should re-run when signals it reads change.
4
-
5
1
  let currentEffect = null
2
+ let onEffectCreated = null
6
3
 
7
- export function signal(initialValue) {
8
- let value = initialValue
9
- const subscribers = new Set()
10
-
11
- function accessor(newValue) {
12
- if (newValue === undefined) {
13
- // READ — track dependency if inside an effect
14
- if (currentEffect) {
15
- subscribers.add(currentEffect)
16
- currentEffect.deps.add(subscribers)
17
- }
18
- return value
4
+ export function setEffectHook(fn) { onEffectCreated = fn }
5
+
6
+ let batchDepth = 0
7
+ const pendingEffects = new Set()
8
+
9
+ export function batch(fn) {
10
+ batchDepth++
11
+ try {
12
+ return fn()
13
+ } finally {
14
+ batchDepth--
15
+ if (batchDepth === 0) {
16
+ while (pendingEffects.size > 0) {
17
+ const toRun = [...pendingEffects]
18
+ pendingEffects.clear()
19
+ for (const eff of toRun) eff()
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ function scheduleEffect(effectFn) {
26
+ if (batchDepth > 0) {
27
+ pendingEffects.add(effectFn)
19
28
  } else {
20
- // WRITE — notify subscribers only if value changed
21
- if (newValue !== value) {
22
- value = newValue
23
- for (const sub of [...subscribers]) sub()
24
- }
29
+ effectFn()
25
30
  }
26
- }
31
+ }
27
32
 
28
- return accessor
33
+ export function signal(initialValue) {
34
+ let value = initialValue
35
+ const subscribers = new Set()
36
+
37
+ function accessor() {
38
+ if (arguments.length === 0) {
39
+ if (currentEffect) {
40
+ subscribers.add(currentEffect)
41
+ currentEffect.deps.add(subscribers)
42
+ }
43
+ return value
44
+ } else {
45
+ const newValue = arguments[0]
46
+ if (!Object.is(value, newValue)) {
47
+ value = newValue
48
+ for (const sub of [...subscribers]) scheduleEffect(sub)
49
+ }
50
+ }
51
+ }
52
+
53
+ accessor.isSignal = true
54
+ return accessor
29
55
  }
30
56
 
31
57
  export function effect(fn) {
32
- const run = () => {
33
- // Clean up previous subscriptions before re-running
34
- for (const dep of run.deps) dep.delete(run)
35
- run.deps.clear()
36
-
37
- currentEffect = run
38
- try { fn() }
39
- finally { currentEffect = null }
40
- }
41
-
42
- run.deps = new Set()
43
- run() // run immediately to collect dependencies
44
- return run
58
+ const run = () => {
59
+ for (const dep of run.deps) dep.delete(run)
60
+ run.deps.clear()
61
+
62
+ currentEffect = run
63
+ try { fn() }
64
+ finally { currentEffect = null }
65
+ }
66
+
67
+ run.deps = new Set()
68
+ const cleanup = () => {
69
+ for (const dep of run.deps) dep.delete(run)
70
+ run.deps.clear()
71
+ }
72
+
73
+ if (onEffectCreated) onEffectCreated(cleanup)
74
+ run()
75
+
76
+ return cleanup
45
77
  }
46
78
 
47
79
  export function computed(fn) {
48
- // A computed is a read-only signal derived from other signals
49
- const result = signal(undefined)
50
- effect(() => result(fn()))
51
- return result
80
+ let value
81
+ let dirty = true
82
+ const subscribers = new Set()
83
+
84
+ const computedNode = () => {
85
+ if (!dirty) {
86
+ dirty = true
87
+ for (const sub of [...subscribers]) scheduleEffect(sub)
88
+ }
89
+ }
90
+
91
+ computedNode.deps = new Set()
92
+
93
+ function accessor() {
94
+ if (arguments.length === 0) {
95
+ if (currentEffect) {
96
+ subscribers.add(currentEffect)
97
+ currentEffect.deps.add(subscribers)
98
+ }
99
+ if (dirty) {
100
+ for (const dep of computedNode.deps) dep.delete(computedNode)
101
+ computedNode.deps.clear()
102
+
103
+ const prevEffect = currentEffect
104
+ currentEffect = computedNode
105
+ try {
106
+ const newValue = fn()
107
+ if (newValue !== value) value = newValue
108
+ } finally {
109
+ currentEffect = prevEffect
110
+ dirty = false
111
+ }
112
+ }
113
+ return value
114
+ }
115
+ console.warn('[framework] computed() is read-only')
116
+ }
117
+
118
+ accessor.dispose = () => {
119
+ for (const dep of computedNode.deps) dep.delete(computedNode)
120
+ computedNode.deps.clear()
121
+ subscribers.clear()
122
+ }
123
+
124
+ accessor.isSignal = true
125
+ accessor.readonly = true
126
+ return accessor
127
+ }
128
+
129
+ export function untrack(fn) {
130
+ const prevEffect = currentEffect
131
+ currentEffect = null
132
+ try {
133
+ return fn()
134
+ } finally {
135
+ currentEffect = prevEffect
136
+ }
52
137
  }
@@ -0,0 +1,3 @@
1
+ // ─── Fusée Framework Main Types ──────────────────────────────────────────────
2
+
3
+ export * from './types/index'
@@ -1,4 +1,4 @@
1
1
  // ─── Framework Public API ─────────────────────────────────────────────────────
2
- export { signal, computed, effect } from './core/signal.js'
2
+ export { signal, computed, effect, batch, untrack } from './core/signal.js'
3
3
  export { defineComponent, onMount, onUnmount } from './core/component.js'
4
4
  export { createRouter, navigate, mountOutlet } from './router/router.js'