a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals

feat: build through cli

+8
ROADMAP.md
··· 161 161 - Web 162 162 - Tauri 163 163 - Wails 164 + 165 + ## Docs 166 + 167 + - [ ] Document `charge()` bootstrap flow and declarative state/computed attributes (`data-volt-state`, `data-volt-computed:*`). 168 + - [ ] Add async effect guide covering abort signals, debounce/throttle, retries, and `onError` handling. 169 + - [ ] Write lifecycle instrumentation docs for `registerGlobalHook`, `registerElementHook`, `getElementBindings`, and plugin `context.lifecycle` callbacks. 170 + - [ ] Explain `data-volt-bind:*` semantics, especially boolean attribute handling and dependency subscription behavior. 171 + - [ ] Refresh README and overview content to use `data-volt-*` naming and reflect the current module layout.
+1 -1
cli/eslint.config.js
··· 23 23 tsconfigRootDir: import.meta.dirname, 24 24 }, 25 25 }, 26 - ignores: ["eslint.config.js"], 26 + ignores: ["eslint.config.js", "tsdown.config.ts"], 27 27 rules: { 28 28 "no-undef": "off", 29 29 "@typescript-eslint/no-unused-vars": [
+42
cli/src/commands/build.ts
··· 1 + import { echo } from "$console/echo.js"; 2 + import { buildLibrary, copyBuildArtifacts, findBuildArtifacts } from "$utils/build.js"; 3 + import path from "node:path"; 4 + 5 + export type BuildCommandOptions = { minify?: boolean; css?: boolean }; 6 + 7 + /** 8 + * Build command implementation. 9 + * 10 + * Builds the Volt.js library and copies the build artifacts to the specified 11 + * output directory. Supports optional minification and CSS inclusion. 12 + */ 13 + export async function buildCommand(outDir: string = ".", options: BuildCommandOptions = {}): Promise<void> { 14 + const minify = options.minify !== false; 15 + const includeCss = options.css !== false; 16 + 17 + const resolvedOutDir = path.resolve(process.cwd(), outDir); 18 + 19 + echo.title("\nBuilding Volt.js library...\n"); 20 + echo.info("Building library..."); 21 + await buildLibrary(); 22 + 23 + echo.info("Finding build artifacts..."); 24 + const artifacts = await findBuildArtifacts(); 25 + 26 + echo.info(`Copying to ${resolvedOutDir}...`); 27 + await copyBuildArtifacts(artifacts, { outDir: resolvedOutDir, minify, includeCss }); 28 + 29 + echo.success("\nBuild completed successfully!\n"); 30 + 31 + if (minify) { 32 + echo.info(`Output: ${outDir}${outDir.endsWith("/") ? "" : "/"}volt.min.js`); 33 + if (includeCss) { 34 + echo.info(` ${outDir}${outDir.endsWith("/") ? "" : "/"}volt.min.css\n`); 35 + } 36 + } else { 37 + echo.info(`Output: ${outDir}${outDir.endsWith("/") ? "" : "/"}volt.js`); 38 + if (includeCss) { 39 + echo.info(` ${outDir}${outDir.endsWith("/") ? "" : "/"}volt.css\n`); 40 + } 41 + } 42 + }
+4 -5
cli/src/commands/css-docs.ts
··· 1 + import { echo } from "$console/echo.js"; 2 + import { getDocsPath, getLibSrcPath } from "$utils/paths.js"; 3 + import { trackVersion } from "$versioning/tracker.js"; 1 4 import { mkdir, readFile, writeFile } from "node:fs/promises"; 2 5 import path from "node:path"; 3 - import { echo } from "../console/echo.js"; 4 - import { trackVersion } from "../versioning/tracker.js"; 5 - import { getLibSrcPath, getDocsPath } from "../utils/paths.js"; 6 6 7 7 type CSSComment = { selector: string; comment: string }; 8 8 ··· 265 265 continue; 266 266 } 267 267 268 - lines.push(`### \`${comment.selector}\``, ""); 269 - lines.push(comment.comment, ""); 268 + lines.push(`### \`${comment.selector}\``, "", comment.comment, ""); 270 269 } 271 270 272 271 return lines.join("\n");
+5 -5
cli/src/commands/docs.ts
··· 1 + import { echo } from "$console/echo.js"; 2 + import { getDocsPath, getLibSrcPath } from "$utils/paths.js"; 3 + import { trackVersion } from "$versioning/tracker.js"; 1 4 import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; 2 5 import path from "node:path"; 3 6 import ts from "typescript"; 4 - import { echo } from "../console/echo.js"; 5 - import { trackVersion } from "../versioning/tracker.js"; 6 - import { getLibSrcPath, getDocsPath } from "../utils/paths.js"; 7 7 8 8 type Member = { name: string; type: string; docs?: string }; 9 9 ··· 31 31 return { description: "", examples: [] }; 32 32 } 33 33 34 - const comments = ranges.map((range) => fullText.substring(range.pos, range.end)); 34 + const comments = ranges.map((range) => fullText.slice(range.pos, range.end)); 35 35 const jsdocComments = comments.filter((c) => c.trim().startsWith("/**")); 36 36 37 37 if (jsdocComments.length === 0) { ··· 85 85 function extractFnSig(node: ts.FunctionDeclaration, sourceFile: ts.SourceFile): string { 86 86 const start = node.getStart(sourceFile); 87 87 const end = node.body ? node.body.getStart(sourceFile) : node.getEnd(); 88 - return sourceFile.text.substring(start, end).trim().replaceAll(/\s+/g, " "); 88 + return sourceFile.text.slice(start, end).trim().replaceAll(/\s+/g, " "); 89 89 } 90 90 91 91 /**
+8 -97
cli/src/commands/example.ts
··· 1 - import { existsSync } from "node:fs"; 1 + import { echo } from "$console/echo.js"; 2 + import { type BuildArtifacts, buildLibrary, copyBuildArtifacts, findBuildArtifacts } from "$utils/build.js"; 3 + import { getExamplesPath } from "$utils/paths.js"; 2 4 import { mkdir, readFile, writeFile } from "node:fs/promises"; 3 5 import path from "node:path"; 4 - import { minify as terserMinify } from "terser"; 5 - import { echo } from "../console/echo.js"; 6 - import { findMonorepoRoot, getLibPath, getExamplesPath } from "../utils/paths.js"; 7 6 8 - type BuildArtifacts = { jsPath: string; cssPath: string }; 9 - 10 - /** 11 - * Build the library using pnpm workspace commands 12 - */ 13 - async function buildLibrary(): Promise<void> { 14 - const { execSync } = await import("node:child_process"); 15 - const monorepoRoot = await findMonorepoRoot(); 16 - 17 - try { 18 - execSync("pnpm --filter volt build:lib", { cwd: monorepoRoot, stdio: "inherit" }); 19 - } catch { 20 - throw new Error("Library build failed. Make sure Vite is configured correctly."); 21 - } 22 - } 23 - 24 - /** 25 - * Find the library build artifacts in dist/ 26 - */ 27 - async function findBuildArtifacts(): Promise<BuildArtifacts> { 28 - const libPath = await getLibPath(); 29 - const distDir = path.join(libPath, "dist"); 30 - const jsPath = path.join(distDir, "volt.js"); 31 - const cssPath = path.join(libPath, "src", "styles", "base.css"); 32 - 33 - if (!existsSync(jsPath)) { 34 - throw new Error(`Library JS not found at ${jsPath}. Build may have failed.`); 35 - } 36 - 37 - if (!existsSync(cssPath)) { 38 - throw new Error(`Base CSS not found at ${cssPath}.`); 39 - } 40 - 41 - return { jsPath, cssPath }; 42 - } 43 - 44 - async function minifyJS(code: string): Promise<string> { 45 - const result = await terserMinify(code, { 46 - compress: { 47 - dead_code: true, 48 - drop_debugger: true, 49 - conditionals: true, 50 - evaluate: true, 51 - booleans: true, 52 - loops: true, 53 - unused: true, 54 - hoist_funs: true, 55 - keep_fargs: false, 56 - hoist_vars: false, 57 - if_return: true, 58 - join_vars: true, 59 - side_effects: true, 60 - }, 61 - mangle: { toplevel: true }, 62 - format: { comments: false }, 63 - }); 64 - 65 - if (!result.code) { 66 - throw new Error("Minification failed - no output generated"); 67 - } 68 - 69 - return result.code; 70 - } 71 - 72 - // TODO: use terser 73 - function minifyCSS(code: string): string { 74 - return code.replaceAll(/\/\*[\s\S]*?\*\//g, "").replaceAll(/\s+/g, " ").replaceAll(/\s*([{}:;,])\s*/g, "$1").trim(); 75 - } 7 + export type BuildMode = "markup" | "programmatic"; 76 8 77 9 /** 78 10 * Create minified build artifacts in examples/dist/ 79 11 */ 80 12 async function createMinifiedArtifacts(artifacts: BuildArtifacts, examplesDir: string): Promise<void> { 81 13 const examplesDistDir = path.join(examplesDir, "dist"); 82 - await mkdir(examplesDistDir, { recursive: true }); 83 - 84 - const jsContent = await readFile(artifacts.jsPath, "utf8"); 85 - const cssContent = await readFile(artifacts.cssPath, "utf8"); 86 - 87 - echo.info(" Minifying JavaScript..."); 88 - const minifiedJS = await minifyJS(jsContent); 89 - 90 - echo.info(" Minifying CSS..."); 91 - const minifiedCSS = minifyCSS(cssContent); 92 - 93 - const voltJSPath = path.join(examplesDistDir, "volt.min.js"); 94 - const voltCSSPath = path.join(examplesDistDir, "volt.min.css"); 95 - 96 - await writeFile(voltJSPath, minifiedJS, "utf8"); 97 - await writeFile(voltCSSPath, minifiedCSS, "utf8"); 98 - 99 - echo.ok(` Created: examples/dist/volt.min.js (${Math.round(minifiedJS.length / 1024)} KB)`); 100 - echo.ok(` Created: examples/dist/volt.min.css (${Math.round(minifiedCSS.length / 1024)} KB)`); 14 + await copyBuildArtifacts(artifacts, { outDir: examplesDistDir, minify: true, includeCss: true }); 101 15 } 102 16 103 - function generateHTML(name: string, mode: "markup" | "programmatic", standalone: boolean): string { 17 + function generateHTML(name: string, mode: BuildMode, standalone: boolean): string { 104 18 const cssPath = standalone ? "volt.min.css" : "../dist/volt.min.css"; 105 19 const jsPath = standalone ? "volt.min.js" : "../dist/volt.min.js"; 106 20 ··· 182 96 `; 183 97 } 184 98 185 - /** 186 - * Generate app.js template 187 - */ 188 99 function generateAppJS(): string { 189 100 return `// Import volt.js functions if needed 190 101 // import { mount, signal, computed, effect } from '../dist/volt.min.js'; ··· 209 120 async function createExampleFiles( 210 121 exampleDir: string, 211 122 name: string, 212 - mode: "markup" | "programmatic", 123 + mode: BuildMode, 213 124 standalone: boolean, 214 125 ): Promise<void> { 215 126 await mkdir(exampleDir, { recursive: true }); ··· 255 166 */ 256 167 export async function exampleCommand( 257 168 name: string, 258 - options: { mode?: "markup" | "programmatic"; standalone?: boolean } = {}, 169 + options: { mode?: BuildMode; standalone?: boolean } = {}, 259 170 ): Promise<void> { 260 171 const mode = options.mode || "programmatic"; 261 172 const standalone = options.standalone || false;
+3 -4
cli/src/commands/stats.ts
··· 1 + import { echo } from "$console/echo.js"; 2 + import { findMonorepoRoot, getLibSrcPath, getLibTestPath } from "$utils/paths.js"; 1 3 import { readdir, readFile, stat } from "node:fs/promises"; 2 4 import path from "node:path"; 3 - import { echo } from "../console/echo.js"; 4 - import { getLibSrcPath, getLibTestPath, findMonorepoRoot } from "../utils/paths.js"; 5 5 6 6 type FileStats = { path: string; lines: number; totalLines: number }; 7 7 type DirectoryStats = { totalLines: number; codeLines: number; files: FileStats[] }; ··· 92 92 export async function statsCommand(includeFull: boolean): Promise<void> { 93 93 const monorepoRoot = await findMonorepoRoot(); 94 94 const srcDir = await getLibSrcPath(); 95 + const srcStats = await collectStats(srcDir, monorepoRoot); 95 96 96 97 echo.title("\nVolt.js Code Statistics\n"); 97 - 98 - const srcStats = await collectStats(srcDir, monorepoRoot); 99 98 100 99 echo.label("Source Code (src/):"); 101 100 echo.text(` Files: ${srcStats.files.length}`);
+21 -6
cli/src/index.ts
··· 1 1 /* eslint-disable unicorn/no-process-exit */ 2 + import { buildCommand } from "$commands/build.js"; 3 + import { cssDocsCommand } from "$commands/css-docs.js"; 4 + import { docsCommand } from "$commands/docs.js"; 5 + import { type BuildMode, exampleCommand } from "$commands/example.js"; 6 + import { statsCommand } from "$commands/stats.js"; 7 + import { echo } from "$console/echo.js"; 2 8 import { Command } from "commander"; 3 - import { cssDocsCommand } from "./commands/css-docs.js"; 4 - import { docsCommand } from "./commands/docs.js"; 5 - import { exampleCommand } from "./commands/example.js"; 6 - import { statsCommand } from "./commands/stats.js"; 7 - import { echo } from "./console/echo.js"; 8 9 9 10 const program = new Command(); 10 11 ··· 31 32 } 32 33 }); 33 34 35 + program.command("build [outDir]").description("Build the library and output to a directory").option( 36 + "--no-minify", 37 + "Skip minification (outputs volt.js instead of volt.min.js)", 38 + ).option("--no-css", "Skip CSS output").action( 39 + async (outDir: string | undefined, options: { minify: boolean; css: boolean }) => { 40 + try { 41 + await buildCommand(outDir, options); 42 + } catch (error) { 43 + echo.err("Error building library:", error); 44 + process.exit(1); 45 + } 46 + }, 47 + ); 48 + 34 49 program.command("css-docs").description("Generate CSS documentation from base.css comments and variables").action( 35 50 async () => { 36 51 try { ··· 49 64 "Example mode: markup (declarative) or programmatic (imperative)", 50 65 "programmatic", 51 66 ).option("--standalone", "Create standalone example with local copies of volt.min.js and volt.min.css", false).action( 52 - async (name: string, options: { mode: "markup" | "programmatic"; standalone: boolean }) => { 67 + async (name: string, options: { mode: BuildMode; standalone: boolean }) => { 53 68 try { 54 69 await exampleCommand(name, options); 55 70 } catch (error) {
+133
cli/src/utils/build.ts
··· 1 + import { echo } from "$console/echo.js"; 2 + import { findMonorepoRoot, getLibPath } from "$utils/paths.js"; 3 + import { existsSync } from "node:fs"; 4 + import { mkdir, readFile, writeFile } from "node:fs/promises"; 5 + import path from "node:path"; 6 + import { minify as terserMinify } from "terser"; 7 + 8 + export type BuildArtifacts = { jsPath: string; cssPath: string }; 9 + 10 + export type BuildOptions = { outDir: string; minify?: boolean; includeCss?: boolean }; 11 + 12 + /** 13 + * Build the library using pnpm workspace commands. 14 + * 15 + * Runs `pnpm --filter volt build:lib` in the monorepo root to compile the Volt.js library into lib/dist/. 16 + */ 17 + export async function buildLibrary(): Promise<void> { 18 + const { execSync } = await import("node:child_process"); 19 + const monorepoRoot = await findMonorepoRoot(); 20 + 21 + try { 22 + execSync("pnpm --filter volt build:lib", { cwd: monorepoRoot, stdio: "inherit" }); 23 + } catch { 24 + throw new Error("Library build failed. Make sure Vite is configured correctly."); 25 + } 26 + } 27 + 28 + /** 29 + * Find the library build artifacts in lib/dist/. 30 + * 31 + * Locates the compiled JavaScript and base CSS files after a successful build. 32 + */ 33 + export async function findBuildArtifacts(): Promise<BuildArtifacts> { 34 + const libPath = await getLibPath(); 35 + const distDir = path.join(libPath, "dist"); 36 + const jsPath = path.join(distDir, "volt.js"); 37 + const cssPath = path.join(libPath, "src", "styles", "base.css"); 38 + 39 + if (!existsSync(jsPath)) { 40 + throw new Error(`Library JS not found at ${jsPath}. Build may have failed.`); 41 + } 42 + 43 + if (!existsSync(cssPath)) { 44 + throw new Error(`Base CSS not found at ${cssPath}.`); 45 + } 46 + 47 + return { jsPath, cssPath }; 48 + } 49 + 50 + /** 51 + * Minify JavaScript code using Terser. 52 + * 53 + * Applies aggressive compression and mangling to reduce bundle size. 54 + */ 55 + export async function minifyJS(code: string): Promise<string> { 56 + const result = await terserMinify(code, { 57 + compress: { 58 + dead_code: true, 59 + drop_debugger: true, 60 + conditionals: true, 61 + evaluate: true, 62 + booleans: true, 63 + loops: true, 64 + unused: true, 65 + hoist_funs: true, 66 + keep_fargs: false, 67 + hoist_vars: false, 68 + if_return: true, 69 + join_vars: true, 70 + side_effects: true, 71 + }, 72 + mangle: { toplevel: true }, 73 + format: { comments: false }, 74 + }); 75 + 76 + if (!result.code) { 77 + throw new Error("Minification failed - no output generated"); 78 + } 79 + 80 + return result.code; 81 + } 82 + 83 + /** 84 + * Minify CSS code using regex-based compression. 85 + * 86 + * Removes comments, normalizes whitespace, and strips spacing around CSS syntax characters. 87 + */ 88 + export function minifyCSS(code: string): string { 89 + return code.replaceAll(/\/\*[\s\S]*?\*\//g, "").replaceAll(/\s+/g, " ").replaceAll(/\s*([{}:;,])\s*/g, "$1").trim(); 90 + } 91 + 92 + /** 93 + * Copy build artifacts to the specified output directory. 94 + * 95 + * Reads the built JavaScript and CSS files, optionally minifies them, and writes them to the target directory. 96 + * Creates the directory if needed. 97 + * The output file naming depends on whether minification is enabled. 98 + */ 99 + export async function copyBuildArtifacts(artifacts: BuildArtifacts, options: BuildOptions): Promise<void> { 100 + const { outDir, minify = true, includeCss = true } = options; 101 + 102 + await mkdir(outDir, { recursive: true }); 103 + 104 + const jsContent = await readFile(artifacts.jsPath, "utf8"); 105 + const jsFilename = minify ? "volt.min.js" : "volt.js"; 106 + let outputJS = jsContent; 107 + 108 + if (minify) { 109 + echo.info(" Minifying JavaScript..."); 110 + outputJS = await minifyJS(jsContent); 111 + } 112 + 113 + const jsOutputPath = path.join(outDir, jsFilename); 114 + await writeFile(jsOutputPath, outputJS, "utf8"); 115 + const jsSize = Math.round(outputJS.length / 1024); 116 + echo.ok(` Created: ${jsFilename} (${jsSize} KB)`); 117 + 118 + if (includeCss) { 119 + const cssContent = await readFile(artifacts.cssPath, "utf8"); 120 + const cssFilename = minify ? "volt.min.css" : "volt.css"; 121 + let outputCSS = cssContent; 122 + 123 + if (minify) { 124 + echo.info(" Minifying CSS..."); 125 + outputCSS = minifyCSS(cssContent); 126 + } 127 + 128 + const cssOutputPath = path.join(outDir, cssFilename); 129 + await writeFile(cssOutputPath, outputCSS, "utf8"); 130 + const cssSize = Math.round(outputCSS.length / 1024); 131 + echo.ok(` Created: ${cssFilename} (${cssSize} KB)`); 132 + } 133 + }
+8 -1
cli/tsconfig.json
··· 14 14 "esModuleInterop": true, 15 15 "isolatedModules": true, 16 16 "verbatimModuleSyntax": true, 17 - "skipLibCheck": true 17 + "skipLibCheck": true, 18 + "baseUrl": ".", 19 + "paths": { 20 + "$commands/*": ["./src/commands/*"], 21 + "$utils/*": ["./src/utils/*"], 22 + "$versioning/*": ["./src/versioning/*"], 23 + "$console/*": ["./src/console/*"] 24 + } 18 25 }, 19 26 "include": ["src"] 20 27 }
+6
cli/tsdown.config.ts
··· 6 6 clean: true, 7 7 shims: true, 8 8 banner: { js: "#!/usr/bin/env node" }, 9 + alias: { 10 + $commands: "./src/commands", 11 + $utils: "./src/utils", 12 + $versioning: "./src/versioning", 13 + $console: "./src/console", 14 + }, 9 15 });
+7 -1
dprint.json
··· 1 1 { 2 2 "typescript": { "preferSingleLine": true, "jsx.bracketPosition": "sameLine" }, 3 3 "json": { "preferSingleLine": true, "lineWidth": 121, "indentWidth": 2 }, 4 - "markup": { "preferAttrsSingleLine": true, "printWidth": 121, "styleIndent": true, "scriptIndent": true }, 4 + "markup": { 5 + "preferAttrsSingleLine": true, 6 + "printWidth": 121, 7 + "styleIndent": true, 8 + "scriptIndent": true, 9 + "closingBracketSameLine": true 10 + }, 5 11 "excludes": ["**/node_modules"], 6 12 "plugins": [ 7 13 "https://plugins.dprint.dev/typescript-0.95.8.wasm",