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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/addons/posts/files/lib/posts.js +4 -1
  3. package/index.js +1 -1
  4. package/package.json +1 -1
  5. package/templates/blog/Dockerfile +20 -0
  6. package/templates/blog/Procfile +1 -0
  7. package/templates/blog/README.md +38 -0
  8. package/templates/blog/dockerignore +6 -0
  9. package/templates/blog/env +3 -0
  10. package/templates/blog/fly.toml +15 -0
  11. package/templates/blog/gitignore +5 -0
  12. package/templates/blog/package.json +17 -0
  13. package/templates/blog/pages/_theme.js +35 -0
  14. package/templates/blog/pages/about.md +8 -0
  15. package/templates/blog/posts/2026-06-10-themes.md +11 -0
  16. package/templates/blog/posts/2026-06-20-markdown-and-seo.md +14 -0
  17. package/templates/blog/posts/2026-06-28-hello-volt.md +17 -0
  18. package/templates/blog/public/favicon.webp +0 -0
  19. package/templates/blog/public/logo.webp +0 -0
  20. package/templates/blog/public/volt-ssr.js +63 -0
  21. package/templates/blog/public/volt.js +273 -0
  22. package/templates/blog/render.yaml +15 -0
  23. package/templates/blog/server.js +464 -0
  24. package/templates/blog/setup/index.html +28 -0
  25. package/templates/blog/setup/setup.js +200 -0
  26. package/templates/blog/setup/studio.html +29 -0
  27. package/templates/blog/views/index.html +25 -0
  28. package/templates/default/views/index.html +2 -4
  29. package/templates/docs/Dockerfile +20 -0
  30. package/templates/docs/Procfile +1 -0
  31. package/templates/docs/README.md +24 -0
  32. package/templates/docs/dockerignore +6 -0
  33. package/templates/docs/env +2 -0
  34. package/templates/docs/fly.toml +15 -0
  35. package/templates/docs/gitignore +5 -0
  36. package/templates/docs/package.json +17 -0
  37. package/templates/docs/pages/_theme.js +32 -0
  38. package/templates/docs/pages/configuration.md +13 -0
  39. package/templates/docs/pages/deployment.md +9 -0
  40. package/templates/docs/pages/getting-started.md +14 -0
  41. package/templates/docs/public/favicon.webp +0 -0
  42. package/templates/docs/public/logo.webp +0 -0
  43. package/templates/docs/public/volt-ssr.js +63 -0
  44. package/templates/docs/public/volt.js +273 -0
  45. package/templates/docs/render.yaml +15 -0
  46. package/templates/docs/server.js +464 -0
  47. package/templates/docs/setup/index.html +28 -0
  48. package/templates/docs/setup/setup.js +200 -0
  49. package/templates/docs/setup/studio.html +29 -0
  50. package/templates/docs/views/index.html +15 -0
  51. 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
- const t = new Date(d);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,6 @@
1
+ node_modules
2
+ .git
3
+ .env
4
+ *.log
5
+ npm-debug.log*
6
+ .DS_Store
@@ -0,0 +1,3 @@
1
+ VOLT_ADDONS=pages,posts
2
+ SITE_NAME=My Volt Blog
3
+ # SITE_URL=https://example.com # set for absolute RSS/canonical links
@@ -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,5 @@
1
+ node_modules
2
+ npm-debug.log*
3
+ .DS_Store
4
+ .env
5
+ *.local
@@ -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,8 @@
1
+ ---
2
+ title: About
3
+ description: About this Volt blog.
4
+ ---
5
+ # About
6
+
7
+ This is a plain **page** (`pages/about.md`), served at `/about`. Pages and posts
8
+ share the same theme and SEO. No database, no admin — just markdown on disk.
@@ -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
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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