create-magnetic-app 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/README.md +75 -0
- package/bin/create.js +57 -0
- package/package.json +20 -0
- package/src/index.js +969 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# create-magnetic-app
|
|
2
|
+
|
|
3
|
+
Scaffold a new Magnetic server-driven UI application.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx create-magnetic-app my-app
|
|
9
|
+
cd my-app
|
|
10
|
+
magnetic dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Templates
|
|
14
|
+
|
|
15
|
+
### `todo` (default)
|
|
16
|
+
|
|
17
|
+
Full todo app with state management, multiple pages, and components:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx create-magnetic-app my-app --template todo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### `blank`
|
|
24
|
+
|
|
25
|
+
Minimal app with a single page:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx create-magnetic-app my-app --template blank
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Generated Structure
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
my-app/
|
|
35
|
+
pages/ ← TSX page components (filename = route)
|
|
36
|
+
IndexPage.tsx → /
|
|
37
|
+
AboutPage.tsx → /about
|
|
38
|
+
NotFoundPage.tsx → * (catch-all)
|
|
39
|
+
components/ ← shared TSX components
|
|
40
|
+
server/
|
|
41
|
+
state.ts ← business logic (state, reducer, view model)
|
|
42
|
+
public/
|
|
43
|
+
magnetic.js ← client runtime (~1.5KB gzipped)
|
|
44
|
+
style.css ← app styles
|
|
45
|
+
magnetic.json ← app config
|
|
46
|
+
tsconfig.json ← IDE/TypeScript support
|
|
47
|
+
README.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## What You Edit
|
|
51
|
+
|
|
52
|
+
- `pages/*.tsx` — page components (auto-routed)
|
|
53
|
+
- `components/*.tsx` — shared components
|
|
54
|
+
- `server/state.ts` — all business logic
|
|
55
|
+
- `public/style.css` — styles
|
|
56
|
+
|
|
57
|
+
## What You Never Edit
|
|
58
|
+
|
|
59
|
+
- `public/magnetic.js` — client runtime (provided by framework)
|
|
60
|
+
- Generated bridge code (auto-generated by CLI)
|
|
61
|
+
- Bundle output (`dist/app.js`)
|
|
62
|
+
|
|
63
|
+
## For AI Agents
|
|
64
|
+
|
|
65
|
+
1. Scaffold: `npx create-magnetic-app <name>`
|
|
66
|
+
2. Edit only: `pages/`, `components/`, `server/state.ts`, `public/style.css`
|
|
67
|
+
3. Run: `magnetic dev` → opens at `http://localhost:3003`
|
|
68
|
+
4. Actions: `onClick="action_name"` dispatches to `reduce()` in `state.ts`
|
|
69
|
+
5. Forms: `onSubmit="action_name"` collects FormData as payload
|
|
70
|
+
6. Navigation: `<Link href="/path">` for client-side routing
|
|
71
|
+
7. State shape: `initialState()` → `reduce(state, action, payload)` → `toViewModel(state)` → passed as page props
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
package/bin/create.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* create-magnetic-app CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* create-magnetic-app <project-name> [--template todo|blank] [--dir <target-dir>]
|
|
7
|
+
*/
|
|
8
|
+
import { scaffold } from '../src/index.js';
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const name = args.find(a => !a.startsWith('--'));
|
|
14
|
+
|
|
15
|
+
if (!name || name === '--help' || name === '-h') {
|
|
16
|
+
console.log(`
|
|
17
|
+
create-magnetic-app — scaffold a new Magnetic project
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
create-magnetic-app <project-name> [options]
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--template <name> Template: "todo" (default) or "blank"
|
|
24
|
+
--dir <path> Target directory (default: current directory)
|
|
25
|
+
`);
|
|
26
|
+
process.exit(name ? 0 : 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dirFlag = args.indexOf('--dir');
|
|
30
|
+
const targetDir = dirFlag !== -1 && args[dirFlag + 1]
|
|
31
|
+
? resolve(args[dirFlag + 1], name)
|
|
32
|
+
: resolve(process.cwd(), name);
|
|
33
|
+
|
|
34
|
+
const tplFlag = args.indexOf('--template');
|
|
35
|
+
const template = tplFlag !== -1 && args[tplFlag + 1] ? args[tplFlag + 1] : 'todo';
|
|
36
|
+
|
|
37
|
+
// Try to find the built client runtime
|
|
38
|
+
const runtimePaths = [
|
|
39
|
+
resolve(import.meta.dirname, '../../../sdk-web-runtime/dist/magnetic.min.js'),
|
|
40
|
+
resolve(import.meta.dirname, '../../../../apps/task-board/public/magnetic.js'),
|
|
41
|
+
resolve(process.cwd(), 'js/packages/sdk-web-runtime/dist/magnetic.min.js'),
|
|
42
|
+
resolve(process.cwd(), 'apps/task-board/public/magnetic.js'),
|
|
43
|
+
];
|
|
44
|
+
let runtimeSrc = null;
|
|
45
|
+
for (const p of runtimePaths) {
|
|
46
|
+
if (existsSync(p)) { runtimeSrc = p; break; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const dir = scaffold(targetDir, { name, template, runtimeSrc });
|
|
50
|
+
console.log(`\n✓ Created Magnetic app: ${name}`);
|
|
51
|
+
console.log(` ${dir}\n`);
|
|
52
|
+
console.log(` Next steps:`);
|
|
53
|
+
console.log(` cd ${name}`);
|
|
54
|
+
console.log(` magnetic dev\n`);
|
|
55
|
+
if (!runtimeSrc) {
|
|
56
|
+
console.log(` ⚠ Client runtime (magnetic.js) not found — copy it to public/magnetic.js`);
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-magnetic-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a new Magnetic server-driven UI app",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-magnetic-app": "./bin/create.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": ["src", "bin"],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": { "type": "git", "url": "https://github.com/inventhq/magnetic.git", "directory": "js/packages/create-magnetic-app" },
|
|
17
|
+
"homepage": "https://github.com/inventhq/magnetic#readme",
|
|
18
|
+
"keywords": ["magnetic", "create-app", "scaffolder", "server-driven-ui"],
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-magnetic-app — scaffolds a new Magnetic project.
|
|
3
|
+
*
|
|
4
|
+
* Generates:
|
|
5
|
+
* <name>/
|
|
6
|
+
* ├── pages/
|
|
7
|
+
* │ ├── IndexPage.tsx (main page component)
|
|
8
|
+
* │ └── NotFoundPage.tsx (404 page)
|
|
9
|
+
* ├── components/
|
|
10
|
+
* │ └── (shared components)
|
|
11
|
+
* ├── server/
|
|
12
|
+
* │ └── state.ts (business logic — state, reducer, viewModel)
|
|
13
|
+
* ├── public/
|
|
14
|
+
* │ ├── magnetic.js (client runtime)
|
|
15
|
+
* │ └── style.css (app styles)
|
|
16
|
+
* ├── magnetic.json (project config)
|
|
17
|
+
* └── README.md
|
|
18
|
+
*
|
|
19
|
+
* Developer writes ONLY pages/components + server/state.ts.
|
|
20
|
+
* The @magnetic/cli handles bundling, bridge generation, and server management.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from 'fs';
|
|
23
|
+
import { join, resolve } from 'path';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} dest — target directory
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {string} opts.name — project name
|
|
29
|
+
* @param {string} [opts.runtimeSrc] — path to magnetic.js to copy
|
|
30
|
+
* @param {string} [opts.template] — template name: "blank" | "todo" (default: "todo")
|
|
31
|
+
* @param {object} [opts.files] — extra files to write: { relativePath: content }
|
|
32
|
+
*/
|
|
33
|
+
export function scaffold(dest, opts = {}) {
|
|
34
|
+
const name = opts.name || 'my-magnetic-app';
|
|
35
|
+
const template = opts.template || 'todo';
|
|
36
|
+
const dir = resolve(dest);
|
|
37
|
+
|
|
38
|
+
// Create directories
|
|
39
|
+
for (const d of ['pages', 'components', 'server', 'public']) {
|
|
40
|
+
mkdirSync(join(dir, d), { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// magnetic.json
|
|
44
|
+
writeFileSync(join(dir, 'magnetic.json'), JSON.stringify({
|
|
45
|
+
name,
|
|
46
|
+
server: 'http://localhost:3003',
|
|
47
|
+
}, null, 2) + '\n');
|
|
48
|
+
|
|
49
|
+
// tsconfig.json for IDE support
|
|
50
|
+
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
|
51
|
+
compilerOptions: {
|
|
52
|
+
target: 'ES2020',
|
|
53
|
+
module: 'ESNext',
|
|
54
|
+
moduleResolution: 'bundler',
|
|
55
|
+
jsx: 'react-jsx',
|
|
56
|
+
jsxImportSource: '@magneticjs/server',
|
|
57
|
+
strict: true,
|
|
58
|
+
noEmit: true,
|
|
59
|
+
allowImportingTsExtensions: true,
|
|
60
|
+
},
|
|
61
|
+
include: ['pages', 'components', 'server'],
|
|
62
|
+
}, null, 2) + '\n');
|
|
63
|
+
|
|
64
|
+
// Generate template files
|
|
65
|
+
if (template === 'todo') {
|
|
66
|
+
writeTodoTemplate(dir, name);
|
|
67
|
+
} else {
|
|
68
|
+
writeBlankTemplate(dir, name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// README + GUIDE
|
|
72
|
+
writeFileSync(join(dir, 'README.md'), readme(name));
|
|
73
|
+
writeFileSync(join(dir, 'GUIDE.md'), guide(name));
|
|
74
|
+
|
|
75
|
+
// Copy client runtime if available
|
|
76
|
+
if (opts.runtimeSrc && existsSync(opts.runtimeSrc)) {
|
|
77
|
+
copyFileSync(opts.runtimeSrc, join(dir, 'public/magnetic.js'));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Write any extra files
|
|
81
|
+
if (opts.files) {
|
|
82
|
+
for (const [rel, content] of Object.entries(opts.files)) {
|
|
83
|
+
const p = join(dir, rel);
|
|
84
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
85
|
+
writeFileSync(p, content);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return dir;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Todo template ──────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function writeTodoTemplate(dir, name) {
|
|
95
|
+
// server/state.ts — business logic
|
|
96
|
+
writeFileSync(join(dir, 'server/state.ts'), `// ${name} — Server-side state and reducer
|
|
97
|
+
// This is your business logic. The Magnetic platform runs this on the server.
|
|
98
|
+
// State is never exposed to the client — only the rendered UI (JSON DOM) is sent.
|
|
99
|
+
|
|
100
|
+
export interface Todo {
|
|
101
|
+
id: number;
|
|
102
|
+
title: string;
|
|
103
|
+
completed: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AppState {
|
|
107
|
+
todos: Todo[];
|
|
108
|
+
nextId: number;
|
|
109
|
+
filter: 'all' | 'active' | 'done';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function initialState(): AppState {
|
|
113
|
+
return {
|
|
114
|
+
todos: [],
|
|
115
|
+
nextId: 1,
|
|
116
|
+
filter: 'all',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function reduce(state: AppState, action: string, payload: any): AppState {
|
|
121
|
+
switch (action) {
|
|
122
|
+
case 'add_todo': {
|
|
123
|
+
const title = payload?.title?.trim();
|
|
124
|
+
if (!title) return state;
|
|
125
|
+
return {
|
|
126
|
+
...state,
|
|
127
|
+
todos: [...state.todos, { id: state.nextId, title, completed: false }],
|
|
128
|
+
nextId: state.nextId + 1,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'filter_all':
|
|
133
|
+
return { ...state, filter: 'all' };
|
|
134
|
+
case 'filter_active':
|
|
135
|
+
return { ...state, filter: 'active' };
|
|
136
|
+
case 'filter_done':
|
|
137
|
+
return { ...state, filter: 'done' };
|
|
138
|
+
|
|
139
|
+
case 'clear_completed':
|
|
140
|
+
return { ...state, todos: state.todos.filter(t => !t.completed) };
|
|
141
|
+
|
|
142
|
+
default: {
|
|
143
|
+
// Parameterized actions: toggle_1, delete_1
|
|
144
|
+
const toggleMatch = action.match(/^toggle_(\\d+)$/);
|
|
145
|
+
if (toggleMatch) {
|
|
146
|
+
const id = parseInt(toggleMatch[1], 10);
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
todos: state.todos.map(t =>
|
|
150
|
+
t.id === id ? { ...t, completed: !t.completed } : t
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const deleteMatch = action.match(/^delete_(\\d+)$/);
|
|
156
|
+
if (deleteMatch) {
|
|
157
|
+
const id = parseInt(deleteMatch[1], 10);
|
|
158
|
+
return { ...state, todos: state.todos.filter(t => t.id !== id) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return state;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function toViewModel(state: AppState) {
|
|
167
|
+
const visibleTodos = state.todos
|
|
168
|
+
.filter(t => {
|
|
169
|
+
if (state.filter === 'active') return !t.completed;
|
|
170
|
+
if (state.filter === 'done') return t.completed;
|
|
171
|
+
return true;
|
|
172
|
+
})
|
|
173
|
+
.map(t => ({
|
|
174
|
+
...t,
|
|
175
|
+
completedClass: t.completed ? 'completed' : '',
|
|
176
|
+
checkmark: t.completed ? '✓' : '○',
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
const active = state.todos.filter(t => !t.completed).length;
|
|
180
|
+
const done = state.todos.filter(t => t.completed).length;
|
|
181
|
+
const total = state.todos.length;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
...state,
|
|
185
|
+
visibleTodos,
|
|
186
|
+
activeCount: active,
|
|
187
|
+
doneCount: done,
|
|
188
|
+
totalCount: total,
|
|
189
|
+
summary: total === 0 ? 'No todos yet' : \`\${active} active, \${done} done\`,
|
|
190
|
+
filterAllClass: state.filter === 'all' ? 'active' : '',
|
|
191
|
+
filterActiveClass: state.filter === 'active' ? 'active' : '',
|
|
192
|
+
filterDoneClass: state.filter === 'done' ? 'active' : '',
|
|
193
|
+
isEmpty: visibleTodos.length === 0,
|
|
194
|
+
emptyMessage:
|
|
195
|
+
state.filter === 'active'
|
|
196
|
+
? 'All done! Nothing active.'
|
|
197
|
+
: state.filter === 'done'
|
|
198
|
+
? 'No completed todos yet.'
|
|
199
|
+
: 'Add your first todo above!',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
`);
|
|
203
|
+
|
|
204
|
+
// pages/IndexPage.tsx — main page
|
|
205
|
+
writeFileSync(join(dir, 'pages/IndexPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
|
|
206
|
+
import { TodoInput } from '../components/TodoInput.tsx';
|
|
207
|
+
import { TodoFilters } from '../components/TodoFilters.tsx';
|
|
208
|
+
import { TodoItem } from '../components/TodoItem.tsx';
|
|
209
|
+
|
|
210
|
+
export function IndexPage(props: any) {
|
|
211
|
+
return (
|
|
212
|
+
<div class="todo-app" key="app">
|
|
213
|
+
<Head>
|
|
214
|
+
<title>{\`\${props.summary} | ${name}\`}</title>
|
|
215
|
+
<meta name="description" content="A todo app built with Magnetic" />
|
|
216
|
+
</Head>
|
|
217
|
+
|
|
218
|
+
<nav class="topnav" key="nav">
|
|
219
|
+
<Link href="/" class="nav-link active">Todos</Link>
|
|
220
|
+
<Link href="/about" class="nav-link">About</Link>
|
|
221
|
+
</nav>
|
|
222
|
+
|
|
223
|
+
<div class="header" key="header">
|
|
224
|
+
<h1 key="title">${name}</h1>
|
|
225
|
+
<p class="subtitle" key="subtitle">{props.summary}</p>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<TodoInput />
|
|
229
|
+
|
|
230
|
+
<TodoFilters
|
|
231
|
+
allClass={props.filterAllClass}
|
|
232
|
+
activeClass={props.filterActiveClass}
|
|
233
|
+
doneClass={props.filterDoneClass}
|
|
234
|
+
/>
|
|
235
|
+
|
|
236
|
+
<div class="todo-list" key="todo-list">
|
|
237
|
+
{props.visibleTodos.map((todo: any) => (
|
|
238
|
+
<TodoItem todo={todo} />
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{props.isEmpty && <p class="empty" key="empty">{props.emptyMessage}</p>}
|
|
243
|
+
|
|
244
|
+
{props.doneCount > 0 && (
|
|
245
|
+
<div class="footer" key="footer">
|
|
246
|
+
<button class="clear-btn" onClick="clear_completed" key="clear">
|
|
247
|
+
Clear completed ({props.doneCount})
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
`);
|
|
255
|
+
|
|
256
|
+
// pages/AboutPage.tsx
|
|
257
|
+
writeFileSync(join(dir, 'pages/AboutPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
|
|
258
|
+
|
|
259
|
+
export function AboutPage(props: any) {
|
|
260
|
+
return (
|
|
261
|
+
<div class="about-page" key="about">
|
|
262
|
+
<Head>
|
|
263
|
+
<title>About | ${name}</title>
|
|
264
|
+
</Head>
|
|
265
|
+
|
|
266
|
+
<nav class="topnav" key="nav">
|
|
267
|
+
<Link href="/" class="nav-link">Todos</Link>
|
|
268
|
+
<Link href="/about" class="nav-link active">About</Link>
|
|
269
|
+
</nav>
|
|
270
|
+
|
|
271
|
+
<div class="content" key="content">
|
|
272
|
+
<h1>About</h1>
|
|
273
|
+
<p>This app was built with Magnetic — a server-driven UI framework.</p>
|
|
274
|
+
|
|
275
|
+
<h2>How it works</h2>
|
|
276
|
+
<ul>
|
|
277
|
+
<li>All state lives on the server (Rust + V8)</li>
|
|
278
|
+
<li>UI is rendered as JSON DOM descriptors on the server</li>
|
|
279
|
+
<li>The client is a thin rendering shell (~1.5KB)</li>
|
|
280
|
+
<li>Real-time updates via Server-Sent Events</li>
|
|
281
|
+
<li>Actions are sent to the server, which re-renders and pushes updates</li>
|
|
282
|
+
</ul>
|
|
283
|
+
|
|
284
|
+
<h2>What the developer writes</h2>
|
|
285
|
+
<ul>
|
|
286
|
+
<li>TSX page components (this page!)</li>
|
|
287
|
+
<li>Business logic in server/state.ts</li>
|
|
288
|
+
<li>That's it. No client-side JS, no state management, no build config.</li>
|
|
289
|
+
</ul>
|
|
290
|
+
|
|
291
|
+
<p><Link href="/">← Back to todos</Link></p>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
`);
|
|
297
|
+
|
|
298
|
+
// pages/NotFoundPage.tsx
|
|
299
|
+
writeFileSync(join(dir, 'pages/NotFoundPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
|
|
300
|
+
|
|
301
|
+
export function NotFoundPage(props: any) {
|
|
302
|
+
return (
|
|
303
|
+
<div class="not-found" key="404">
|
|
304
|
+
<Head><title>404 | ${name}</title></Head>
|
|
305
|
+
<h1>404</h1>
|
|
306
|
+
<p>Page not found</p>
|
|
307
|
+
<p><Link href="/">← Back home</Link></p>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
`);
|
|
312
|
+
|
|
313
|
+
// components/TodoInput.tsx
|
|
314
|
+
writeFileSync(join(dir, 'components/TodoInput.tsx'), `export function TodoInput() {
|
|
315
|
+
return (
|
|
316
|
+
<form class="add-form" onSubmit="add_todo" key="input">
|
|
317
|
+
<input
|
|
318
|
+
type="text"
|
|
319
|
+
name="title"
|
|
320
|
+
placeholder="What needs to be done?"
|
|
321
|
+
autocomplete="off"
|
|
322
|
+
autofocus
|
|
323
|
+
/>
|
|
324
|
+
<button type="submit">Add</button>
|
|
325
|
+
</form>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
`);
|
|
329
|
+
|
|
330
|
+
// components/TodoFilters.tsx
|
|
331
|
+
writeFileSync(join(dir, 'components/TodoFilters.tsx'), `export function TodoFilters(props: {
|
|
332
|
+
allClass: string;
|
|
333
|
+
activeClass: string;
|
|
334
|
+
doneClass: string;
|
|
335
|
+
}) {
|
|
336
|
+
return (
|
|
337
|
+
<div class="filters" key="filters">
|
|
338
|
+
<button class={\`filter-btn \${props.allClass}\`} onClick="filter_all" key="f-all">All</button>
|
|
339
|
+
<button class={\`filter-btn \${props.activeClass}\`} onClick="filter_active" key="f-active">Active</button>
|
|
340
|
+
<button class={\`filter-btn \${props.doneClass}\`} onClick="filter_done" key="f-done">Done</button>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
`);
|
|
345
|
+
|
|
346
|
+
// components/TodoItem.tsx
|
|
347
|
+
writeFileSync(join(dir, 'components/TodoItem.tsx'), `export function TodoItem(props: { todo: any }) {
|
|
348
|
+
const { todo } = props;
|
|
349
|
+
return (
|
|
350
|
+
<div class={\`todo-card \${todo.completedClass}\`} key={\`todo-\${todo.id}\`}>
|
|
351
|
+
<button class="check" onClick={\`toggle_\${todo.id}\`} key={\`chk-\${todo.id}\`}>
|
|
352
|
+
{todo.checkmark}
|
|
353
|
+
</button>
|
|
354
|
+
<span class="todo-title" key={\`title-\${todo.id}\`}>{todo.title}</span>
|
|
355
|
+
<button class="delete" onClick={\`delete_\${todo.id}\`} key={\`del-\${todo.id}\`}>
|
|
356
|
+
×
|
|
357
|
+
</button>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
`);
|
|
362
|
+
|
|
363
|
+
// public/style.css
|
|
364
|
+
writeFileSync(join(dir, 'public/style.css'), todoStyles(name));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── Blank template ─────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
function writeBlankTemplate(dir, name) {
|
|
370
|
+
writeFileSync(join(dir, 'server/state.ts'), `export interface AppState {}
|
|
371
|
+
|
|
372
|
+
export function initialState(): AppState {
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function reduce(state: AppState, action: string, payload: any): AppState {
|
|
377
|
+
return state;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function toViewModel(state: AppState) {
|
|
381
|
+
return { ...state };
|
|
382
|
+
}
|
|
383
|
+
`);
|
|
384
|
+
|
|
385
|
+
writeFileSync(join(dir, 'pages/IndexPage.tsx'), `import { Head } from '@magneticjs/server/jsx-runtime';
|
|
386
|
+
|
|
387
|
+
export function IndexPage(props: any) {
|
|
388
|
+
return (
|
|
389
|
+
<div class="app" key="app">
|
|
390
|
+
<Head><title>${name}</title></Head>
|
|
391
|
+
<h1 key="title">Welcome to ${name}</h1>
|
|
392
|
+
<p key="desc">Edit pages/IndexPage.tsx to get started.</p>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
`);
|
|
397
|
+
|
|
398
|
+
writeFileSync(join(dir, 'pages/NotFoundPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
|
|
399
|
+
|
|
400
|
+
export function NotFoundPage(props: any) {
|
|
401
|
+
return (
|
|
402
|
+
<div class="not-found" key="404">
|
|
403
|
+
<Head><title>404 | ${name}</title></Head>
|
|
404
|
+
<h1>404</h1>
|
|
405
|
+
<p>Page not found</p>
|
|
406
|
+
<p><Link href="/">← Back home</Link></p>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
`);
|
|
411
|
+
|
|
412
|
+
writeFileSync(join(dir, 'public/style.css'), `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
413
|
+
body {
|
|
414
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
415
|
+
background: #0a0a0a; color: #e4e4e7;
|
|
416
|
+
display: flex; justify-content: center;
|
|
417
|
+
min-height: 100vh; padding: 2rem;
|
|
418
|
+
}
|
|
419
|
+
.app {
|
|
420
|
+
max-width: 520px; width: 100%;
|
|
421
|
+
background: #141414; border: 1px solid #252525; border-radius: 16px;
|
|
422
|
+
padding: 2rem; text-align: center;
|
|
423
|
+
}
|
|
424
|
+
h1 { font-size: 1.75rem; color: #fff; margin-bottom: .5rem; }
|
|
425
|
+
p { color: #a1a1aa; }
|
|
426
|
+
.not-found { text-align: center; padding: 3rem; }
|
|
427
|
+
.not-found h1 { font-size: 3rem; color: #6366f1; }
|
|
428
|
+
.not-found a { color: #6366f1; text-decoration: none; }
|
|
429
|
+
`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Styles ─────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
function todoStyles(name) {
|
|
435
|
+
return `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
436
|
+
body {
|
|
437
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
438
|
+
background: #0a0a0a; color: #e4e4e7;
|
|
439
|
+
display: flex; justify-content: center;
|
|
440
|
+
min-height: 100vh; padding: 2rem;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* Navigation */
|
|
444
|
+
.topnav {
|
|
445
|
+
display: flex; gap: 1rem; margin-bottom: 1.25rem;
|
|
446
|
+
justify-content: center;
|
|
447
|
+
}
|
|
448
|
+
.nav-link {
|
|
449
|
+
color: #71717a; text-decoration: none; font-size: .85rem;
|
|
450
|
+
font-weight: 500; padding: .25rem .5rem; border-radius: 6px;
|
|
451
|
+
transition: all .15s;
|
|
452
|
+
}
|
|
453
|
+
.nav-link:hover { color: #e4e4e7; }
|
|
454
|
+
.nav-link.active { color: #6366f1; }
|
|
455
|
+
|
|
456
|
+
/* App container */
|
|
457
|
+
.todo-app, .about-page, .not-found {
|
|
458
|
+
max-width: 520px; width: 100%;
|
|
459
|
+
background: #141414; border: 1px solid #252525; border-radius: 16px;
|
|
460
|
+
padding: 1.5rem; box-shadow: 0 8px 32px rgba(0,0,0,.5);
|
|
461
|
+
}
|
|
462
|
+
.header { text-align: center; margin-bottom: 1.25rem; }
|
|
463
|
+
.header h1 { font-size: 1.75rem; font-weight: 700; color: #fff; margin-bottom: .25rem; }
|
|
464
|
+
.subtitle { font-size: .85rem; color: #71717a; }
|
|
465
|
+
|
|
466
|
+
/* Input form */
|
|
467
|
+
.add-form {
|
|
468
|
+
display: flex; gap: .5rem; margin-bottom: 1rem;
|
|
469
|
+
}
|
|
470
|
+
.add-form input {
|
|
471
|
+
flex: 1; background: #0d0d0d; border: 1px solid #333;
|
|
472
|
+
border-radius: 10px; padding: .6rem .875rem; color: #e4e4e7;
|
|
473
|
+
font-size: .9rem; outline: none; transition: border-color .15s;
|
|
474
|
+
}
|
|
475
|
+
.add-form input:focus { border-color: #6366f1; }
|
|
476
|
+
.add-form button {
|
|
477
|
+
background: #6366f1; color: #fff; border: none;
|
|
478
|
+
border-radius: 10px; padding: .6rem 1.25rem; font-size: .9rem;
|
|
479
|
+
font-weight: 600; cursor: pointer; transition: background .15s;
|
|
480
|
+
}
|
|
481
|
+
.add-form button:hover { background: #4f46e5; }
|
|
482
|
+
|
|
483
|
+
/* Filters */
|
|
484
|
+
.filters {
|
|
485
|
+
display: flex; gap: .375rem; margin-bottom: 1rem;
|
|
486
|
+
justify-content: center;
|
|
487
|
+
}
|
|
488
|
+
.filter-btn {
|
|
489
|
+
background: #1a1a2e; color: #71717a; border: 1px solid #252525;
|
|
490
|
+
border-radius: 8px; padding: .375rem .875rem; font-size: .8rem;
|
|
491
|
+
cursor: pointer; transition: all .15s;
|
|
492
|
+
}
|
|
493
|
+
.filter-btn:hover { color: #e4e4e7; border-color: #333; }
|
|
494
|
+
.filter-btn.active {
|
|
495
|
+
background: #6366f1; color: #fff; border-color: #6366f1;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* Todo list */
|
|
499
|
+
.todo-list { display: flex; flex-direction: column; gap: .5rem; }
|
|
500
|
+
.todo-card {
|
|
501
|
+
display: flex; align-items: center; gap: .625rem;
|
|
502
|
+
background: #0d0d0d; border: 1px solid #252525; border-radius: 10px;
|
|
503
|
+
padding: .625rem .875rem; transition: all .15s;
|
|
504
|
+
}
|
|
505
|
+
.todo-card:hover { border-color: #333; }
|
|
506
|
+
.todo-card.completed { opacity: .5; }
|
|
507
|
+
.todo-card.completed .todo-title { text-decoration: line-through; color: #71717a; }
|
|
508
|
+
|
|
509
|
+
.check {
|
|
510
|
+
width: 28px; height: 28px; flex-shrink: 0;
|
|
511
|
+
display: flex; align-items: center; justify-content: center;
|
|
512
|
+
background: transparent; border: 2px solid #333;
|
|
513
|
+
border-radius: 50%; color: #71717a; font-size: .8rem;
|
|
514
|
+
cursor: pointer; transition: all .15s;
|
|
515
|
+
}
|
|
516
|
+
.check:hover { border-color: #6366f1; color: #6366f1; }
|
|
517
|
+
.todo-card.completed .check {
|
|
518
|
+
background: #6366f1; border-color: #6366f1; color: #fff;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.todo-title { flex: 1; font-size: .9rem; }
|
|
522
|
+
|
|
523
|
+
.delete {
|
|
524
|
+
background: transparent; border: none; color: #71717a;
|
|
525
|
+
font-size: 1.1rem; cursor: pointer; padding: .25rem;
|
|
526
|
+
line-height: 1; transition: color .15s;
|
|
527
|
+
}
|
|
528
|
+
.delete:hover { color: #ef4444; }
|
|
529
|
+
|
|
530
|
+
.empty {
|
|
531
|
+
text-align: center; color: #71717a; font-size: .85rem;
|
|
532
|
+
padding: 2rem 0; font-style: italic;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
.footer {
|
|
536
|
+
margin-top: 1rem; text-align: center;
|
|
537
|
+
}
|
|
538
|
+
.clear-btn {
|
|
539
|
+
background: transparent; border: 1px solid #333; color: #71717a;
|
|
540
|
+
border-radius: 8px; padding: .375rem .875rem; font-size: .8rem;
|
|
541
|
+
cursor: pointer; transition: all .15s;
|
|
542
|
+
}
|
|
543
|
+
.clear-btn:hover { color: #ef4444; border-color: #ef4444; }
|
|
544
|
+
|
|
545
|
+
/* About page */
|
|
546
|
+
.about-page .content { line-height: 1.6; }
|
|
547
|
+
.about-page h1 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: .75rem; }
|
|
548
|
+
.about-page h2 { font-size: 1.1rem; font-weight: 600; color: #e4e4e7; margin: 1.25rem 0 .5rem; }
|
|
549
|
+
.about-page p { color: #a1a1aa; margin-bottom: .5rem; }
|
|
550
|
+
.about-page ul { padding-left: 1.25rem; color: #a1a1aa; }
|
|
551
|
+
.about-page li { margin-bottom: .375rem; }
|
|
552
|
+
.about-page a { color: #6366f1; text-decoration: none; }
|
|
553
|
+
.about-page a:hover { text-decoration: underline; }
|
|
554
|
+
|
|
555
|
+
/* 404 */
|
|
556
|
+
.not-found { text-align: center; padding: 3rem 1.5rem; }
|
|
557
|
+
.not-found h1 { font-size: 3rem; font-weight: 800; color: #6366f1; margin-bottom: .5rem; }
|
|
558
|
+
.not-found p { color: #71717a; margin-bottom: .5rem; }
|
|
559
|
+
.not-found a { color: #6366f1; text-decoration: none; }
|
|
560
|
+
.not-found a:hover { text-decoration: underline; }
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── GUIDE ──────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
function guide(name) {
|
|
567
|
+
return `# Magnetic Developer Guide
|
|
568
|
+
|
|
569
|
+
A complete reference for building components, pages, and business logic.
|
|
570
|
+
|
|
571
|
+
## Architecture Overview
|
|
572
|
+
|
|
573
|
+
\`\`\`
|
|
574
|
+
Developer writes: Magnetic handles:
|
|
575
|
+
pages/*.tsx → Auto-bridge generation
|
|
576
|
+
components/*.tsx → Bundling (esbuild)
|
|
577
|
+
server/state.ts → Rust V8 server execution
|
|
578
|
+
public/style.css → SSR + SSE + action dispatch
|
|
579
|
+
\`\`\`
|
|
580
|
+
|
|
581
|
+
**Key principle**: All state lives on the server. The client is a ~1.5KB
|
|
582
|
+
rendering shell. Your TSX runs in V8 on the server — never in the browser.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Pages
|
|
587
|
+
|
|
588
|
+
Pages are TSX files in \`pages/\`. The filename determines the route:
|
|
589
|
+
|
|
590
|
+
| File | Route |
|
|
591
|
+
|--------------------------|-------------|
|
|
592
|
+
| \`pages/IndexPage.tsx\` | \`/\` |
|
|
593
|
+
| \`pages/AboutPage.tsx\` | \`/about\` |
|
|
594
|
+
| \`pages/SettingsPage.tsx\` | \`/settings\` |
|
|
595
|
+
| \`pages/[id].tsx\` | \`/:id\` |
|
|
596
|
+
| \`pages/NotFoundPage.tsx\` | \`*\` (404) |
|
|
597
|
+
|
|
598
|
+
### Page template
|
|
599
|
+
|
|
600
|
+
\`\`\`tsx
|
|
601
|
+
import { Head, Link } from '@magneticjs/server/jsx-runtime';
|
|
602
|
+
|
|
603
|
+
export function MyPage(props: any) {
|
|
604
|
+
return (
|
|
605
|
+
<div key="page">
|
|
606
|
+
<Head>
|
|
607
|
+
<title>Page Title</title>
|
|
608
|
+
<meta name="description" content="Page description" />
|
|
609
|
+
</Head>
|
|
610
|
+
|
|
611
|
+
<h1 key="heading">{props.someValue}</h1>
|
|
612
|
+
<Link href="/other">Go somewhere</Link>
|
|
613
|
+
</div>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
\`\`\`
|
|
617
|
+
|
|
618
|
+
### Rules for pages
|
|
619
|
+
- **Export a named function** matching the filename (e.g. \`MyPage\`)
|
|
620
|
+
- **\`props\`** = the view model from \`toViewModel()\` in \`server/state.ts\`
|
|
621
|
+
- **\`key\`** attributes help the client efficiently patch the DOM — add them to elements that change
|
|
622
|
+
- **\`<Head>\`** sets \`<title>\` and \`<meta>\` during SSR
|
|
623
|
+
- **\`<Link>\`** does client-side navigation (no full page reload)
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## Components
|
|
628
|
+
|
|
629
|
+
Components are TSX files in \`components/\`. They are regular functions that
|
|
630
|
+
receive props and return JSX. Import them into pages or other components.
|
|
631
|
+
|
|
632
|
+
### Example: Button component
|
|
633
|
+
|
|
634
|
+
\`\`\`tsx
|
|
635
|
+
// components/Button.tsx
|
|
636
|
+
export function Button(props: {
|
|
637
|
+
label: string;
|
|
638
|
+
action: string;
|
|
639
|
+
variant?: 'primary' | 'danger' | 'ghost';
|
|
640
|
+
}) {
|
|
641
|
+
const cls = \\\`btn btn-\\\${props.variant || 'primary'}\\\`;
|
|
642
|
+
return (
|
|
643
|
+
<button class={cls} onClick={props.action} key={\\\`btn-\\\${props.action}\\\`}>
|
|
644
|
+
{props.label}
|
|
645
|
+
</button>
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
\`\`\`
|
|
649
|
+
|
|
650
|
+
### Example: Card component
|
|
651
|
+
|
|
652
|
+
\`\`\`tsx
|
|
653
|
+
// components/Card.tsx
|
|
654
|
+
export function Card(props: { title: string; children?: any }) {
|
|
655
|
+
return (
|
|
656
|
+
<div class="card" key={\\\`card-\\\${props.title}\\\`}>
|
|
657
|
+
<h3 class="card-title">{props.title}</h3>
|
|
658
|
+
<div class="card-body">{props.children}</div>
|
|
659
|
+
</div>
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
\`\`\`
|
|
663
|
+
|
|
664
|
+
### Example: List component with iteration
|
|
665
|
+
|
|
666
|
+
\`\`\`tsx
|
|
667
|
+
// components/ItemList.tsx
|
|
668
|
+
export function ItemList(props: { items: Array<{ id: number; name: string }> }) {
|
|
669
|
+
return (
|
|
670
|
+
<ul class="item-list" key="items">
|
|
671
|
+
{props.items.map(item => (
|
|
672
|
+
<li key={\\\`item-\\\${item.id}\\\`}>
|
|
673
|
+
<span>{item.name}</span>
|
|
674
|
+
<button onClick={\\\`delete_\\\${item.id}\\\`}>Remove</button>
|
|
675
|
+
</li>
|
|
676
|
+
))}
|
|
677
|
+
</ul>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
\`\`\`
|
|
681
|
+
|
|
682
|
+
### Example: Conditional rendering
|
|
683
|
+
|
|
684
|
+
\`\`\`tsx
|
|
685
|
+
// components/StatusBadge.tsx
|
|
686
|
+
export function StatusBadge(props: { isOnline: boolean }) {
|
|
687
|
+
return (
|
|
688
|
+
<span class={\\\`badge \\\${props.isOnline ? 'online' : 'offline'}\\\`} key="status">
|
|
689
|
+
{props.isOnline ? 'Online' : 'Offline'}
|
|
690
|
+
</span>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
\`\`\`
|
|
694
|
+
|
|
695
|
+
### Example: Form component
|
|
696
|
+
|
|
697
|
+
\`\`\`tsx
|
|
698
|
+
// components/SearchForm.tsx
|
|
699
|
+
export function SearchForm() {
|
|
700
|
+
return (
|
|
701
|
+
<form onSubmit="search" key="search-form">
|
|
702
|
+
<input type="text" name="query" placeholder="Search..." />
|
|
703
|
+
<button type="submit">Search</button>
|
|
704
|
+
</form>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
\`\`\`
|
|
708
|
+
|
|
709
|
+
When the form is submitted, Magnetic collects all \`<input>\` values by their
|
|
710
|
+
\`name\` attribute and sends them as the action payload:
|
|
711
|
+
\`{ query: "user typed this" }\`
|
|
712
|
+
|
|
713
|
+
### Example: Live input (debounced)
|
|
714
|
+
|
|
715
|
+
\`\`\`tsx
|
|
716
|
+
// components/LiveSearch.tsx
|
|
717
|
+
export function LiveSearch() {
|
|
718
|
+
return (
|
|
719
|
+
<input
|
|
720
|
+
type="text"
|
|
721
|
+
name="q"
|
|
722
|
+
placeholder="Type to search..."
|
|
723
|
+
onInput="live_search"
|
|
724
|
+
key="live-search"
|
|
725
|
+
/>
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
\`\`\`
|
|
729
|
+
|
|
730
|
+
\`onInput\` is debounced (300ms). Payload: \`{ value: "current input value" }\`
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
## Business Logic (server/state.ts)
|
|
735
|
+
|
|
736
|
+
All business logic lives in \`server/state.ts\`. This file exports 3 functions:
|
|
737
|
+
|
|
738
|
+
### 1. \`initialState()\` — starting state
|
|
739
|
+
|
|
740
|
+
\`\`\`ts
|
|
741
|
+
export interface AppState {
|
|
742
|
+
items: Item[];
|
|
743
|
+
searchQuery: string;
|
|
744
|
+
currentUser: string | null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export function initialState(): AppState {
|
|
748
|
+
return {
|
|
749
|
+
items: [],
|
|
750
|
+
searchQuery: '',
|
|
751
|
+
currentUser: null,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
\`\`\`
|
|
755
|
+
|
|
756
|
+
### 2. \`reduce(state, action, payload)\` — handles actions
|
|
757
|
+
|
|
758
|
+
This is a **pure function**. Given the current state, an action name, and a
|
|
759
|
+
payload, return the new state. Never mutate — always return a new object.
|
|
760
|
+
|
|
761
|
+
\`\`\`ts
|
|
762
|
+
export function reduce(state: AppState, action: string, payload: any): AppState {
|
|
763
|
+
switch (action) {
|
|
764
|
+
// Simple action (no payload)
|
|
765
|
+
case 'reset':
|
|
766
|
+
return initialState();
|
|
767
|
+
|
|
768
|
+
// Action with payload (from form submission)
|
|
769
|
+
case 'add_item': {
|
|
770
|
+
const name = payload?.name?.trim();
|
|
771
|
+
if (!name) return state;
|
|
772
|
+
return {
|
|
773
|
+
...state,
|
|
774
|
+
items: [...state.items, { id: Date.now(), name, done: false }],
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Action with payload (from onInput)
|
|
779
|
+
case 'live_search':
|
|
780
|
+
return { ...state, searchQuery: payload?.value || '' };
|
|
781
|
+
|
|
782
|
+
// Parameterized actions (action name encodes the ID)
|
|
783
|
+
default: {
|
|
784
|
+
const m = action.match(/^toggle_(\\\\d+)$/);
|
|
785
|
+
if (m) {
|
|
786
|
+
const id = parseInt(m[1], 10);
|
|
787
|
+
return {
|
|
788
|
+
...state,
|
|
789
|
+
items: state.items.map(i =>
|
|
790
|
+
i.id === id ? { ...i, done: !i.done } : i
|
|
791
|
+
),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
return state;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
\`\`\`
|
|
799
|
+
|
|
800
|
+
### 3. \`toViewModel(state)\` — prepares data for the UI
|
|
801
|
+
|
|
802
|
+
Transform raw state into the shape your pages and components need.
|
|
803
|
+
This is where you compute derived values, filter lists, format strings, etc.
|
|
804
|
+
|
|
805
|
+
\`\`\`ts
|
|
806
|
+
export function toViewModel(state: AppState) {
|
|
807
|
+
const filtered = state.items.filter(i =>
|
|
808
|
+
i.name.toLowerCase().includes(state.searchQuery.toLowerCase())
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
items: filtered.map(i => ({
|
|
813
|
+
...i,
|
|
814
|
+
statusClass: i.done ? 'done' : '',
|
|
815
|
+
statusIcon: i.done ? '✓' : '○',
|
|
816
|
+
})),
|
|
817
|
+
totalCount: state.items.length,
|
|
818
|
+
doneCount: state.items.filter(i => i.done).length,
|
|
819
|
+
hasSearch: state.searchQuery.length > 0,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
\`\`\`
|
|
823
|
+
|
|
824
|
+
The object returned by \`toViewModel()\` is passed as \`props\` to every page component.
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
828
|
+
## Event Reference
|
|
829
|
+
|
|
830
|
+
Events in Magnetic are **action names** (strings), not JavaScript callbacks.
|
|
831
|
+
|
|
832
|
+
| JSX Prop | HTML Behavior | Payload |
|
|
833
|
+
|----------------|-----------------------------------------|----------------------------|
|
|
834
|
+
| \`onClick\` | Click → POST to server | \`{}\` |
|
|
835
|
+
| \`onSubmit\` | Form submit → collect inputs by name | \`{ name: value, ... }\` |
|
|
836
|
+
| \`onInput\` | Keystroke (300ms debounce) | \`{ value: "current text" }\` |
|
|
837
|
+
|
|
838
|
+
### How actions flow
|
|
839
|
+
|
|
840
|
+
\`\`\`
|
|
841
|
+
User clicks button (onClick="do_thing")
|
|
842
|
+
→ magnetic.js POSTs to /actions/do_thing
|
|
843
|
+
→ Rust server calls reduce(state, "do_thing", {})
|
|
844
|
+
→ New state → toViewModel() → page re-renders
|
|
845
|
+
→ New JSON DOM sent back to client
|
|
846
|
+
→ magnetic.js patches the DOM in-place
|
|
847
|
+
→ SSE broadcasts update to all connected clients
|
|
848
|
+
\`\`\`
|
|
849
|
+
|
|
850
|
+
### Parameterized actions
|
|
851
|
+
|
|
852
|
+
Encode IDs in the action name:
|
|
853
|
+
|
|
854
|
+
\`\`\`tsx
|
|
855
|
+
<button onClick={\\\`delete_\\\${item.id}\\\`}>Delete</button>
|
|
856
|
+
\`\`\`
|
|
857
|
+
|
|
858
|
+
Then parse in the reducer:
|
|
859
|
+
\`\`\`ts
|
|
860
|
+
const m = action.match(/^delete_(\\\\d+)$/);
|
|
861
|
+
if (m) {
|
|
862
|
+
const id = parseInt(m[1], 10);
|
|
863
|
+
return { ...state, items: state.items.filter(i => i.id !== id) };
|
|
864
|
+
}
|
|
865
|
+
\`\`\`
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
## Navigation
|
|
870
|
+
|
|
871
|
+
Use \`<Link>\` for client-side navigation (no page reload):
|
|
872
|
+
|
|
873
|
+
\`\`\`tsx
|
|
874
|
+
import { Link } from '@magneticjs/server/jsx-runtime';
|
|
875
|
+
|
|
876
|
+
<Link href="/about">About</Link>
|
|
877
|
+
<Link href="/users/42">User Profile</Link>
|
|
878
|
+
\`\`\`
|
|
879
|
+
|
|
880
|
+
Under the hood, \`<Link>\` renders an \`<a>\` with \`onClick="navigate:/about"\`.
|
|
881
|
+
The client runtime intercepts this, does \`pushState\`, and requests the new
|
|
882
|
+
page from the server.
|
|
883
|
+
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
## Keys — Important!
|
|
887
|
+
|
|
888
|
+
Every element that **changes between renders** should have a \`key\` attribute.
|
|
889
|
+
Keys help the client runtime efficiently patch the DOM instead of re-creating it.
|
|
890
|
+
|
|
891
|
+
\`\`\`tsx
|
|
892
|
+
// Good: stable keys on dynamic content
|
|
893
|
+
{items.map(item => (
|
|
894
|
+
<div key={\\\`item-\\\${item.id}\\\`}>{item.name}</div>
|
|
895
|
+
))}
|
|
896
|
+
|
|
897
|
+
// Good: keys on sections that update
|
|
898
|
+
<h1 key="title">{props.title}</h1>
|
|
899
|
+
<p key="count">{props.count} items</p>
|
|
900
|
+
|
|
901
|
+
// Not needed: static content that never changes
|
|
902
|
+
<footer>Made with Magnetic</footer>
|
|
903
|
+
\`\`\`
|
|
904
|
+
|
|
905
|
+
---
|
|
906
|
+
|
|
907
|
+
## Styling
|
|
908
|
+
|
|
909
|
+
Put CSS in \`public/style.css\`. It's automatically inlined into the SSR HTML
|
|
910
|
+
(no extra network request). Use standard CSS — no build step needed.
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
## Tips
|
|
915
|
+
|
|
916
|
+
1. **Think server-first**: Your TSX never runs in the browser. No \`useState\`, no \`useEffect\`, no DOM APIs.
|
|
917
|
+
2. **State is private**: The client never sees your AppState. Only the JSON DOM is sent.
|
|
918
|
+
3. **Keep reducers pure**: No side effects, no \`fetch()\`, no timers. Just state → new state.
|
|
919
|
+
4. **View model is your API**: \`toViewModel()\` shapes exactly what the UI sees.
|
|
920
|
+
5. **Small components**: Break UI into small components with clear props.
|
|
921
|
+
6. **Keys matter**: Add \`key\` to any element whose content or presence changes.
|
|
922
|
+
`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ── README ─────────────────────────────────────────────────────────
|
|
926
|
+
|
|
927
|
+
function readme(name) {
|
|
928
|
+
return `# ${name}
|
|
929
|
+
|
|
930
|
+
A server-driven UI app built with [Magnetic](https://github.com/inventhq/magnetic).
|
|
931
|
+
|
|
932
|
+
## Quick Start
|
|
933
|
+
|
|
934
|
+
\`\`\`bash
|
|
935
|
+
# Start development server (auto-rebuilds on file changes)
|
|
936
|
+
magnetic dev
|
|
937
|
+
|
|
938
|
+
# Or: build + deploy to a Magnetic platform server
|
|
939
|
+
magnetic build
|
|
940
|
+
magnetic push --server http://your-platform:3003
|
|
941
|
+
\`\`\`
|
|
942
|
+
|
|
943
|
+
Then open http://localhost:3003
|
|
944
|
+
|
|
945
|
+
## Project Structure
|
|
946
|
+
|
|
947
|
+
\`\`\`
|
|
948
|
+
${name}/
|
|
949
|
+
├── pages/ ← Page components (auto-routed by filename)
|
|
950
|
+
│ ├── IndexPage.tsx → /
|
|
951
|
+
│ ├── AboutPage.tsx → /about
|
|
952
|
+
│ └── NotFoundPage.tsx→ * (404)
|
|
953
|
+
├── components/ ← Shared components
|
|
954
|
+
├── server/
|
|
955
|
+
│ └── state.ts ← Business logic (state, reducer, viewModel)
|
|
956
|
+
├── public/ ← Static files (CSS, images, client runtime)
|
|
957
|
+
└── magnetic.json ← Project config
|
|
958
|
+
\`\`\`
|
|
959
|
+
|
|
960
|
+
## How It Works
|
|
961
|
+
|
|
962
|
+
1. **You write** pages (TSX) + business logic (state.ts)
|
|
963
|
+
2. **Magnetic CLI** auto-generates the bridge, bundles, and runs the Rust V8 server
|
|
964
|
+
3. **The server** renders your TSX to JSON DOM descriptors and pushes updates via SSE
|
|
965
|
+
4. **The client** (~1.5KB) is a thin rendering shell — no React, no virtual DOM
|
|
966
|
+
|
|
967
|
+
All state lives on the server. The client never sees your business logic.
|
|
968
|
+
`;
|
|
969
|
+
}
|