create-vanillaforge 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/bin/cli.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * create-vanillaforge — project scaffolder
4
+ *
5
+ * Usage:
6
+ * npx create-vanillaforge <project-name> [--template=<name>]
7
+ *
8
+ * Templates: minimal | full | todo-app | router-app
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { createInterface } from 'readline';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
18
+
19
+ const TEMPLATES = [
20
+ {
21
+ name: 'minimal',
22
+ label: 'Minimal',
23
+ description: 'counter app, no plugins — best starting point',
24
+ },
25
+ {
26
+ name: 'full',
27
+ label: 'Full',
28
+ description: 'all plugins: icons, theme, alerts, fonts, store',
29
+ },
30
+ {
31
+ name: 'todo-app',
32
+ label: 'Todo App',
33
+ description: 'task list with filtering and localStorage',
34
+ },
35
+ {
36
+ name: 'router-app',
37
+ label: 'Router App',
38
+ description: 'multi-page app with routing and composition',
39
+ },
40
+ ];
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Argument parsing
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function parseArgs(argv) {
47
+ const args = argv.slice(2);
48
+ let projectName = null;
49
+ let template = null;
50
+
51
+ for (const arg of args) {
52
+ if (arg.startsWith('--template=')) {
53
+ template = arg.slice('--template='.length);
54
+ } else if (!arg.startsWith('--')) {
55
+ projectName = arg;
56
+ }
57
+ }
58
+
59
+ return { projectName, template };
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Prompt helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function ask(rl, question) {
67
+ return new Promise((resolve) => rl.question(question, resolve));
68
+ }
69
+
70
+ async function promptProjectName(rl) {
71
+ const answer = await ask(rl, 'Project name: ');
72
+ const name = answer.trim();
73
+ if (!name) {
74
+ console.error('Error: project name is required.');
75
+ process.exit(1);
76
+ }
77
+ return name;
78
+ }
79
+
80
+ async function promptTemplate(rl) {
81
+ console.log('\nWhich template would you like to use?\n');
82
+ TEMPLATES.forEach((t, i) => {
83
+ const num = String(i + 1).padStart(2);
84
+ console.log(` ${num}) ${t.label.padEnd(12)} ${t.description}`);
85
+ });
86
+ console.log('');
87
+ const answer = await ask(rl, 'Template (1): ');
88
+ const index = (parseInt(answer.trim(), 10) || 1) - 1;
89
+ if (index < 0 || index >= TEMPLATES.length) {
90
+ console.error(`Error: invalid selection "${answer.trim()}".`);
91
+ process.exit(1);
92
+ }
93
+ return TEMPLATES[index].name;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // File copy
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function copyDir(src, dest) {
101
+ fs.mkdirSync(dest, { recursive: true });
102
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
103
+ const srcPath = path.join(src, entry.name);
104
+ const destPath = path.join(dest, entry.name);
105
+ if (entry.isDirectory()) {
106
+ copyDir(srcPath, destPath);
107
+ } else {
108
+ fs.copyFileSync(srcPath, destPath);
109
+ }
110
+ }
111
+ }
112
+
113
+ function rewriteFile(filePath, replacements) {
114
+ let content = fs.readFileSync(filePath, 'utf8');
115
+ for (const [from, to] of replacements) {
116
+ content = content.split(from).join(to);
117
+ }
118
+ fs.writeFileSync(filePath, content, 'utf8');
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Main
123
+ // ---------------------------------------------------------------------------
124
+
125
+ async function main() {
126
+ const { projectName: argName, template: argTemplate } = parseArgs(process.argv);
127
+
128
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
129
+
130
+ const projectName = argName || (await promptProjectName(rl));
131
+ const template = argTemplate || (await promptTemplate(rl));
132
+
133
+ rl.close();
134
+
135
+ if (!TEMPLATES.find((t) => t.name === template)) {
136
+ console.error(`Error: unknown template "${template}". Choose from: ${TEMPLATES.map((t) => t.name).join(', ')}`);
137
+ process.exit(1);
138
+ }
139
+
140
+ const targetDir = path.resolve(process.cwd(), projectName);
141
+
142
+ if (fs.existsSync(targetDir)) {
143
+ console.error(`Error: directory "${projectName}" already exists.`);
144
+ process.exit(1);
145
+ }
146
+
147
+ const templateDir = path.join(TEMPLATES_DIR, template);
148
+ copyDir(templateDir, targetDir);
149
+
150
+ // Patch the app name into the generated files
151
+ const htmlPath = path.join(targetDir, 'index.html');
152
+ if (fs.existsSync(htmlPath)) {
153
+ rewriteFile(htmlPath, [['{{project-name}}', projectName]]);
154
+ }
155
+
156
+ const pkgPath = path.join(targetDir, 'package.json');
157
+ if (fs.existsSync(pkgPath)) {
158
+ rewriteFile(pkgPath, [['{{project-name}}', projectName]]);
159
+ }
160
+
161
+ console.log(`\nScaffolded "${projectName}" with the "${template}" template.\n`);
162
+ console.log('Next steps:\n');
163
+ console.log(` cd ${projectName}`);
164
+ console.log(' npm install');
165
+ console.log(' npm run dev\n');
166
+ console.log('No build step needed for development. Open http://localhost:3000\n');
167
+ }
168
+
169
+ main().catch((err) => {
170
+ console.error(err.message || err);
171
+ process.exit(1);
172
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "create-vanillaforge",
3
+ "version": "1.0.0",
4
+ "description": "Scaffold a new VanillaForge project",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-vanillaforge": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "templates/"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "keywords": [
17
+ "vanillaforge",
18
+ "scaffold",
19
+ "create-app",
20
+ "spa"
21
+ ],
22
+ "author": "Stephen Musyoka (https://github.com/Steve-GitCodex)",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/Steve-GitCodex/vanillaforge.git",
27
+ "directory": "create-vanillaforge"
28
+ }
29
+ }
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{project-name}}</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "vanillaforge": "./node_modules/vanillaforge/src/framework.js"
11
+ }
12
+ }
13
+ </script>
14
+ </head>
15
+ <body>
16
+ <div id="main-content"></div>
17
+ <script type="module" src="./src/app.js"></script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "{{project-name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "npx http-server . -p 3000 -c-1 --cors -o"
7
+ },
8
+ "dependencies": {
9
+ "vanillaforge": "^1.9.0"
10
+ }
11
+ }
@@ -0,0 +1,25 @@
1
+ import {
2
+ createApp,
3
+ iconsPlugin,
4
+ themePlugin,
5
+ alertsPlugin,
6
+ fontsPlugin,
7
+ storePlugin,
8
+ } from 'vanillaforge';
9
+ import { HomeComponent } from './components/home.js';
10
+
11
+ const app = createApp({ appName: '{{project-name}}' });
12
+
13
+ app.use(themePlugin, {
14
+ tokens: { primary: '#3b82f6', radius: '8px' },
15
+ });
16
+ app.use(iconsPlugin);
17
+ app.use(alertsPlugin);
18
+ app.use(fontsPlugin, { families: ['Inter'] });
19
+ app.use(storePlugin);
20
+
21
+ await app.initialize({
22
+ routes: { '/': HomeComponent },
23
+ });
24
+
25
+ await app.start();
@@ -0,0 +1,70 @@
1
+ import { BaseComponent } from 'vanillaforge';
2
+
3
+ export class HomeComponent extends BaseComponent {
4
+ constructor(eventBus, props) {
5
+ super(eventBus, props);
6
+ this.name = 'home';
7
+ this.state = { count: 0 };
8
+ this._unsub = null;
9
+ }
10
+
11
+ async onInit() {
12
+ const store = this.service('store');
13
+ const saved = store.get('count') ?? 0;
14
+ this.state = { count: saved };
15
+ this._unsub = store.subscribe('count', (value) => {
16
+ this.setState({ count: value ?? 0 });
17
+ });
18
+ }
19
+
20
+ getLifecycle() {
21
+ return {
22
+ onUnmount: () => this._unsub?.(),
23
+ };
24
+ }
25
+
26
+ getTemplate() {
27
+ return `
28
+ <div style="max-width:520px;margin:80px auto;text-align:center">
29
+ <h1 style="margin-bottom:8px">{{project-name}}</h1>
30
+ <p style="color:var(--vf-text-muted);margin-bottom:32px">
31
+ Count: <strong>${this.state.count}</strong>
32
+ </p>
33
+ <div style="display:flex;gap:12px;justify-content:center">
34
+ <button class="vf-btn vf-btn-primary" data-action="increment">
35
+ ${this.icon('plus', { size: 16 })} Increment
36
+ </button>
37
+ <button class="vf-btn vf-btn-secondary" data-action="showInfo">
38
+ ${this.icon('info', { size: 16 })} Info
39
+ </button>
40
+ <button class="vf-btn vf-btn-danger" data-action="reset">
41
+ ${this.icon('close', { size: 16 })} Reset
42
+ </button>
43
+ </div>
44
+ </div>`;
45
+ }
46
+
47
+ getMethods() {
48
+ return {
49
+ increment: () => {
50
+ const store = this.service('store');
51
+ store.set('count', (store.get('count') ?? 0) + 1);
52
+ },
53
+
54
+ reset: async () => {
55
+ const alerts = this.service('alerts');
56
+ const ok = await alerts.confirm('Reset the counter to zero?', { danger: true });
57
+ if (ok) {
58
+ this.service('store').set('count', 0);
59
+ alerts.success('Counter reset.');
60
+ }
61
+ },
62
+
63
+ showInfo: () => {
64
+ this.service('alerts').info(
65
+ 'Built with VanillaForge — icons, theme, alerts, fonts, and store. Zero external dependencies.'
66
+ );
67
+ },
68
+ };
69
+ }
70
+ }
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{project-name}}</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "vanillaforge": "./node_modules/vanillaforge/src/framework.js"
11
+ }
12
+ }
13
+ </script>
14
+ </head>
15
+ <body>
16
+ <div id="main-content"></div>
17
+ <script type="module" src="./src/app.js"></script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "{{project-name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "npx http-server . -p 3000 -c-1 --cors -o"
7
+ },
8
+ "dependencies": {
9
+ "vanillaforge": "^1.9.0"
10
+ }
11
+ }
@@ -0,0 +1,35 @@
1
+ import { createApp, BaseComponent } from 'vanillaforge';
2
+
3
+ class HomeComponent extends BaseComponent {
4
+ constructor(eventBus, props) {
5
+ super(eventBus, props);
6
+ this.name = 'home';
7
+ this.state = { count: 0 };
8
+ }
9
+
10
+ getTemplate() {
11
+ return `
12
+ <div style="font-family:system-ui;max-width:480px;margin:80px auto;text-align:center">
13
+ <h1>VanillaForge</h1>
14
+ <p style="color:#666;margin-bottom:32px">Count: <strong>${this.state.count}</strong></p>
15
+ <button data-action="increment"
16
+ style="padding:10px 24px;border:none;border-radius:6px;background:#3b82f6;color:#fff;cursor:pointer;font-size:1rem">
17
+ Increment
18
+ </button>
19
+ </div>`;
20
+ }
21
+
22
+ getMethods() {
23
+ return {
24
+ increment: () => this.setState({ count: this.state.count + 1 }),
25
+ };
26
+ }
27
+ }
28
+
29
+ const app = createApp({ appName: '{{project-name}}' });
30
+
31
+ await app.initialize({
32
+ routes: { '/': HomeComponent },
33
+ });
34
+
35
+ await app.start();
@@ -0,0 +1,85 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{project-name}}</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "vanillaforge": "./node_modules/vanillaforge/src/framework.js"
11
+ }
12
+ }
13
+ </script>
14
+ <style>
15
+ body { padding: 40px 20px; font-family: var(--vf-font-sans, system-ui, sans-serif); }
16
+ .shell { max-width: 640px; margin: 0 auto; }
17
+ .topbar { display:flex; align-items:center; gap:12px; margin-bottom:24px; }
18
+ .topbar h2 { font-size:1.1rem; }
19
+ .muted { color:var(--vf-text-muted); font-size:.85rem; }
20
+
21
+ .user-list { list-style:none; margin-top:16px; }
22
+ .component.user-card { display:block; }
23
+ .user-card { border-top:1px solid var(--vf-border); }
24
+ .user-card:first-child { border-top:none; }
25
+ .user-card-row {
26
+ display:flex; align-items:center;
27
+ justify-content:space-between; padding:14px 4px;
28
+ }
29
+ .user-card-link {
30
+ display:flex; align-items:center; gap:12px;
31
+ text-decoration:none; color:inherit; flex:1;
32
+ }
33
+ .user-card-link:hover .user-card-name { color:var(--vf-primary); }
34
+ .user-card-avatar {
35
+ width:36px; height:36px; border-radius:50%;
36
+ background:var(--vf-primary); color:#fff;
37
+ display:flex; align-items:center; justify-content:center;
38
+ font-weight:700; font-size:.95rem; flex-shrink:0;
39
+ }
40
+ .user-card-name { display:block; font-weight:600; }
41
+ .user-card-role { font-size:.82rem; color:var(--vf-text-muted); }
42
+ .user-card-actions { display:flex; align-items:center; gap:2px; }
43
+ .user-card-toggle, .user-card-remove {
44
+ background:none; border:none; cursor:pointer;
45
+ color:var(--vf-text-muted); padding:4px;
46
+ border-radius:var(--vf-radius-sm); display:flex; align-items:center;
47
+ }
48
+ .user-card-toggle:hover { background:var(--vf-border); color:var(--vf-text); }
49
+ .user-card-remove:hover { background:#fef2f2; color:var(--vf-danger,#ef4444); }
50
+ .user-card-bio {
51
+ padding:0 4px 14px 4px; color:var(--vf-text); font-size:.92rem; line-height:1.55;
52
+ }
53
+ .user-card-bio p { margin-bottom:10px; }
54
+ .user-card-detail-link {
55
+ display:inline-flex; align-items:center; gap:4px;
56
+ color:var(--vf-primary); text-decoration:none;
57
+ font-weight:600; font-size:.88rem;
58
+ }
59
+ .user-card-detail-link:hover { text-decoration:underline; }
60
+ .detail-header { display:flex; align-items:center; gap:16px; margin:16px 0; }
61
+ .detail-avatar {
62
+ width:56px; height:56px; border-radius:50%;
63
+ background:var(--vf-primary); color:#fff;
64
+ display:flex; align-items:center; justify-content:center;
65
+ font-weight:700; font-size:1.4rem; flex-shrink:0;
66
+ }
67
+ .back-link {
68
+ display:inline-flex; align-items:center; gap:6px;
69
+ margin-bottom:16px; color:var(--vf-primary);
70
+ text-decoration:none; font-weight:600;
71
+ }
72
+ .back-link:hover { text-decoration:underline; }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <div class="shell">
77
+ <div class="topbar">
78
+ <h2>{{project-name}}</h2>
79
+ <span class="muted">· routing + composition + icons + alerts</span>
80
+ </div>
81
+ <div id="main-content"></div>
82
+ </div>
83
+ <script type="module" src="./src/app.js"></script>
84
+ </body>
85
+ </html>
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "{{project-name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "npx http-server . -p 3000 -c-1 --cors -o"
7
+ },
8
+ "dependencies": {
9
+ "vanillaforge": "^1.9.0"
10
+ }
11
+ }
@@ -0,0 +1,29 @@
1
+ import { createApp, iconsPlugin, themePlugin, alertsPlugin } from 'vanillaforge';
2
+ import { UsersList } from './components/users-list.js';
3
+ import { UserDetail } from './components/user-detail.js';
4
+ import { getUser } from './data/users.js';
5
+
6
+ const basePath = window.location.pathname
7
+ .replace(/\/[^/]*\.[^/]*$/, '')
8
+ .replace(/\/$/, '');
9
+
10
+ const app = createApp({
11
+ appName: '{{project-name}}',
12
+ router: { basePath },
13
+ });
14
+
15
+ app.use(themePlugin, { tokens: { primary: '#3b82f6', radius: '8px' } });
16
+ app.use(iconsPlugin);
17
+ app.use(alertsPlugin);
18
+
19
+ await app.initialize({
20
+ routes: {
21
+ '/': UsersList,
22
+ '/users/:id': {
23
+ component: UserDetail,
24
+ loader: async ({ params }) => getUser(params.id),
25
+ },
26
+ },
27
+ });
28
+
29
+ await app.start();
@@ -0,0 +1,65 @@
1
+ import { BaseComponent } from 'vanillaforge';
2
+
3
+ export class UserCard extends BaseComponent {
4
+ constructor(eventBus, props = {}) {
5
+ super(eventBus, props);
6
+ this.name = 'user-card';
7
+ this.state = { expanded: false };
8
+ }
9
+
10
+ getTemplate() {
11
+ const { user } = this.props;
12
+ const { expanded } = this.state;
13
+
14
+ return `
15
+ <li class="user-card" data-expanded="${expanded}">
16
+ <div class="user-card-row">
17
+ <a class="user-card-link" href="/users/${user.id}">
18
+ <div class="user-card-avatar">${user.name.charAt(0)}</div>
19
+ <div>
20
+ <strong class="user-card-name">${user.name}</strong>
21
+ <span class="user-card-role">${user.role}</span>
22
+ </div>
23
+ </a>
24
+ <div class="user-card-actions">
25
+ <button class="user-card-toggle" data-action="toggle"
26
+ title="${expanded ? 'Collapse' : 'Expand'}">
27
+ ${this.icon(expanded ? 'chevron-up' : 'chevron-down', { size: 18 })}
28
+ </button>
29
+ <button class="user-card-remove" data-action="remove"
30
+ title="Remove ${user.name}">
31
+ ${this.icon('trash', { size: 15 })}
32
+ </button>
33
+ </div>
34
+ </div>
35
+ ${expanded ? `
36
+ <div class="user-card-bio">
37
+ <p>${user.bio}</p>
38
+ <a class="user-card-detail-link" href="/users/${user.id}">
39
+ Full profile ${this.icon('arrow-right', { size: 14 })}
40
+ </a>
41
+ </div>` : ''}
42
+ </li>`;
43
+ }
44
+
45
+ getMethods() {
46
+ return {
47
+ toggle: () => this.setState({ expanded: !this.state.expanded }),
48
+
49
+ remove: () => {
50
+ const { user } = this.props;
51
+ const alerts = this.service('alerts');
52
+ if (!alerts) return;
53
+ alerts.confirm(`Remove ${user.name} from the list?`, {
54
+ title: 'Remove user',
55
+ confirmText: 'Remove',
56
+ danger: true,
57
+ onConfirm: () => {
58
+ alerts.success(`${user.name} removed`);
59
+ this.eventBus.emit('user:remove', { id: user.id });
60
+ },
61
+ });
62
+ },
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,42 @@
1
+ import { BaseComponent } from 'vanillaforge';
2
+
3
+ export class UserDetail extends BaseComponent {
4
+ constructor(eventBus, props = {}) {
5
+ super(eventBus, props);
6
+ this.name = 'user-detail';
7
+ }
8
+
9
+ get user() {
10
+ return this.props.data ?? null;
11
+ }
12
+
13
+ getTemplate() {
14
+ const user = this.user;
15
+
16
+ if (!user) {
17
+ return `
18
+ <section class="vf-card">
19
+ <h1>Not found</h1>
20
+ <p class="muted">No person with that id.</p>
21
+ <a class="back-link" href="/">
22
+ ${this.icon('arrow-left', { size: 16 })} Back to people
23
+ </a>
24
+ </section>`;
25
+ }
26
+
27
+ return `
28
+ <section class="vf-card">
29
+ <a class="back-link" href="/">
30
+ ${this.icon('arrow-left', { size: 16 })} Back to people
31
+ </a>
32
+ <div class="detail-header">
33
+ <div class="detail-avatar">${user.name.charAt(0)}</div>
34
+ <div>
35
+ <h1>${user.name}</h1>
36
+ <p style="color:var(--vf-primary);font-weight:600;margin-top:2px">${user.role}</p>
37
+ </div>
38
+ </div>
39
+ <p>${user.bio}</p>
40
+ </section>`;
41
+ }
42
+ }
@@ -0,0 +1,41 @@
1
+ import { BaseComponent } from 'vanillaforge';
2
+ import { UserCard } from './user-card.js';
3
+ import { users as initialUsers } from '../data/users.js';
4
+
5
+ export class UsersList extends BaseComponent {
6
+ constructor(eventBus, props = {}) {
7
+ super(eventBus, props);
8
+ this.name = 'users-list';
9
+ this.state = { users: [...initialUsers] };
10
+ }
11
+
12
+ async onInit() {
13
+ this.eventBus.on('user:remove', ({ id }) => {
14
+ this.setState({ users: this.state.users.filter((u) => u.id !== id) });
15
+ });
16
+ }
17
+
18
+ getTemplate() {
19
+ const { users } = this.state;
20
+
21
+ if (users.length === 0) {
22
+ return `
23
+ <section class="vf-card">
24
+ <h1>People</h1>
25
+ <p class="muted" style="margin-top:16px">No users left in the list.</p>
26
+ </section>`;
27
+ }
28
+
29
+ return `
30
+ <section class="vf-card">
31
+ <h1>People</h1>
32
+ <p class="muted">
33
+ ${this.icon('info', { size: 14 })}
34
+ Click a name to view the full profile, or use the toggle to expand.
35
+ </p>
36
+ <ul class="user-list">
37
+ ${users.map((u) => this.child(UserCard, { user: u }, u.id)).join('')}
38
+ </ul>
39
+ </section>`;
40
+ }
41
+ }
@@ -0,0 +1,10 @@
1
+ export const users = [
2
+ { id: 1, name: 'Ada Lovelace', role: 'Mathematician', bio: 'Wrote the first algorithm intended for a machine.' },
3
+ { id: 2, name: 'Alan Turing', role: 'Computer Scientist', bio: 'Formalised computation and the Turing machine.' },
4
+ { id: 3, name: 'Grace Hopper', role: 'Engineer', bio: 'Pioneered machine-independent programming languages.' },
5
+ { id: 4, name: 'Dennis Ritchie',role: 'Programmer', bio: 'Created the C language and co-created Unix.' },
6
+ ];
7
+
8
+ export function getUser(id) {
9
+ return users.find((u) => u.id === Number(id)) || null;
10
+ }
@@ -0,0 +1,32 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{project-name}}</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "vanillaforge": "./node_modules/vanillaforge/src/framework.js"
11
+ }
12
+ }
13
+ </script>
14
+ <style>
15
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
16
+ body {
17
+ font-family: var(--vf-font-sans, system-ui, sans-serif);
18
+ background: var(--vf-background, #f4f5f7);
19
+ color: var(--vf-text, #0f172a);
20
+ min-height: 100vh;
21
+ padding: 48px 20px;
22
+ }
23
+ .app-shell { max-width: 600px; margin: 0 auto; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div class="app-shell">
28
+ <div id="main-content"></div>
29
+ </div>
30
+ <script type="module" src="./src/app.js"></script>
31
+ </body>
32
+ </html>
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "{{project-name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "npx http-server . -p 3000 -c-1 --cors -o"
7
+ },
8
+ "dependencies": {
9
+ "vanillaforge": "^1.9.0"
10
+ }
11
+ }
@@ -0,0 +1,15 @@
1
+ import { createApp, themePlugin, iconsPlugin, alertsPlugin } from 'vanillaforge';
2
+ import { TodoApp } from './components/todo-app.js';
3
+
4
+ const app = createApp({ appName: '{{project-name}}' });
5
+
6
+ app.use(themePlugin);
7
+ app.use(iconsPlugin);
8
+ app.use(alertsPlugin);
9
+
10
+ await app.initialize({
11
+ routes: { '/': TodoApp },
12
+ components: { 'todo-app': TodoApp },
13
+ });
14
+
15
+ await app.start();
@@ -0,0 +1,173 @@
1
+ import { BaseComponent } from 'vanillaforge';
2
+
3
+ export class TodoApp extends BaseComponent {
4
+ constructor(eventBus, props) {
5
+ super(eventBus, props);
6
+ this.name = 'todo-app';
7
+ this.state = {
8
+ todos: this._load(),
9
+ newTodo: '',
10
+ filter: 'all',
11
+ };
12
+ }
13
+
14
+ _load() {
15
+ try {
16
+ const saved = localStorage.getItem('vf-todos');
17
+ return saved ? JSON.parse(saved) : [];
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ _save() {
24
+ try {
25
+ localStorage.setItem('vf-todos', JSON.stringify(this.state.todos));
26
+ } catch {
27
+ // localStorage unavailable
28
+ }
29
+ }
30
+
31
+ _filtered() {
32
+ const { todos, filter } = this.state;
33
+ if (filter === 'active') return todos.filter((t) => !t.done);
34
+ if (filter === 'completed') return todos.filter((t) => t.done);
35
+ return todos;
36
+ }
37
+
38
+ _stats() {
39
+ const total = this.state.todos.length;
40
+ const done = this.state.todos.filter((t) => t.done).length;
41
+ return { total, done, active: total - done };
42
+ }
43
+
44
+ _esc(text) {
45
+ const d = document.createElement('div');
46
+ d.textContent = text;
47
+ return d.innerHTML;
48
+ }
49
+
50
+ getTemplate() {
51
+ const items = this._filtered();
52
+ const stats = this._stats();
53
+ const { filter } = this.state;
54
+ const filterBtn = (f, label) =>
55
+ `<button class="vf-btn ${filter === f ? 'vf-btn-primary' : 'vf-btn-secondary'}"
56
+ style="padding:6px 14px;font-size:.85rem" data-action="setFilter" data-filter="${f}">
57
+ ${label}
58
+ </button>`;
59
+
60
+ return `
61
+ <div class="vf-card" style="border-radius:16px;overflow:hidden;box-shadow:var(--vf-shadow-md)">
62
+ <div style="background:linear-gradient(135deg,#1e293b,#0f172a);color:#f8fafc;padding:28px 24px;text-align:center">
63
+ <h1 style="font-size:1.6rem;font-weight:700;letter-spacing:-.02em">Todos</h1>
64
+ <p style="color:#94a3b8;font-size:.9rem;margin-top:4px">Built with VanillaForge</p>
65
+ </div>
66
+ <div style="padding:24px">
67
+ <div style="display:flex;gap:10px;margin-bottom:20px">
68
+ <input type="text"
69
+ class="vf-input"
70
+ style="flex:1;padding:12px;border:2px solid var(--vf-border);border-radius:var(--vf-radius);font-size:1rem"
71
+ placeholder="What needs to be done?"
72
+ value="${this._esc(this.state.newTodo)}"
73
+ data-input="inputChange"
74
+ data-keydown="keyDown">
75
+ <button class="vf-btn vf-btn-primary" data-action="add"
76
+ style="padding:12px 20px">
77
+ ${this.icon('plus', { size: 18 })} Add
78
+ </button>
79
+ </div>
80
+
81
+ ${stats.total > 0 ? `
82
+ <div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
83
+ ${filterBtn('all', 'All (' + stats.total + ')')}
84
+ ${filterBtn('active', 'Active (' + stats.active + ')')}
85
+ ${filterBtn('completed', 'Done (' + stats.done + ')')}
86
+ ${stats.done > 0 ? `
87
+ <button class="vf-btn vf-btn-danger"
88
+ style="padding:6px 14px;font-size:.85rem;margin-left:auto"
89
+ data-action="clearDone">
90
+ Clear done
91
+ </button>` : ''}
92
+ </div>
93
+ ` : ''}
94
+
95
+ ${items.length > 0 ? `
96
+ <ul style="list-style:none">
97
+ ${items.map((t) => `
98
+ <li data-key="${t.id}"
99
+ style="display:flex;align-items:center;gap:12px;padding:12px;border-radius:var(--vf-radius);
100
+ margin-bottom:8px;background:${t.done ? 'var(--vf-border)' : 'var(--vf-surface)'}">
101
+ <input type="checkbox" ${t.done ? 'checked' : ''}
102
+ data-change="toggle" data-id="${t.id}"
103
+ style="width:18px;height:18px;cursor:pointer;flex-shrink:0">
104
+ <span style="flex:1;${t.done ? 'text-decoration:line-through;opacity:.5' : ''}">
105
+ ${this._esc(t.text)}
106
+ </span>
107
+ <button class="vf-btn vf-btn-danger" data-action="remove" data-id="${t.id}"
108
+ style="padding:4px 10px;font-size:.8rem">
109
+ ${this.icon('trash', { size: 14 })}
110
+ </button>
111
+ </li>
112
+ `).join('')}
113
+ </ul>
114
+ ` : `
115
+ <div style="text-align:center;padding:40px;color:var(--vf-text-muted)">
116
+ ${this.icon('check', { size: 40 })}
117
+ <p style="margin-top:12px">
118
+ ${filter === 'active' ? 'No active todos.' : filter === 'completed' ? 'Nothing completed yet.' : 'Add a todo above.'}
119
+ </p>
120
+ </div>
121
+ `}
122
+ </div>
123
+ </div>`;
124
+ }
125
+
126
+ getMethods() {
127
+ return {
128
+ inputChange: (e) => this.setState({ newTodo: e.target.value }, false),
129
+
130
+ keyDown: (e) => {
131
+ if (e.key === 'Enter') this.getMethods().add();
132
+ },
133
+
134
+ add: () => {
135
+ const text = this.state.newTodo.trim();
136
+ if (!text) return;
137
+ const todo = { id: Date.now(), text, done: false };
138
+ this.setState({ todos: [...this.state.todos, todo], newTodo: '' });
139
+ this._save();
140
+ },
141
+
142
+ toggle: (e) => {
143
+ const id = Number(e.target.dataset.id);
144
+ const todos = this.state.todos.map((t) =>
145
+ t.id === id ? { ...t, done: !t.done } : t
146
+ );
147
+ this.setState({ todos });
148
+ this._save();
149
+ },
150
+
151
+ remove: async (e) => {
152
+ const id = Number(e.target.closest('[data-id]').dataset.id);
153
+ const alerts = this.service('alerts');
154
+ const ok = alerts
155
+ ? await alerts.confirm('Delete this todo?', { danger: true })
156
+ : true;
157
+ if (ok) {
158
+ this.setState({ todos: this.state.todos.filter((t) => t.id !== id) });
159
+ this._save();
160
+ }
161
+ },
162
+
163
+ setFilter: (e) => {
164
+ this.setState({ filter: e.target.closest('[data-filter]').dataset.filter });
165
+ },
166
+
167
+ clearDone: () => {
168
+ this.setState({ todos: this.state.todos.filter((t) => !t.done) });
169
+ this._save();
170
+ },
171
+ };
172
+ }
173
+ }