fusee-framework 1.6.0 → 1.8.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.
Files changed (129) hide show
  1. package/README.md +145 -123
  2. package/app/benchmark/benchmark.js +633 -0
  3. package/app/benchmark/index.html +102 -0
  4. package/app/benchmark/styles.css +249 -0
  5. package/app/components/Counter.js +17 -7
  6. package/app/components/CounterSection.js +3 -4
  7. package/app/main.js +50 -62
  8. package/app/main.ts +78 -63
  9. package/app/pages/404.js +16 -0
  10. package/app/pages/[...all].js +35 -0
  11. package/app/pages/[...all].ts +35 -0
  12. package/app/pages/_layout.js +53 -0
  13. package/app/pages/_layout.ts +26 -0
  14. package/app/pages/about.js +23 -0
  15. package/app/pages/about.ts +29 -0
  16. package/app/pages/async-work.js +65 -0
  17. package/app/pages/composables.js +158 -0
  18. package/app/pages/composables.ts +158 -0
  19. package/app/pages/dynamic.js +40 -0
  20. package/app/pages/dynamic.ts +40 -0
  21. package/app/pages/index.js +137 -0
  22. package/app/pages/index.ts +87 -0
  23. package/app/pages/login.ts +21 -0
  24. package/app/pages/objects/[id].js +56 -0
  25. package/app/pages/objects/[id].ts +56 -0
  26. package/app/pages/posts/[category]/[slug].js +56 -0
  27. package/app/pages/posts/[category]/[slug].ts +56 -0
  28. package/app/pages/router-example.js +79 -0
  29. package/app/pages/router-example.ts +56 -0
  30. package/app/pages/scroll-demo.ts +102 -0
  31. package/app/pages/test-for.js +65 -0
  32. package/app/pages/test-for.ts +80 -0
  33. package/app/pages/users/[id].js +24 -0
  34. package/app/pages/users/[id].ts +46 -0
  35. package/app/pages/users/index.js +22 -0
  36. package/app/pages/users/index.ts +29 -0
  37. package/app/pages/users.js +43 -0
  38. package/app/pages/users.ts +78 -0
  39. package/app/providers/ChildComponent.js +8 -2
  40. package/app/providers/ChildComponent.ts +47 -47
  41. package/app/providers/GrandchildComponent.js +19 -0
  42. package/app/providers/ParentComponent.js +5 -1
  43. package/app/routes/About.js +1 -1
  44. package/app/routes/About.ts +1 -1
  45. package/app/routes/AuthRoutes.ts +41 -0
  46. package/app/routes/Home.js +9 -0
  47. package/app/routes/NotFound.js +16 -0
  48. package/app/routes/NotFound.ts +16 -0
  49. package/app/routes/RouterExample.js +78 -0
  50. package/app/routes/RouterExample.ts +56 -0
  51. package/app/routes/TestResource.js +47 -0
  52. package/app/routes/WildcardDemo.js +35 -0
  53. package/app/routes/WildcardDemo.ts +35 -0
  54. package/app/services.js +15 -0
  55. package/auto-imports.d.ts +9 -0
  56. package/bin/create-fusee-linux +0 -0
  57. package/bin/create-fusee-mac-arm64 +0 -0
  58. package/bin/create-fusee-mac-intel +0 -0
  59. package/bin/create-fusee-win.exe +0 -0
  60. package/bin/create-fusee.exe +0 -0
  61. package/bin/index.js +38 -0
  62. package/create-fusee-go/cmd/generate.go +72 -0
  63. package/create-fusee-go/cmd/init.go +38 -0
  64. package/create-fusee-go/cmd/root.go +121 -0
  65. package/create-fusee-go/go.mod +10 -0
  66. package/create-fusee-go/go.sum +10 -0
  67. package/create-fusee-go/internal/assets/assets.go +78 -0
  68. package/create-fusee-go/internal/assets/embed/framework/auto-import-preset.js +9 -0
  69. package/create-fusee-go/internal/assets/embed/framework/core/compiler.js +253 -0
  70. package/create-fusee-go/internal/assets/embed/framework/core/component.js +266 -0
  71. package/create-fusee-go/internal/assets/embed/framework/core/composable.js +27 -0
  72. package/create-fusee-go/internal/assets/embed/framework/core/directives.js +875 -0
  73. package/create-fusee-go/internal/assets/embed/framework/core/evaluator.js +153 -0
  74. package/create-fusee-go/internal/assets/embed/framework/core/event-delegation.js +191 -0
  75. package/create-fusee-go/internal/assets/embed/framework/core/signal.js +412 -0
  76. package/create-fusee-go/internal/assets/embed/framework/core/store.js +81 -0
  77. package/create-fusee-go/internal/assets/embed/framework/index.d.ts +3 -0
  78. package/create-fusee-go/internal/assets/embed/framework/index.js +10 -0
  79. package/create-fusee-go/internal/assets/embed/framework/router/file-router.js +177 -0
  80. package/create-fusee-go/internal/assets/embed/framework/router/router.js +462 -0
  81. package/create-fusee-go/internal/assets/embed/framework/types/compiler.d.ts +15 -0
  82. package/create-fusee-go/internal/assets/embed/framework/types/component.d.ts +88 -0
  83. package/create-fusee-go/internal/assets/embed/framework/types/composable.d.ts +14 -0
  84. package/create-fusee-go/internal/assets/embed/framework/types/directives.d.ts +55 -0
  85. package/create-fusee-go/internal/assets/embed/framework/types/evaluator.d.ts +13 -0
  86. package/create-fusee-go/internal/assets/embed/framework/types/event-delegation.d.ts +32 -0
  87. package/create-fusee-go/internal/assets/embed/framework/types/index.d.ts +22 -0
  88. package/create-fusee-go/internal/assets/embed/framework/types/router.d.ts +32 -0
  89. package/create-fusee-go/internal/assets/embed/framework/types/signal.d.ts +79 -0
  90. package/create-fusee-go/internal/assets/embed/framework/types/store.d.ts +8 -0
  91. package/create-fusee-go/internal/assets/templates/Counter.tmpl +26 -0
  92. package/create-fusee-go/internal/assets/templates/component.tmpl +11 -0
  93. package/create-fusee-go/internal/assets/templates/components/Loading.tmpl +24 -0
  94. package/create-fusee-go/internal/assets/templates/index.html.tmpl +15 -0
  95. package/create-fusee-go/internal/assets/templates/main.tmpl +14 -0
  96. package/create-fusee-go/internal/assets/templates/package.json.tmpl +14 -0
  97. package/create-fusee-go/internal/assets/templates/page.tmpl +12 -0
  98. package/create-fusee-go/internal/assets/templates/pages/about.tmpl +20 -0
  99. package/create-fusee-go/internal/assets/templates/pages/index.tmpl +22 -0
  100. package/create-fusee-go/internal/assets/templates/pages/layout.tmpl +18 -0
  101. package/create-fusee-go/internal/assets/templates/tsconfig.json.tmpl +14 -0
  102. package/create-fusee-go/internal/assets/templates/vite.config.js.tmpl +18 -0
  103. package/create-fusee-go/main.go +7 -0
  104. package/framework/auto-import-preset.js +13 -9
  105. package/framework/core/compiler.js +252 -249
  106. package/framework/core/component.js +279 -260
  107. package/framework/core/di.js +187 -0
  108. package/framework/core/directives.js +819 -786
  109. package/framework/core/evaluator.js +153 -70
  110. package/framework/core/event-delegation.js +33 -14
  111. package/framework/core/reconcile.js +151 -0
  112. package/framework/core/signal.js +679 -403
  113. package/framework/core/store.js +322 -81
  114. package/framework/index.js +12 -11
  115. package/framework/router/file-router.js +177 -0
  116. package/framework/router/router.js +554 -80
  117. package/framework/types/component.d.ts +89 -88
  118. package/framework/types/di.d.ts +43 -0
  119. package/framework/types/event-delegation.d.ts +0 -58
  120. package/framework/types/index.d.ts +32 -22
  121. package/framework/types/reconcile.d.ts +7 -0
  122. package/framework/types/router.d.ts +42 -15
  123. package/framework/types/signal.d.ts +147 -78
  124. package/framework/types/store.d.ts +66 -8
  125. package/index.html +638 -626
  126. package/package.json +5 -3
  127. package/tsconfig.json +1 -0
  128. package/vite.config.js +7 -1
  129. /package/{framework → create-fusee-go/internal/assets/embed/framework}/bin/create-fusee.js +0 -0
