create-volt 0.24.0 → 0.26.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 CHANGED
@@ -4,6 +4,33 @@ 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.26.0] - 2026-06-29
8
+
9
+ ### Added
10
+ - **Themes / shared layout for the `pages` add-on** — pages render into a layout:
11
+ (1) `pages/_header.html` + `_footer.html` partials (no code); (2) a local
12
+ `pages/_theme.js` exporting `layout({ title, head, content, meta })` (+ optional
13
+ `css`); (3) a third-party `volt-theme-<name>` package selected with
14
+ `THEME=<name>`. `create-volt create-theme <name>` scaffolds one. Resolution:
15
+ THEME → local `_theme.js` → built-in default.
16
+ - **One stylesheet for page + editor** — the active theme's CSS is served at
17
+ `/_theme.css` (a theme's `export const css`, or `pages/_theme.css`, or the
18
+ default). Pages link it, and the WYSIWYG editor loads it into RTEPro's
19
+ `exportCSS`, so the preview matches the published page — CSS authored once.
20
+ - **Site-wide OG image default** — `OG_IMAGE` in `.env` is the `og:image` for
21
+ pages without a per-page `image`.
22
+
23
+ ## [0.25.0] - 2026-06-29
24
+
25
+ ### Added
26
+ - **Per-page SEO on the `pages` add-on** — front-matter now drives the page head:
27
+ `description` (meta description + og:description), `image` (og:image), `type`
28
+ (og:type), `canonical`, and **`jsonld`** (a one-line JSON string rendered into a
29
+ validated `<script type="application/ld+json">` block, with `<` escaped to
30
+ prevent breakout). Open Graph + Twitter + JSON-LD per page — the Yoast-style SEO
31
+ a migrated WordPress site expects. `volt-addon-editor` 0.4.0 adds a SEO panel to
32
+ set these from the editor.
33
+
7
34
  ## [0.24.0] - 2026-06-29
8
35
 
9
36
  ### Added
@@ -343,6 +370,8 @@ All notable changes to `create-volt` are documented here. The format follows
343
370
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
344
371
  and auto-detects npm / pnpm / yarn / bun for the install step.
345
372
 
373
+ [0.26.0]: https://github.com/MIR-2025/volt/releases/tag/v0.26.0
374
+ [0.25.0]: https://github.com/MIR-2025/volt/releases/tag/v0.25.0
346
375
  [0.24.0]: https://github.com/MIR-2025/volt/releases/tag/v0.24.0
347
376
  [0.23.0]: https://github.com/MIR-2025/volt/releases/tag/v0.23.0
348
377
  [0.22.0]: https://github.com/MIR-2025/volt/releases/tag/v0.22.0
@@ -3,6 +3,7 @@
3
3
  // Pages are code-owned files (trusted), so their markdown HTML is served as-is.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
6
7
  // express + marked are imported lazily in pagesRouter() so this module's pure
7
8
  // helpers (parseFrontMatter, isSafeSlug) load without those deps installed.
8
9
 
