fusee-framework 0.1.0-alpha.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ import { defineComponent, signal, type SignalAccessor } from '../../framework/index.js'
2
+ import { UserForm } from '../components/UserForm.js'
3
+ import { CounterSection } from '../components/CounterSection.js'
4
+
5
+ type HomeResult = {
6
+ title: SignalAccessor<string>
7
+ changeTitle: () => void
8
+ template: string
9
+ }
10
+
11
+ export const Home = defineComponent({
12
+ components: { UserForm, CounterSection },
13
+ setup(): HomeResult {
14
+ const title = signal('Welcome!')
15
+
16
+ return {
17
+ title,
18
+ changeTitle: () => title('Changed Title!'),
19
+ template: `
20
+ <div class="page">
21
+ <h1>{{ title.toUpperCase() }}</h1>
22
+ <p>This is an SPA built with my own JavaScript custom framework!</p>
23
+ <p>Made with passion by Sebastian Șomu</p>
24
+
25
+ <button @click="changeTitle" class="primary-btn" style="margin-bottom: 20px;">Change App Title</button>
26
+
27
+ {{ UserForm }}
28
+
29
+ {{ CounterSection :pageTitle="title" }}
30
+ </div>
31
+ `
32
+ }
33
+ }
34
+ })
@@ -0,0 +1,43 @@
1
+ import { defineComponent, signal } from '../../framework/index.js'
2
+
3
+ export const TestFor = defineComponent({
4
+ setup() {
5
+ const items = signal([
6
+ { id: 1, name: 'Item 1' },
7
+ { id: 2, name: 'Item 2' },
8
+ { id: 3, name: 'Item 3' }
9
+ ])
10
+
11
+ return {
12
+ items,
13
+ addItem: () => {
14
+ const nextId = Math.max(0, ...items().map(i => i.id)) + 1
15
+ items([...items(), { id: nextId, name: `Item ${nextId}` }])
16
+ },
17
+ shuffleItems: () => {
18
+ const shuffled = [...items()].sort(() => Math.random() - 0.5)
19
+ items(shuffled)
20
+ },
21
+ template: `
22
+ <div class="page">
23
+ <h1>Keyed f-for Test</h1>
24
+ <p>Focus an input below, then click "Shuffle" or "Add". The focus should stay!</p>
25
+
26
+ <div style="margin-bottom: 20px; display: flex; gap: 10px;">
27
+ <button @click="addItem" class="primary-btn">Add Item</button>
28
+ <button @click="shuffleItems" class="primary-btn">Shuffle Items</button>
29
+ </div>
30
+
31
+ <div style="display: flex; flex-direction: column; gap: 10px;">
32
+ <div f-for="item in items" :key="item.id"
33
+ style="padding: 10px; border: 1px solid #333; display: flex; gap: 10px; align-items: center;">
34
+ <span>ID: {{ item.id }}</span>
35
+ <input type="text" :value="item.name" placeholder="Type here..."
36
+ style="background: #111; color: #fff; border: 1px solid #444; padding: 5px;" />
37
+ </div>
38
+ </div>
39
+ </div>
40
+ `
41
+ }
42
+ }
43
+ })
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const sourceFramework = path.join(__dirname, '..');
10
+
11
+ const projectName = process.argv[2] || 'my-fusee-app';
12
+ const projectPath = path.isAbsolute(projectName) ? projectName : path.join(process.cwd(), projectName);
13
+
14
+ async function scaffold() {
15
+ console.log(`\n🚀 Welcome to Fusée Framework Scaffolder!`);
16
+ const choice = await new Promise(resolve => {
17
+ rl.question('🧬 Select language template [JavaScript (js) / TypeScript (ts)] (default: js): ', (answer) => {
18
+ resolve(answer.toLowerCase() === 'ts' ? 'ts' : 'js');
19
+ });
20
+ }); rl.close();
21
+
22
+ console.log(`\n🚀 Scaffolding a new project in: ${projectPath}...`);
23
+
24
+ function copyDir(src, dest, skipTypes = false) {
25
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
26
+ fs.readdirSync(src).forEach(item => {
27
+ // Sărim folderul types SAU orice fișier .d.ts rătăcit
28
+ if (skipTypes && (item === 'types' || item.endsWith('.d.ts'))) return;
29
+ if (item === 'bin' || item === 'node_modules' || item === '.git') return;
30
+
31
+ const s = path.join(src, item);
32
+ const d = path.join(dest, item);
33
+ if (fs.lstatSync(s).isDirectory()) copyDir(s, d, skipTypes);
34
+ else fs.copyFileSync(s, d);
35
+ });
36
+ }
37
+
38
+ const coreFolders = ['app', 'app/components'];
39
+ coreFolders.forEach(f => {
40
+ const dir = path.join(projectPath, f);
41
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
42
+ });
43
+
44
+ console.log('📦 Injecting Fusée Core Engine (Strict Mode)...');
45
+ copyDir(sourceFramework, path.join(projectPath, 'framework'), choice === 'js');
46
+
47
+ const packageJson = {
48
+ name: projectName === '.' ? 'fusee-project' : projectName,
49
+ version: '1.0.0',
50
+ type: 'module',
51
+ scripts: { "dev": "vite", "build": "vite build" },
52
+ devDependencies: { "vite": "^5.0.0" }
53
+ };
54
+ if (choice === 'ts') packageJson.devDependencies["typescript"] = "^5.0.0";
55
+ fs.writeFileSync(path.join(projectPath, 'package.json'), JSON.stringify(packageJson, null, 2));
56
+
57
+ if (choice === 'ts') {
58
+ const tsConfig = { compilerOptions: { "target": "ESNext", "module": "ESNext", "moduleResolution": "node", "strict": true, "esModuleInterop": true, "skipLibCheck": true } };
59
+ fs.writeFileSync(path.join(projectPath, 'tsconfig.json'), JSON.stringify(tsConfig, null, 2));
60
+ }
61
+
62
+ const ext = choice === 'ts' ? 'ts' : 'js';
63
+ const indexHtml = `
64
+ <!DOCTYPE html>
65
+ <html>
66
+ <head>
67
+ <meta charset="UTF-8">
68
+ <title>Strict Fusée App</title>
69
+ <style>
70
+ body { background: #050505; color: white; font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
71
+ #app { text-align: center; background: #111; padding: 3rem; border-radius: 20px; border: 1px solid #222; box-shadow: 0 10px 40px rgba(255, 51, 51, 0.1); }
72
+ h1 { color: #ff3333; text-shadow: 0 0 20px rgba(255, 51, 51, 0.3); margin: 0; font-size: 2.5rem; }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <div id="app">
77
+ <h1>Fusée 🚀</h1>
78
+ <p style="color: #ff9999; opacity: 0.8; margin-bottom: 2rem;">A reactive framework built with passion.</p>
79
+ {{ Counter }}
80
+ </div>
81
+ <script type="module" src="/app/main.${ext}"></script>
82
+ </body>
83
+ </html>
84
+ `;
85
+ fs.writeFileSync(path.join(projectPath, 'index.html'), indexHtml);
86
+
87
+ const btnStyle = "background:#ff3333; color:white; border:none; width:45px; height:45px; border-radius:12px; cursor:pointer; font-size:1.6rem; font-weight:bold; transition: 0.3s; box-shadow: 0 4px 15px rgba(255, 51, 51, 0.2);";
88
+ const counterContent = choice === 'ts'
89
+ ? `import { signal } from '../../framework/core/signal.js';
90
+ import { defineComponent } from '../../framework/core/component.js';
91
+
92
+ export const Counter = defineComponent({
93
+ setup() {
94
+ const count = signal<number>(0);
95
+ return {
96
+ count,
97
+ inc: () => count(count() + 1),
98
+ dec: () => count(count() - 1),
99
+ template: \`
100
+ <div style="display:flex; align-items:center; justify-content:center; gap:2.5rem;">
101
+ <button @click="dec" style="${btnStyle}">-</button>
102
+ <span style="font-size:2.8rem; font-weight:bold; min-width:70px;">{{ count }}</span>
103
+ <button @click="inc" style="${btnStyle}">+</button>
104
+ </div>
105
+ \`
106
+ };
107
+ }
108
+ });`
109
+ : `import { signal } from '../../framework/core/signal.js';
110
+ import { defineComponent } from '../../framework/core/component.js';
111
+
112
+ export const Counter = defineComponent({
113
+ setup() {
114
+ const count = signal(0);
115
+ return {
116
+ count,
117
+ inc: () => count(count() + 1),
118
+ dec: () => count(count() - 1),
119
+ template: \`
120
+ <div style="display:flex; align-items:center; justify-content:center; gap:2.5rem;">
121
+ <button @click="dec" style="${btnStyle}">-</button>
122
+ <span style="font-size:2.8rem; font-weight:bold; min-width:70px;">{{ count }}</span>
123
+ <button @click="inc" style="${btnStyle}">+</button>
124
+ </div>
125
+ \`
126
+ };
127
+ }
128
+ });`;
129
+
130
+ fs.writeFileSync(path.join(projectPath, `app/components/Counter.${ext}`), counterContent);
131
+
132
+ const mainContent = `import { mountTemplate } from '../framework/core/compiler.js';
133
+ import { Counter } from './components/Counter.${ext}';
134
+
135
+ const root = document.getElementById('app');
136
+ if (root) {
137
+ mountTemplate(root.innerHTML, root, {}, { Counter });
138
+ }
139
+ `;
140
+ fs.writeFileSync(path.join(projectPath, `app/main.${ext}`), mainContent);
141
+
142
+ console.log(`\n✅ Fusée Project Ready!`);
143
+ const instructions = projectName === '.' ? 'npm install && npm run dev' : `cd ${projectName} && npm install && npm run dev`;
144
+ console.log(`👉 Run: ${instructions}\n`);
145
+ }
146
+
147
+ scaffold();
@@ -0,0 +1,214 @@
1
+ import { effect, batch } from './signal.js'
2
+ import { processDirectives } from './directives.js'
3
+ import {
4
+ evaluateExpression,
5
+ parseInterpolation,
6
+ sanitizeAttr,
7
+ MUSTACHE_RE
8
+ } from './evaluator.js'
9
+
10
+ export function mountTemplate(template, container, context, components) {
11
+ const effects = []
12
+ let processed = template.replace(/(<[a-zA-Z0-9-]+\s[^>]*?)\s:([a-zA-Z][a-zA-Z0-9-]*)=/g, '$1 data-bind-$2=')
13
+
14
+ for (const name of Object.keys(components)) {
15
+ const re = new RegExp(`\\{\\{\\s*${name}(\\s+.*?|\\s*)\\}\\}`, 'g')
16
+ processed = processed.replace(re, (match, propsStr) => {
17
+ let attrs = ''
18
+ if (propsStr && propsStr.trim()) {
19
+ const attrRegex = /(:?[a-zA-Z0-9-]+)="([^"]*)"/g
20
+ let attrMatch
21
+ while ((attrMatch = attrRegex.exec(propsStr)) !== null) {
22
+ const attrName = attrMatch[1]
23
+ const attrValue = attrMatch[2]
24
+
25
+ if (attrName.startsWith(':')) {
26
+ attrs += ` data-bind-prop-${attrName.slice(1).toLowerCase()}="${attrValue}"`
27
+ } else {
28
+ attrs += ` data-prop-${attrName.toLowerCase()}="${attrValue}"`
29
+ }
30
+ }
31
+ }
32
+ return `<div data-component="${name}"${attrs}></div>`
33
+ })
34
+ }
35
+
36
+ container.innerHTML = processed
37
+ compileNode(container, context, components, effects)
38
+
39
+ return { effects }
40
+ }
41
+
42
+ export function compileNode(node, context, components, effects) {
43
+ // Handle f-once (render once and discard reactive tracking for this subtree)
44
+ if (node.nodeType === 1 && node.hasAttribute('f-once')) {
45
+ node.removeAttribute('f-once')
46
+ const temporaryEffects = []
47
+
48
+ // Process ONLY this node and its children ONCE
49
+ processDirectives(node, context, components, temporaryEffects)
50
+ processBindingAttrs(node, context, temporaryEffects)
51
+ processMustaches(node, context, temporaryEffects)
52
+ bindComponents(node, components, context, temporaryEffects)
53
+
54
+ // Recurse children but with temporary effects
55
+ let child = node.firstChild
56
+ while (child) {
57
+ compileOnce(child, context, components, temporaryEffects)
58
+ child = child.nextSibling
59
+ }
60
+
61
+ // Cleanup immediately
62
+ for (const cleanup of temporaryEffects) if (typeof cleanup === 'function') cleanup()
63
+ return
64
+ }
65
+
66
+ if (node.nodeType === 1) {
67
+ processDirectives(node, context, components, effects)
68
+ processBindingAttrs(node, context, effects)
69
+ bindComponents(node, components, context, effects)
70
+ } else if (node.nodeType === 3) {
71
+ processMustaches(node, context, effects)
72
+ }
73
+
74
+ let child = node.firstChild
75
+ while (child) {
76
+ compileNode(child, context, components, effects)
77
+ child = child.nextSibling
78
+ }
79
+ }
80
+
81
+ function compileOnce(node, context, components, effects) {
82
+ if (node.nodeType === 1) {
83
+ processDirectives(node, context, components, effects)
84
+ processBindingAttrs(node, context, effects)
85
+ bindComponents(node, components, context, effects)
86
+ } else if (node.nodeType === 3) {
87
+ processMustaches(node, context, effects)
88
+ }
89
+
90
+ let child = node.firstChild
91
+ while (child) {
92
+ compileOnce(child, context, components, effects)
93
+ child = child.nextSibling
94
+ }
95
+ }
96
+
97
+ function processMustaches(node, context, effects) {
98
+ if (node.nodeType !== 3) return
99
+ const text = node.textContent
100
+ if (!MUSTACHE_RE.test(text)) return
101
+
102
+ const parts = parseInterpolation(text)
103
+ const fragment = document.createDocumentFragment()
104
+
105
+ for (const part of parts) {
106
+ if (part.type === 'static') {
107
+ fragment.appendChild(document.createTextNode(part.value))
108
+ } else {
109
+ const reactiveNode = document.createTextNode('')
110
+ fragment.appendChild(reactiveNode)
111
+ const key = part.key
112
+ const e = effect(() => {
113
+ const resolved = evaluateExpression(key, context)
114
+ reactiveNode.textContent = String(resolved ?? '')
115
+ })
116
+ effects.push(e)
117
+ }
118
+ }
119
+ node.parentNode.replaceChild(fragment, node)
120
+ }
121
+
122
+ function processBindingAttrs(el, context, effects) {
123
+ if (el.nodeType !== 1) return
124
+ for (const attr of [...el.attributes]) {
125
+ if (attr.name.startsWith('data-bind-') && !attr.name.startsWith('data-bind-prop-')) {
126
+ const realAttr = attr.name.slice(10)
127
+ const expr = attr.value
128
+ el.removeAttribute(attr.name)
129
+
130
+ const e = effect(() => {
131
+ let resolved = evaluateExpression(expr, context)
132
+ if (realAttr === 'class') {
133
+ if (!el._staticClass) el._staticClass = el.getAttribute('class') || ''
134
+ let dynamicClass = ''
135
+ if (Array.isArray(resolved)) dynamicClass = resolved.filter(Boolean).join(' ')
136
+ else if (resolved !== null && typeof resolved === 'object') {
137
+ dynamicClass = Object.entries(resolved).filter(([_, v]) => !!v).map(([k]) => k).join(' ')
138
+ } else dynamicClass = String(resolved ?? '')
139
+ el.className = (el._staticClass + ' ' + dynamicClass).trim()
140
+ } else if (realAttr === 'style') {
141
+ if (resolved !== null && typeof resolved === 'object') {
142
+ for (const k in resolved) el.style[k] = resolved[k]
143
+ } else el.style.cssText = String(resolved ?? '')
144
+ } else {
145
+ if (typeof resolved === 'boolean') {
146
+ resolved ? el.setAttribute(realAttr, '') : el.removeAttribute(realAttr)
147
+ } else el.setAttribute(realAttr, sanitizeAttr(realAttr, String(resolved)))
148
+ }
149
+ })
150
+ effects.push(e)
151
+ continue
152
+ }
153
+
154
+ if (MUSTACHE_RE.test(attr.value)) {
155
+ const attrName = attr.name
156
+ const parts = parseInterpolation(attr.value)
157
+ el.removeAttribute(attrName)
158
+ const e = effect(() => {
159
+ let result = ''
160
+ for (const part of parts) {
161
+ result += part.type === 'static' ? part.value : String(evaluateExpression(part.key, context) ?? '')
162
+ }
163
+ el.setAttribute(attrName, sanitizeAttr(attrName, result))
164
+ })
165
+ effects.push(e)
166
+ }
167
+ }
168
+ }
169
+
170
+ function bindComponents(container, components, context, effects) {
171
+ const placeholders = container.querySelectorAll('[data-component]')
172
+ const els = container.matches && container.matches('[data-component]') ? [container, ...placeholders] : placeholders
173
+ for (const placeholder of els) {
174
+ const name = placeholder.dataset.component
175
+ const ComponentFn = components[name]
176
+ if (ComponentFn) {
177
+ const props = {}
178
+ for (const attr of [...placeholder.attributes]) {
179
+ const attrName = attr.name.toLowerCase()
180
+
181
+ if (attrName.startsWith('data-prop-')) {
182
+ props[attrName.slice(10)] = attr.value
183
+ } else if (attrName.startsWith('data-bind-prop-')) {
184
+ const propName = attrName.slice(15)
185
+ const expression = attr.value.trim()
186
+
187
+ if (expression in context) {
188
+ Object.defineProperty(props, propName, {
189
+ get() {
190
+ const val = context[expression]
191
+ return typeof val === 'function' && val.isSignal ? val() : val
192
+ },
193
+ enumerable: true,
194
+ configurable: true
195
+ })
196
+ } else {
197
+ Object.defineProperty(props, propName, {
198
+ get() {
199
+ return evaluateExpression(expression, context)
200
+ },
201
+ enumerable: true,
202
+ configurable: true
203
+ })
204
+ }
205
+ }
206
+ }
207
+ const childComponent = ComponentFn(props)
208
+ childComponent.render(placeholder)
209
+ if (effects) {
210
+ effects.push(() => childComponent.unmount())
211
+ }
212
+ }
213
+ }
214
+ }
@@ -1,128 +1,119 @@
1
- // ─── Component System ─────────────────────────────────────────────────────────
2
- import { effect } from './signal.js'
1
+ import { effect, setEffectHook, batch } from './signal.js'
2
+ import { mountTemplate } from './compiler.js'
3
3
 
