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