@@ -50,13 +51,37 @@ export function parseFrontMatter(src) {
50
51
  // slugs are restricted to a safe charset — no dots/slashes → no path traversal
51
52
  export const isSafeSlug = (s) => /^[a-z0-9][a-z0-9-]*$/i.test(s);
52
53
 
53
- function shell(title, inner) {
54
- return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
55
- <meta name="viewport" content="width=device-width, initial-scale=1" />
56
- <title>${esc(title)}</title>
57
- <style>
58
- :root { color-scheme: light dark }
59
- body { max-width: 720px; margin: 2.5rem auto; padding: 0 1.1rem; font: 17px/1.7 system-ui, -apple-system, sans-serif; }
54
+ // SEO/social head from front-matter: description, image, type, canonical, jsonld.
55
+ function metaHead(meta) {
56
+ const t = [];
57
+ const title = meta.title || "";
58
+ const desc = meta.description || "";
59
+ if (title) t.push(`<meta property="og:title" content="${esc(title)}" />`);
60
+ if (desc) {
61
+ t.push(`<meta name="description" content="${esc(desc)}" />`);
62
+ t.push(`<meta property="og:description" content="${esc(desc)}" />`);
63
+ }
64
+ t.push(`<meta property="og:type" content="${esc(meta.type || "website")}" />`);
65
+ const image = meta.image || process.env.OG_IMAGE; // per-page, else a site-wide default
66
+ if (image) t.push(`<meta property="og:image" content="${esc(image)}" />`);
67
+ if (meta.url || meta.canonical) t.push(`<meta property="og:url" content="${esc(meta.url || meta.canonical)}" />`);
68
+ if (meta.canonical) t.push(`<link rel="canonical" href="${esc(meta.canonical)}" />`);
69
+ t.push(`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}" />`);
70
+ if (meta.jsonld) {
71
+ let ok = false;
72
+ try {
73
+ JSON.parse(meta.jsonld);
74
+ ok = true;
75
+ } catch {
76
+ /* invalid JSON-LD → skip */
77
+ }
78
+ if (ok) t.push(`<script type="application/ld+json">${meta.jsonld.replace(/</g, "\\u003c")}</script>`);
79
+ }
80
+ return t.join("\n");
81
+ }
82
+
83
+ const DEFAULT_CSS = `:root { color-scheme: light dark }
84
+ body { max-width: 720px; margin: 0 auto; padding: 2rem 1.1rem; font: 17px/1.7 system-ui, -apple-system, sans-serif; }
60
85
  h1, h2, h3 { line-height: 1.25; margin: 1.6rem 0 .6rem }
61
86
  pre { background: #0b0d11; color: #cfe3ff; padding: 1rem; border-radius: 10px; overflow: auto }
62
87
  code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em }
@@ -64,14 +89,64 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .
64
89
  img { max-width: 100% } a { color: #0b67d6 }
65
90
  blockquote { border-left: 3px solid #ccc; margin: 1rem 0; padding: .2rem 1rem; opacity: .8 }
66
91
  table { border-collapse: collapse } td, th { border: 1px solid #ccc; padding: .4rem .7rem }
67
- </style></head><body>${inner}</body></html>`;
92
+ header, footer { max-width: 720px; margin: 0 auto; padding: 0 1.1rem }`;
93
+
94
+ // Built-in default theme: wraps content with optional pages/_header.html and
95
+ // pages/_footer.html partials (read fresh each request → live edits).
96
+ function defaultLayout(dir) {
97
+ const part = (f) => {
98
+ const p = path.join(dir, f);
99
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
100
+ };
101
+ // links /_theme.css (the active theme's CSS) so the page and the editor preview
102
+ // share one stylesheet.
103
+ return ({ title, head, content }) => `<!doctype html><html lang="en"><head><meta charset="utf-8" />
104
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
105
+ <title>${esc(title)}</title>
106
+ ${head}
107
+ <link rel="stylesheet" href="/_theme.css" /></head><body>${part("_header.html")}<main>${content}</main>${part("_footer.html")}</body></html>`;
108
+ }
109
+
110
+ // The active theme's CSS — a `_theme.css` override in pages/, else the default.
111
+ function themeCss(dir) {
112
+ const p = path.join(dir, "_theme.css");
113
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : DEFAULT_CSS;
114
+ }
115
+
116
+ // Resolve the layout + its CSS. THEME=<name> → npm "volt-theme-<name>"; else a
117
+ // local pages/_theme.js; else the built-in default. A theme may `export const css`
118
+ // (served at /_theme.css, shared with the editor); otherwise pages/_theme.css or
119
+ // the default CSS is used.
120
+ async function loadTheme(dir, env) {
121
+ const wrap = (m) => {
122
+ const layout = m && (m.layout || m.default);
123
+ return layout ? { layout, css: m.css || themeCss(dir) } : null;
124
+ };
125
+ if (env.THEME) {
126
+ for (const id of [`volt-theme-${env.THEME}`, env.THEME]) {
127
+ try {
128
+ const t = wrap(await import(id));
129
+ if (t) return t;
130
+ } catch {
131
+ /* try next */
132
+ }
133
+ }
134
+ }
135
+ const local = path.join(dir, "_theme.js");
136
+ if (fs.existsSync(local)) {
137
+ const t = wrap(await import(pathToFileURL(local).href));
138
+ if (t) return t;
139
+ }
140
+ return { layout: defaultLayout(dir), css: themeCss(dir) };
68
141
  }
69
142
 
70
143
  export async function pagesRouter({ dir }) {
71
144
  const express = (await import("express")).default;
72
145
  const { marked } = await import("marked");
73
146
  ensure(dir);
147
+ const { layout, css } = await loadTheme(dir, process.env);
74
148
  const r = express.Router();
149
+ r.get("/_theme.css", (_req, res) => res.type("css").send(css));
75
150
  r.get("/:slug", (req, res, next) => {
76
151
  const slug = req.params.slug;
77
152
  if (!isSafeSlug(slug)) return next(); // safe slug only — no traversal
@@ -80,8 +155,9 @@ export async function pagesRouter({ dir }) {
80
155
  const { meta, body } = parseFrontMatter(fs.readFileSync(file, "utf8"));
81
156
  // `format: html` pages (e.g. from the WYSIWYG editor) are served verbatim to
82
157
  // preserve complex layouts; everything else is markdown rendered with marked.
83
- const inner = meta.format === "html" ? body : marked.parse(body);
84
- res.type("html").send(shell(meta.title || slug, inner));
158
+ const content = meta.format === "html" ? body : marked.parse(body);
159
+ const m = { ...meta, title: meta.title || slug };
160
+ res.type("html").send(layout({ title: m.title, head: metaHead(m), content, meta: m }));
85
161
  });
86
162
  return r;
87
163
  }
package/index.js CHANGED
@@ -183,6 +183,54 @@ if (positionals[0] === "create-addon") {
183
183
  process.exit(0);
184
184
  }
185
185
 
186
+ // --- `create-theme` subcommand: scaffold a publishable theme (shared layout) ---
187
+ if (positionals[0] === "create-theme") {
188
+ const name = positionals[1];
189
+ if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) die(`Usage: ${cyan("create-volt create-theme <name>")} — name: lowercase letters, numbers, hyphens.`);
190
+ const dir = path.resolve(`volt-theme-${name}`);
191
+ if (fs.existsSync(dir) && !flags.has("--force")) die(`${cyan(dir)} already exists (use ${cyan("--force")}).`);
192
+ const THEME_INDEX = [
193
+ "// A Volt theme. layout(ctx) wraps page content in a full HTML document —",
194
+ "// this is your shared header/footer/styling for every page.",
195
+ "// ctx = { title, head, content, meta }",
196
+ "// - title: page title (from front-matter)",
197
+ "// - head: SEO/social tags (OG, Twitter, canonical, JSON-LD) — put in <head>",
198
+ "// - content: the rendered page HTML",
199
+ "// - meta: the front-matter object",
200
+ "// `css` is served at /_theme.css and shared with the WYSIWYG editor preview.",
201
+ "export const css = `body { font: 17px/1.7 system-ui, sans-serif; max-width: 760px; margin: 0 auto; padding: 1rem } header, footer { opacity: .75; padding: .5rem 0 }`;",
202
+ "",
203
+ "export function layout({ title, head, content }) {",
204
+ ' return `<!doctype html><html lang="en"><head><meta charset="utf-8" />',
205
+ '<meta name="viewport" content="width=device-width, initial-scale=1" />',
206
+ "<title>${title}</title>",
207
+ "${head}",
208
+ '<link rel="stylesheet" href="/_theme.css" />',
209
+ "</head><body>",
210
+ ' <header><strong>__NAME__</strong> &middot; <a href="/">Home</a></header>',
211
+ " <main>${content}</main>",
212
+ " <footer><small>Built with Volt</small></footer>",
213
+ "</body></html>`;",
214
+ "}",
215
+ "",
216
+ ].join("\n");
217
+ const THEME_README = [
218
+ "# volt-theme-__NAME__", "", "A [Volt](https://voltjs.com) theme — a shared layout (header/footer/styling) for `pages`.", "",
219
+ "## Use (in a Volt app with the pages add-on)", "", "```", "npm install volt-theme-__NAME__", "```", "",
220
+ "Then set `THEME=__NAME__` in `.env` and restart. Every page renders inside this theme's `layout()`.", "",
221
+ "## Develop", "", "Edit `index.js` — `layout({ title, head, content, meta })` returns the full HTML document. Put `head` in `<head>` (it carries the SEO/OG/JSON-LD tags).", "", "```", "npm publish", "```", "",
222
+ ].join("\n");
223
+ fs.mkdirSync(dir, { recursive: true });
224
+ const pkgJson = { name: `volt-theme-${name}`, version: "0.1.0", description: `A Volt theme: ${name}`, type: "module", main: "index.js", keywords: ["volt", "volt-theme", "theme"], files: ["index.js"], license: "MIT" };
225
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkgJson, null, 2) + "\n");
226
+ fs.writeFileSync(path.join(dir, "index.js"), THEME_INDEX.replace(/__NAME__/g, name));
227
+ fs.writeFileSync(path.join(dir, "README.md"), THEME_README.replace(/__NAME__/g, name));
228
+ console.log(`${cyan("✓ created")} ${path.relative(process.cwd(), dir) || dir} — a Volt theme.`);
229
+ console.log(dim(` edit index.js (layout), then publish: cd volt-theme-${name} && npm publish`));
230
+ console.log(dim(` use it: npm install volt-theme-${name} then set THEME=${name} in .env`));
231
+ process.exit(0);
232
+ }
233
+
186
234
  // --- `update` subcommand: refresh public/volt.js in the current app to the
187
235
  // version bundled with this create-volt (so `npx create-volt@latest update`
188
236
  // pulls the latest library). Only touches the library file — never the user's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.24.0",
3
+ "version": "0.26.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": {