create-volt 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 +59 -0
- package/index.js +153 -0
- package/package.json +36 -0
- package/template/README.md +41 -0
- package/template/gitignore +5 -0
- package/template/package.json +16 -0
- package/template/public/app.js +66 -0
- package/template/public/volt.js +265 -0
- package/template/server.js +64 -0
- package/template/views/index.html +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# create-volt
|
|
2
|
+
|
|
3
|
+
Scaffold a new [Volt](https://github.com/) app in one command — a tiny,
|
|
4
|
+
**no-build**, signals-based UI with **Socket.io hot reload**. Think
|
|
5
|
+
`create-react-app`, but the whole framework is one ~260-line file you can read.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm create volt@latest my-app
|
|
11
|
+
# or
|
|
12
|
+
npx create-volt my-app
|
|
13
|
+
# or with pnpm / yarn / bun
|
|
14
|
+
pnpm create volt my-app
|
|
15
|
+
yarn create volt my-app
|
|
16
|
+
bun create volt my-app
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd my-app
|
|
23
|
+
npm run dev # → http://localhost:26628
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Edit `public/app.js` and save — the page hot-reloads itself.
|
|
27
|
+
|
|
28
|
+
## Options
|
|
29
|
+
|
|
30
|
+
| Flag | Effect |
|
|
31
|
+
| ---------------- | ------------------------------------------------------- |
|
|
32
|
+
| `--skip-install` | Don't run the install step (scaffold files only) |
|
|
33
|
+
| `--force` | Scaffold into an existing non-empty directory |
|
|
34
|
+
| `-h`, `--help` | Show help |
|
|
35
|
+
| `-v`, `--version`| Print the create-volt version |
|
|
36
|
+
|
|
37
|
+
The installer auto-detects whichever package manager invoked it
|
|
38
|
+
(npm / pnpm / yarn / bun) and uses it for `install`.
|
|
39
|
+
|
|
40
|
+
## What you get
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
my-app/
|
|
44
|
+
├── public/
|
|
45
|
+
│ ├── volt.js the Volt library (no build step)
|
|
46
|
+
│ └── app.js your app — Counter + Todos demo
|
|
47
|
+
├── views/index.html the HTML shell
|
|
48
|
+
├── server.js dev server (Express + Socket.io + file watcher)
|
|
49
|
+
├── package.json
|
|
50
|
+
└── .gitignore
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
Node.js ≥ 16.7. Works on Linux, macOS and Windows.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-volt — scaffold a new Volt app, à la create-react-app.
|
|
3
|
+
//
|
|
4
|
+
// npm create volt@latest my-app
|
|
5
|
+
// npx create-volt my-app
|
|
6
|
+
// npm create volt@latest my-app -- --skip-install
|
|
7
|
+
//
|
|
8
|
+
// Cross-platform: pure node: APIs, path.join everywhere, recursive fs.cpSync,
|
|
9
|
+
// and the install step shells out to whichever package manager invoked it.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const pkg = require("./package.json");
|
|
20
|
+
|
|
21
|
+
// --- tiny ANSI helpers (no deps; degrade to plain text when not a TTY) ---
|
|
22
|
+
const tty = process.stdout.isTTY;
|
|
23
|
+
const c = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
24
|
+
const bold = c(1), dim = c(2), cyan = c(36), green = c(32), red = c(31), yellow = c(33);
|
|
25
|
+
|
|
26
|
+
function die(msg) {
|
|
27
|
+
console.error(`\n${red("✖")} ${msg}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HELP = `
|
|
32
|
+
${bold("⚡ create-volt")} — scaffold a new Volt app
|
|
33
|
+
|
|
34
|
+
${bold("Usage")}
|
|
35
|
+
npm create volt@latest <project-directory> [options]
|
|
36
|
+
npx create-volt <project-directory> [options]
|
|
37
|
+
|
|
38
|
+
${bold("Options")}
|
|
39
|
+
--skip-install Don't run the package manager install step
|
|
40
|
+
--force Scaffold into an existing non-empty directory
|
|
41
|
+
-h, --help Show this help
|
|
42
|
+
-v, --version Show the create-volt version
|
|
43
|
+
|
|
44
|
+
${bold("Example")}
|
|
45
|
+
npm create volt@latest my-app
|
|
46
|
+
cd my-app && npm run dev
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
// --- arg parsing ---
|
|
50
|
+
const argv = process.argv.slice(2);
|
|
51
|
+
const flags = new Set(argv.filter((a) => a.startsWith("-")));
|
|
52
|
+
const positionals = argv.filter((a) => !a.startsWith("-"));
|
|
53
|
+
|
|
54
|
+
if (flags.has("-h") || flags.has("--help")) {
|
|
55
|
+
console.log(HELP);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
if (flags.has("-v") || flags.has("--version")) {
|
|
59
|
+
console.log(pkg.version);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const skipInstall = flags.has("--skip-install");
|
|
64
|
+
const force = flags.has("--force");
|
|
65
|
+
|
|
66
|
+
const rawName = positionals[0];
|
|
67
|
+
if (!rawName) die(`Please specify a project directory:\n ${cyan("npm create volt@latest")} ${green("<project-directory>")}`);
|
|
68
|
+
|
|
69
|
+
// Validate: a single path segment, npm-name-ish, no traversal.
|
|
70
|
+
const projectName = rawName.replace(/\/+$/, "");
|
|
71
|
+
if (projectName.includes("..") || /[<>:"|?*\x00-\x1f]/.test(projectName)) {
|
|
72
|
+
die(`Invalid project name: ${green(rawName)}`);
|
|
73
|
+
}
|
|
74
|
+
if (!/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(path.basename(projectName))) {
|
|
75
|
+
die(`"${path.basename(projectName)}" is not a valid npm package name. Use lowercase letters, digits and dashes.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const targetDir = path.resolve(process.cwd(), projectName);
|
|
79
|
+
const appName = path.basename(targetDir);
|
|
80
|
+
const templateDir = path.join(__dirname, "template");
|
|
81
|
+
|
|
82
|
+
// --- target directory checks ---
|
|
83
|
+
if (fs.existsSync(targetDir)) {
|
|
84
|
+
const existing = fs.readdirSync(targetDir).filter((f) => f !== ".git");
|
|
85
|
+
if (existing.length && !force) {
|
|
86
|
+
die(`Directory ${green(projectName)} already exists and is not empty.\n Pass ${cyan("--force")} to scaffold into it anyway.`);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`\n${bold("⚡ Creating a new Volt app in")} ${cyan(targetDir)}\n`);
|
|
93
|
+
|
|
94
|
+
// --- copy the template tree (recursive, cross-platform) ---
|
|
95
|
+
fs.cpSync(templateDir, targetDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
// npm strips a real ".gitignore" from published packages, so the template ships
|
|
98
|
+
// it as "gitignore" — rename it back in the generated project.
|
|
99
|
+
const shippedGitignore = path.join(targetDir, "gitignore");
|
|
100
|
+
if (fs.existsSync(shippedGitignore)) {
|
|
101
|
+
fs.renameSync(shippedGitignore, path.join(targetDir, ".gitignore"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- stamp the project name into package.json ---
|
|
105
|
+
const appPkgPath = path.join(targetDir, "package.json");
|
|
106
|
+
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
|
|
107
|
+
appPkg.name = appName;
|
|
108
|
+
fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
|
|
109
|
+
|
|
110
|
+
const created = [
|
|
111
|
+
"public/volt.js — the Volt library (no build step)",
|
|
112
|
+
"public/app.js — your app (Counter + Todos demo)",
|
|
113
|
+
"views/index.html — the HTML shell",
|
|
114
|
+
"server.js — dev server with hot reload",
|
|
115
|
+
];
|
|
116
|
+
console.log(green("✔") + " Files created:");
|
|
117
|
+
for (const line of created) console.log(" " + dim(line));
|
|
118
|
+
console.log("");
|
|
119
|
+
|
|
120
|
+
// --- detect the package manager that invoked us (npm / pnpm / yarn / bun) ---
|
|
121
|
+
function detectPM() {
|
|
122
|
+
const ua = process.env.npm_config_user_agent || "";
|
|
123
|
+
if (ua.startsWith("pnpm")) return "pnpm";
|
|
124
|
+
if (ua.startsWith("yarn")) return "yarn";
|
|
125
|
+
if (ua.startsWith("bun")) return "bun";
|
|
126
|
+
return "npm";
|
|
127
|
+
}
|
|
128
|
+
const pm = detectPM();
|
|
129
|
+
|
|
130
|
+
// --- install dependencies (unless skipped) ---
|
|
131
|
+
let installed = false;
|
|
132
|
+
if (!skipInstall) {
|
|
133
|
+
console.log(`${bold("Installing dependencies with")} ${cyan(pm)}…\n`);
|
|
134
|
+
const res = spawnSync(pm, ["install"], {
|
|
135
|
+
cwd: targetDir,
|
|
136
|
+
stdio: "inherit",
|
|
137
|
+
shell: process.platform === "win32", // .cmd shims need a shell on Windows
|
|
138
|
+
});
|
|
139
|
+
if (res.status === 0) {
|
|
140
|
+
installed = true;
|
|
141
|
+
} else {
|
|
142
|
+
console.log(`\n${yellow("!")} ${pm} install did not complete — you can run it yourself below.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- next steps ---
|
|
147
|
+
const runCmd = pm === "npm" ? "npm run dev" : `${pm} dev`;
|
|
148
|
+
const installCmd = pm === "yarn" ? "yarn" : `${pm} install`;
|
|
149
|
+
console.log(`\n${green("✔")} ${bold("Done!")} Next steps:\n`);
|
|
150
|
+
console.log(` ${cyan("cd")} ${projectName}`);
|
|
151
|
+
if (!installed) console.log(` ${cyan(installCmd)}`);
|
|
152
|
+
console.log(` ${cyan(runCmd)}`);
|
|
153
|
+
console.log(`\nThen open ${cyan("http://localhost:26628")} and edit ${bold("public/app.js")}.\n`);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-volt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-volt": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"template"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=16.7.0"
|
|
15
|
+
},
|
|
16
|
+
"author": "Richard Whitney",
|
|
17
|
+
"homepage": "https://github.com/MIR-2025/volt#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/MIR-2025/volt.git",
|
|
21
|
+
"directory": "packages/create-volt"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/MIR-2025/volt/issues"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"volt",
|
|
28
|
+
"create-volt",
|
|
29
|
+
"scaffold",
|
|
30
|
+
"signals",
|
|
31
|
+
"no-build",
|
|
32
|
+
"hot-reload",
|
|
33
|
+
"ui"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# ⚡ Volt app
|
|
2
|
+
|
|
3
|
+
A tiny, **no-build**, signals-based UI app with **Socket.io hot reload**.
|
|
4
|
+
Not React: no JSX, no virtual DOM, no re-render-the-world. State lives in
|
|
5
|
+
*signals*; only the exact text/attribute that changed updates.
|
|
6
|
+
|
|
7
|
+
## Run
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install # if you scaffolded with --skip-install
|
|
11
|
+
npm run dev # → http://localhost:26628 (PORT env to override)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Edit anything in `public/` or `views/` and save — the dev server pushes a
|
|
15
|
+
reload over Socket.io and the page refreshes itself.
|
|
16
|
+
|
|
17
|
+
## Project layout
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
public/volt.js the Volt library (a single ~260-line file, no build step)
|
|
21
|
+
public/app.js your app — the Counter + Todos demo to start from
|
|
22
|
+
views/index.html the HTML shell (loads socket.io then app.js as a module)
|
|
23
|
+
server.js the dev server (Express + Socket.io + file watcher)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import { signal, computed, effect, el, html, mount } from "/volt.js";
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **`signal(initial)`** → a getter/setter function. `n()` reads, `n(next)` writes.
|
|
33
|
+
- **`computed(fn)`** → read-only derived signal, auto-updating.
|
|
34
|
+
- **`effect(fn)`** → runs `fn`, re-runs when any signal it read changes. Returns a disposer.
|
|
35
|
+
- **`el(tag, props?, ...children)`** → a DOM element. `onClick` = listener; a function
|
|
36
|
+
prop = reactive attribute; a function child = live region.
|
|
37
|
+
- **``html`...` ``** → tagged-template markup. `${signal}` holes update in place;
|
|
38
|
+
`onclick=${fn}` = listener; `value=${signal}` = reactive attribute.
|
|
39
|
+
- **`mount(target, ...children)`** → append children into a selector/element.
|
|
40
|
+
|
|
41
|
+
Scaffolded with [`create-volt`](https://www.npmjs.com/package/create-volt).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "volt-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "A Volt app — no-build, signals-based UI with Socket.io hot reload.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "server.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node server.js",
|
|
10
|
+
"dev": "node server.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"express": "^4.19.2",
|
|
14
|
+
"socket.io": "^4.7.5"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// app.js — demo app. Shows BOTH authoring styles on one shared signal engine,
|
|
2
|
+
// with live hot reload. Edit anything here (or in index.html) and save: the
|
|
3
|
+
// dev server pushes a reload over Socket.io and the page refreshes.
|
|
4
|
+
|
|
5
|
+
import { signal, computed, el, html, mount } from "/volt.js";
|
|
6
|
+
|
|
7
|
+
// --- Counter, written with el() helpers (imperative, zero template parsing) ---
|
|
8
|
+
function Counter() {
|
|
9
|
+
const n = signal(0);
|
|
10
|
+
return el("div", { class: "card-x p-4 mb-4" },
|
|
11
|
+
el("h2", { class: "h5 mb-3" }, "Counter — built with el()"),
|
|
12
|
+
el("div", { class: "d-flex align-items-center gap-3" },
|
|
13
|
+
el("button", { class: "btn btn-outline-secondary", onClick: () => n(n() - 1) }, "−"),
|
|
14
|
+
el("span", { class: "fs-4 fw-bold", style: "min-width:3ch;text-align:center" },
|
|
15
|
+
() => String(n())), // function-child: only this text node updates
|
|
16
|
+
el("button", { class: "btn btn-primary", onClick: () => n(n() + 1) }, "+"),
|
|
17
|
+
el("span", { class: "text-muted ms-2" },
|
|
18
|
+
() => (n() % 2 === 0 ? "even" : "odd")),
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- Todo list, written with html`` templates (markup-first) ---
|
|
24
|
+
function Todos() {
|
|
25
|
+
const items = signal([]); // [{ id, text, done }]
|
|
26
|
+
const draft = signal("");
|
|
27
|
+
const remaining = computed(() => items().filter((t) => !t.done).length);
|
|
28
|
+
|
|
29
|
+
const add = () => {
|
|
30
|
+
const text = draft().trim();
|
|
31
|
+
if (!text) return;
|
|
32
|
+
items([...items(), { id: Date.now() + Math.random(), text, done: false }]);
|
|
33
|
+
draft("");
|
|
34
|
+
};
|
|
35
|
+
const toggle = (id) =>
|
|
36
|
+
items(items().map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
|
|
37
|
+
const remove = (id) => items(items().filter((t) => t.id !== id));
|
|
38
|
+
|
|
39
|
+
const row = (t) => html`
|
|
40
|
+
<li class="list-group-item d-flex align-items-center gap-2">
|
|
41
|
+
<input class="form-check-input mt-0" type="checkbox"
|
|
42
|
+
checked=${t.done} onchange=${() => toggle(t.id)} />
|
|
43
|
+
<span class=${() => "flex-grow-1 " + (t.done ? "text-decoration-line-through text-muted" : "")}>
|
|
44
|
+
${t.text}
|
|
45
|
+
</span>
|
|
46
|
+
<button class="btn btn-sm btn-outline-danger" onclick=${() => remove(t.id)}>✕</button>
|
|
47
|
+
</li>`;
|
|
48
|
+
|
|
49
|
+
return html`
|
|
50
|
+
<div class="card-x p-4">
|
|
51
|
+
<h2 class="h5 mb-3">Todos — built with html\`\`</h2>
|
|
52
|
+
<div class="input-group mb-3">
|
|
53
|
+
<input class="form-control" placeholder="Add a task…"
|
|
54
|
+
value=${draft}
|
|
55
|
+
oninput=${(e) => draft(e.target.value)}
|
|
56
|
+
onkeydown=${(e) => e.key === "Enter" && add()} />
|
|
57
|
+
<button class="btn btn-primary" onclick=${add}>Add</button>
|
|
58
|
+
</div>
|
|
59
|
+
<ul class="list-group mb-2">
|
|
60
|
+
${() => items().map(row)}
|
|
61
|
+
</ul>
|
|
62
|
+
<small class="text-muted">${remaining} remaining</small>
|
|
63
|
+
</div>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mount("#app", Counter(), Todos());
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// volt.js — a tiny, no-build, signals-based UI library.
|
|
2
|
+
//
|
|
3
|
+
// Not React: there is no JSX, no virtual DOM, and no "re-render the whole
|
|
4
|
+
// component" step. State lives in *signals*; reading a signal inside a piece of
|
|
5
|
+
// UI subscribes that exact piece; writing the signal re-runs only those
|
|
6
|
+
// subscribers and touches only the precise text node / attribute that changed.
|
|
7
|
+
//
|
|
8
|
+
// Two ways to author UI, same engine underneath:
|
|
9
|
+
// 1. html`` — tagged-template markup with ${signal} holes
|
|
10
|
+
// 2. el(...) — imperative DOM helpers with function-children
|
|
11
|
+
// They interoperate freely (drop an el() node into an html`` template, etc.).
|
|
12
|
+
//
|
|
13
|
+
// Public API: signal, computed, effect, el, html, mount.
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Reactive core (signals + effects with ownership-based disposal)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let activeEffect = null;
|
|
20
|
+
|
|
21
|
+
// A signal is a function: call with no args to read, one arg to write.
|
|
22
|
+
// const n = signal(0); n(); // read → 0
|
|
23
|
+
// n(n()+1); // write → notifies subscribers
|
|
24
|
+
export function signal(value) {
|
|
25
|
+
const subs = new Set();
|
|
26
|
+
return function sig(...args) {
|
|
27
|
+
if (args.length) {
|
|
28
|
+
const next = args[0];
|
|
29
|
+
if (next === value) return value; // no-op on identical value
|
|
30
|
+
value = next;
|
|
31
|
+
for (const eff of [...subs]) eff.run(); // copy: run() mutates subs
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
if (activeEffect) {
|
|
35
|
+
subs.add(activeEffect);
|
|
36
|
+
activeEffect.deps.add(subs);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// effect(fn) runs fn now, tracks every signal it reads, and re-runs it whenever
|
|
43
|
+
// any of those change. Effects created *inside* another effect are owned by it
|
|
44
|
+
// and disposed before each re-run — so dynamic regions clean up after themselves.
|
|
45
|
+
export function effect(fn) {
|
|
46
|
+
const eff = {
|
|
47
|
+
deps: new Set(),
|
|
48
|
+
children: new Set(),
|
|
49
|
+
parent: activeEffect,
|
|
50
|
+
run() {
|
|
51
|
+
disposeChildren(eff);
|
|
52
|
+
cleanupDeps(eff);
|
|
53
|
+
const prev = activeEffect;
|
|
54
|
+
activeEffect = eff;
|
|
55
|
+
try {
|
|
56
|
+
fn();
|
|
57
|
+
} finally {
|
|
58
|
+
activeEffect = prev;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
dispose() {
|
|
62
|
+
disposeChildren(eff);
|
|
63
|
+
cleanupDeps(eff);
|
|
64
|
+
if (eff.parent) eff.parent.children.delete(eff);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
if (activeEffect) activeEffect.children.add(eff);
|
|
68
|
+
eff.run();
|
|
69
|
+
return () => eff.dispose();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// computed(fn) is a read-only derived signal: () => value, auto-updating.
|
|
73
|
+
export function computed(fn) {
|
|
74
|
+
const s = signal(undefined);
|
|
75
|
+
effect(() => s(fn()));
|
|
76
|
+
return () => s();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanupDeps(eff) {
|
|
80
|
+
for (const subs of eff.deps) subs.delete(eff);
|
|
81
|
+
eff.deps.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function disposeChildren(eff) {
|
|
85
|
+
for (const child of [...eff.children]) child.dispose();
|
|
86
|
+
eff.children.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// DOM helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
// el(tag, props?, ...children) → a real DOM element.
|
|
94
|
+
// props: { onClick: fn } → event listener
|
|
95
|
+
// { class: () => ... } → reactive attribute (function = live)
|
|
96
|
+
// { id: 'x' } → static attribute
|
|
97
|
+
// children: strings, numbers, nodes, arrays, or functions (functions = live)
|
|
98
|
+
export function el(tag, props, ...children) {
|
|
99
|
+
const node = document.createElement(tag);
|
|
100
|
+
if (props) {
|
|
101
|
+
for (const [key, val] of Object.entries(props)) {
|
|
102
|
+
if (key.startsWith("on") && typeof val === "function") {
|
|
103
|
+
node.addEventListener(key.slice(2).toLowerCase(), val);
|
|
104
|
+
} else if (typeof val === "function") {
|
|
105
|
+
effect(() => setAttr(node, key, val()));
|
|
106
|
+
} else {
|
|
107
|
+
setAttr(node, key, val);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const child of children) appendChild(node, child);
|
|
112
|
+
return node;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// mount(target, ...children) appends children into target (selector or element).
|
|
116
|
+
// Top-level function-children are reactive too.
|
|
117
|
+
export function mount(target, ...children) {
|
|
118
|
+
const parent = typeof target === "string" ? document.querySelector(target) : target;
|
|
119
|
+
for (const child of children) appendChild(parent, child);
|
|
120
|
+
return parent;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setAttr(node, name, value) {
|
|
124
|
+
if (name === "value") {
|
|
125
|
+
node.value = value ?? "";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (name === "checked" || name === "disabled" || name === "selected") {
|
|
129
|
+
node[name] = !!value && value !== "false";
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (value === false || value == null) {
|
|
133
|
+
node.removeAttribute(name);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
node.setAttribute(name, value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Append a child, making function-children into self-updating dynamic regions
|
|
140
|
+
// bounded by two comment anchors (so they can render text, nodes, or lists).
|
|
141
|
+
function appendChild(parent, child) {
|
|
142
|
+
if (typeof child === "function") {
|
|
143
|
+
const start = document.createComment("");
|
|
144
|
+
const end = document.createComment("");
|
|
145
|
+
parent.appendChild(start);
|
|
146
|
+
parent.appendChild(end);
|
|
147
|
+
effect(() => renderRange(start, end, child()));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const node of toNodes(child)) parent.appendChild(node);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Replace everything between the start/end anchors with `value`'s nodes.
|
|
154
|
+
function renderRange(start, end, value) {
|
|
155
|
+
let n = start.nextSibling;
|
|
156
|
+
while (n && n !== end) {
|
|
157
|
+
const t = n.nextSibling;
|
|
158
|
+
n.remove();
|
|
159
|
+
n = t;
|
|
160
|
+
}
|
|
161
|
+
for (const node of toNodes(value)) end.parentNode.insertBefore(node, end);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Normalize any child value into an array of DOM nodes.
|
|
165
|
+
function toNodes(value) {
|
|
166
|
+
if (value == null || value === false || value === true) return [];
|
|
167
|
+
if (Array.isArray(value)) return value.flatMap(toNodes);
|
|
168
|
+
if (value instanceof Node) return [value];
|
|
169
|
+
return [document.createTextNode(String(value))];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// html`` template layer (parses once, wires holes to the same primitives)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const PH = (i) => `__voltph${i}__`;
|
|
177
|
+
const PH_RE = /__voltph(\d+)__/g;
|
|
178
|
+
|
|
179
|
+
// We're inside an open tag (attribute context) if the last '<' comes after the
|
|
180
|
+
// last '>' in the accumulated string.
|
|
181
|
+
function isAttrContext(str) {
|
|
182
|
+
return str.lastIndexOf("<") > str.lastIndexOf(">");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function html(strings, ...values) {
|
|
186
|
+
let acc = "";
|
|
187
|
+
strings.forEach((str, i) => {
|
|
188
|
+
acc += str;
|
|
189
|
+
if (i < values.length) {
|
|
190
|
+
acc += isAttrContext(acc) ? PH(i) : `<!--${PH(i)}-->`;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const tpl = document.createElement("template");
|
|
195
|
+
tpl.innerHTML = acc.trim();
|
|
196
|
+
|
|
197
|
+
// Bind attribute holes.
|
|
198
|
+
for (const node of tpl.content.querySelectorAll("*")) {
|
|
199
|
+
for (const attr of [...node.attributes]) {
|
|
200
|
+
PH_RE.lastIndex = 0;
|
|
201
|
+
if (PH_RE.test(attr.value)) bindAttr(node, attr, values);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Bind node holes (comment placeholders).
|
|
206
|
+
const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_COMMENT);
|
|
207
|
+
const holes = [];
|
|
208
|
+
let c;
|
|
209
|
+
while ((c = walker.nextNode())) {
|
|
210
|
+
const m = c.data.match(/^__voltph(\d+)__$/);
|
|
211
|
+
if (m) holes.push([c, Number(m[1])]);
|
|
212
|
+
}
|
|
213
|
+
for (const [comment, i] of holes) bindNodeHole(comment, values[i]);
|
|
214
|
+
|
|
215
|
+
const nodes = [...tpl.content.childNodes];
|
|
216
|
+
return nodes.length === 1 ? nodes[0] : nodes;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function bindAttr(node, attr, values) {
|
|
220
|
+
const name = attr.name;
|
|
221
|
+
const raw = attr.value;
|
|
222
|
+
const single = raw.match(/^__voltph(\d+)__$/);
|
|
223
|
+
node.removeAttribute(name);
|
|
224
|
+
|
|
225
|
+
// onX=${fn} → event listener
|
|
226
|
+
if (name.startsWith("on") && single) {
|
|
227
|
+
node.addEventListener(name.slice(2).toLowerCase(), values[Number(single[1])]);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Otherwise a (possibly mixed) attribute value. If any hole is a function it
|
|
232
|
+
// is read inside the effect, so the attribute stays live.
|
|
233
|
+
effect(() => {
|
|
234
|
+
const text = raw.replace(PH_RE, (_, j) => {
|
|
235
|
+
const v = values[Number(j)];
|
|
236
|
+
return String(typeof v === "function" ? v() : v ?? "");
|
|
237
|
+
});
|
|
238
|
+
setAttr(node, name, text);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function bindNodeHole(comment, value) {
|
|
243
|
+
const start = document.createComment("");
|
|
244
|
+
comment.parentNode.insertBefore(start, comment); // `comment` becomes the end anchor
|
|
245
|
+
if (typeof value === "function") {
|
|
246
|
+
effect(() => renderRange(start, comment, value()));
|
|
247
|
+
} else {
|
|
248
|
+
renderRange(start, comment, value);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Hot reload client — listens for the dev server's reload event over Socket.io
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
(function startHotReload() {
|
|
257
|
+
const connect = () => {
|
|
258
|
+
if (!window.io) return false;
|
|
259
|
+
const socket = window.io();
|
|
260
|
+
socket.on("volt:reload", () => location.reload());
|
|
261
|
+
console.log("[volt] hot reload connected");
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
if (!connect()) window.addEventListener("load", connect);
|
|
265
|
+
})();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// server.js — minimal dev server for a Volt app. Express serves the static
|
|
2
|
+
// client and the index view; Socket.io carries the hot-reload signal; a file
|
|
3
|
+
// watcher on views/ and public/ broadcasts a (debounced) reload on any save.
|
|
4
|
+
//
|
|
5
|
+
// Cross-platform: paths are resolved relative to this file, and the watcher
|
|
6
|
+
// falls back to a manual recursive walk where native recursive fs.watch is
|
|
7
|
+
// unavailable (older Linux Node builds).
|
|
8
|
+
//
|
|
9
|
+
// npm run dev # start the dev server
|
|
10
|
+
// PORT=4000 npm run dev # override the port
|
|
11
|
+
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import express from "express";
|
|
17
|
+
import { Server as SocketServer } from "socket.io";
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const PORT = Number(process.env.PORT) || 26628;
|
|
21
|
+
|
|
22
|
+
const app = express();
|
|
23
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
24
|
+
app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
|
|
25
|
+
|
|
26
|
+
const server = http.createServer(app);
|
|
27
|
+
const io = new SocketServer(server);
|
|
28
|
+
|
|
29
|
+
// --- Hot reload: watch views/ + public/, debounce bursts, broadcast a reload ---
|
|
30
|
+
const watchDirs = ["views", "public"].map((d) => path.join(__dirname, d));
|
|
31
|
+
let timer = null;
|
|
32
|
+
function onChange(file) {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
36
|
+
io.emit("volt:reload");
|
|
37
|
+
}, 80);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Watch a directory recursively. Tries native recursive fs.watch first; if the
|
|
41
|
+
// platform/runtime doesn't support it, walks the tree and watches each dir.
|
|
42
|
+
function watchRecursive(dir) {
|
|
43
|
+
try {
|
|
44
|
+
fs.watch(dir, { recursive: true }, (_event, file) => onChange(file));
|
|
45
|
+
return;
|
|
46
|
+
} catch {
|
|
47
|
+
// Native recursive watch unsupported — fall back to per-directory watchers.
|
|
48
|
+
}
|
|
49
|
+
const watchDir = (d) => {
|
|
50
|
+
try {
|
|
51
|
+
fs.watch(d, (_event, file) => onChange(file));
|
|
52
|
+
} catch {
|
|
53
|
+
/* directory vanished between walk and watch — ignore */
|
|
54
|
+
}
|
|
55
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
56
|
+
if (entry.isDirectory()) watchDir(path.join(d, entry.name));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
watchDir(dir);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const dir of watchDirs) watchRecursive(dir);
|
|
63
|
+
|
|
64
|
+
server.listen(PORT, () => console.log(`⚡ Volt dev server → http://localhost:${PORT}`));
|
|
@@ -0,0 +1,40 @@
|
|
|
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" />
|
|
6
|
+
<title>Volt — signals, no build, hot reload</title>
|
|
7
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
8
|
+
<style>
|
|
9
|
+
body { background: #0f1115; color: #e7e9ee; }
|
|
10
|
+
.brand { font-weight: 800; letter-spacing: -.02em; }
|
|
11
|
+
.accent { color: #ffd24a; }
|
|
12
|
+
.card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
|
|
13
|
+
.form-control, .input-group-text { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
|
|
14
|
+
.form-control:focus { background: #0f1115; color: #e7e9ee; border-color: #ffd24a; box-shadow: none; }
|
|
15
|
+
.list-group-item { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
|
|
16
|
+
a { color: #ffd24a; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<main class="container py-5" style="max-width: 720px;">
|
|
21
|
+
<header class="text-center mb-5">
|
|
22
|
+
<h1 class="brand display-5"><span class="accent">⚡ Volt</span></h1>
|
|
23
|
+
<p class="text-muted mb-0">
|
|
24
|
+
Fine-grained signals · no JSX · no virtual DOM · live hot reload.
|
|
25
|
+
Edit <code>public/app.js</code> and save — this page reloads itself.
|
|
26
|
+
</p>
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
<div id="app"></div>
|
|
30
|
+
|
|
31
|
+
<footer class="text-center text-muted mt-5">
|
|
32
|
+
<small>Both cards run on the same signal engine — one uses <code>el()</code>, the other <code>html``</code>.</small>
|
|
33
|
+
</footer>
|
|
34
|
+
</main>
|
|
35
|
+
|
|
36
|
+
<!-- Socket.io client (auto-served by the dev server) powers hot reload. -->
|
|
37
|
+
<script src="/socket.io/socket.io.js"></script>
|
|
38
|
+
<script type="module" src="/app.js"></script>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|