fusee-framework 0.1.0-alpha.1 → 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,335 @@
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 anchor = document.createComment('f-for')
86
+ const parent = el.parentNode
87
+ parent.insertBefore(anchor, el)
88
+
89
+ const templateNode = el.cloneNode(true)
90
+ parent.removeChild(el)
91
+
92
+ let previousNodes = []
93
+ let previousEffects = []
94
+
95
+ effects.push(effect(() => {
96
+ const rawItems = evaluateExpression(arrayExpr, context)
97
+ let items = []
98
+
99
+ if (Array.isArray(rawItems)) {
100
+ items = rawItems.map((item, index) => ({ value: item, key: index, index }))
101
+ } else if (typeof rawItems === 'number' && !isNaN(rawItems)) {
102
+ items = Array.from({ length: Math.max(0, Math.floor(rawItems)) }, (_, i) => ({ value: i + 1, key: i, index: i }))
103
+ } else if (rawItems && typeof rawItems === 'object') {
104
+ items = Object.entries(rawItems).map(([key, value], index) => ({ value, key, index }))
105
+ }
106
+
107
+ for (const e of previousEffects) if (typeof e === 'function') e()
108
+ for (const node of previousNodes) node.parentNode?.removeChild(node)
109
+
110
+ previousNodes = []
111
+ previousEffects = []
112
+
113
+ const fragment = document.createDocumentFragment()
114
+ items.forEach((item) => {
115
+ const clone = templateNode.cloneNode(true)
116
+ const childContext = Object.create(context)
117
+
118
+ childContext[itemName] = item.value
119
+ if (keyName) childContext[keyName] = item.key
120
+ if (indexName) childContext[indexName] = item.index
121
+
122
+ const childEffects = []
123
+ compileNode(clone, childContext, components, childEffects)
124
+
125
+ fragment.appendChild(clone)
126
+ previousNodes.push(clone)
127
+ previousEffects.push(...childEffects)
128
+ })
129
+
130
+ anchor.parentNode.insertBefore(fragment, anchor)
131
+ }))
132
+ }
133
+ }
134
+
135
+ export function processIf(root, context, components, effects) {
136
+ const ifEls = Array.from(root.querySelectorAll('[f-if]'))
137
+ const processedEls = new Set()
138
+
139
+ for (const ifEl of ifEls) {
140
+ if (processedEls.has(ifEl)) continue
141
+
142
+ const parent = ifEl.parentNode
143
+ if (!parent) continue
144
+
145
+ const group = []
146
+ let curr = ifEl
147
+
148
+ while (curr) {
149
+ if (curr.nodeType === 1) {
150
+ const type = curr.hasAttribute('f-if') ? 'if' :
151
+ curr.hasAttribute('f-elif') ? 'elif' :
152
+ curr.hasAttribute('f-else-if') ? 'elif' :
153
+ curr.hasAttribute('f-else') ? 'else' : null
154
+
155
+ if (type) {
156
+ const expr = type === 'else' ? 'true' : curr.getAttribute(`f-${type === 'elif' && curr.hasAttribute('f-else-if') ? 'else-if' : type}`)
157
+ group.push({ node: curr, expr, type, isElement: true })
158
+ processedEls.add(curr)
159
+
160
+ curr.removeAttribute('f-if')
161
+ curr.removeAttribute('f-elif')
162
+ curr.removeAttribute('f-else-if')
163
+ curr.removeAttribute('f-else')
164
+
165
+ if (type === 'else') break
166
+ } else {
167
+ break
168
+ }
169
+ } else if (curr.nodeType === 3 && curr.textContent.trim() === '' || curr.nodeType === 8) {
170
+ group.push({ node: curr, isElement: false })
171
+ } else {
172
+ break
173
+ }
174
+ curr = curr.nextSibling
175
+ }
176
+
177
+ const templates = group
178
+ .filter(item => item.isElement)
179
+ .map(item => ({
180
+ template: item.node.cloneNode(true),
181
+ expr: item.expr,
182
+ type: item.type
183
+ }))
184
+
185
+ const anchor = document.createComment('f-condition-anchor')
186
+ parent.insertBefore(anchor, ifEl)
187
+
188
+ for (const item of group) item.node.remove()
189
+
190
+ let activeIndex = -1
191
+ let activeNode = null
192
+ let activeCleanup = []
193
+
194
+ effects.push(effect(() => {
195
+ let nextIndex = -1
196
+ for (let i = 0; i < templates.length; i++) {
197
+ const isMatch = templates[i].type === 'else' ? true : evaluateExpression(templates[i].expr, context)
198
+ if (isMatch) {
199
+ nextIndex = i
200
+ break
201
+ }
202
+ }
203
+
204
+ if (nextIndex !== activeIndex) {
205
+ for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
206
+ activeCleanup = []
207
+ if (activeNode) {
208
+ activeNode.remove()
209
+ activeNode = null
210
+ }
211
+
212
+ if (nextIndex !== -1) {
213
+ activeNode = templates[nextIndex].template.cloneNode(true)
214
+ compileNode(activeNode, context, components, activeCleanup)
215
+ anchor.parentNode.insertBefore(activeNode, anchor.nextSibling)
216
+ }
217
+ activeIndex = nextIndex
218
+ }
219
+ }))
220
+ }
221
+ }
222
+
223
+ export function processShow(root, context, effects) {
224
+ const showEls = root.querySelectorAll('[f-show]')
225
+ for (const el of showEls) {
226
+ if (!el.parentNode) continue
227
+
228
+ const expr = el.getAttribute('f-show')
229
+ el.removeAttribute('f-show')
230
+ const originalDisplay = el.style.display === 'none' ? '' : el.style.display
231
+
232
+ effects.push(effect(() => {
233
+ const val = evaluateExpression(expr, context)
234
+ el.style.display = val ? originalDisplay : 'none'
235
+ }))
236
+ }
237
+ }
238
+
239
+ export function processHtml(root, context, effects) {
240
+ const els = root.querySelectorAll('[f-html]')
241
+ for (const el of els) {
242
+ if (!el.parentNode) continue
243
+
244
+ const expr = el.getAttribute('f-html')
245
+ el.removeAttribute('f-html')
246
+
247
+ const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
248
+ if (isDev) {
249
+ console.warn(`[framework] f-html can expose the site to XSS. Use only with trusted content.`)
250
+ }
251
+
252
+ effects.push(effect(() => {
253
+ const val = evaluateExpression(expr, context)
254
+ el.innerHTML = String(val ?? '')
255
+ }))
256
+ }
257
+ }
258
+ export function processEvents(root, context) {
259
+ const els = root.querySelectorAll('*')
260
+ const targets = root.attributes ? [root, ...els] : els
261
+
262
+ for (const el of targets) {
263
+ if (!el.attributes) continue
264
+
265
+ for (const attr of [...el.attributes]) {
266
+ if (attr.name.startsWith('@')) {
267
+ const fullEventName = attr.name.slice(1)
268
+ const parts = fullEventName.split('.')
269
+ const eventName = parts[0]
270
+ const modifiers = parts.slice(1)
271
+ const expr = attr.value
272
+
273
+ el.removeAttribute(attr.name)
274
+
275
+ const handler = (e) => {
276
+ if (modifiers.includes('prevent')) e.preventDefault()
277
+ if (modifiers.includes('stop')) e.stopPropagation()
278
+ if (modifiers.includes('self') && e.target !== el) return
279
+
280
+ // Specific handling for common modifiers like .enter, .escape etc on keyboard events
281
+ if (e instanceof KeyboardEvent) {
282
+ const key = e.key.toLowerCase()
283
+ const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
284
+ if (keyModifiers.length > 0) {
285
+ const match = keyModifiers.some(m => {
286
+ if (m === 'escape') return key === 'escape' || key === 'esc'
287
+ if (m === 'space') return key === ' ' || key === 'spacebar'
288
+ return key === m
289
+ })
290
+ if (!match) return
291
+ }
292
+ }
293
+
294
+ batch(() => {
295
+ const potentialFn = context[expr]
296
+ if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
297
+ potentialFn(e)
298
+ } else {
299
+ evaluateExpression(expr, context, { $event: e }, false)
300
+ }
301
+ })
302
+ }
303
+
304
+ const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
305
+ const options = {
306
+ once: modifiers.includes('once'),
307
+ capture: modifiers.includes('capture') || modifiers.includes('away')
308
+ }
309
+
310
+ targetNode.addEventListener(eventName, handler, options)
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ export function processText(root, context, effects) {
317
+ const els = root.querySelectorAll('[f-text]')
318
+ for (const el of els) {
319
+ if (!el.parentNode) continue
320
+ const expr = el.getAttribute('f-text')
321
+ el.removeAttribute('f-text')
322
+ effects.push(effect(() => {
323
+ const val = evaluateExpression(expr, context)
324
+ el.textContent = String(val ?? '')
325
+ }))
326
+ }
327
+ }
328
+
329
+ export function processCloak(root) {
330
+ const els = root.querySelectorAll('[f-cloak]')
331
+ const targets = root.hasAttribute && root.hasAttribute('f-cloak') ? [root, ...els] : els
332
+ for (const el of targets) {
333
+ el.removeAttribute('f-cloak')
334
+ }
335
+ }
@@ -0,0 +1,67 @@
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
+
10
+ // Map global context
11
+ for (const k in context) {
12
+ keys.push(k)
13
+ const val = context[k]
14
+ values.push(unwrapSignals && typeof val === 'function' && val.isSignal ? val() : val)
15
+ }
16
+
17
+ // Map extra context (like $event)
18
+ for (const k in extraContext) {
19
+ if (!keys.includes(k)) {
20
+ keys.push(k)
21
+ values.push(extraContext[k])
22
+ }
23
+ }
24
+
25
+ try {
26
+ const isMultiStatement = expr.includes(';') || expr.includes('console.')
27
+ const body = isMultiStatement ? expr : `return ${expr}`
28
+ const fn = new Function(...keys, body)
29
+ return fn(...values)
30
+ } catch (e) {
31
+ console.warn(`[framework] Error evaluating "${expr}":`, e)
32
+ return ''
33
+ }
34
+ }
35
+
36
+ export function parseInterpolation(str) {
37
+ const parts = []
38
+ const regex = /\{\{\s*(.+?)\s*\}\}/g
39
+ let lastIndex = 0
40
+ let match
41
+
42
+ while ((match = regex.exec(str)) !== null) {
43
+ if (match.index > lastIndex) {
44
+ parts.push({ type: 'static', value: str.slice(lastIndex, match.index) })
45
+ }
46
+ parts.push({ type: 'dynamic', key: match[1] })
47
+ lastIndex = regex.lastIndex
48
+ }
49
+
50
+ if (lastIndex < str.length) {
51
+ parts.push({ type: 'static', value: str.slice(lastIndex) })
52
+ }
53
+
54
+ return parts
55
+ }
56
+
57
+ export function sanitizeAttr(name, value) {
58
+ const attrName = name.toLowerCase()
59
+ if (SENSITIVE_ATTRS.includes(attrName)) {
60
+ const trimmed = value.trim()
61
+ if (DANGEROUS_SCHEMES.test(trimmed)) {
62
+ console.warn(`[framework] Blocked potential XSS on ${name}: "${value}"`)
63
+ return 'about:blank'
64
+ }
65
+ }
66
+ return value
67
+ }
@@ -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'