create-volt 0.32.0 → 0.33.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/CHANGELOG.md +15 -0
- package/addons/posts/files/lib/posts.js +4 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/templates/blog/Dockerfile +20 -0
- package/templates/blog/Procfile +1 -0
- package/templates/blog/README.md +38 -0
- package/templates/blog/dockerignore +6 -0
- package/templates/blog/env +3 -0
- package/templates/blog/fly.toml +15 -0
- package/templates/blog/gitignore +5 -0
- package/templates/blog/package.json +17 -0
- package/templates/blog/pages/_theme.js +35 -0
- package/templates/blog/pages/about.md +8 -0
- package/templates/blog/posts/2026-06-10-themes.md +11 -0
- package/templates/blog/posts/2026-06-20-markdown-and-seo.md +14 -0
- package/templates/blog/posts/2026-06-28-hello-volt.md +17 -0
- package/templates/blog/public/favicon.webp +0 -0
- package/templates/blog/public/logo.webp +0 -0
- package/templates/blog/public/volt-ssr.js +63 -0
- package/templates/blog/public/volt.js +273 -0
- package/templates/blog/render.yaml +15 -0
- package/templates/blog/server.js +464 -0
- package/templates/blog/setup/index.html +28 -0
- package/templates/blog/setup/setup.js +200 -0
- package/templates/blog/setup/studio.html +29 -0
- package/templates/blog/views/index.html +25 -0
- package/templates/default/views/index.html +2 -4
- package/templates/docs/Dockerfile +20 -0
- package/templates/docs/Procfile +1 -0
- package/templates/docs/README.md +24 -0
- package/templates/docs/dockerignore +6 -0
- package/templates/docs/env +2 -0
- package/templates/docs/fly.toml +15 -0
- package/templates/docs/gitignore +5 -0
- package/templates/docs/package.json +17 -0
- package/templates/docs/pages/_theme.js +32 -0
- package/templates/docs/pages/configuration.md +13 -0
- package/templates/docs/pages/deployment.md +9 -0
- package/templates/docs/pages/getting-started.md +14 -0
- package/templates/docs/public/favicon.webp +0 -0
- package/templates/docs/public/logo.webp +0 -0
- package/templates/docs/public/volt-ssr.js +63 -0
- package/templates/docs/public/volt.js +273 -0
- package/templates/docs/render.yaml +15 -0
- package/templates/docs/server.js +464 -0
- package/templates/docs/setup/index.html +28 -0
- package/templates/docs/setup/setup.js +200 -0
- package/templates/docs/setup/studio.html +29 -0
- package/templates/docs/views/index.html +15 -0
- package/templates/starter/views/index.html +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.33.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Two content templates** showcasing the pages/posts/theme system:
|
|
11
|
+
`--template blog` (markdown posts → /blog, categories, tags, RSS, an /about
|
|
12
|
+
page, per-post SEO, a serif theme) and `--template docs` (markdown pages in a
|
|
13
|
+
sidebar layout). Both ship a pre-configured `.env` and boot straight into the
|
|
14
|
+
app — no wizard.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Dark-on-dark text in the default + starter apps: `.text-muted` is overridden to
|
|
18
|
+
a readable color on the dark background (the demo footer caption was dropped).
|
|
19
|
+
- `posts`: `YYYY-MM-DD` dates render on the correct day (parsed as local, not UTC).
|
|
20
|
+
|
|
7
21
|
## [0.32.0] - 2026-06-29
|
|
8
22
|
|
|
9
23
|
### Changed
|
|
@@ -432,6 +446,7 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
432
446
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
433
447
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
434
448
|
|
|
449
|
+
[0.33.0]: https://github.com/MIR-2025/volt/releases/tag/v0.33.0
|
|
435
450
|
[0.32.0]: https://github.com/MIR-2025/volt/releases/tag/v0.32.0
|
|
436
451
|
[0.31.0]: https://github.com/MIR-2025/volt/releases/tag/v0.31.0
|
|
437
452
|
[0.30.0]: https://github.com/MIR-2025/volt/releases/tag/v0.30.0
|
|
@@ -17,7 +17,10 @@ const tagsOf = (meta) => String(meta.tags || "").split(",").map((s) => s.trim())
|
|
|
17
17
|
|
|
18
18
|
function fmtDate(d) {
|
|
19
19
|
if (!d) return "";
|
|
20
|
-
|
|
20
|
+
// parse YYYY-MM-DD as *local* midnight (new Date("2026-06-28") is UTC → off by a
|
|
21
|
+
// day in negative-offset zones); fall back to Date() for full timestamps.
|
|
22
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(d).trim());
|
|
23
|
+
const t = m ? new Date(+m[1], +m[2] - 1, +m[3]) : new Date(d);
|
|
21
24
|
return isNaN(t.getTime()) ? esc(d) : t.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
|
22
25
|
}
|
|
23
26
|
|
package/index.js
CHANGED
|
@@ -40,7 +40,7 @@ ${bold("Usage")}
|
|
|
40
40
|
npx create-volt@latest studio # browse your data — ephemeral, localhost (like Prisma Studio)
|
|
41
41
|
|
|
42
42
|
${bold("Options")}
|
|
43
|
-
--template <name> Template: default | starter | guestbook (default: default)
|
|
43
|
+
--template <name> Template: default | blog | docs | starter | guestbook (default: default)
|
|
44
44
|
--port <number> Dev port for the app (default: derived from today's date)
|
|
45
45
|
--skip-install Don't run the package manager install step
|
|
46
46
|
--no-git Don't initialize a git repository
|
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Volt app — production container. Runs on Render, Fly.io, Railway, DO App
|
|
2
|
+
# Platform, and any container host. They handle the server, DNS, and TLS; you
|
|
3
|
+
# just set config as env vars.
|
|
4
|
+
#
|
|
5
|
+
# Configure via the platform's env vars (NOT a committed .env):
|
|
6
|
+
# VOLT_ADDONS=db,auth,... DB_DRIVER=... MONGODB_URI / DATABASE_URL
|
|
7
|
+
# MEDIA_DRIVER=s3 S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET etc.
|
|
8
|
+
#
|
|
9
|
+
# Tip: run the local wizard first (`npm run dev`) so the add-on packages are
|
|
10
|
+
# saved into package.json; commit package.json, then deploy and set the same
|
|
11
|
+
# config as env vars here. NODE_ENV=production makes the app boot straight up
|
|
12
|
+
# (no setup wizard).
|
|
13
|
+
FROM node:22-alpine
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
ENV NODE_ENV=production
|
|
16
|
+
COPY package*.json ./
|
|
17
|
+
RUN npm install --omit=dev
|
|
18
|
+
COPY . .
|
|
19
|
+
EXPOSE 8080
|
|
20
|
+
CMD ["node", "server.js"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
web: node server.js
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Volt blog
|
|
2
|
+
|
|
3
|
+
A blog built with [Volt](https://voltjs.com) — markdown posts, one theme, real SEO. No build step, no database, no admin to attack.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install
|
|
7
|
+
npm run dev # → http://localhost:26629
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Where things live
|
|
11
|
+
|
|
12
|
+
| Path | What |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `posts/*.md` | Blog posts → `/blog`, `/blog/<slug>`, `/category/<name>`, `/tag/<name>`, `/feed.xml` |
|
|
15
|
+
| `pages/*.md` | Standalone pages (e.g. `pages/about.md` → `/about`) |
|
|
16
|
+
| `pages/_theme.js` | The site theme (layout + CSS served at `/_theme.css`) |
|
|
17
|
+
| `views/index.html` | The home page |
|
|
18
|
+
| `.env` | `VOLT_ADDONS=pages,posts`, `SITE_NAME`, optional `SITE_URL` (absolute RSS/canonical) |
|
|
19
|
+
|
|
20
|
+
## Write a post
|
|
21
|
+
|
|
22
|
+
Drop a file in `posts/`:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
---
|
|
26
|
+
title: My Post
|
|
27
|
+
date: 2026-07-01 # or a 2026-07-01-my-post.md filename prefix
|
|
28
|
+
author: You
|
|
29
|
+
category: Guides
|
|
30
|
+
tags: volt, markdown
|
|
31
|
+
description: A short excerpt + og:description.
|
|
32
|
+
---
|
|
33
|
+
# My Post
|
|
34
|
+
|
|
35
|
+
Markdown body. Single posts get Open Graph + an Article JSON-LD automatically.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Set `draft: true` to hide a post. Run `npm run dev -- --edit` to add features (auth, a database, the WYSIWYG editor).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Fly.io — run `fly launch` (it uses the Dockerfile), then set config:
|
|
2
|
+
# fly secrets set VOLT_ADDONS=db,auth DB_DRIVER=mongodb MONGODB_URI=...
|
|
3
|
+
app = "volt-app"
|
|
4
|
+
|
|
5
|
+
[build]
|
|
6
|
+
|
|
7
|
+
[http_service]
|
|
8
|
+
internal_port = 8080
|
|
9
|
+
force_https = true
|
|
10
|
+
auto_stop_machines = true
|
|
11
|
+
auto_start_machines = true
|
|
12
|
+
|
|
13
|
+
[env]
|
|
14
|
+
PORT = "8080"
|
|
15
|
+
NODE_ENV = "production"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "volt-blog",
|
|
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.22.2",
|
|
14
|
+
"socket.io": "^4.8.3",
|
|
15
|
+
"marked": "^18.0.5"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// _theme.js — the blog theme. layout() wraps every page + post; `css` is served
|
|
2
|
+
// at /_theme.css and shared with the WYSIWYG editor preview. Edit freely.
|
|
3
|
+
const NAME = process.env.SITE_NAME || "My Volt Blog";
|
|
4
|
+
|
|
5
|
+
export const css = `:root{--ink:#1f2329;--bg:#fbfaf8;--accent:#2557d6;--muted:#6b7280;--line:#e7e3da}
|
|
6
|
+
*{box-sizing:border-box}
|
|
7
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:18px/1.7 Georgia,"Times New Roman",serif}
|
|
8
|
+
.wrap{max-width:720px;margin:0 auto;padding:0 1.2rem}
|
|
9
|
+
header.site{border-bottom:1px solid var(--line);padding:1.2rem 0;margin-bottom:2rem}
|
|
10
|
+
header.site .wrap{display:flex;align-items:baseline;gap:1.2rem;font-family:system-ui,sans-serif}
|
|
11
|
+
header.site a.brand{font-weight:800;font-size:1.2rem;color:var(--ink);text-decoration:none}
|
|
12
|
+
header.site nav{margin-left:auto;display:flex;gap:1.2rem}
|
|
13
|
+
header.site nav a{color:var(--muted);text-decoration:none}
|
|
14
|
+
header.site nav a:hover{color:var(--accent)}
|
|
15
|
+
main{padding-bottom:3rem}
|
|
16
|
+
h1,h2,h3{line-height:1.2}
|
|
17
|
+
a{color:var(--accent)}
|
|
18
|
+
.post-meta{font-family:system-ui,sans-serif}
|
|
19
|
+
.post-tag{font-family:system-ui,sans-serif;font-size:.85rem;background:#eef1f5;color:var(--accent);padding:.1em .5em;border-radius:6px;text-decoration:none}
|
|
20
|
+
pre{background:#f1ede4;padding:1rem;border-radius:8px;overflow:auto;font:14px/1.5 ui-monospace,monospace}
|
|
21
|
+
:not(pre)>code{background:#f1ede4;padding:.1em .35em;border-radius:4px;font-size:.9em}
|
|
22
|
+
blockquote{border-left:3px solid var(--accent);margin:1.2rem 0;padding:.2rem 1rem;color:var(--muted);font-style:italic}
|
|
23
|
+
img{max-width:100%;border-radius:8px}
|
|
24
|
+
footer.site{border-top:1px solid var(--line);margin-top:3rem;padding:1.5rem 0;color:var(--muted);font-size:.9rem;font-family:system-ui,sans-serif}`;
|
|
25
|
+
|
|
26
|
+
export function layout({ title, head, content }) {
|
|
27
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
|
+
<title>${title}</title>${head}<link rel="icon" href="/favicon.webp" /><link rel="stylesheet" href="/_theme.css" />
|
|
29
|
+
<link rel="alternate" type="application/rss+xml" title="${NAME}" href="/feed.xml" /></head><body>
|
|
30
|
+
<header class="site"><div class="wrap"><a class="brand" href="/"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> ${NAME}</a>
|
|
31
|
+
<nav><a href="/blog">Blog</a><a href="/about">About</a><a href="/feed.xml">RSS</a></nav></div></header>
|
|
32
|
+
<main><div class="wrap">${content}</div></main>
|
|
33
|
+
<footer class="site"><div class="wrap">${NAME} — built with Volt</div></footer>
|
|
34
|
+
</body></html>`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Theming your blog
|
|
3
|
+
author: You
|
|
4
|
+
category: Guides
|
|
5
|
+
tags: themes, css
|
|
6
|
+
description: One stylesheet powers the whole site — and the editor preview.
|
|
7
|
+
---
|
|
8
|
+
# Theming your blog
|
|
9
|
+
|
|
10
|
+
The look lives in `pages/_theme.js` (layout) and is served at `/_theme.css`.
|
|
11
|
+
Change it once and every page, post, and the editor preview update together.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Markdown, with real SEO
|
|
3
|
+
author: You
|
|
4
|
+
category: Guides
|
|
5
|
+
tags: seo, markdown
|
|
6
|
+
description: Per-post Open Graph + an auto Article JSON-LD, from front-matter.
|
|
7
|
+
---
|
|
8
|
+
# Markdown, with real SEO
|
|
9
|
+
|
|
10
|
+
Every post gets Open Graph + Twitter tags and an **Article JSON-LD** block
|
|
11
|
+
automatically — the SEO a WordPress site needs a plugin for.
|
|
12
|
+
|
|
13
|
+
Set `image:` in front-matter for a social card, or `SITE_URL` in `.env` for
|
|
14
|
+
absolute links. Categories and tags become `/category/<name>` and `/tag/<name>`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Hello, Volt
|
|
3
|
+
author: You
|
|
4
|
+
category: Announcements
|
|
5
|
+
tags: volt, getting-started
|
|
6
|
+
description: The first post on a Volt blog — markdown in, themed HTML out.
|
|
7
|
+
---
|
|
8
|
+
# Hello, Volt
|
|
9
|
+
|
|
10
|
+
This post is a plain markdown file in `posts/`. The **posts** add-on renders it
|
|
11
|
+
at `/blog/hello-volt`, lists it on `/blog`, and adds it to `/feed.xml`.
|
|
12
|
+
|
|
13
|
+
- Front-matter sets the title, author, category, and tags.
|
|
14
|
+
- The date came from the `2026-06-28-` filename prefix.
|
|
15
|
+
- It renders inside your site **theme** (see `pages/_theme.js`).
|
|
16
|
+
|
|
17
|
+
Edit this file, save, refresh. That's the whole workflow.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// volt-ssr.js — server-side rendering for Volt. Renders the same html`` markup,
|
|
2
|
+
// h() elements, and signal values to an HTML string in Node (no DOM), so a Volt
|
|
3
|
+
// app can be fully server-rendered for SEO and hydrate interactive islands with
|
|
4
|
+
// volt.js on the client.
|
|
5
|
+
//
|
|
6
|
+
// import { html, h, raw, renderToString } from "./volt-ssr.js";
|
|
7
|
+
// renderToString(html`<p>${name}</p>`) // → "<p>Ada</p>" (name is escaped)
|
|
8
|
+
//
|
|
9
|
+
// Authoring matches the client: html`` interpolations render as escaped text;
|
|
10
|
+
// nest html``/h() nodes for structure; use raw() for trusted pre-built HTML.
|
|
11
|
+
|
|
12
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
13
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
14
|
+
|
|
15
|
+
// trusted, pre-rendered HTML — emitted verbatim. Use only for content you control.
|
|
16
|
+
export const raw = (s) => ({ __raw: String(s) });
|
|
17
|
+
|
|
18
|
+
// tagged-template markup; the literal chunks are trusted, ${values} are escaped.
|
|
19
|
+
export const html = (strings, ...values) => ({ __tpl: true, strings, values });
|
|
20
|
+
|
|
21
|
+
const isNode = (x) => x && typeof x === "object" && (x.__tpl || x.__raw || x.__el);
|
|
22
|
+
|
|
23
|
+
// hyperscript element: h(tag, props?, ...children)
|
|
24
|
+
export function h(tag, props, ...children) {
|
|
25
|
+
if (props === undefined || props === null || isNode(props) || Array.isArray(props) || typeof props !== "object") {
|
|
26
|
+
if (props !== undefined && props !== null) children.unshift(props);
|
|
27
|
+
props = {};
|
|
28
|
+
}
|
|
29
|
+
return { __el: true, tag, props, children };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const read = (v) => (typeof v === "function" ? v() : v); // resolve signals/thunks (once)
|
|
33
|
+
|
|
34
|
+
function attrs(props) {
|
|
35
|
+
let out = "";
|
|
36
|
+
for (const [k, rawVal] of Object.entries(props)) {
|
|
37
|
+
if (k === "children" || k.startsWith("on")) continue; // event handlers don't SSR
|
|
38
|
+
const v = read(rawVal);
|
|
39
|
+
if (v == null || v === false) continue;
|
|
40
|
+
const name = k === "className" ? "class" : k;
|
|
41
|
+
out += v === true ? ` ${name}` : ` ${name}="${esc(v)}"`;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderToString(node) {
|
|
47
|
+
const v = read(node);
|
|
48
|
+
if (v == null || v === false || v === true) return "";
|
|
49
|
+
if (typeof v === "string" || typeof v === "number") return esc(v);
|
|
50
|
+
if (v.__raw != null) return v.__raw;
|
|
51
|
+
if (Array.isArray(v)) return v.map(renderToString).join("");
|
|
52
|
+
if (v.__tpl) {
|
|
53
|
+
let out = v.strings[0];
|
|
54
|
+
for (let i = 0; i < v.values.length; i++) out += renderToString(v.values[i]) + v.strings[i + 1];
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
if (v.__el) {
|
|
58
|
+
if (typeof v.tag === "function") return renderToString(v.tag({ ...v.props, children: v.children }));
|
|
59
|
+
const open = `<${v.tag}${attrs(v.props)}>`;
|
|
60
|
+
return VOID.has(v.tag) ? open : `${open}${v.children.map(renderToString).join("")}</${v.tag}>`;
|
|
61
|
+
}
|
|
62
|
+
return esc(String(v));
|
|
63
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
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
|
+
disposed: false,
|
|
51
|
+
run() {
|
|
52
|
+
// A signal write notifies a *snapshot* of subscribers; a parent re-render
|
|
53
|
+
// can dispose this effect before its turn in that snapshot — so skip if so.
|
|
54
|
+
if (eff.disposed) return;
|
|
55
|
+
disposeChildren(eff);
|
|
56
|
+
cleanupDeps(eff);
|
|
57
|
+
const prev = activeEffect;
|
|
58
|
+
activeEffect = eff;
|
|
59
|
+
try {
|
|
60
|
+
fn();
|
|
61
|
+
} finally {
|
|
62
|
+
activeEffect = prev;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
dispose() {
|
|
66
|
+
eff.disposed = true;
|
|
67
|
+
disposeChildren(eff);
|
|
68
|
+
cleanupDeps(eff);
|
|
69
|
+
if (eff.parent) eff.parent.children.delete(eff);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
if (activeEffect) activeEffect.children.add(eff);
|
|
73
|
+
eff.run();
|
|
74
|
+
return () => eff.dispose();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// computed(fn) is a read-only derived signal: () => value, auto-updating.
|
|
78
|
+
export function computed(fn) {
|
|
79
|
+
const s = signal(undefined);
|
|
80
|
+
effect(() => s(fn()));
|
|
81
|
+
return () => s();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanupDeps(eff) {
|
|
85
|
+
for (const subs of eff.deps) subs.delete(eff);
|
|
86
|
+
eff.deps.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function disposeChildren(eff) {
|
|
90
|
+
for (const child of [...eff.children]) child.dispose();
|
|
91
|
+
eff.children.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// DOM helpers
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
// el(tag, props?, ...children) → a real DOM element.
|
|
99
|
+
// props: { onClick: fn } → event listener
|
|
100
|
+
// { class: () => ... } → reactive attribute (function = live)
|
|
101
|
+
// { id: 'x' } → static attribute
|
|
102
|
+
// children: strings, numbers, nodes, arrays, or functions (functions = live)
|
|
103
|
+
export function el(tag, props, ...children) {
|
|
104
|
+
const node = document.createElement(tag);
|
|
105
|
+
if (props) {
|
|
106
|
+
for (const [key, val] of Object.entries(props)) {
|
|
107
|
+
if (key.startsWith("on") && typeof val === "function") {
|
|
108
|
+
node.addEventListener(key.slice(2).toLowerCase(), val);
|
|
109
|
+
} else if (typeof val === "function") {
|
|
110
|
+
effect(() => setAttr(node, key, val()));
|
|
111
|
+
} else {
|
|
112
|
+
setAttr(node, key, val);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const child of children) appendChild(node, child);
|
|
117
|
+
return node;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// mount(target, ...children) appends children into target (selector or element).
|
|
121
|
+
// Top-level function-children are reactive too.
|
|
122
|
+
export function mount(target, ...children) {
|
|
123
|
+
const parent = typeof target === "string" ? document.querySelector(target) : target;
|
|
124
|
+
for (const child of children) appendChild(parent, child);
|
|
125
|
+
return parent;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function setAttr(node, name, value) {
|
|
129
|
+
if (name === "value") {
|
|
130
|
+
const v = value ?? "";
|
|
131
|
+
if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (name === "checked" || name === "disabled" || name === "selected") {
|
|
135
|
+
node[name] = !!value && value !== "false";
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (value === false || value == null) {
|
|
139
|
+
node.removeAttribute(name);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
node.setAttribute(name, value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Append a child, making function-children into self-updating dynamic regions
|
|
146
|
+
// bounded by two comment anchors (so they can render text, nodes, or lists).
|
|
147
|
+
function appendChild(parent, child) {
|
|
148
|
+
if (typeof child === "function") {
|
|
149
|
+
const start = document.createComment("");
|
|
150
|
+
const end = document.createComment("");
|
|
151
|
+
parent.appendChild(start);
|
|
152
|
+
parent.appendChild(end);
|
|
153
|
+
effect(() => renderRange(start, end, child()));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
for (const node of toNodes(child)) parent.appendChild(node);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Replace everything between the start/end anchors with `value`'s nodes.
|
|
160
|
+
function renderRange(start, end, value) {
|
|
161
|
+
if (!end.parentNode) return; // range detached (parent re-rendered) — nothing to do
|
|
162
|
+
let n = start.nextSibling;
|
|
163
|
+
while (n && n !== end) {
|
|
164
|
+
const t = n.nextSibling;
|
|
165
|
+
n.remove();
|
|
166
|
+
n = t;
|
|
167
|
+
}
|
|
168
|
+
for (const node of toNodes(value)) end.parentNode.insertBefore(node, end);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Normalize any child value into an array of DOM nodes.
|
|
172
|
+
function toNodes(value) {
|
|
173
|
+
if (value == null || value === false || value === true) return [];
|
|
174
|
+
if (Array.isArray(value)) return value.flatMap(toNodes);
|
|
175
|
+
if (value instanceof Node) return [value];
|
|
176
|
+
return [document.createTextNode(String(value))];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// html`` template layer (parses once, wires holes to the same primitives)
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
const PH = (i) => `__voltph${i}__`;
|
|
184
|
+
const PH_RE = /__voltph(\d+)__/g;
|
|
185
|
+
|
|
186
|
+
// We're inside an open tag (attribute context) if the last '<' comes after the
|
|
187
|
+
// last '>' in the accumulated string.
|
|
188
|
+
function isAttrContext(str) {
|
|
189
|
+
return str.lastIndexOf("<") > str.lastIndexOf(">");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function html(strings, ...values) {
|
|
193
|
+
let acc = "";
|
|
194
|
+
strings.forEach((str, i) => {
|
|
195
|
+
acc += str;
|
|
196
|
+
if (i < values.length) {
|
|
197
|
+
acc += isAttrContext(acc) ? PH(i) : `<!--${PH(i)}-->`;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const tpl = document.createElement("template");
|
|
202
|
+
tpl.innerHTML = acc.trim();
|
|
203
|
+
|
|
204
|
+
// Bind attribute holes.
|
|
205
|
+
for (const node of tpl.content.querySelectorAll("*")) {
|
|
206
|
+
for (const attr of [...node.attributes]) {
|
|
207
|
+
PH_RE.lastIndex = 0;
|
|
208
|
+
if (PH_RE.test(attr.value)) bindAttr(node, attr, values);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Bind node holes (comment placeholders).
|
|
213
|
+
const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_COMMENT);
|
|
214
|
+
const holes = [];
|
|
215
|
+
let c;
|
|
216
|
+
while ((c = walker.nextNode())) {
|
|
217
|
+
const m = c.data.match(/^__voltph(\d+)__$/);
|
|
218
|
+
if (m) holes.push([c, Number(m[1])]);
|
|
219
|
+
}
|
|
220
|
+
for (const [comment, i] of holes) bindNodeHole(comment, values[i]);
|
|
221
|
+
|
|
222
|
+
const nodes = [...tpl.content.childNodes];
|
|
223
|
+
return nodes.length === 1 ? nodes[0] : nodes;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function bindAttr(node, attr, values) {
|
|
227
|
+
const name = attr.name;
|
|
228
|
+
const raw = attr.value;
|
|
229
|
+
const single = raw.match(/^__voltph(\d+)__$/);
|
|
230
|
+
node.removeAttribute(name);
|
|
231
|
+
|
|
232
|
+
// onX=${fn} → event listener
|
|
233
|
+
if (name.startsWith("on") && single) {
|
|
234
|
+
node.addEventListener(name.slice(2).toLowerCase(), values[Number(single[1])]);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Otherwise a (possibly mixed) attribute value. If any hole is a function it
|
|
239
|
+
// is read inside the effect, so the attribute stays live.
|
|
240
|
+
effect(() => {
|
|
241
|
+
const text = raw.replace(PH_RE, (_, j) => {
|
|
242
|
+
const v = values[Number(j)];
|
|
243
|
+
return String(typeof v === "function" ? v() : v ?? "");
|
|
244
|
+
});
|
|
245
|
+
setAttr(node, name, text);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function bindNodeHole(comment, value) {
|
|
250
|
+
const start = document.createComment("");
|
|
251
|
+
comment.parentNode.insertBefore(start, comment); // `comment` becomes the end anchor
|
|
252
|
+
if (typeof value === "function") {
|
|
253
|
+
effect(() => renderRange(start, comment, value()));
|
|
254
|
+
} else {
|
|
255
|
+
renderRange(start, comment, value);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Hot reload client — listens for the dev server's reload event over Socket.io
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
(function startHotReload() {
|
|
264
|
+
if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
|
|
265
|
+
const connect = () => {
|
|
266
|
+
if (!window.io) return false;
|
|
267
|
+
const socket = window.io();
|
|
268
|
+
socket.on("volt:reload", () => location.reload());
|
|
269
|
+
console.log("[volt] hot reload connected");
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
if (!connect()) window.addEventListener("load", connect);
|
|
273
|
+
})();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Render blueprint — https://render.com/docs/blueprint-spec
|
|
2
|
+
# Push this repo to GitHub, then in Render: New → Blueprint → pick the repo.
|
|
3
|
+
# Render builds the Dockerfile, gives you HTTPS + a domain, and runs it.
|
|
4
|
+
services:
|
|
5
|
+
- type: web
|
|
6
|
+
name: volt-app
|
|
7
|
+
runtime: docker
|
|
8
|
+
plan: starter
|
|
9
|
+
envVars:
|
|
10
|
+
- key: VOLT_ADDONS
|
|
11
|
+
sync: false # set your add-ons (e.g. "db,auth") in the dashboard
|
|
12
|
+
# Add the rest in the dashboard as needed:
|
|
13
|
+
# DB_DRIVER, MONGODB_URI / DATABASE_URL,
|
|
14
|
+
# MEDIA_DRIVER, S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET,
|
|
15
|
+
# SMTP_URL, MAIL_FROM
|