@@ -1,786 +1,819 @@
1
- import { signal, effect, batch } from './signal.js'
2
- import { evaluateExpression } from './evaluator.js'
3
- import { compileNode } from './compiler.js'
4
- import { registerDelegatedEvent, createEventHandler, isDelegatedEvent } from './event-delegation.js'
5
-
6
- const customDirectives = new Map()
7
-
8
- export function directive(name, definition) {
9
- if (typeof name !== 'string' || !name.trim()) {
10
- console.error('[framework] directive() requires a non-empty name')
11
- return
12
- }
13
- if (!definition || typeof definition !== 'object') {
14
- console.error(`[framework] directive("${name}") requires a definition object`)
15
- return
16
- }
17
- customDirectives.set(name, definition)
18
- }
19
-
20
- export function processCustomDirectives(el, context, effects) {
21
- if (el.nodeType !== 1 || !el.hasAttribute || !el.attributes) return
22
-
23
- for (const attr of [...el.attributes]) {
24
- const match = attr.name.match(/^f-([a-zA-Z0-9-]+)(?::([a-zA-Z0-9-]+))?(?:\.(.+))?$/)
25
-
26
- if (!match) continue
27
-
28
- const directiveName = match[1]
29
- const arg = match[2] || null
30
- const modifiersStr = match[3]
31
- const modifiers = modifiersStr ? modifiersStr.split('.') : []
32
-
33
- const definition = customDirectives.get(directiveName)
34
- if (!definition) {
35
- continue
36
- }
37
-
38
- el.removeAttribute(attr.name)
39
-
40
- const value = evaluateExpression(attr.value, context)
41
- const expression = attr.value
42
-
43
- const binding = {
44
- value,
45
- expression,
46
- arg,
47
- modifiers
48
- }
49
-
50
- if (typeof definition.mounted === 'function') {
51
- definition.mounted(el, binding)
52
- }
53
-
54
- if (typeof definition.updated === 'function') {
55
- effects.push(effect(() => {
56
- const newValue = evaluateExpression(expression, context)
57
-
58
- if (!Object.is(binding.value, newValue)) {
59
- binding.value = newValue
60
- definition.updated(el, binding)
61
- }
62
- }))
63
- }
64
-
65
- if (typeof definition.unmounted === 'function') {
66
- effects.push(() => {
67
- definition.unmounted(el, binding)
68
- })
69
- }
70
- }
71
- }
72
-
73
- export function processDirectives(root, context, components, effects) {
74
- if (processFor(root, context, components, effects)) return true
75
- if (processIf(root, context, components, effects)) return true
76
- if (processIs(root, context, components, effects)) return true
77
-
78
- processShow(root, context, effects)
79
- processModel(root, context, effects)
80
- processRefs(root, context)
81
- processHtml(root, context, effects)
82
- processText(root, context, effects)
83
- processEvents(root, context, effects)
84
- processCustomDirectives(root, context, effects)
85
- processClassList(root, context, effects)
86
- processCloak(root)
87
-
88
- return false
89
- }
90
-
91
- export function processRefs(el, context) {
92
- if (el.nodeType !== 1 || !el.hasAttribute('f-ref')) return
93
-
94
- const refName = el.getAttribute('f-ref')
95
- el.removeAttribute('f-ref')
96
-
97
- const refVar = context[refName]
98
- if (typeof refVar === 'function' && refVar.isSignal && !refVar.readonly) {
99
- refVar(el)
100
- } else {
101
- const reason = refVar?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
102
- console.warn(`[framework] f-ref requires a writable signal. Target "${refName}" is invalid: ${reason}`)
103
- }
104
- }
105
-
106
- export function processModel(el, context, effects) {
107
- if (el.nodeType !== 1 || !el.hasAttribute('f-model')) return
108
-
109
- const expr = el.getAttribute('f-model')
110
- el.removeAttribute('f-model')
111
-
112
- const signalMethod = context[expr]
113
- if (typeof signalMethod === 'function' && signalMethod.isSignal && !signalMethod.readonly) {
114
- effects.push(effect(() => {
115
- const val = signalMethod()
116
- if (el.type === 'checkbox') {
117
- el.checked = !!val
118
- } else {
119
- el.value = val ?? ''
120
- }
121
- }))
122
-
123
- const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio') ? 'change' : 'input'
124
- const handler = () => {
125
- const newVal = el.type === 'checkbox' ? el.checked : el.value
126
- batch(() => signalMethod(newVal))
127
- }
128
- el.addEventListener(eventName, handler)
129
- effects.push(() => el.removeEventListener(eventName, handler))
130
- } else {
131
- const reason = signalMethod?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
132
- console.warn(`[framework] f-model requires a writable signal. Expression "${expr}" is invalid: ${reason}`)
133
- }
134
- }
135
-
136
- export function processFor(el, context, components, effects) {
137
- if (el.nodeType !== 1 || !el.hasAttribute('f-for')) return false
138
-
139
- const expr = el.getAttribute('f-for')
140
- el.removeAttribute('f-for')
141
-
142
- 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*$/)
143
- if (!match) {
144
- console.warn(`[framework] Invalid f-for expression: "${expr}"`)
145
- return false
146
- }
147
-
148
- const itemName = match[4] || match[1]
149
- const keyName = match[2]
150
- const indexName = match[3]
151
- const arrayExpr = match[5]
152
-
153
- const keyExpr = el.getAttribute('data-bind-key') || el.getAttribute(':key')
154
- if (keyExpr) {
155
- el.removeAttribute('data-bind-key')
156
- el.removeAttribute(':key')
157
- }
158
-
159
- const anchor = document.createComment('f-for')
160
- const parent = el.parentNode
161
- parent.insertBefore(anchor, el)
162
-
163
- const templateNode = el.cloneNode(true)
164
- parent.removeChild(el)
165
-
166
- let cache = new Map()
167
- let previousOrder = []
168
-
169
- effects.push(effect(() => {
170
- const rawItems = evaluateExpression(arrayExpr, context)
171
- let items = []
172
-
173
- if (Array.isArray(rawItems)) {
174
- items = rawItems.map((item, index) => ({ value: item, rawIndex: index }))
175
- } else if (typeof rawItems === 'number' && !isNaN(rawItems)) {
176
- items = Array.from({ length: Math.max(0, Math.floor(rawItems)) }, (_, i) => ({ value: i + 1, rawIndex: i }))
177
- } else if (rawItems && typeof rawItems === 'object') {
178
- items = Object.entries(rawItems).map(([key, value], index) => ({ value, key, rawIndex: index }))
179
- }
180
-
181
- const nextCache = new Map()
182
- const nextOrder = []
183
-
184
- const newItemsWithKeys = items.map((item, index) => {
185
- const itemContext = Object.create(context)
186
- itemContext[itemName] = item.value
187
- if (keyName) itemContext[keyName] = item.hasOwnProperty('key') ? item.key : item.rawIndex
188
- if (indexName) itemContext[indexName] = index
189
-
190
- const key = keyExpr ? evaluateExpression(keyExpr, itemContext) : item.rawIndex
191
- return { item, key }
192
- })
193
-
194
- const parentNode = anchor.parentNode
195
- let currentCursor = anchor.nextSibling
196
-
197
- newItemsWithKeys.forEach(({ item, key }, index) => {
198
- let entry = cache.get(key)
199
-
200
- if (entry) {
201
- batch(() => {
202
- if (typeof entry.context[itemName] === 'function' && entry.context[itemName].isSignal) {
203
- entry.context[itemName](item.value)
204
- }
205
- if (keyName && typeof entry.context[keyName] === 'function' && entry.context[keyName].isSignal) {
206
- entry.context[keyName](item.hasOwnProperty('key') ? item.key : item.rawIndex)
207
- }
208
- if (indexName && typeof entry.context[indexName] === 'function' && entry.context[indexName].isSignal) {
209
- entry.context[indexName](index)
210
- }
211
- })
212
-
213
- if (entry.node !== currentCursor) {
214
- parentNode.insertBefore(entry.node, currentCursor)
215
- } else {
216
- currentCursor = currentCursor.nextSibling
217
- }
218
- cache.delete(key)
219
- } else {
220
- const itemContext = Object.create(context)
221
- itemContext[itemName] = signal(item.value)
222
- if (keyName) itemContext[keyName] = signal(item.hasOwnProperty('key') ? item.key : item.rawIndex)
223
- if (indexName) itemContext[indexName] = signal(index)
224
-
225
- const clone = templateNode.cloneNode(true)
226
- const childEffects = []
227
- compileNode(clone, itemContext, components, childEffects)
228
-
229
- parentNode.insertBefore(clone, currentCursor)
230
- entry = { node: clone, effects: childEffects, context: itemContext }
231
- }
232
-
233
- nextCache.set(key, entry)
234
- nextOrder.push(entry.node)
235
- })
236
-
237
- // Cleanup removed nodes
238
- for (const [_, entry] of cache.entries()) {
239
- for (const e of entry.effects) if (typeof e === 'function') e()
240
- entry.node.parentNode?.removeChild(entry.node)
241
- }
242
-
243
- cache = nextCache
244
- previousOrder = nextOrder
245
- }))
246
-
247
- effects.push(() => {
248
- for (const entry of cache.values()) {
249
- for (const cleanup of entry.effects) if (typeof cleanup === 'function') cleanup()
250
- }
251
- cache.clear()
252
- })
253
-
254
- return true
255
- }
256
-
257
- export function processIf(el, context, components, effects) {
258
- if (el.nodeType !== 1 || !el.hasAttribute('f-if')) return false
259
-
260
- const parent = el.parentNode
261
- if (!parent) return false
262
-
263
- const group = []
264
- let curr = el
265
-
266
- while (curr) {
267
- if (curr.nodeType === 1) {
268
- const type = curr.hasAttribute('f-if') ? 'if' :
269
- curr.hasAttribute('f-elif') ? 'elif' :
270
- curr.hasAttribute('f-else-if') ? 'elif' :
271
- curr.hasAttribute('f-else') ? 'else' : null
272
-
273
- if (type) {
274
- const expr = type === 'else' ? 'true' : curr.getAttribute(`f-${type === 'elif' && curr.hasAttribute('f-else-if') ? 'else-if' : type}`)
275
- group.push({ node: curr, expr, type, isElement: true })
276
-
277
- curr.removeAttribute('f-if')
278
- curr.removeAttribute('f-elif')
279
- curr.removeAttribute('f-else-if')
280
- curr.removeAttribute('f-else')
281
-
282
- if (type === 'else') break
283
- } else {
284
- break
285
- }
286
- } else if (curr.nodeType === 3 && curr.textContent.trim() === '' || curr.nodeType === 8) {
287
- group.push({ node: curr, isElement: false })
288
- } else {
289
- break
290
- }
291
- curr = curr.nextSibling
292
- }
293
-
294
- const templates = group
295
- .filter(item => item.isElement)
296
- .map(item => ({
297
- template: item.node.cloneNode(true),
298
- expr: item.expr,
299
- type: item.type
300
- }))
301
-
302
- const anchor = document.createComment('f-condition-anchor')
303
- parent.insertBefore(anchor, el)
304
-
305
- for (const item of group) item.node.remove()
306
-
307
- let activeIndex = -1
308
- let activeNode = null
309
- let activeCleanup = []
310
-
311
- effects.push(effect(() => {
312
- let nextIndex = -1
313
- for (let i = 0; i < templates.length; i++) {
314
- const isMatch = templates[i].type === 'else' ? true : evaluateExpression(templates[i].expr, context)
315
- if (isMatch) {
316
- nextIndex = i
317
- break
318
- }
319
- }
320
-
321
- if (nextIndex !== activeIndex) {
322
- for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
323
- activeCleanup = []
324
- if (activeNode) {
325
- activeNode.remove()
326
- activeNode = null
327
- }
328
-
329
- if (nextIndex !== -1) {
330
- activeNode = templates[nextIndex].template.cloneNode(true)
331
- compileNode(activeNode, context, components, activeCleanup)
332
- anchor.parentNode.insertBefore(activeNode, anchor.nextSibling)
333
- }
334
- activeIndex = nextIndex
335
- }
336
- }))
337
-
338
- effects.push(() => {
339
- for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
340
- activeCleanup = []
341
- })
342
-
343
- return true
344
- }
345
-
346
- export function processIs(el, context, components, effects) {
347
- if (el.nodeType !== 1 || !el.hasAttribute('f-is')) return false
348
-
349
- const expr = el.getAttribute('f-is')
350
- const keepAlive = processKeepAlive(el)
351
-
352
- el.removeAttribute('f-is')
353
-
354
- const anchor = document.createComment('f-keepAlive-anchor')
355
- el.parentNode.insertBefore(anchor, el)
356
- const templateNode = el.cloneNode(true)
357
- el.remove()
358
-
359
- const cache = new Map()
360
- let activeKey = null
361
- let activeCleanup = []
362
-
363
- effects.push(effect(() => {
364
- const rawValue = evaluateExpression(expr, context)
365
-
366
- if (rawValue === activeKey) return
367
-
368
- if (activeKey !== null) {
369
- const currentEntry = cache.get(activeKey)
370
- if (currentEntry) {
371
- if (keepAlive) {
372
- currentEntry.node.remove()
373
- } else {
374
- for (const cleanup of activeCleanup) if (typeof cleanup === 'function') cleanup()
375
- currentEntry.node.remove()
376
- cache.delete(activeKey)
377
- activeCleanup = []
378
- }
379
- }
380
- }
381
-
382
- if (!rawValue) {
383
- activeKey = null
384
- return
385
- }
386
-
387
- let entry = cache.get(rawValue)
388
-
389
- if (entry && keepAlive) {
390
- anchor.parentNode.insertBefore(entry.node, anchor.nextSibling)
391
- activeCleanup = entry.cleanup
392
- } else {
393
- let ComponentFn = typeof rawValue === 'string' ? components[rawValue] : rawValue
394
-
395
- if (typeof ComponentFn !== 'function') {
396
- console.warn(`[framework] f-is: Component "${rawValue}" not found or invalid.`)
397
- return
398
- }
399
-
400
- const compEl = templateNode.cloneNode(true)
401
- compEl._isDynamicComponent = true
402
-
403
- const tempName = '__dynamic_component__'
404
- compEl.setAttribute('data-component', tempName)
405
-
406
- anchor.parentNode.insertBefore(compEl, anchor.nextSibling)
407
-
408
- const childCleanup = []
409
- const dynamicComponents = { ...components, [tempName]: ComponentFn }
410
-
411
- compileNode(compEl, context, dynamicComponents, childCleanup)
412
-
413
- entry = { node: compEl, cleanup: childCleanup }
414
- cache.set(rawValue, entry)
415
- activeCleanup = childCleanup
416
- }
417
-
418
- activeKey = rawValue
419
- }))
420
-
421
- effects.push(() => {
422
- for (const entry of cache.values()) {
423
- for (const c of entry.cleanup) if (typeof c === 'function') c()
424
- entry.node.remove()
425
- }
426
- cache.clear()
427
- })
428
-
429
- return true
430
- }
431
-
432
- function processKeepAlive(el) {
433
- if (el.nodeType !== 1) return false
434
- const hasKeepAlive = el.hasAttribute('f-keepAlive')
435
- if (hasKeepAlive) el.removeAttribute('f-keepAlive')
436
- return hasKeepAlive
437
- }
438
-
439
- export function processShow(el, context, effects) {
440
- if (el.nodeType !== 1 || !el.hasAttribute('f-show')) return
441
-
442
- const expr = el.getAttribute('f-show')
443
- el.removeAttribute('f-show')
444
- const originalDisplay = el.style.display === 'none' ? '' : el.style.display
445
-
446
- effects.push(effect(() => {
447
- const val = evaluateExpression(expr, context)
448
- el.style.display = val ? originalDisplay : 'none'
449
- }))
450
- }
451
-
452
- export function processHtml(el, context, effects) {
453
- if (el.nodeType !== 1 || !el.hasAttribute('f-html')) return
454
-
455
- const expr = el.getAttribute('f-html')
456
- el.removeAttribute('f-html')
457
-
458
- const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
459
- if (isDev) {
460
- console.warn(`[framework] f-html can expose the site to XSS. Use only with trusted content.`)
461
- }
462
-
463
- effects.push(effect(() => {
464
- const val = evaluateExpression(expr, context)
465
- el.innerHTML = String(val ?? '')
466
- }))
467
- }
468
-
469
- export function processEvents(el, context, effects) {
470
- if (el.nodeType !== 1 || !el.attributes) return
471
-
472
- for (const attr of [...el.attributes]) {
473
- // Handle native events: on:click, on:scroll, on:custom-event
474
- if (attr.name.startsWith('on:')) {
475
- const fullEventName = attr.name.slice(3) // Remove 'on:' prefix
476
- const parts = fullEventName.split('.')
477
- const eventName = parts[0]
478
- const modifiers = parts.slice(1)
479
- const expr = attr.value
480
-
481
- el.removeAttribute(attr.name)
482
-
483
- let timeoutId
484
- let lastRun = 0
485
- let throttleTimeoutId
486
-
487
- let debounceMs = 0
488
- let throttleMs = 0
489
-
490
- const parseDuration = (idx) => {
491
- const next = modifiers[idx + 1]
492
- if (next && /^\d/.test(next)) {
493
- if (next.endsWith('ms')) return parseInt(next) || 250
494
- if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
495
- }
496
- return 250
497
- }
498
-
499
- const debounceIdx = modifiers.indexOf('debounce')
500
- if (debounceIdx > -1) debounceMs = parseDuration(debounceIdx)
501
-
502
- const throttleIdx = modifiers.indexOf('throttle')
503
- if (throttleIdx > -1) throttleMs = parseDuration(throttleIdx)
504
-
505
- const executeLogic = (e) => {
506
- batch(() => {
507
- const potentialFn = context[expr]
508
- if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
509
- potentialFn(e)
510
- } else {
511
- evaluateExpression(expr, context, { $event: e }, false)
512
- }
513
- })
514
- }
515
-
516
- const handler = (e) => {
517
- if (modifiers.includes('prevent')) e.preventDefault()
518
- if (modifiers.includes('stop')) e.stopPropagation()
519
- if (modifiers.includes('self') && e.target !== el) return
520
-
521
- if (e instanceof KeyboardEvent) {
522
- const key = e.key.toLowerCase()
523
- const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
524
- if (keyModifiers.length > 0) {
525
- const match = keyModifiers.some(m => {
526
- if (m === 'escape') return key === 'escape' || key === 'esc'
527
- if (m === 'space') return key === ' ' || key === 'spacebar'
528
- return key === m
529
- })
530
- if (!match) return
531
- }
532
- }
533
-
534
- if (throttleMs > 0) {
535
- const now = Date.now()
536
- const remaining = throttleMs - (now - lastRun)
537
-
538
- if (remaining <= 0) {
539
- clearTimeout(throttleTimeoutId)
540
- lastRun = now
541
- executeLogic(e)
542
- } else {
543
- clearTimeout(throttleTimeoutId)
544
- throttleTimeoutId = setTimeout(() => {
545
- lastRun = Date.now()
546
- executeLogic(e)
547
- }, remaining)
548
- }
549
- return
550
- }
551
-
552
- if (debounceMs > 0) {
553
- clearTimeout(timeoutId)
554
- timeoutId = setTimeout(() => executeLogic(e), debounceMs)
555
- } else {
556
- executeLogic(e)
557
- }
558
- }
559
-
560
- const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
561
- const options = {
562
- once: modifiers.includes('once'),
563
- capture: modifiers.includes('capture') || modifiers.includes('away')
564
- }
565
-
566
- targetNode.addEventListener(eventName, handler, options)
567
- if (effects) {
568
- effects.push(() => {
569
- targetNode.removeEventListener(eventName, handler, options)
570
- if (timeoutId) clearTimeout(timeoutId)
571
- if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
572
- })
573
- }
574
- continue
575
- }
576
-
577
- // Handle delegated events: @click, @input, etc.
578
- if (attr.name.startsWith('@')) {
579
- const fullEventName = attr.name.slice(1) // Remove '@' prefix
580
- const parts = fullEventName.split('.')
581
- const eventName = parts[0]
582
- const modifiers = parts.slice(1)
583
- const expr = attr.value
584
-
585
- el.removeAttribute(attr.name)
586
-
587
- const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : null)
588
-
589
- // Window/document events are always native (can't delegate to window/document from document)
590
- if (targetNode) {
591
- // Native handler for window/document events
592
- const handler = createEventHandler(
593
- typeof context[expr] === 'function' && !context[expr].isSignal
594
- ? context[expr]
595
- : expr,
596
- modifiers,
597
- context,
598
- expr
599
- )
600
-
601
- const options = {
602
- once: modifiers.includes('once'),
603
- capture: modifiers.includes('capture') || modifiers.includes('away')
604
- }
605
-
606
- targetNode.addEventListener(eventName, handler, options)
607
- if (effects) {
608
- effects.push(() => {
609
- targetNode.removeEventListener(eventName, handler, options)
610
- })
611
- }
612
- } else if (isDelegatedEvent(eventName) && !modifiers.includes('capture') && !modifiers.includes('away')) {
613
- // Use event delegation for supported events
614
- const potentialFn = context[expr]
615
- const isFunction = typeof potentialFn === 'function' && !potentialFn.isSignal
616
-
617
- const wrappedHandler = createEventHandler(
618
- isFunction ? potentialFn : expr,
619
- modifiers,
620
- context,
621
- expr
622
- )
623
-
624
- const cleanup = registerDelegatedEvent(el, eventName, wrappedHandler, {
625
- modifiers,
626
- context,
627
- expr
628
- })
629
-
630
- if (effects) {
631
- effects.push(cleanup)
632
- }
633
- } else {
634
- // Fallback to native for non-delegated events or when capture/away modifiers are used
635
- let timeoutId
636
- let lastRun = 0
637
- let throttleTimeoutId
638
-
639
- let debounceMs = 0
640
- let throttleMs = 0
641
-
642
- const parseDuration = (idx) => {
643
- const next = modifiers[idx + 1]
644
- if (next && /^\d/.test(next)) {
645
- if (next.endsWith('ms')) return parseInt(next) || 250
646
- if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
647
- }
648
- return 250
649
- }
650
-
651
- const debounceIdx = modifiers.indexOf('debounce')
652
- if (debounceIdx > -1) debounceMs = parseDuration(debounceIdx)
653
-
654
- const throttleIdx = modifiers.indexOf('throttle')
655
- if (throttleIdx > -1) throttleMs = parseDuration(throttleIdx)
656
-
657
- const executeLogic = (e) => {
658
- batch(() => {
659
- const potentialFn = context[expr]
660
- if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
661
- potentialFn(e)
662
- } else {
663
- evaluateExpression(expr, context, { $event: e }, false)
664
- }
665
- })
666
- }
667
-
668
- const handler = (e) => {
669
- if (modifiers.includes('prevent')) e.preventDefault()
670
- if (modifiers.includes('stop')) e.stopPropagation()
671
- if (modifiers.includes('self') && e.target !== el) return
672
-
673
- if (e instanceof KeyboardEvent) {
674
- const key = e.key.toLowerCase()
675
- const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
676
- if (keyModifiers.length > 0) {
677
- const match = keyModifiers.some(m => {
678
- if (m === 'escape') return key === 'escape' || key === 'esc'
679
- if (m === 'space') return key === ' ' || key === 'spacebar'
680
- return key === m
681
- })
682
- if (!match) return
683
- }
684
- }
685
-
686
- if (throttleMs > 0) {
687
- const now = Date.now()
688
- const remaining = throttleMs - (now - lastRun)
689
-
690
- if (remaining <= 0) {
691
- clearTimeout(throttleTimeoutId)
692
- lastRun = now
693
- executeLogic(e)
694
- } else {
695
- clearTimeout(throttleTimeoutId)
696
- throttleTimeoutId = setTimeout(() => {
697
- lastRun = Date.now()
698
- executeLogic(e)
699
- }, remaining)
700
- }
701
- return
702
- }
703
-
704
- if (debounceMs > 0) {
705
- clearTimeout(timeoutId)
706
- timeoutId = setTimeout(() => executeLogic(e), debounceMs)
707
- } else {
708
- executeLogic(e)
709
- }
710
- }
711
-
712
- const options = {
713
- once: modifiers.includes('once'),
714
- capture: modifiers.includes('capture') || modifiers.includes('away')
715
- }
716
-
717
- el.addEventListener(eventName, handler, options)
718
- if (effects) {
719
- effects.push(() => {
720
- el.removeEventListener(eventName, handler, options)
721
- if (timeoutId) clearTimeout(timeoutId)
722
- if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
723
- })
724
- }
725
- }
726
- }
727
- }
728
- }
729
-
730
- export function processText(el, context, effects) {
731
- if (el.nodeType !== 1 || !el.hasAttribute('f-text')) return
732
- const expr = el.getAttribute('f-text')
733
- el.removeAttribute('f-text')
734
- effects.push(effect(() => {
735
- const val = evaluateExpression(expr, context)
736
- el.textContent = String(val ?? '')
737
- }))
738
- }
739
-
740
- export function processCloak(el) {
741
- if (el.nodeType !== 1) return
742
- if (el.hasAttribute('f-cloak')) el.removeAttribute('f-cloak')
743
-
744
- const els = el.querySelectorAll('[f-cloak]')
745
- for (const e of els) e.removeAttribute('f-cloak')
746
- }
747
-
748
- export function processClassList(el, context, effects) {
749
- if (el.nodeType !== 1 || !el.hasAttribute('f-classList')) return
750
-
751
- const expr = el.getAttribute('f-classList')
752
- el.removeAttribute('f-classList')
753
-
754
- const appliedClasses = new Set()
755
-
756
- const e = effect(() => {
757
- const result = evaluateExpression(expr, context)
758
-
759
- if (!result || typeof result !== 'object') {
760
- console.warn(`[framework] f-classList must evaluate to an object, got: ${typeof result}`)
761
- return
762
- }
763
-
764
- for (const [className, shouldApply] of Object.entries(result)) {
765
- const isApplied = appliedClasses.has(className)
766
-
767
- if (shouldApply && !isApplied) {
768
- el.classList.add(className)
769
- appliedClasses.add(className)
770
- } else if (!shouldApply && isApplied) {
771
- el.classList.remove(className)
772
- appliedClasses.delete(className)
773
- }
774
- }
775
-
776
- const currentClasses = new Set(Object.keys(result))
777
- for (const className of [...appliedClasses]) {
778
- if (!currentClasses.has(className)) {
779
- el.classList.remove(className)
780
- appliedClasses.delete(className)
781
- }
782
- }
783
- })
784
-
785
- effects.push(e)
786
- }
1
+ import { signal, effect, batch } from './signal.js'
2
+ import { evaluateExpression } from './evaluator.js'
3
+ import { compileNode } from './compiler.js'
4
+ import { reconcileArrays } from './reconcile.js'
5
+ import { registerDelegatedEvent, createEventHandler, isDelegatedEvent } from './event-delegation.js'
6
+ import { currentRoute, navigate } from '../router/router.js'
7
+
8
+ const customDirectives = new Map()
9
+
10
+ export function directive(name, definition) {
11
+ if (typeof name !== 'string' || !name.trim()) {
12
+ console.error('[framework] directive() requires a non-empty name')
13
+ return
14
+ }
15
+ if (!definition || typeof definition !== 'object') {
16
+ console.error(`[framework] directive("${name}") requires a definition object`)
17
+ return
18
+ }
19
+ customDirectives.set(name, definition)
20
+ }
21
+
22
+ export function processCustomDirectives(el, context, effects) {
23
+ if (el.nodeType !== 1 || !el.hasAttribute || !el.attributes) return
24
+
25
+ for (const attr of [...el.attributes]) {
26
+ const match = attr.name.match(/^f-([a-zA-Z0-9-]+)(?::([a-zA-Z0-9-]+))?(?:\.(.+))?$/)
27
+
28
+ if (!match) continue
29
+
30
+ const directiveName = match[1]
31
+ const arg = match[2] || null
32
+ const modifiersStr = match[3]
33
+ const modifiers = modifiersStr ? modifiersStr.split('.') : []
34
+
35
+ const definition = customDirectives.get(directiveName)
36
+ if (!definition) {
37
+ continue
38
+ }
39
+
40
+ el.removeAttribute(attr.name)
41
+
42
+ const value = evaluateExpression(attr.value, context)
43
+ const expression = attr.value
44
+
45
+ const binding = {
46
+ value,
47
+ expression,
48
+ arg,
49
+ modifiers
50
+ }
51
+
52
+ if (typeof definition.mounted === 'function') {
53
+ definition.mounted(el, binding)
54
+ }
55
+
56
+ if (typeof definition.updated === 'function') {
57
+ effects.push(effect(() => {
58
+ const newValue = evaluateExpression(expression, context)
59
+
60
+ if (!Object.is(binding.value, newValue)) {
61
+ binding.value = newValue
62
+ definition.updated(el, binding)
63
+ }
64
+ }))
65
+ }
66
+
67
+ if (typeof definition.unmounted === 'function') {
68
+ effects.push(() => {
69
+ definition.unmounted(el, binding)
70
+ })
71
+ }
72
+ }
73
+ }
74
+
75
+ export function processDirectives(root, context, components, effects) {
76
+ if (processFor(root, context, components, effects)) return true
77
+ if (processIf(root, context, components, effects)) return true
78
+ if (processIs(root, context, components, effects)) return true
79
+
80
+ processShow(root, context, effects)
81
+ processModel(root, context, effects)
82
+ processRefs(root, context)
83
+ processHtml(root, context, effects)
84
+ processText(root, context, effects)
85
+ processEvents(root, context, effects)
86
+ processCustomDirectives(root, context, effects)
87
+ processClassList(root, context, effects)
88
+ processLinks(root, effects)
89
+ processCloak(root)
90
+
91
+ return false
92
+ }
93
+
94
+ export function processRefs(el, context) {
95
+ if (el.nodeType !== 1 || !el.hasAttribute('f-ref')) return
96
+
97
+ const refName = el.getAttribute('f-ref')
98
+ el.removeAttribute('f-ref')
99
+
100
+ const refVar = context[refName]
101
+ if (typeof refVar === 'function' && refVar.isSignal && !refVar.readonly) {
102
+ refVar(el)
103
+ } else {
104
+ const reason = refVar?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
105
+ console.warn(`[framework] f-ref requires a writable signal. Target "${refName}" is invalid: ${reason}`)
106
+ }
107
+ }
108
+
109
+ export function processModel(el, context, effects) {
110
+ if (el.nodeType !== 1 || !el.hasAttribute('f-model')) return
111
+
112
+ const expr = el.getAttribute('f-model')
113
+ el.removeAttribute('f-model')
114
+
115
+ const signalMethod = context[expr]
116
+ if (typeof signalMethod === 'function' && signalMethod.isSignal && !signalMethod.readonly) {
117
+ effects.push(effect(() => {
118
+ const val = signalMethod()
119
+ if (el.type === 'checkbox') {
120
+ el.checked = !!val
121
+ } else {
122
+ el.value = val ?? ''
123
+ }
124
+ }))
125
+
126
+ const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio') ? 'change' : 'input'
127
+ const handler = () => {
128
+ const newVal = el.type === 'checkbox' ? el.checked : el.value
129
+ batch(() => signalMethod(newVal))
130
+ }
131
+ el.addEventListener(eventName, handler)
132
+ effects.push(() => el.removeEventListener(eventName, handler))
133
+ } else {
134
+ const reason = signalMethod?.readonly ? 'it is read-only (computed)' : 'it is not a signal'
135
+ console.warn(`[framework] f-model requires a writable signal. Expression "${expr}" is invalid: ${reason}`)
136
+ }
137
+ }
138
+
139
+ export function processFor(el, context, components, effects) {
140
+ if (el.nodeType !== 1 || !el.hasAttribute('f-for')) return false
141
+
142
+ const expr = el.getAttribute('f-for')
143
+ el.removeAttribute('f-for')
144
+
145
+ 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*$/)
146
+ if (!match) {
147
+ console.warn(`[framework] Invalid f-for expression: "${expr}"`)
148
+ return false
149
+ }
150
+
151
+ const itemName = match[4] || match[1]
152
+ const keyName = match[2]
153
+ const indexName = match[3]
154
+ const arrayExpr = match[5]
155
+
156
+ const keyExpr = el.getAttribute('data-bind-key') || el.getAttribute(':key')
157
+ if (keyExpr) {
158
+ el.removeAttribute('data-bind-key')
159
+ el.removeAttribute(':key')
160
+ }
161
+
162
+ const anchor = document.createComment('f-for')
163
+ const parent = el.parentNode
164
+ parent.insertBefore(anchor, el)
165
+
166
+ const templateNode = el.cloneNode(true)
167
+ parent.removeChild(el)
168
+
169
+ let oldRenderedNodes = []
170
+
171
+ effects.push(effect(() => {
172
+ const rawItems = evaluateExpression(arrayExpr, context)
173
+ let items = []
174
+
175
+ if (Array.isArray(rawItems)) {
176
+ items = rawItems.map((item, index) => ({ value: item, rawIndex: index }))
177
+ } else if (typeof rawItems === 'number' && !isNaN(rawItems)) {
178
+ items = Array.from({ length: Math.max(0, Math.floor(rawItems)) }, (_, i) => ({ value: i + 1, rawIndex: i }))
179
+ } else if (rawItems && typeof rawItems === 'object') {
180
+ items = Object.entries(rawItems).map(([key, value], index) => ({ value, key, rawIndex: index }))
181
+ }
182
+
183
+ const newItemsWithKeys = items.map((item, index) => {
184
+ const itemContext = Object.create(context)
185
+ itemContext[itemName] = item.value
186
+ if (keyName) itemContext[keyName] = item.hasOwnProperty('key') ? item.key : item.rawIndex
187
+ if (indexName) itemContext[indexName] = index
188
+
189
+ const key = keyExpr ? evaluateExpression(keyExpr, itemContext) : item.rawIndex
190
+ return { item, key, index }
191
+ })
192
+
193
+ const parentNode = anchor.parentNode
194
+
195
+ oldRenderedNodes = reconcileArrays(
196
+ parentNode,
197
+ anchor,
198
+ oldRenderedNodes,
199
+ newItemsWithKeys,
200
+ (obj) => obj.key,
201
+ (newItem, index) => {
202
+ const itemContext = Object.create(context)
203
+ itemContext[itemName] = signal(newItem.item.value)
204
+ if (keyName) itemContext[keyName] = signal(newItem.item.hasOwnProperty('key') ? newItem.item.key : newItem.item.rawIndex)
205
+ if (indexName) itemContext[indexName] = signal(index)
206
+
207
+ const clone = templateNode.cloneNode(true)
208
+ const childEffects = []
209
+ compileNode(clone, itemContext, components, childEffects)
210
+
211
+ return { node: clone, effects: childEffects, context: itemContext, key: newItem.key }
212
+ },
213
+ (oldNode, newItem, index) => {
214
+ batch(() => {
215
+ if (typeof oldNode.context[itemName] === 'function' && oldNode.context[itemName].isSignal) {
216
+ oldNode.context[itemName](newItem.item.value)
217
+ }
218
+ if (keyName && typeof oldNode.context[keyName] === 'function' && oldNode.context[keyName].isSignal) {
219
+ oldNode.context[keyName](newItem.item.hasOwnProperty('key') ? newItem.item.key : newItem.item.rawIndex)
220
+ }
221
+ if (indexName && typeof oldNode.context[indexName] === 'function' && oldNode.context[indexName].isSignal) {
222
+ oldNode.context[indexName](index)
223
+ }
224
+ })
225
+ }
226
+ )
227
+ }))
228
+
229
+ effects.push(() => {
230
+ for (const entry of oldRenderedNodes) {
231
+ if (!entry) continue
232
+ for (let i = entry.effects.length - 1; i >= 0; i--) {
233
+ const cleanup = entry.effects[i]
234
+ if (typeof cleanup === 'function') cleanup()
235
+ }
236
+ }
237
+ oldRenderedNodes = []
238
+ })
239
+
240
+ return true
241
+ }
242
+
243
+ export function processIf(el, context, components, effects) {
244
+ if (el.nodeType !== 1 || !el.hasAttribute('f-if')) return false
245
+
246
+ const parent = el.parentNode
247
+ if (!parent) return false
248
+
249
+ const group = []
250
+ let curr = el
251
+
252
+ while (curr) {
253
+ if (curr.nodeType === 1) {
254
+ const type = curr.hasAttribute('f-if') ? 'if' :
255
+ curr.hasAttribute('f-elif') ? 'elif' :
256
+ curr.hasAttribute('f-else-if') ? 'elif' :
257
+ curr.hasAttribute('f-else') ? 'else' : null
258
+
259
+ if (type) {
260
+ const expr = type === 'else' ? 'true' : curr.getAttribute(`f-${type === 'elif' && curr.hasAttribute('f-else-if') ? 'else-if' : type}`)
261
+ group.push({ node: curr, expr, type, isElement: true })
262
+
263
+ curr.removeAttribute('f-if')
264
+ curr.removeAttribute('f-elif')
265
+ curr.removeAttribute('f-else-if')
266
+ curr.removeAttribute('f-else')
267
+
268
+ if (type === 'else') break
269
+ } else {
270
+ break
271
+ }
272
+ } else if (curr.nodeType === 3 && curr.textContent.trim() === '' || curr.nodeType === 8) {
273
+ group.push({ node: curr, isElement: false })
274
+ } else {
275
+ break
276
+ }
277
+ curr = curr.nextSibling
278
+ }
279
+
280
+ const templates = group
281
+ .filter(item => item.isElement)
282
+ .map(item => ({
283
+ template: item.node.cloneNode(true),
284
+ expr: item.expr,
285
+ type: item.type
286
+ }))
287
+
288
+ const anchor = document.createComment('f-condition-anchor')
289
+ parent.insertBefore(anchor, el)
290
+
291
+ for (const item of group) item.node.remove()
292
+
293
+ let activeIndex = -1
294
+ let activeNode = null
295
+ let activeCleanup = []
296
+
297
+ effects.push(effect(() => {
298
+ let nextIndex = -1
299
+ for (let i = 0; i < templates.length; i++) {
300
+ const isMatch = templates[i].type === 'else' ? true : evaluateExpression(templates[i].expr, context)
301
+ if (isMatch) {
302
+ nextIndex = i
303
+ break
304
+ }
305
+ }
306
+
307
+ if (nextIndex !== activeIndex) {
308
+ for (let i = activeCleanup.length - 1; i >= 0; i--) {
309
+ const cleanup = activeCleanup[i]
310
+ if (typeof cleanup === 'function') cleanup()
311
+ }
312
+ activeCleanup = []
313
+ if (activeNode) {
314
+ activeNode.remove()
315
+ activeNode = null
316
+ }
317
+
318
+ if (nextIndex !== -1) {
319
+ activeNode = templates[nextIndex].template.cloneNode(true)
320
+ compileNode(activeNode, context, components, activeCleanup)
321
+ anchor.parentNode.insertBefore(activeNode, anchor.nextSibling)
322
+ }
323
+ activeIndex = nextIndex
324
+ }
325
+ }))
326
+
327
+ effects.push(() => {
328
+ for (let i = activeCleanup.length - 1; i >= 0; i--) {
329
+ const cleanup = activeCleanup[i]
330
+ if (typeof cleanup === 'function') cleanup()
331
+ }
332
+ activeCleanup = []
333
+ })
334
+
335
+ return true
336
+ }
337
+
338
+ export function processIs(el, context, components, effects) {
339
+ if (el.nodeType !== 1 || !el.hasAttribute('f-is')) return false
340
+
341
+ const expr = el.getAttribute('f-is')
342
+ const keepAlive = processKeepAlive(el)
343
+
344
+ el.removeAttribute('f-is')
345
+
346
+ const anchor = document.createComment('f-keepAlive-anchor')
347
+ el.parentNode.insertBefore(anchor, el)
348
+ const templateNode = el.cloneNode(true)
349
+ el.remove()
350
+
351
+ const cache = new Map()
352
+ let activeKey = null
353
+ let activeCleanup = []
354
+
355
+ effects.push(effect(() => {
356
+ const rawValue = evaluateExpression(expr, context)
357
+
358
+ if (rawValue === activeKey) return
359
+
360
+ if (activeKey !== null) {
361
+ const currentEntry = cache.get(activeKey)
362
+ if (currentEntry) {
363
+ if (keepAlive) {
364
+ currentEntry.node.remove()
365
+ } else {
366
+ for (let i = activeCleanup.length - 1; i >= 0; i--) {
367
+ const cleanup = activeCleanup[i]
368
+ if (typeof cleanup === 'function') cleanup()
369
+ }
370
+ currentEntry.node.remove()
371
+ cache.delete(activeKey)
372
+ activeCleanup = []
373
+ }
374
+ }
375
+ }
376
+
377
+ if (!rawValue) {
378
+ activeKey = null
379
+ return
380
+ }
381
+
382
+ let entry = cache.get(rawValue)
383
+
384
+ if (entry && keepAlive) {
385
+ anchor.parentNode.insertBefore(entry.node, anchor.nextSibling)
386
+ activeCleanup = entry.cleanup
387
+ } else {
388
+ let ComponentFn = typeof rawValue === 'string' ? components[rawValue] : rawValue
389
+
390
+ if (typeof ComponentFn !== 'function') {
391
+ console.warn(`[framework] f-is: Component "${rawValue}" not found or invalid.`)
392
+ return
393
+ }
394
+
395
+ const compEl = templateNode.cloneNode(true)
396
+ compEl._isDynamicComponent = true
397
+
398
+ const tempName = '__dynamic_component__'
399
+ compEl.setAttribute('data-component', tempName)
400
+
401
+ anchor.parentNode.insertBefore(compEl, anchor.nextSibling)
402
+
403
+ const childCleanup = []
404
+ const dynamicComponents = { ...components, [tempName]: ComponentFn }
405
+
406
+ compileNode(compEl, context, dynamicComponents, childCleanup)
407
+
408
+ entry = { node: compEl, cleanup: childCleanup }
409
+ cache.set(rawValue, entry)
410
+ activeCleanup = childCleanup
411
+ }
412
+
413
+ activeKey = rawValue
414
+ }))
415
+
416
+ effects.push(() => {
417
+ for (const entry of cache.values()) {
418
+ for (let i = entry.cleanup.length - 1; i >= 0; i--) {
419
+ const cleanup = entry.cleanup[i]
420
+ if (typeof cleanup === 'function') cleanup()
421
+ }
422
+ entry.node.remove()
423
+ }
424
+ cache.clear()
425
+ })
426
+
427
+ return true
428
+ }
429
+
430
+ function processKeepAlive(el) {
431
+ if (el.nodeType !== 1) return false
432
+ const hasKeepAlive = el.hasAttribute('f-keepAlive')
433
+ if (hasKeepAlive) el.removeAttribute('f-keepAlive')
434
+ return hasKeepAlive
435
+ }
436
+
437
+ export function processShow(el, context, effects) {
438
+ if (el.nodeType !== 1 || !el.hasAttribute('f-show')) return
439
+
440
+ const expr = el.getAttribute('f-show')
441
+ el.removeAttribute('f-show')
442
+ const originalDisplay = el.style.display === 'none' ? '' : el.style.display
443
+
444
+ effects.push(effect(() => {
445
+ const val = evaluateExpression(expr, context)
446
+ el.style.display = val ? originalDisplay : 'none'
447
+ }))
448
+ }
449
+
450
+ export function processHtml(el, context, effects) {
451
+ if (el.nodeType !== 1 || !el.hasAttribute('f-html')) return
452
+
453
+ const expr = el.getAttribute('f-html')
454
+ el.removeAttribute('f-html')
455
+
456
+ const isDev = typeof import.meta.env !== 'undefined' ? !!import.meta.env.DEV : true
457
+ if (isDev) {
458
+ console.warn(`[framework] f-html can expose the site to XSS. Use only with trusted content.`)
459
+ }
460
+
461
+ effects.push(effect(() => {
462
+ const val = evaluateExpression(expr, context)
463
+ el.innerHTML = String(val ?? '')
464
+ }))
465
+ }
466
+
467
+ export function processEvents(el, context, effects) {
468
+ if (el.nodeType !== 1 || !el.attributes) return
469
+
470
+ for (const attr of [...el.attributes]) {
471
+ if (attr.name.startsWith('on:')) {
472
+ const fullEventName = attr.name.slice(3)
473
+ const parts = fullEventName.split('.')
474
+ const eventName = parts[0]
475
+ const modifiers = parts.slice(1)
476
+ const expr = attr.value
477
+
478
+ el.removeAttribute(attr.name)
479
+
480
+ let timeoutId
481
+ let lastRun = 0
482
+ let throttleTimeoutId
483
+
484
+ let debounceMs = 0
485
+ let throttleMs = 0
486
+
487
+ const parseDuration = (idx) => {
488
+ const next = modifiers[idx + 1]
489
+ if (next && /^\d/.test(next)) {
490
+ if (next.endsWith('ms')) return parseInt(next) || 250
491
+ if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
492
+ }
493
+ return 250
494
+ }
495
+
496
+ const debounceIdx = modifiers.indexOf('debounce')
497
+ if (debounceIdx > -1) debounceMs = parseDuration(debounceIdx)
498
+
499
+ const throttleIdx = modifiers.indexOf('throttle')
500
+ if (throttleIdx > -1) throttleMs = parseDuration(throttleIdx)
501
+
502
+ const executeLogic = (e) => {
503
+ batch(() => {
504
+ const potentialFn = context[expr]
505
+ if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
506
+ potentialFn(e)
507
+ } else {
508
+ evaluateExpression(expr, context, { $event: e }, false)
509
+ }
510
+ })
511
+ }
512
+
513
+ const handler = (e) => {
514
+ if (modifiers.includes('prevent')) e.preventDefault()
515
+ if (modifiers.includes('stop')) e.stopPropagation()
516
+ if (modifiers.includes('self') && e.target !== el) return
517
+
518
+ if (e instanceof KeyboardEvent) {
519
+ const key = e.key.toLowerCase()
520
+ const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
521
+ if (keyModifiers.length > 0) {
522
+ const match = keyModifiers.some(m => {
523
+ if (m === 'escape') return key === 'escape' || key === 'esc'
524
+ if (m === 'space') return key === ' ' || key === 'spacebar'
525
+ return key === m
526
+ })
527
+ if (!match) return
528
+ }
529
+ }
530
+
531
+ if (throttleMs > 0) {
532
+ const now = Date.now()
533
+ const remaining = throttleMs - (now - lastRun)
534
+
535
+ if (remaining <= 0) {
536
+ clearTimeout(throttleTimeoutId)
537
+ lastRun = now
538
+ executeLogic(e)
539
+ } else {
540
+ clearTimeout(throttleTimeoutId)
541
+ throttleTimeoutId = setTimeout(() => {
542
+ lastRun = Date.now()
543
+ executeLogic(e)
544
+ }, remaining)
545
+ }
546
+ return
547
+ }
548
+
549
+ if (debounceMs > 0) {
550
+ clearTimeout(timeoutId)
551
+ timeoutId = setTimeout(() => executeLogic(e), debounceMs)
552
+ } else {
553
+ executeLogic(e)
554
+ }
555
+ }
556
+
557
+ const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : el)
558
+ const options = {
559
+ once: modifiers.includes('once'),
560
+ capture: modifiers.includes('capture') || modifiers.includes('away')
561
+ }
562
+
563
+ targetNode.addEventListener(eventName, handler, options)
564
+ if (effects) {
565
+ effects.push(() => {
566
+ targetNode.removeEventListener(eventName, handler, options)
567
+ if (timeoutId) clearTimeout(timeoutId)
568
+ if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
569
+ })
570
+ }
571
+ continue
572
+ }
573
+
574
+ if (attr.name.startsWith('@')) {
575
+ const fullEventName = attr.name.slice(1)
576
+ const parts = fullEventName.split('.')
577
+ const eventName = parts[0]
578
+ const modifiers = parts.slice(1)
579
+ const expr = attr.value
580
+
581
+ el.removeAttribute(attr.name)
582
+
583
+ const targetNode = modifiers.includes('window') ? window : (modifiers.includes('document') ? document : null)
584
+
585
+ if (targetNode) {
586
+ const handler = createEventHandler(
587
+ typeof context[expr] === 'function' && !context[expr].isSignal
588
+ ? context[expr]
589
+ : expr,
590
+ modifiers,
591
+ context,
592
+ expr
593
+ )
594
+
595
+ const options = {
596
+ once: modifiers.includes('once'),
597
+ capture: modifiers.includes('capture') || modifiers.includes('away')
598
+ }
599
+
600
+ targetNode.addEventListener(eventName, handler, options)
601
+ if (effects) {
602
+ effects.push(() => {
603
+ targetNode.removeEventListener(eventName, handler, options)
604
+ })
605
+ }
606
+ } else if (isDelegatedEvent(eventName) && !modifiers.includes('capture') && !modifiers.includes('away')) {
607
+ const potentialFn = context[expr]
608
+ const isFunction = typeof potentialFn === 'function' && !potentialFn.isSignal
609
+
610
+ const handlerState = {
611
+ timeoutId: null,
612
+ throttleTimeoutId: null,
613
+ lastRun: 0
614
+ }
615
+
616
+ const wrappedHandler = createEventHandler(
617
+ isFunction ? potentialFn : expr,
618
+ modifiers,
619
+ context,
620
+ expr,
621
+ handlerState
622
+ )
623
+
624
+ const cleanup = registerDelegatedEvent(el, eventName, wrappedHandler, {
625
+ modifiers,
626
+ context,
627
+ expr,
628
+ handlerState
629
+ })
630
+
631
+ if (effects) {
632
+ effects.push(cleanup)
633
+ }
634
+ } else {
635
+ let timeoutId
636
+ let lastRun = 0
637
+ let throttleTimeoutId
638
+
639
+ let debounceMs = 0
640
+ let throttleMs = 0
641
+
642
+ const parseDuration = (idx) => {
643
+ const next = modifiers[idx + 1]
644
+ if (next && /^\d/.test(next)) {
645
+ if (next.endsWith('ms')) return parseInt(next) || 250
646
+ if (next.endsWith('s')) return (parseFloat(next) || 0.25) * 1000
647
+ }
648
+ return 250
649
+ }
650
+
651
+ const debounceIdx = modifiers.indexOf('debounce')
652
+ if (debounceIdx > -1) debounceMs = parseDuration(debounceIdx)
653
+
654
+ const throttleIdx = modifiers.indexOf('throttle')
655
+ if (throttleIdx > -1) throttleMs = parseDuration(throttleIdx)
656
+
657
+ const executeLogic = (e) => {
658
+ batch(() => {
659
+ const potentialFn = context[expr]
660
+ if (typeof potentialFn === 'function' && !potentialFn.isSignal) {
661
+ potentialFn(e)
662
+ } else {
663
+ evaluateExpression(expr, context, { $event: e }, false)
664
+ }
665
+ })
666
+ }
667
+
668
+ const handler = (e) => {
669
+ if (modifiers.includes('prevent')) e.preventDefault()
670
+ if (modifiers.includes('stop')) e.stopPropagation()
671
+ if (modifiers.includes('self') && e.target !== el) return
672
+
673
+ if (e instanceof KeyboardEvent) {
674
+ const key = e.key.toLowerCase()
675
+ const keyModifiers = modifiers.filter(m => ['enter', 'escape', 'tab', 'space', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(m))
676
+ if (keyModifiers.length > 0) {
677
+ const match = keyModifiers.some(m => {
678
+ if (m === 'escape') return key === 'escape' || key === 'esc'
679
+ if (m === 'space') return key === ' ' || key === 'spacebar'
680
+ return key === m
681
+ })
682
+ if (!match) return
683
+ }
684
+ }
685
+
686
+ if (throttleMs > 0) {
687
+ const now = Date.now()
688
+ const remaining = throttleMs - (now - lastRun)
689
+
690
+ if (remaining <= 0) {
691
+ clearTimeout(throttleTimeoutId)
692
+ lastRun = now
693
+ executeLogic(e)
694
+ } else {
695
+ clearTimeout(throttleTimeoutId)
696
+ throttleTimeoutId = setTimeout(() => {
697
+ lastRun = Date.now()
698
+ executeLogic(e)
699
+ }, remaining)
700
+ }
701
+ return
702
+ }
703
+
704
+ if (debounceMs > 0) {
705
+ clearTimeout(timeoutId)
706
+ timeoutId = setTimeout(() => executeLogic(e), debounceMs)
707
+ } else {
708
+ executeLogic(e)
709
+ }
710
+ }
711
+
712
+ const options = {
713
+ once: modifiers.includes('once'),
714
+ capture: modifiers.includes('capture') || modifiers.includes('away')
715
+ }
716
+
717
+ el.addEventListener(eventName, handler, options)
718
+ if (effects) {
719
+ effects.push(() => {
720
+ el.removeEventListener(eventName, handler, options)
721
+ if (timeoutId) clearTimeout(timeoutId)
722
+ if (throttleTimeoutId) clearTimeout(throttleTimeoutId)
723
+ })
724
+ }
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ export function processText(el, context, effects) {
731
+ if (el.nodeType !== 1 || !el.hasAttribute('f-text')) return
732
+ const expr = el.getAttribute('f-text')
733
+ el.removeAttribute('f-text')
734
+ effects.push(effect(() => {
735
+ const val = evaluateExpression(expr, context)
736
+ el.textContent = String(val ?? '')
737
+ }))
738
+ }
739
+
740
+ export function processCloak(el) {
741
+ if (el.nodeType !== 1) return
742
+ if (el.hasAttribute('f-cloak')) el.removeAttribute('f-cloak')
743
+
744
+ const els = el.querySelectorAll('[f-cloak]')
745
+ for (const e of els) e.removeAttribute('f-cloak')
746
+ }
747
+
748
+ export function processClassList(el, context, effects) {
749
+ if (el.nodeType !== 1 || !el.hasAttribute('f-classList')) return
750
+
751
+ const expr = el.getAttribute('f-classList')
752
+ el.removeAttribute('f-classList')
753
+
754
+ const appliedClasses = new Set()
755
+
756
+ const e = effect(() => {
757
+ const result = evaluateExpression(expr, context)
758
+
759
+ if (!result || typeof result !== 'object') {
760
+ console.warn(`[framework] f-classList must evaluate to an object, got: ${typeof result}`)
761
+ return
762
+ }
763
+
764
+ for (const [className, shouldApply] of Object.entries(result)) {
765
+ const isApplied = appliedClasses.has(className)
766
+
767
+ if (shouldApply && !isApplied) {
768
+ el.classList.add(className)
769
+ appliedClasses.add(className)
770
+ } else if (!shouldApply && isApplied) {
771
+ el.classList.remove(className)
772
+ appliedClasses.delete(className)
773
+ }
774
+ }
775
+
776
+ const currentClasses = new Set(Object.keys(result))
777
+ for (const className of [...appliedClasses]) {
778
+ if (!currentClasses.has(className)) {
779
+ el.classList.remove(className)
780
+ appliedClasses.delete(className)
781
+ }
782
+ }
783
+ })
784
+
785
+ effects.push(e)
786
+ }
787
+
788
+ export function processLinks(el, effects) {
789
+ if (el.nodeType !== 1) return
790
+
791
+ const links = el.hasAttribute('f-link') ? [el] : el.querySelectorAll('[f-link]')
792
+
793
+ for (const link of links) {
794
+ if (link.tagName !== 'A') continue
795
+
796
+ const href = link.getAttribute('href')
797
+ if (!href) continue
798
+
799
+ link.removeAttribute('f-link')
800
+
801
+ effects.push(effect(() => {
802
+ const currentPath = currentRoute()
803
+ const isActive = currentPath === href || currentPath.startsWith(href + '/')
804
+
805
+ if (isActive) {
806
+ link.classList.add('active')
807
+ } else {
808
+ link.classList.remove('active')
809
+ }
810
+ }))
811
+
812
+ const clickHandler = (e) => {
813
+ e.preventDefault()
814
+ navigate(href)
815
+ }
816
+ link.addEventListener('click', clickHandler)
817
+ effects.push(() => link.removeEventListener('click', clickHandler))
818
+ }
819
+ }