4
- // Lifecycle context — set while setup() runs
5
4
  let currentInstance = null
6
5
 
7
- export function onMount(fn) {
8
- if (currentInstance) currentInstance._mountHooks.push(fn)
9
- }
10
-
11
- export function onUnmount(fn) {
12
- if (currentInstance) currentInstance._unmountHooks.push(fn)
13
- }
6
+ setEffectHook(eff => {
7
+ if (currentInstance) currentInstance._effects.push(eff)
8
+ })
14
9
 
15
- export function defineComponent(options) {
16
- return function ComponentFactory(props = {}) {
17
- const instance = {
18
- props,
19
- _mountHooks: [],
20
- _unmountHooks: [],
21
- _effects: [],
22
- _element: null,
23
- }
10
+ function resolveProps(schema, received) {
11
+ const isArray = Array.isArray(schema)
12
+ const resolved = {}
24
13
 
25
- // Run setup() with lifecycle context active
26
- currentInstance = instance
27
- const result = options.setup(props)
28
- currentInstance = null
29
-
30
- // Validate props defined in component
31
- const allowedProps = options.props || []
32
- for (const key of Object.keys(props)) {
33
- if (!allowedProps.includes(key)) {
34
- console.warn(`[framework] Unknown prop "${key}" passed to component.`)
35
- }
14
+ const normalizedSchema = {}
15
+ if (isArray) {
16
+ schema.forEach(k => normalizedSchema[k.toLowerCase()] = k)
17
+ } else {
18
+ for (const key of Object.keys(schema)) {
19
+ normalizedSchema[key.toLowerCase()] = key
20
+ }
36
21
  }
37
22
 
38
- // ── Template rendering ───────────────────────────────────────────────────
39
- function render(container) {
40
- instance._element = container
23
+ for (const receivedKey of Object.keys(received)) {
24
+ const schemaKey = isArray
25
+ ? schema.find(k => k.toLowerCase() === receivedKey.toLowerCase())
26
+ : normalizedSchema[receivedKey.toLowerCase()]
41
27
 
42
- const e = effect(() => {
43
- const html = resolveTemplate(result.template, result, options.components || {})
44
- container.innerHTML = html
45
- bindEvents(container, result)
46
- bindComponents(container, options.components || {})
47
- })
48
-
49
- instance._effects.push(e)
50
-
51
- // Run mount hooks after first render
52
- for (const hook of instance._mountHooks) hook()
28
+ if (!schemaKey) {
29
+ console.warn(`[framework] Unknown prop "${receivedKey}"`)
30
+ continue
31
+ }
53
32
 
54
- return instance
33
+ const descriptor = Object.getOwnPropertyDescriptor(received, receivedKey)
34
+ if (descriptor && descriptor.get) {
35
+ Object.defineProperty(resolved, schemaKey, {
36
+ get: descriptor.get,
37
+ enumerable: true,
38
+ configurable: true
39
+ })
40
+ } else {
41
+ resolved[schemaKey] = received[receivedKey]
42
+ }
55
43
  }
56
44
 
57
- // ── Unmount ──────────────────────────────────────────────────────────────
58
- function unmount() {
59
- for (const hook of instance._unmountHooks) hook()
60
- // Clean up all reactive effects
61
- for (const e of instance._effects) {
62
- for (const dep of e.deps) dep.delete(e)
63
- e.deps.clear()
64
- }
65
- if (instance._element) instance._element.innerHTML = ''
45
+ if (!isArray) {
46
+ for (const [key, config] of Object.entries(schema)) {
47
+ if (!(key in resolved)) {
48
+ if (config.required) {
49
+ console.error(`[framework] Required prop "${key}" is missing`)
50
+ }
51
+ if (config.default !== undefined) {
52
+ resolved[key] = typeof config.default === 'function'
53
+ ? config.default()
54
+ : config.default
55
+ }
56
+ } else if (config.type) {
57
+ const value = resolved[key]
58
+ const expectedType = config.type.name
59
+ const actualType = typeof value
60
+ const typeMap = { String: 'string', Number: 'number', Boolean: 'boolean' }
61
+ if (typeMap[expectedType] && actualType !== typeMap[expectedType]) {
62
+ console.warn(`[framework] Prop "${key}" expected ${expectedType} but got ${actualType}`)
63
+ }
64
+ }
65
+ }
66
66
  }
67
67
 
68
- return { render, unmount, instance }
69
- }
68
+ return resolved
70
69
  }
71
70
 
72
- // ── Template resolver ─────────────────────────────────────────────────────────
73
- // Replaces {{ expr }} with evaluated values from the component result object.
74
- function resolveTemplate(template, context, components) {
75
- return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
76
- if (key in components) {
77
- // Nested component: {{ Counter }} → render component placeholder
78
- return `<div data-component="${key}"></div>`
79
- }
80
- const val = context[key]
81
- if (typeof val === 'function') {
82
- // Could be a signal — call it to get value
83
- try { return val() } catch { return '' }
84
- }
85
- return val ?? ''
86
- })
71
+ export function onMount(fn) {
72
+ if (currentInstance) currentInstance._mountHooks.push(fn)
87
73
  }
88
74
 
89
- // ── Event binding ─────────────────────────────────────────────────────────────
90
- // Finds all elements with @event attributes and attaches listeners.
91
- // Re-runs after every template re-render (innerHTML wipe clears old listeners).
92
- function bindEvents(container, context) {
93
- const all = container.querySelectorAll('*')
94
- for (const el of all) {
95
- for (const attr of [...el.attributes]) {
96
- if (attr.name.startsWith('@')) {
97
- const eventName = attr.name.slice(1) // @click → click
98
- const handlerName = attr.value // "increment"
99
- const handler = context[handlerName]
100
- if (typeof handler === 'function') {
101
- el.removeAttribute(attr.name)
102
- el.addEventListener(eventName, handler)
103
- }
104
- }
105
- }
106
- }
75
+ export function onUnmount(fn) {
76
+ if (currentInstance) currentInstance._unmountHooks.push(fn)
107
77
  }
108
78
 
109
- // ── Nested component mounting ─────────────────────────────────────────────────
110
- // Finds data-component placeholders and mounts child components inside them.
111
- function bindComponents(container, components) {
112
- const placeholders = container.querySelectorAll('[data-component]')
113
- for (const placeholder of placeholders) {
114
- const name = placeholder.dataset.component
115
- const ComponentFn = components[name]
116
- if (ComponentFn) {
117
- // Read props from data-prop-* attributes if present
118
- const props = {}
119
- for (const attr of [...placeholder.attributes]) {
120
- if (attr.name.startsWith('data-prop-')) {
121
- props[attr.name.slice(10)] = attr.value
79
+ export function defineComponent(options) {
80
+ return function ComponentFactory(props = {}) {
81
+ const instance = {
82
+ props: options.props ? resolveProps(options.props, props) : props,
83
+ _mountHooks: [],
84
+ _unmountHooks: [],
85
+ _effects: [],
86
+ _element: null,
122
87
  }
123
- }
124
- const child = ComponentFn(props)
125
- child.render(placeholder)
88
+
89
+ currentInstance = instance
90
+ const result = options.setup(instance.props)
91
+ currentInstance = null
92
+
93
+ function render(container) {
94
+ instance._element = container
95
+
96
+ const { effects } = mountTemplate(
97
+ result.template,
98
+ container,
99
+ result,
100
+ options.components || {}
101
+ )
102
+
103
+ instance._effects.push(...effects)
104
+ for (const hook of instance._mountHooks) hook()
105
+
106
+ return instance
107
+ }
108
+
109
+ function unmount() {
110
+ for (const hook of instance._unmountHooks) hook()
111
+ for (const e of instance._effects) {
112
+ if (typeof e === 'function') e()
113
+ }
114
+ if (instance._element) instance._element.innerHTML = ''
115
+ }
116
+
117
+ return { render, unmount, instance }
126
118
  }
127
- }
128
- }
119
+ }