decantr 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/cli/commands/build.js +17 -0
- package/cli/commands/dev.js +18 -0
- package/cli/commands/init.js +247 -0
- package/cli/commands/test.js +38 -0
- package/cli/index.js +38 -0
- package/package.json +55 -0
- package/src/core/index.js +176 -0
- package/src/core/lifecycle.js +36 -0
- package/src/css/atoms.js +159 -0
- package/src/css/index.js +40 -0
- package/src/css/runtime.js +47 -0
- package/src/router/hash.js +17 -0
- package/src/router/history.js +18 -0
- package/src/router/index.js +121 -0
- package/src/state/index.js +152 -0
- package/src/state/scheduler.js +63 -0
- package/src/test/dom.js +275 -0
- package/src/test/index.js +61 -0
- package/tools/builder.js +224 -0
- package/tools/css-extract.js +41 -0
- package/tools/dev-server.js +161 -0
- package/tools/minify.js +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Aimi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# decantr
|
|
2
|
+
|
|
3
|
+
AI-first web framework. Zero dependencies. Native JS/CSS/HTML.
|
|
4
|
+
|
|
5
|
+
Decantr is designed for LLMs to generate, read, and maintain — not for human readability. Every project follows predictable patterns with machine-readable manifests so AI agents can understand and modify code without parsing source files.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx decantr init
|
|
11
|
+
npm install
|
|
12
|
+
npx decantr dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Zero dependencies** — Pure JavaScript, CSS, HTML
|
|
18
|
+
- **Signal-based reactivity** — Fine-grained DOM updates, no virtual DOM
|
|
19
|
+
- **Direct DOM rendering** — `h()` creates real elements, no diffing overhead
|
|
20
|
+
- **Atomic CSS** — Terse, auto-generated utility classes (`p4`, `bg1`, `flex`)
|
|
21
|
+
- **Dual router** — Hash or History API, user chooses at init
|
|
22
|
+
- **Built-in test runner** — Wraps `node:test` with DOM testing helpers
|
|
23
|
+
- **AI manifests** — `.decantr/` directory with JSON schemas for LLM consumption
|
|
24
|
+
- **< 2KB gzipped** — Hello world JS runtime under 2KB gzipped
|
|
25
|
+
|
|
26
|
+
## Architecture
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
decantr/core — h(), text(), cond(), list(), mount()
|
|
30
|
+
decantr/state — createSignal, createEffect, createMemo, createStore, batch
|
|
31
|
+
decantr/router — createRouter, link, navigate, useRoute
|
|
32
|
+
decantr/css — css(), define()
|
|
33
|
+
decantr/test — render, fire, flush + node:test re-exports
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## CLI Commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
decantr init # Scaffold a new project
|
|
40
|
+
decantr dev # Start dev server with hot reload
|
|
41
|
+
decantr build # Production build
|
|
42
|
+
decantr test # Run tests
|
|
43
|
+
decantr test --watch # Run tests in watch mode
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Component Pattern
|
|
47
|
+
|
|
48
|
+
Every component is a function that returns an HTMLElement:
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
import { h, text } from 'decantr/core';
|
|
52
|
+
import { createSignal } from 'decantr/state';
|
|
53
|
+
|
|
54
|
+
export function Counter({ initial = 0 } = {}) {
|
|
55
|
+
const [count, setCount] = createSignal(initial);
|
|
56
|
+
return h('div', { class: 'flex gap2 p4' },
|
|
57
|
+
h('button', { onclick: () => setCount(c => c - 1) }, '-'),
|
|
58
|
+
h('span', null, text(() => String(count()))),
|
|
59
|
+
h('button', { onclick: () => setCount(c => c + 1) }, '+')
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
- Node.js >= 22.0.0
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function run() {
|
|
5
|
+
const cwd = process.cwd();
|
|
6
|
+
let config = { build: { outDir: 'dist', inline: false, sourcemap: false } };
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const raw = await readFile(join(cwd, 'decantr.config.json'), 'utf-8');
|
|
10
|
+
config = JSON.parse(raw);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
// Use defaults
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { build } = await import('../../tools/builder.js');
|
|
16
|
+
await build(cwd, config.build || {});
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function run() {
|
|
5
|
+
const cwd = process.cwd();
|
|
6
|
+
let config = { dev: { port: 3000 } };
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const raw = await readFile(join(cwd, 'decantr.config.json'), 'utf-8');
|
|
10
|
+
config = JSON.parse(raw);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
// Use defaults
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const port = config.dev?.port || 3000;
|
|
16
|
+
const { startDevServer } = await import('../../tools/dev-server.js');
|
|
17
|
+
startDevServer(cwd, port);
|
|
18
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin, stdout } from 'node:process';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const templates = {
|
|
7
|
+
packageJson: (name) => JSON.stringify({
|
|
8
|
+
name,
|
|
9
|
+
version: '0.1.0',
|
|
10
|
+
type: 'module',
|
|
11
|
+
scripts: {
|
|
12
|
+
dev: 'decantr dev',
|
|
13
|
+
build: 'decantr build',
|
|
14
|
+
test: 'decantr test'
|
|
15
|
+
},
|
|
16
|
+
dependencies: {
|
|
17
|
+
decantr: '^0.1.0'
|
|
18
|
+
}
|
|
19
|
+
}, null, 2),
|
|
20
|
+
|
|
21
|
+
config: (name, routerMode, port) => JSON.stringify({
|
|
22
|
+
$schema: 'https://decantr.ai/schemas/config.v1.json',
|
|
23
|
+
name,
|
|
24
|
+
router: routerMode,
|
|
25
|
+
dev: { port },
|
|
26
|
+
build: { outDir: 'dist', inline: false, sourcemap: false }
|
|
27
|
+
}, null, 2),
|
|
28
|
+
|
|
29
|
+
indexHtml: (name) => `<!DOCTYPE html>
|
|
30
|
+
<html lang="en">
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="UTF-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
34
|
+
<title>${name}</title>
|
|
35
|
+
<style>:root{--c0:#111;--c1:#2563eb;--c2:#f8fafc;--c3:#e2e8f0;--c4:#94a3b8;--c5:#475569;--c6:#1e293b;--c7:#f59e0b;--c8:#10b981;--c9:#ef4444}*{margin:0;box-sizing:border-box}body{font-family:system-ui,-apple-system,sans-serif;color:var(--c0);background:var(--c2)}</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<div id="app"></div>
|
|
39
|
+
<script type="module" src="/src/app.js"></script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>`,
|
|
42
|
+
|
|
43
|
+
appJs: (routerMode, includeCounter) => `import { h, mount } from 'decantr/core';
|
|
44
|
+
import { createRouter, link } from 'decantr/router';
|
|
45
|
+
import { Home } from './pages/home.js';
|
|
46
|
+
import { About } from './pages/about.js';
|
|
47
|
+
|
|
48
|
+
const router = createRouter({
|
|
49
|
+
mode: '${routerMode}',
|
|
50
|
+
routes: [
|
|
51
|
+
{ path: '/', component: Home },
|
|
52
|
+
{ path: '/about', component: About }
|
|
53
|
+
]
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function App() {
|
|
57
|
+
return h('div', null,
|
|
58
|
+
h('nav', { class: 'flex gap4 p4 bg1' },
|
|
59
|
+
link({ href: '/', class: 'fg2 nounder' }, 'Home'),
|
|
60
|
+
link({ href: '/about', class: 'fg2 nounder' }, 'About')
|
|
61
|
+
),
|
|
62
|
+
router.outlet()
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mount(document.getElementById('app'), App);
|
|
67
|
+
`,
|
|
68
|
+
|
|
69
|
+
homeJs: (includeCounter) => `import { h } from 'decantr/core';
|
|
70
|
+
${includeCounter ? "import { Counter } from '../components/counter.js';\n" : ''}
|
|
71
|
+
/** @returns {HTMLElement} */
|
|
72
|
+
export function Home() {
|
|
73
|
+
return h('main', { class: 'p8' },
|
|
74
|
+
h('h1', { class: 't32 bold' }, 'Welcome to Decantr'),
|
|
75
|
+
h('p', { class: 'py4 fg5' }, 'AI-first web framework. Zero dependencies.')${includeCounter ? ',\n h(\'div\', { class: \'py4\' }, Counter({ initial: 0 }))' : ''}
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
`,
|
|
79
|
+
|
|
80
|
+
aboutJs: () => `import { h } from 'decantr/core';
|
|
81
|
+
|
|
82
|
+
/** @returns {HTMLElement} */
|
|
83
|
+
export function About() {
|
|
84
|
+
return h('main', { class: 'p8' },
|
|
85
|
+
h('h1', { class: 't32 bold' }, 'About'),
|
|
86
|
+
h('p', { class: 'py4 fg5' }, 'Built with Decantr — the AI-first framework.')
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
`,
|
|
90
|
+
|
|
91
|
+
counterJs: () => `import { h, text } from 'decantr/core';
|
|
92
|
+
import { createSignal } from 'decantr/state';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {{ initial?: number }} props
|
|
96
|
+
* @returns {HTMLElement}
|
|
97
|
+
*/
|
|
98
|
+
export function Counter({ initial = 0 } = {}) {
|
|
99
|
+
const [count, setCount] = createSignal(initial);
|
|
100
|
+
|
|
101
|
+
return h('div', { class: 'flex gap2 p4 aic' },
|
|
102
|
+
h('button', { onclick: () => setCount(c => c - 1), class: 'p2 px4 r2 bg1 fg2 pointer b0 t16' }, '-'),
|
|
103
|
+
h('span', { class: 'p2 t20 bold' }, text(() => String(count()))),
|
|
104
|
+
h('button', { onclick: () => setCount(c => c + 1), class: 'p2 px4 r2 bg1 fg2 pointer b0 t16' }, '+')
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
`,
|
|
108
|
+
|
|
109
|
+
counterTestJs: () => `import { describe, it, assert, render, fire, flush } from 'decantr/test';
|
|
110
|
+
import { Counter } from '../src/components/counter.js';
|
|
111
|
+
|
|
112
|
+
describe('Counter', () => {
|
|
113
|
+
it('renders with initial value', () => {
|
|
114
|
+
const { container } = render(() => Counter({ initial: 5 }));
|
|
115
|
+
assert.ok(container.textContent.includes('5'));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('increments on + click', async () => {
|
|
119
|
+
const { container, getByText } = render(() => Counter({ initial: 0 }));
|
|
120
|
+
fire(getByText('+'), 'click');
|
|
121
|
+
await flush();
|
|
122
|
+
assert.ok(container.textContent.includes('1'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('decrements on - click', async () => {
|
|
126
|
+
const { container, getByText } = render(() => Counter({ initial: 5 }));
|
|
127
|
+
fire(getByText('-'), 'click');
|
|
128
|
+
await flush();
|
|
129
|
+
assert.ok(container.textContent.includes('4'));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
`,
|
|
133
|
+
|
|
134
|
+
manifest: (name, routerMode) => JSON.stringify({
|
|
135
|
+
$schema: 'https://decantr.ai/schemas/manifest.v1.json',
|
|
136
|
+
version: '0.1.0',
|
|
137
|
+
name,
|
|
138
|
+
router: routerMode,
|
|
139
|
+
entrypoint: 'src/app.js',
|
|
140
|
+
shell: 'public/index.html',
|
|
141
|
+
mountTarget: '#app',
|
|
142
|
+
components: '.decantr/components.json',
|
|
143
|
+
routes: '.decantr/routes.json',
|
|
144
|
+
state: '.decantr/state.json'
|
|
145
|
+
}, null, 2),
|
|
146
|
+
|
|
147
|
+
components: (includeCounter) => JSON.stringify({
|
|
148
|
+
$schema: 'https://decantr.ai/schemas/components.v1.json',
|
|
149
|
+
components: includeCounter ? [{
|
|
150
|
+
id: 'counter',
|
|
151
|
+
file: 'src/components/counter.js',
|
|
152
|
+
exportName: 'Counter',
|
|
153
|
+
description: 'Increment/decrement counter with display',
|
|
154
|
+
props: [{ name: 'initial', type: 'number', default: 0, required: false }],
|
|
155
|
+
signals: ['count'],
|
|
156
|
+
effects: [],
|
|
157
|
+
children: false
|
|
158
|
+
}] : []
|
|
159
|
+
}, null, 2),
|
|
160
|
+
|
|
161
|
+
routes: (routerMode) => JSON.stringify({
|
|
162
|
+
$schema: 'https://decantr.ai/schemas/routes.v1.json',
|
|
163
|
+
mode: routerMode,
|
|
164
|
+
routes: [
|
|
165
|
+
{ path: '/', component: 'home', file: 'src/pages/home.js', exportName: 'Home', title: 'Home' },
|
|
166
|
+
{ path: '/about', component: 'about', file: 'src/pages/about.js', exportName: 'About', title: 'About' }
|
|
167
|
+
]
|
|
168
|
+
}, null, 2),
|
|
169
|
+
|
|
170
|
+
state: (includeCounter) => JSON.stringify({
|
|
171
|
+
$schema: 'https://decantr.ai/schemas/state.v1.json',
|
|
172
|
+
signals: includeCounter ? [{
|
|
173
|
+
id: 'count',
|
|
174
|
+
file: 'src/components/counter.js',
|
|
175
|
+
type: 'number',
|
|
176
|
+
initial: 0,
|
|
177
|
+
usedBy: ['Counter']
|
|
178
|
+
}] : [],
|
|
179
|
+
effects: [],
|
|
180
|
+
memos: []
|
|
181
|
+
}, null, 2)
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
async function ask(rl, question, defaultVal) {
|
|
185
|
+
const answer = await rl.question(`${question} (${defaultVal}): `);
|
|
186
|
+
return answer.trim() || defaultVal;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function askChoice(rl, question, options, defaultVal) {
|
|
190
|
+
console.log(`\n${question}`);
|
|
191
|
+
options.forEach((opt, i) => console.log(` ${i + 1}) ${opt}`));
|
|
192
|
+
const answer = await rl.question(`Choose [${defaultVal}]: `);
|
|
193
|
+
const idx = parseInt(answer.trim()) - 1;
|
|
194
|
+
return (idx >= 0 && idx < options.length) ? options[idx] : defaultVal;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function run() {
|
|
198
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
199
|
+
const cwd = process.cwd();
|
|
200
|
+
|
|
201
|
+
console.log('\n decantr init — Create a new project\n');
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const name = await ask(rl, 'Project name?', basename(cwd));
|
|
205
|
+
const routerMode = await askChoice(rl, 'Router mode?', ['history', 'hash'], 'history');
|
|
206
|
+
const includeCounterAnswer = await askChoice(rl, 'Include example counter component?', ['yes', 'no'], 'yes');
|
|
207
|
+
const includeCounter = includeCounterAnswer === 'yes';
|
|
208
|
+
const port = parseInt(await ask(rl, 'Dev server port?', '3000'));
|
|
209
|
+
|
|
210
|
+
// Create directories
|
|
211
|
+
const dirs = ['src/pages', 'src/components', 'public', '.decantr', 'test'];
|
|
212
|
+
for (const dir of dirs) {
|
|
213
|
+
await mkdir(join(cwd, dir), { recursive: true });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Write files
|
|
217
|
+
const files = [
|
|
218
|
+
['package.json', templates.packageJson(name)],
|
|
219
|
+
['decantr.config.json', templates.config(name, routerMode, port)],
|
|
220
|
+
['public/index.html', templates.indexHtml(name)],
|
|
221
|
+
['src/app.js', templates.appJs(routerMode, includeCounter)],
|
|
222
|
+
['src/pages/home.js', templates.homeJs(includeCounter)],
|
|
223
|
+
['src/pages/about.js', templates.aboutJs()],
|
|
224
|
+
['.decantr/manifest.json', templates.manifest(name, routerMode)],
|
|
225
|
+
['.decantr/components.json', templates.components(includeCounter)],
|
|
226
|
+
['.decantr/routes.json', templates.routes(routerMode)],
|
|
227
|
+
['.decantr/state.json', templates.state(includeCounter)]
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
if (includeCounter) {
|
|
231
|
+
files.push(['src/components/counter.js', templates.counterJs()]);
|
|
232
|
+
files.push(['test/counter.test.js', templates.counterTestJs()]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const [path, content] of files) {
|
|
236
|
+
await writeFile(join(cwd, path), content + '\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
console.log(`\n Project "${name}" created successfully!\n`);
|
|
240
|
+
console.log(' Next steps:');
|
|
241
|
+
console.log(' npm install');
|
|
242
|
+
console.log(' npx decantr dev\n');
|
|
243
|
+
|
|
244
|
+
} finally {
|
|
245
|
+
rl.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { glob } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function run() {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
const args = process.argv.slice(3);
|
|
8
|
+
const watch = args.includes('--watch');
|
|
9
|
+
|
|
10
|
+
const testArgs = ['--test'];
|
|
11
|
+
if (watch) testArgs.push('--watch');
|
|
12
|
+
|
|
13
|
+
// Find test files
|
|
14
|
+
const testFiles = [];
|
|
15
|
+
const pattern = join(cwd, 'test', '**', '*.test.js');
|
|
16
|
+
|
|
17
|
+
// Use node:fs glob (Node 22+)
|
|
18
|
+
const { glob: fsGlob } = await import('node:fs/promises');
|
|
19
|
+
for await (const file of fsGlob(pattern)) {
|
|
20
|
+
testFiles.push(file);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (testFiles.length === 0) {
|
|
24
|
+
console.log('No test files found in test/**/*.test.js');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
testArgs.push(...testFiles);
|
|
29
|
+
|
|
30
|
+
const child = spawn('node', testArgs, {
|
|
31
|
+
cwd,
|
|
32
|
+
stdio: 'inherit'
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
child.on('exit', (code) => {
|
|
36
|
+
process.exit(code || 0);
|
|
37
|
+
});
|
|
38
|
+
}
|
package/cli/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
|
|
5
|
+
const { positionals } = parseArgs({ allowPositionals: true, strict: false });
|
|
6
|
+
const command = positionals[0];
|
|
7
|
+
|
|
8
|
+
switch (command) {
|
|
9
|
+
case 'init':
|
|
10
|
+
await import('./commands/init.js').then(m => m.run());
|
|
11
|
+
break;
|
|
12
|
+
case 'dev':
|
|
13
|
+
await import('./commands/dev.js').then(m => m.run());
|
|
14
|
+
break;
|
|
15
|
+
case 'build':
|
|
16
|
+
await import('./commands/build.js').then(m => m.run());
|
|
17
|
+
break;
|
|
18
|
+
case 'test':
|
|
19
|
+
await import('./commands/test.js').then(m => m.run());
|
|
20
|
+
break;
|
|
21
|
+
default:
|
|
22
|
+
console.log(`
|
|
23
|
+
decantr v0.1.0 — AI-first web framework
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
init Create a new decantr project
|
|
27
|
+
dev Start development server
|
|
28
|
+
build Build for production
|
|
29
|
+
test Run tests
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
npx decantr init
|
|
33
|
+
npx decantr dev
|
|
34
|
+
npx decantr build
|
|
35
|
+
npx decantr test [--watch]
|
|
36
|
+
`);
|
|
37
|
+
break;
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "decantr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-first web framework. Zero dependencies. Native JS/CSS/HTML.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.0.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"decantr": "./cli/index.js"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./src/core/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./core": {
|
|
17
|
+
"import": "./src/core/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./state": {
|
|
20
|
+
"import": "./src/state/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./router": {
|
|
23
|
+
"import": "./src/router/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./css": {
|
|
26
|
+
"import": "./src/css/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./test": {
|
|
29
|
+
"import": "./src/test/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src/",
|
|
34
|
+
"cli/",
|
|
35
|
+
"tools/"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test test/**/*.test.js"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"framework",
|
|
42
|
+
"ai",
|
|
43
|
+
"llm",
|
|
44
|
+
"web",
|
|
45
|
+
"signals",
|
|
46
|
+
"zero-dependency"
|
|
47
|
+
],
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"author": "David Aimi",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/david-aimi/decantr"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://decantr.ai"
|
|
55
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { createEffect } from '../state/index.js';
|
|
2
|
+
export { onMount, onDestroy } from './lifecycle.js';
|
|
3
|
+
import { drainMountQueue, drainDestroyQueue } from './lifecycle.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} tag
|
|
7
|
+
* @param {Object|null} props
|
|
8
|
+
* @param {...(string|number|Node|Function)} children
|
|
9
|
+
* @returns {HTMLElement}
|
|
10
|
+
*/
|
|
11
|
+
export function h(tag, props, ...children) {
|
|
12
|
+
const el = document.createElement(tag);
|
|
13
|
+
|
|
14
|
+
if (props) {
|
|
15
|
+
for (const key in props) {
|
|
16
|
+
const val = props[key];
|
|
17
|
+
if (key.startsWith('on') && typeof val === 'function') {
|
|
18
|
+
el.addEventListener(key.slice(2).toLowerCase(), val);
|
|
19
|
+
} else if (typeof val === 'function' && key !== 'ref') {
|
|
20
|
+
createEffect(() => {
|
|
21
|
+
const v = val();
|
|
22
|
+
if (key === 'class' || key === 'className') {
|
|
23
|
+
el.className = v;
|
|
24
|
+
} else if (key === 'style' && typeof v === 'object') {
|
|
25
|
+
Object.assign(el.style, v);
|
|
26
|
+
} else {
|
|
27
|
+
el.setAttribute(key, v);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
} else if (key === 'ref' && typeof val === 'function') {
|
|
31
|
+
val(el);
|
|
32
|
+
} else if (key === 'class' || key === 'className') {
|
|
33
|
+
el.className = val;
|
|
34
|
+
} else if (key === 'style' && typeof val === 'object') {
|
|
35
|
+
Object.assign(el.style, val);
|
|
36
|
+
} else if (val !== false && val != null) {
|
|
37
|
+
el.setAttribute(key, val === true ? '' : String(val));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
appendChildren(el, children);
|
|
43
|
+
return el;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {Function} getter
|
|
48
|
+
* @returns {Text}
|
|
49
|
+
*/
|
|
50
|
+
export function text(getter) {
|
|
51
|
+
const node = document.createTextNode('');
|
|
52
|
+
createEffect(() => {
|
|
53
|
+
node.nodeValue = String(getter());
|
|
54
|
+
});
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Function} condition
|
|
60
|
+
* @param {Function} thenFn
|
|
61
|
+
* @param {Function} [elseFn]
|
|
62
|
+
* @returns {HTMLElement}
|
|
63
|
+
*/
|
|
64
|
+
export function cond(condition, thenFn, elseFn) {
|
|
65
|
+
const container = document.createElement('d-cond');
|
|
66
|
+
let currentNode = null;
|
|
67
|
+
|
|
68
|
+
createEffect(() => {
|
|
69
|
+
const result = condition();
|
|
70
|
+
if (currentNode) {
|
|
71
|
+
container.removeChild(currentNode);
|
|
72
|
+
currentNode = null;
|
|
73
|
+
}
|
|
74
|
+
const fn = result ? thenFn : elseFn;
|
|
75
|
+
if (fn) {
|
|
76
|
+
currentNode = fn();
|
|
77
|
+
if (currentNode) container.appendChild(currentNode);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return container;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {Function} itemsGetter
|
|
86
|
+
* @param {Function} keyFn
|
|
87
|
+
* @param {Function} renderFn
|
|
88
|
+
* @returns {HTMLElement}
|
|
89
|
+
*/
|
|
90
|
+
export function list(itemsGetter, keyFn, renderFn) {
|
|
91
|
+
const container = document.createElement('d-list');
|
|
92
|
+
/** @type {Map<*, {node: Node}>} */
|
|
93
|
+
let currentMap = new Map();
|
|
94
|
+
|
|
95
|
+
createEffect(() => {
|
|
96
|
+
const items = itemsGetter();
|
|
97
|
+
const newMap = new Map();
|
|
98
|
+
const newNodes = [];
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < items.length; i++) {
|
|
101
|
+
const item = items[i];
|
|
102
|
+
const key = keyFn(item, i);
|
|
103
|
+
const existing = currentMap.get(key);
|
|
104
|
+
|
|
105
|
+
if (existing) {
|
|
106
|
+
newMap.set(key, existing);
|
|
107
|
+
newNodes.push(existing.node);
|
|
108
|
+
} else {
|
|
109
|
+
const node = renderFn(item, i);
|
|
110
|
+
newMap.set(key, { node });
|
|
111
|
+
newNodes.push(node);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Remove nodes no longer in list
|
|
116
|
+
for (const [key, entry] of currentMap) {
|
|
117
|
+
if (!newMap.has(key) && entry.node.parentNode === container) {
|
|
118
|
+
container.removeChild(entry.node);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Append/reorder
|
|
123
|
+
for (let i = 0; i < newNodes.length; i++) {
|
|
124
|
+
const node = newNodes[i];
|
|
125
|
+
const current = container.childNodes[i];
|
|
126
|
+
if (node !== current) {
|
|
127
|
+
container.insertBefore(node, current || null);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
currentMap = newMap;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return container;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {HTMLElement} root
|
|
139
|
+
* @param {Function} component
|
|
140
|
+
*/
|
|
141
|
+
export function mount(root, component) {
|
|
142
|
+
const result = component();
|
|
143
|
+
if (result) root.appendChild(result);
|
|
144
|
+
// Flush mount queue
|
|
145
|
+
const fns = drainMountQueue();
|
|
146
|
+
for (const fn of fns) {
|
|
147
|
+
const cleanup = fn();
|
|
148
|
+
if (typeof cleanup === 'function') {
|
|
149
|
+
// store cleanup for later
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {HTMLElement} el
|
|
156
|
+
* @param {Array} children
|
|
157
|
+
*/
|
|
158
|
+
function appendChildren(el, children) {
|
|
159
|
+
for (let i = 0; i < children.length; i++) {
|
|
160
|
+
const child = children[i];
|
|
161
|
+
if (child == null || child === false) continue;
|
|
162
|
+
if (Array.isArray(child)) {
|
|
163
|
+
appendChildren(el, child);
|
|
164
|
+
} else if (child && typeof child === 'object' && child.nodeType) {
|
|
165
|
+
el.appendChild(child);
|
|
166
|
+
} else if (typeof child === 'function') {
|
|
167
|
+
const textNode = document.createTextNode('');
|
|
168
|
+
createEffect(() => {
|
|
169
|
+
textNode.nodeValue = String(child());
|
|
170
|
+
});
|
|
171
|
+
el.appendChild(textNode);
|
|
172
|
+
} else {
|
|
173
|
+
el.appendChild(document.createTextNode(String(child)));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|