fusee-framework 0.1.0-alpha.1 β 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -8
- package/app/components/Counter.js +111 -34
- package/app/components/Counter.ts +99 -0
- package/app/components/CounterSection.js +34 -0
- package/app/components/CounterSection.ts +45 -0
- package/app/components/UserForm.js +31 -0
- package/app/components/UserForm.ts +38 -0
- package/app/main.js +2 -2
- package/app/main.ts +15 -0
- package/app/routes/About.js +17 -17
- package/app/routes/About.ts +29 -0
- package/app/routes/Home.js +22 -17
- package/app/routes/Home.ts +34 -0
- package/framework/bin/create-fusee.js +147 -0
- package/framework/core/compiler.js +214 -0
- package/framework/core/component.js +98 -107
- package/framework/core/directives.js +335 -0
- package/framework/core/evaluator.js +67 -0
- package/framework/core/signal.js +125 -40
- package/framework/index.d.ts +3 -0
- package/framework/index.js +1 -1
- package/framework/router/router.js +52 -43
- package/framework/types/compiler.d.ts +25 -0
- package/framework/types/component.d.ts +44 -0
- package/framework/types/directives.d.ts +32 -0
- package/framework/types/evaluator.d.ts +15 -0
- package/framework/types/index.d.ts +9 -0
- package/framework/types/router.d.ts +17 -0
- package/framework/types/signal.d.ts +30 -0
- package/index.html +75 -8
- package/package.json +14 -3
- package/tsconfig.json +38 -0
- package/vite.config.js +35 -0
|
@@ -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
|
-
|
|
2
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
69
|
-
}
|
|
68
|
+
return resolved
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
+
}
|