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

Configure Feed

Select the types of activity you want to include in your feed.

feat: if & for directives fix: cli fs walk

+1220 -31
+1 -1
cli/package.json
··· 29 29 "typescript-eslint": "^8.46.1", 30 30 "vitest": "^3.2.4" 31 31 }, 32 - "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.1", "typescript": "^5.9.3" } 32 + "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.1", "terser": "^5.44.0", "typescript": "^5.9.3" } 33 33 }
+58 -10
cli/pnpm-lock.yaml
··· 14 14 commander: 15 15 specifier: ^14.0.1 16 16 version: 14.0.1 17 + terser: 18 + specifier: ^5.44.0 19 + version: 5.44.0 17 20 typescript: 18 21 specifier: ^5.9.3 19 22 version: 5.9.3 ··· 47 50 version: 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) 48 51 vitest: 49 52 specifier: ^3.2.4 50 - version: 3.2.4(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1) 53 + version: 3.2.4(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) 51 54 52 55 packages: 53 56 ··· 314 317 '@jridgewell/resolve-uri@3.1.2': 315 318 resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 316 319 engines: {node: '>=6.0.0'} 320 + 321 + '@jridgewell/source-map@0.3.11': 322 + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} 317 323 318 324 '@jridgewell/sourcemap-codec@1.5.5': 319 325 resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} ··· 704 710 engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} 705 711 hasBin: true 706 712 713 + buffer-from@1.1.2: 714 + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} 715 + 707 716 builtin-modules@5.0.0: 708 717 resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} 709 718 engines: {node: '>=18.20'} ··· 776 785 commander@14.0.1: 777 786 resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} 778 787 engines: {node: '>=20'} 788 + 789 + commander@2.20.3: 790 + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} 779 791 780 792 concat-map@0.0.1: 781 793 resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} ··· 1287 1299 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 1288 1300 engines: {node: '>=0.10.0'} 1289 1301 1302 + source-map-support@0.5.21: 1303 + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} 1304 + 1305 + source-map@0.6.1: 1306 + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 1307 + engines: {node: '>=0.10.0'} 1308 + 1290 1309 stackback@0.0.2: 1291 1310 resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} 1292 1311 ··· 1307 1326 supports-color@7.2.0: 1308 1327 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 1309 1328 engines: {node: '>=8'} 1329 + 1330 + terser@5.44.0: 1331 + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} 1332 + engines: {node: '>=10'} 1333 + hasBin: true 1310 1334 1311 1335 tinybench@2.9.0: 1312 1336 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} ··· 1695 1719 1696 1720 '@jridgewell/resolve-uri@3.1.2': {} 1697 1721 1722 + '@jridgewell/source-map@0.3.11': 1723 + dependencies: 1724 + '@jridgewell/gen-mapping': 0.3.13 1725 + '@jridgewell/trace-mapping': 0.3.31 1726 + 1698 1727 '@jridgewell/sourcemap-codec@1.5.5': {} 1699 1728 1700 1729 '@jridgewell/trace-mapping@0.3.31': ··· 1959 1988 chai: 5.3.3 1960 1989 tinyrainbow: 2.0.0 1961 1990 1962 - '@vitest/mocker@3.2.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1))': 1991 + '@vitest/mocker@3.2.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))': 1963 1992 dependencies: 1964 1993 '@vitest/spy': 3.2.4 1965 1994 estree-walker: 3.0.3 1966 1995 magic-string: 0.30.19 1967 1996 optionalDependencies: 1968 - vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1) 1997 + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) 1969 1998 1970 1999 '@vitest/pretty-format@3.2.4': 1971 2000 dependencies: ··· 2050 2079 node-releases: 2.0.25 2051 2080 update-browserslist-db: 1.1.3(browserslist@4.26.3) 2052 2081 2082 + buffer-from@1.1.2: {} 2083 + 2053 2084 builtin-modules@5.0.0: {} 2054 2085 2055 2086 bumpp@10.3.1: ··· 2129 2160 color-name@1.1.4: {} 2130 2161 2131 2162 commander@14.0.1: {} 2163 + 2164 + commander@2.20.3: {} 2132 2165 2133 2166 concat-map@0.0.1: {} 2134 2167 ··· 2647 2680 2648 2681 source-map-js@1.2.1: {} 2649 2682 2683 + source-map-support@0.5.21: 2684 + dependencies: 2685 + buffer-from: 1.1.2 2686 + source-map: 0.6.1 2687 + 2688 + source-map@0.6.1: {} 2689 + 2650 2690 stackback@0.0.2: {} 2651 2691 2652 2692 std-env@3.10.0: {} ··· 2662 2702 supports-color@7.2.0: 2663 2703 dependencies: 2664 2704 has-flag: 4.0.0 2705 + 2706 + terser@5.44.0: 2707 + dependencies: 2708 + '@jridgewell/source-map': 0.3.11 2709 + acorn: 8.15.0 2710 + commander: 2.20.3 2711 + source-map-support: 0.5.21 2665 2712 2666 2713 tinybench@2.9.0: {} 2667 2714 ··· 2754 2801 dependencies: 2755 2802 punycode: 2.3.1 2756 2803 2757 - vite-node@3.2.4(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1): 2804 + vite-node@3.2.4(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1): 2758 2805 dependencies: 2759 2806 cac: 6.7.14 2760 2807 debug: 4.4.3 2761 2808 es-module-lexer: 1.7.0 2762 2809 pathe: 2.0.3 2763 - vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1) 2810 + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) 2764 2811 transitivePeerDependencies: 2765 2812 - '@types/node' 2766 2813 - jiti ··· 2775 2822 - tsx 2776 2823 - yaml 2777 2824 2778 - vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1): 2825 + vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1): 2779 2826 dependencies: 2780 2827 esbuild: 0.25.11 2781 2828 fdir: 6.5.0(picomatch@4.0.3) ··· 2787 2834 '@types/node': 24.8.1 2788 2835 fsevents: 2.3.3 2789 2836 jiti: 2.6.1 2837 + terser: 5.44.0 2790 2838 yaml: 2.8.1 2791 2839 2792 - vitest@3.2.4(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1): 2840 + vitest@3.2.4(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1): 2793 2841 dependencies: 2794 2842 '@types/chai': 5.2.2 2795 2843 '@vitest/expect': 3.2.4 2796 - '@vitest/mocker': 3.2.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1)) 2844 + '@vitest/mocker': 3.2.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) 2797 2845 '@vitest/pretty-format': 3.2.4 2798 2846 '@vitest/runner': 3.2.4 2799 2847 '@vitest/snapshot': 3.2.4 ··· 2811 2859 tinyglobby: 0.2.15 2812 2860 tinypool: 1.1.1 2813 2861 tinyrainbow: 2.0.0 2814 - vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1) 2815 - vite-node: 3.2.4(@types/node@24.8.1)(jiti@2.6.1)(yaml@2.8.1) 2862 + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) 2863 + vite-node: 3.2.4(@types/node@24.8.1)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) 2816 2864 why-is-node-running: 2.3.0 2817 2865 optionalDependencies: 2818 2866 '@types/node': 24.8.1
+263
cli/src/commands/example.ts
··· 1 + import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; 2 + import { existsSync } from "node:fs"; 3 + import path from "node:path"; 4 + import { minify as terserMinify } from "terser"; 5 + import { echo } from "../console/echo.js"; 6 + 7 + type BuildArtifacts = { jsPath: string; cssPath: string }; 8 + 9 + /** 10 + * Find the project root by walking up the directory tree. 11 + * Looks for a directory containing dist/assets/ or package.json with name "volt" 12 + */ 13 + async function findProjectRoot(startDir: string): Promise<string> { 14 + let currentDir = startDir; 15 + const maxDepth = 10; 16 + let depth = 0; 17 + 18 + while (depth < maxDepth) { 19 + const distAssetsPath = path.join(currentDir, "dist", "assets"); 20 + const packageJsonPath = path.join(currentDir, "package.json"); 21 + 22 + if (existsSync(distAssetsPath)) { 23 + return currentDir; 24 + } 25 + 26 + if (existsSync(packageJsonPath)) { 27 + try { 28 + const pkgContent = JSON.parse(await readFile(packageJsonPath, "utf8")); 29 + if (pkgContent.name === "volt") { 30 + return currentDir; 31 + } 32 + } catch { 33 + // Continue searching 34 + } 35 + } 36 + 37 + const parentDir = path.dirname(currentDir); 38 + if (parentDir === currentDir) { 39 + throw new Error("Could not find Volt project root. Make sure you're in the Volt project directory."); 40 + } 41 + 42 + currentDir = parentDir; 43 + depth++; 44 + } 45 + 46 + throw new Error("Could not find Volt project root. Make sure you're in the Volt project directory."); 47 + } 48 + 49 + /** 50 + * Find the hashed build artifacts in dist/assets/ 51 + */ 52 + async function findBuildArtifacts(root: string): Promise<BuildArtifacts> { 53 + const distAssetsDir = path.join(root, "dist", "assets"); 54 + 55 + let entries: string[]; 56 + try { 57 + const dirents = await readdir(distAssetsDir, { withFileTypes: true }); 58 + entries = dirents.filter((d) => d.isFile()).map((d) => d.name); 59 + } catch { 60 + throw new Error("Build artifacts not found. Run 'pnpm build' first to generate dist/assets/"); 61 + } 62 + 63 + const jsFile = entries.find((f) => f.startsWith("index-") && f.endsWith(".js")); 64 + const cssFile = entries.find((f) => f.startsWith("index-") && f.endsWith(".css")); 65 + 66 + if (!jsFile || !cssFile) { 67 + throw new Error("Build artifacts incomplete. Expected index-*.js and index-*.css in dist/assets/"); 68 + } 69 + 70 + return { jsPath: path.join(distAssetsDir, jsFile), cssPath: path.join(distAssetsDir, cssFile) }; 71 + } 72 + 73 + /** 74 + * Minify JavaScript code using terser 75 + */ 76 + async function minifyJS(code: string): Promise<string> { 77 + const result = await terserMinify(code, { 78 + compress: { 79 + dead_code: true, 80 + drop_debugger: true, 81 + conditionals: true, 82 + evaluate: true, 83 + booleans: true, 84 + loops: true, 85 + unused: true, 86 + hoist_funs: true, 87 + keep_fargs: false, 88 + hoist_vars: false, 89 + if_return: true, 90 + join_vars: true, 91 + side_effects: true, 92 + }, 93 + mangle: { toplevel: true }, 94 + format: { comments: false }, 95 + }); 96 + 97 + if (!result.code) { 98 + throw new Error("Minification failed - no output generated"); 99 + } 100 + 101 + return result.code; 102 + } 103 + 104 + /** 105 + * Minify CSS code (simple minification) 106 + */ 107 + function minifyCSS(code: string): string { 108 + return code.replaceAll(/\/\*[\s\S]*?\*\//g, "").replaceAll(/\s+/g, " ").replaceAll(/\s*([{}:;,])\s*/g, "$1").trim(); 109 + } 110 + 111 + /** 112 + * Create minified build artifacts in examples/dist/ 113 + */ 114 + async function createMinifiedArtifacts(artifacts: BuildArtifacts, examplesDir: string): Promise<void> { 115 + const examplesDistDir = path.join(examplesDir, "dist"); 116 + await mkdir(examplesDistDir, { recursive: true }); 117 + 118 + const jsContent = await readFile(artifacts.jsPath, "utf8"); 119 + const cssContent = await readFile(artifacts.cssPath, "utf8"); 120 + 121 + echo.info(" Minifying JavaScript..."); 122 + const minifiedJS = await minifyJS(jsContent); 123 + 124 + echo.info(" Minifying CSS..."); 125 + const minifiedCSS = minifyCSS(cssContent); 126 + 127 + const voltJSPath = path.join(examplesDistDir, "volt.min.js"); 128 + const voltCSSPath = path.join(examplesDistDir, "volt.min.css"); 129 + 130 + await writeFile(voltJSPath, minifiedJS, "utf8"); 131 + await writeFile(voltCSSPath, minifiedCSS, "utf8"); 132 + 133 + echo.ok(` Created: examples/dist/volt.min.js (${Math.round(minifiedJS.length / 1024)} KB)`); 134 + echo.ok(` Created: examples/dist/volt.min.css (${Math.round(minifiedCSS.length / 1024)} KB)`); 135 + } 136 + 137 + function generateHTML(name: string): string { 138 + return `<!DOCTYPE html> 139 + <html lang="en"> 140 + <head> 141 + <meta charset="UTF-8"> 142 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 143 + <title>${name} - Volt.js Example</title> 144 + <link rel="stylesheet" href="../dist/volt.min.css"> 145 + <link rel="stylesheet" href="app.css"> 146 + </head> 147 + <body> 148 + <div id="app"> 149 + <h1>${name}</h1> 150 + <!-- Add your HTML here with data-x-* attributes --> 151 + </div> 152 + 153 + <script type="module" src="../dist/volt.min.js"></script> 154 + <script type="module" src="app.js"></script> 155 + </body> 156 + </html> 157 + `; 158 + } 159 + 160 + function generateREADME(name: string): string { 161 + return `# ${name} 162 + 163 + ## Description 164 + 165 + Brief description of what this example demonstrates. 166 + 167 + ## Features 168 + 169 + - Feature 1 170 + - Feature 2 171 + - Feature 3 172 + 173 + ## Running the Example 174 + 175 + 1. Make sure the project is built: \`pnpm build\` from the root 176 + 2. Open \`index.html\` in a browser 177 + 3. Or use a local server: \`python3 -m http.server 8000\` 178 + 179 + ## Code Highlights 180 + 181 + Explain key parts of the implementation here. 182 + `; 183 + } 184 + 185 + /** 186 + * Generate app.js template 187 + */ 188 + function generateAppJS(): string { 189 + return `// Import volt.js functions if needed 190 + // import { mount, signal, computed, effect } from '../dist/volt.min.js'; 191 + 192 + // Add your custom JavaScript here 193 + // Example: 194 + // const state = { 195 + // count: signal(0) 196 + // }; 197 + 198 + // mount(document.querySelector('#app'), state); 199 + `; 200 + } 201 + 202 + function generateAppCSS(): string { 203 + return `/* Add your custom styles here */ 204 + 205 + /* Example: 206 + body { 207 + font-family: system-ui, sans-serif; 208 + max-width: 800px; 209 + margin: 0 auto; 210 + padding: 2rem; 211 + } 212 + */ 213 + `; 214 + } 215 + 216 + /** 217 + * Create example directory with all files 218 + */ 219 + async function createExampleFiles(exampleDir: string, name: string): Promise<void> { 220 + await mkdir(exampleDir, { recursive: true }); 221 + 222 + const files = [ 223 + { path: "index.html", content: generateHTML(name) }, 224 + { path: "README.md", content: generateREADME(name) }, 225 + { path: "app.js", content: generateAppJS() }, 226 + { path: "app.css", content: generateAppCSS() }, 227 + ]; 228 + 229 + for (const file of files) { 230 + const filePath = path.join(exampleDir, file.path); 231 + await writeFile(filePath, file.content, "utf8"); 232 + echo.ok(` Created: examples/${name}/${file.path}`); 233 + } 234 + } 235 + 236 + /** 237 + * Example (generator) command implementation 238 + * 239 + * Creates a new example scaffold with minified volt.js build artifacts 240 + */ 241 + export async function exampleCommand(name: string): Promise<void> { 242 + const root = await findProjectRoot(process.cwd()); 243 + const examplesDir = path.join(root, "examples"); 244 + const exampleDir = path.join(examplesDir, name); 245 + 246 + echo.title(`\nCreating example: ${name}\n`); 247 + 248 + echo.info("Finding build artifacts..."); 249 + const artifacts = await findBuildArtifacts(root); 250 + 251 + echo.info("Creating minified build artifacts..."); 252 + await createMinifiedArtifacts(artifacts, examplesDir); 253 + 254 + echo.info(`\nScaffolding example files...`); 255 + await createExampleFiles(exampleDir, name); 256 + 257 + echo.success(`\nExample created successfully!\n`); 258 + echo.info(`Location: examples/${name}/`); 259 + echo.info(`Next steps:`); 260 + echo.text(` 1. Edit examples/${name}/index.html to add your UI`); 261 + echo.text(` 2. Edit examples/${name}/app.js to add your logic`); 262 + echo.text(` 3. Open examples/${name}/index.html in a browser\n`); 263 + }
+13
cli/src/index.ts
··· 1 + /* eslint-disable unicorn/no-process-exit */ 1 2 import { Command } from "commander"; 2 3 import { cssDocsCommand } from "./commands/css-docs.js"; 3 4 import { docsCommand } from "./commands/docs.js"; 5 + import { exampleCommand } from "./commands/example.js"; 4 6 import { statsCommand } from "./commands/stats.js"; 5 7 import { echo } from "./console/echo.js"; 6 8 ··· 39 41 } 40 42 }, 41 43 ); 44 + 45 + const example = program.command("example").description("Manage examples for Volt.js"); 46 + 47 + example.command("new <name>").description("Create a new example with scaffolded files").action(async (name: string) => { 48 + try { 49 + await exampleCommand(name); 50 + } catch (error) { 51 + echo.err("Error creating example:", error); 52 + process.exit(1); 53 + } 54 + }); 42 55 43 56 program.parse();
+6 -1
eslint.config.js
··· 23 23 tsconfigRootDir: import.meta.dirname, 24 24 }, 25 25 }, 26 - ignores: ["./cli/**", "eslint.config.js", "vite.config.ts"], 26 + ignores: [ 27 + "./cli/**", 28 + "eslint.config.js", 29 + "vite.config.ts", 30 + "./examples/**", 31 + ], 27 32 rules: { 28 33 "no-undef": "off", 29 34 "@typescript-eslint/no-unused-vars": [
+179 -5
src/core/binder.ts
··· 8 8 import { getPlugin } from "./plugin"; 9 9 10 10 /** 11 - * Mount Volt.js on a root element and its descendants. 12 - * Binds all data-x-* attributes to the provided scope. 11 + * Mount Volt.js on a root element and its descendants and binds all data-x-* attributes to the provided scope. 13 12 * Returns a cleanup function to unmount and dispose all bindings. 14 13 * 15 14 * @param root - Root element to mount on ··· 24 23 const attributes = getVoltAttributes(element); 25 24 const context: BindingContext = { element, scope, cleanups: [] }; 26 25 27 - for (const [name, value] of attributes) { 28 - bindAttribute(context, name, value); 26 + if (attributes.has("for")) { 27 + const forExpression = attributes.get("for")!; 28 + bindFor(context, forExpression); 29 + } else if (attributes.has("if")) { 30 + const ifExpression = attributes.get("if")!; 31 + bindIf(context, ifExpression); 32 + } else { 33 + for (const [name, value] of attributes) { 34 + bindAttribute(context, name, value); 35 + } 29 36 } 30 37 31 38 allCleanups.push(...context.cleanups); ··· 68 75 } 69 76 case "class": { 70 77 bindClass(context, value); 78 + break; 79 + } 80 + case "for": { 81 + bindFor(context, value); 71 82 break; 72 83 } 73 84 default: { ··· 222 233 } 223 234 224 235 if ( 225 - typeof current === "object" && current !== null && "get" in current && "set" in current && "subscribe" in current 236 + typeof current === "object" 237 + && current !== null 238 + && "get" in current 239 + && "subscribe" in current 240 + && typeof (current as { get: unknown }).get === "function" 241 + && typeof (current as { subscribe: unknown }).subscribe === "function" 226 242 ) { 227 243 return current as Signal<unknown>; 244 + } 245 + 246 + return undefined; 247 + } 248 + 249 + /** 250 + * Bind data-x-for to render a list of items. 251 + * Subscribes to array signal and re-renders when array changes. 252 + * 253 + * @param context - Binding context 254 + * @param expression - Expression like "item in items" or "(item, index) in items" 255 + */ 256 + function bindFor(context: BindingContext, expression: string): void { 257 + const parsed = parseForExpression(expression); 258 + if (!parsed) { 259 + console.error(`Invalid data-x-for expression: "${expression}"`); 260 + return; 261 + } 262 + 263 + const { itemName, indexName, arrayPath } = parsed; 264 + const template = context.element as HTMLElement; 265 + const parent = template.parentElement; 266 + 267 + if (!parent) { 268 + console.error("data-x-for element must have a parent"); 269 + return; 270 + } 271 + 272 + const placeholder = document.createComment(`for: ${expression}`); 273 + template.before(placeholder); 274 + template.remove(); 275 + 276 + const renderedElements: Element[] = []; 277 + const renderedCleanups: CleanupFunction[] = []; 278 + 279 + const render = () => { 280 + for (const cleanup of renderedCleanups) { 281 + cleanup(); 282 + } 283 + renderedCleanups.length = 0; 284 + 285 + for (const element of renderedElements) { 286 + element.remove(); 287 + } 288 + renderedElements.length = 0; 289 + 290 + const arrayValue = evaluate(arrayPath, context.scope); 291 + if (!Array.isArray(arrayValue)) { 292 + return; 293 + } 294 + 295 + for (const [index, item] of arrayValue.entries()) { 296 + const clone = template.cloneNode(true) as Element; 297 + delete (clone as HTMLElement).dataset.xFor; 298 + 299 + const itemScope: Scope = { ...context.scope, [itemName]: item }; 300 + if (indexName) { 301 + itemScope[indexName] = index; 302 + } 303 + 304 + const cleanup = mount(clone, itemScope); 305 + renderedCleanups.push(cleanup); 306 + renderedElements.push(clone); 307 + 308 + placeholder.before(clone); 309 + } 310 + }; 311 + 312 + render(); 313 + 314 + const signal = findSignalInScope(context.scope, arrayPath); 315 + if (signal) { 316 + const unsubscribe = signal.subscribe(render); 317 + context.cleanups.push(unsubscribe); 318 + } 319 + 320 + context.cleanups.push(() => { 321 + for (const cleanup of renderedCleanups) { 322 + cleanup(); 323 + } 324 + }); 325 + } 326 + 327 + /** 328 + * Bind data-x-if to conditionally render an element. 329 + * Subscribes to condition signal and shows/hides element when condition changes. 330 + * 331 + * @param context - Binding context 332 + * @param expression - Expression to evaluate as condition 333 + */ 334 + function bindIf(context: BindingContext, expression: string): void { 335 + const template = context.element as HTMLElement; 336 + const parent = template.parentElement; 337 + 338 + if (!parent) { 339 + console.error("data-x-if element must have a parent"); 340 + return; 341 + } 342 + 343 + const placeholder = document.createComment(`if: ${expression}`); 344 + template.before(placeholder); 345 + template.remove(); 346 + 347 + let currentElement: Element | undefined; 348 + let currentCleanup: CleanupFunction | undefined; 349 + 350 + const render = () => { 351 + const condition = evaluate(expression, context.scope); 352 + const shouldShow = Boolean(condition); 353 + 354 + if (shouldShow && !currentElement) { 355 + currentElement = template.cloneNode(true) as Element; 356 + delete (currentElement as HTMLElement).dataset.xIf; 357 + currentCleanup = mount(currentElement, context.scope); 358 + placeholder.before(currentElement); 359 + } else if (!shouldShow && currentElement) { 360 + if (currentCleanup) { 361 + currentCleanup(); 362 + } 363 + currentElement.remove(); 364 + currentElement = undefined; 365 + currentCleanup = undefined; 366 + } 367 + }; 368 + 369 + render(); 370 + 371 + const signal = findSignalInScope(context.scope, expression); 372 + if (signal) { 373 + const unsubscribe = signal.subscribe(render); 374 + context.cleanups.push(unsubscribe); 375 + } 376 + 377 + context.cleanups.push(() => { 378 + if (currentCleanup) { 379 + currentCleanup(); 380 + } 381 + }); 382 + } 383 + 384 + /** 385 + * Parse a data-x-for expression. 386 + * Supports: "item in items" or "(item, index) in items" 387 + * 388 + * @param expr - The for expression 389 + * @returns Parsed parts or undefined if invalid 390 + */ 391 + function parseForExpression(expr: string): { itemName: string; indexName?: string; arrayPath: string } | undefined { 392 + const trimmed = expr.trim(); 393 + 394 + const withIndex = /^\((\w+)\s*,\s*(\w+)\)\s+in\s+(.+)$/.exec(trimmed); 395 + if (withIndex) { 396 + return { itemName: withIndex[1], indexName: withIndex[2], arrayPath: withIndex[3].trim() }; 397 + } 398 + 399 + const simple = /^(\w+)\s+in\s+(.+)$/.exec(trimmed); 400 + if (simple) { 401 + return { itemName: simple[1], indexName: undefined, arrayPath: simple[2].trim() }; 228 402 } 229 403 230 404 return undefined;
+19 -6
src/core/dom.ts
··· 5 5 /** 6 6 * Walk the DOM tree and collect all elements with data-x-* attributes. 7 7 * Returns elements in document order (parent before children). 8 + * Skips children of elements with data-x-for or data-x-if since those 9 + * will be processed when the parent element is cloned and mounted. 8 10 * 9 11 * @param root - The root element to start walking from 10 12 * @returns Array of elements with data-x-* attributes 11 13 */ 12 14 export function walkDOM(root: Element): Element[] { 13 15 const elements: Element[] = []; 14 - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); 16 + 17 + function walk(element: Element): void { 18 + if (hasVoltAttribute(element)) { 19 + elements.push(element); 20 + 21 + if ( 22 + Object.hasOwn((element as HTMLElement).dataset, "xFor") 23 + || Object.hasOwn((element as HTMLElement).dataset, "xIf") 24 + ) { 25 + return; 26 + } 27 + } 15 28 16 - let node = walker.currentNode as Element; 17 - do { 18 - if (hasVoltAttribute(node)) { 19 - elements.push(node); 29 + for (const child of element.children) { 30 + walk(child); 20 31 } 21 - } while ((node = walker.nextNode() as Element)); 32 + } 33 + 34 + walk(root); 22 35 23 36 return elements; 24 37 }
+8 -8
src/core/evaluator.ts
··· 7 7 /** 8 8 * Evaluate a simple expression against a scope object. 9 9 * Supports: 10 - * - Property access: "count", "user.name", "items.length" 11 - * - Simple literals: "true", "false", "null", "undefined" 12 - * - Numbers: "42", "3.14" 13 - * - Strings: "'hello'", '"world"' 10 + * - Property access: "count", "user.name", "items.length" 11 + * - Simple literals: "true", "false", "null", "undefined" 12 + * - Numbers: "42", "3.14" 13 + * - Strings: "'hello'", '"world"' 14 14 * 15 15 * @param expression - The expression string to evaluate 16 16 * @param scope - The scope object containing values ··· 81 81 } 82 82 83 83 /** 84 - * Check if a value is a Signal. 84 + * Check if a value is a Signal or ComputedSignal. 85 85 * 86 86 * @param value - Value to check 87 - * @returns true if the value is a Signal 87 + * @returns true if the value is a Signal or ComputedSignal 88 88 */ 89 89 function isSignal(value: unknown): value is { get: () => unknown } { 90 90 return (typeof value === "object" 91 91 && value !== null 92 92 && "get" in value 93 - && "set" in value 94 93 && "subscribe" in value 95 - && typeof value.get === "function"); 94 + && typeof value.get === "function" 95 + && typeof (value as { subscribe: unknown }).subscribe === "function"); 96 96 }
+246
test/core/for-binding.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { signal } from "@volt/core/signal"; 3 + import { describe, expect, it } from "vitest"; 4 + 5 + describe("data-x-for binding", () => { 6 + it("renders a list from array signal", () => { 7 + const container = document.createElement("div"); 8 + container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 9 + 10 + const items = signal(["apple", "banana", "cherry"]); 11 + mount(container, { items }); 12 + 13 + const ul = container.querySelector("ul")!; 14 + const listItems = ul.querySelectorAll("li"); 15 + 16 + expect(listItems.length).toBe(3); 17 + expect(listItems[0]?.textContent).toBe("apple"); 18 + expect(listItems[1]?.textContent).toBe("banana"); 19 + expect(listItems[2]?.textContent).toBe("cherry"); 20 + }); 21 + 22 + it("updates list when signal changes", () => { 23 + const container = document.createElement("div"); 24 + container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 25 + 26 + const items = signal(["one", "two"]); 27 + mount(container, { items }); 28 + 29 + const ul = container.querySelector("ul")!; 30 + let listItems = ul.querySelectorAll("li"); 31 + 32 + expect(listItems.length).toBe(2); 33 + expect(listItems[0]?.textContent).toBe("one"); 34 + expect(listItems[1]?.textContent).toBe("two"); 35 + 36 + items.set(["one", "two", "three", "four"]); 37 + listItems = ul.querySelectorAll("li"); 38 + 39 + expect(listItems.length).toBe(4); 40 + expect(listItems[2]?.textContent).toBe("three"); 41 + expect(listItems[3]?.textContent).toBe("four"); 42 + 43 + items.set(["solo"]); 44 + listItems = ul.querySelectorAll("li"); 45 + 46 + expect(listItems.length).toBe(1); 47 + expect(listItems[0]?.textContent).toBe("solo"); 48 + }); 49 + 50 + it("renders list with object properties", () => { 51 + const container = document.createElement("div"); 52 + container.innerHTML = ` 53 + <ul> 54 + <li data-x-for="user in users"> 55 + <span data-x-text="user.name"></span> 56 + </li> 57 + </ul> 58 + `; 59 + 60 + const users = signal([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]); 61 + 62 + mount(container, { users }); 63 + 64 + const spans = container.querySelectorAll("span"); 65 + expect(spans.length).toBe(2); 66 + expect(spans[0]?.textContent).toBe("Alice"); 67 + expect(spans[1]?.textContent).toBe("Bob"); 68 + }); 69 + 70 + it("supports index access with (item, index) syntax", () => { 71 + const container = document.createElement("div"); 72 + container.innerHTML = ` 73 + <ul> 74 + <li data-x-for="(item, i) in items"> 75 + <span data-x-text="i"></span>: <span data-x-text="item"></span> 76 + </li> 77 + </ul> 78 + `; 79 + 80 + const items = signal(["first", "second", "third"]); 81 + mount(container, { items }); 82 + 83 + const listItems = container.querySelectorAll("li"); 84 + expect(listItems.length).toBe(3); 85 + 86 + const firstItem = listItems[0]?.querySelectorAll("span"); 87 + expect(firstItem?.[0]?.textContent).toBe("0"); 88 + expect(firstItem?.[1]?.textContent).toBe("first"); 89 + 90 + const secondItem = listItems[1]?.querySelectorAll("span"); 91 + expect(secondItem?.[0]?.textContent).toBe("1"); 92 + expect(secondItem?.[1]?.textContent).toBe("second"); 93 + }); 94 + 95 + it("handles empty arrays", () => { 96 + const container = document.createElement("div"); 97 + container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 98 + 99 + const items = signal<string[]>([]); 100 + mount(container, { items }); 101 + 102 + const listItems = container.querySelectorAll("li"); 103 + expect(listItems.length).toBe(0); 104 + 105 + items.set(["now", "there", "are", "items"]); 106 + const updatedItems = container.querySelectorAll("li"); 107 + expect(updatedItems.length).toBe(4); 108 + }); 109 + 110 + it("handles static arrays (non-signal)", () => { 111 + const container = document.createElement("div"); 112 + container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 113 + 114 + mount(container, { items: ["static", "array"] }); 115 + 116 + const listItems = container.querySelectorAll("li"); 117 + expect(listItems.length).toBe(2); 118 + expect(listItems[0]?.textContent).toBe("static"); 119 + expect(listItems[1]?.textContent).toBe("array"); 120 + }); 121 + 122 + it("supports event handlers in list items", () => { 123 + const container = document.createElement("div"); 124 + container.innerHTML = ` 125 + <ul> 126 + <li data-x-for="item in items"> 127 + <button data-x-on-click="handleClick" data-x-text="item.text"></button> 128 + </li> 129 + </ul> 130 + `; 131 + 132 + let clickedItem = ""; 133 + const handleClick = (event: Event) => { 134 + const button = event.target as HTMLButtonElement; 135 + clickedItem = button.textContent || ""; 136 + }; 137 + 138 + const items = signal([{ text: "Click Me" }, { text: "Or Me" }]); 139 + mount(container, { items, handleClick }); 140 + 141 + const buttons = container.querySelectorAll("button"); 142 + expect(buttons.length).toBe(2); 143 + 144 + buttons[0]?.click(); 145 + expect(clickedItem).toBe("Click Me"); 146 + 147 + buttons[1]?.click(); 148 + expect(clickedItem).toBe("Or Me"); 149 + }); 150 + 151 + it("supports nested loops", () => { 152 + const container = document.createElement("div"); 153 + container.innerHTML = ` 154 + <div data-x-for="group in groups"> 155 + <ul> 156 + <li data-x-for="item in group.items" data-x-text="item"></li> 157 + </ul> 158 + </div> 159 + `; 160 + 161 + const groups = signal([{ items: ["a", "b"] }, { items: ["c", "d", "e"] }]); 162 + 163 + mount(container, { groups }); 164 + 165 + const divs = container.querySelectorAll("div"); 166 + expect(divs.length).toBe(2); 167 + 168 + const firstGroupItems = divs[0]?.querySelectorAll("li"); 169 + expect(firstGroupItems?.length).toBe(2); 170 + expect(firstGroupItems?.[0]?.textContent).toBe("a"); 171 + 172 + const secondGroupItems = divs[1]?.querySelectorAll("li"); 173 + expect(secondGroupItems?.length).toBe(3); 174 + expect(secondGroupItems?.[2]?.textContent).toBe("e"); 175 + }); 176 + 177 + it("properly cleans up when unmounting", () => { 178 + const container = document.createElement("div"); 179 + container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item.value"></li></ul>`; 180 + 181 + const items = signal([{ value: signal("A") }, { value: signal("B") }]); 182 + const cleanup = mount(container, { items }); 183 + 184 + expect(container.querySelectorAll("li").length).toBe(2); 185 + 186 + const listItemsBefore = container.querySelectorAll("li"); 187 + const textBefore = [listItemsBefore[0]?.textContent, listItemsBefore[1]?.textContent]; 188 + 189 + cleanup(); 190 + 191 + const itemA = items.get()[0]; 192 + const itemB = items.get()[1]; 193 + 194 + itemA.value.set("Changed A"); 195 + itemB.value.set("Changed B"); 196 + 197 + const listItems = container.querySelectorAll("li"); 198 + expect(listItems[0]?.textContent).toBe(textBefore[0]); 199 + expect(listItems[1]?.textContent).toBe(textBefore[1]); 200 + }); 201 + 202 + it("handles non-array values gracefully", () => { 203 + const container = document.createElement("div"); 204 + container.innerHTML = `<ul><li data-x-for="item in notAnArray" data-x-text="item"></li></ul>`; 205 + 206 + mount(container, { notAnArray: "not an array" }); 207 + 208 + const listItems = container.querySelectorAll("li"); 209 + expect(listItems.length).toBe(0); 210 + }); 211 + 212 + it("supports reactive properties within list items", () => { 213 + const container = document.createElement("div"); 214 + container.innerHTML = ` 215 + <ul> 216 + <li data-x-for="todo in todos"> 217 + <span data-x-text="todo.title"></span> 218 + <span data-x-class="todo.completed">Done</span> 219 + </li> 220 + </ul> 221 + `; 222 + 223 + const todos = signal([{ title: signal("Buy milk"), completed: signal({ done: false }) }, { 224 + title: signal("Walk dog"), 225 + completed: signal({ done: true }), 226 + }]); 227 + 228 + mount(container, { todos }); 229 + 230 + const listItems = container.querySelectorAll("li"); 231 + expect(listItems.length).toBe(2); 232 + 233 + const firstTodo = listItems[0]; 234 + const firstTitle = firstTodo?.querySelector("span:first-child"); 235 + const firstStatus = firstTodo?.querySelector("span:last-child"); 236 + 237 + expect(firstTitle?.textContent).toBe("Buy milk"); 238 + expect(firstStatus?.classList.contains("done")).toBe(false); 239 + 240 + todos.get()[0].title.set("Buy eggs"); 241 + todos.get()[0].completed.set({ done: true }); 242 + 243 + expect(firstTitle?.textContent).toBe("Buy eggs"); 244 + expect(firstStatus?.classList.contains("done")).toBe(true); 245 + }); 246 + });
+253
test/core/if-binding.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { signal } from "@volt/core/signal"; 3 + import { describe, expect, it } from "vitest"; 4 + 5 + describe("data-x-if binding", () => { 6 + it("shows element when condition is truthy", () => { 7 + const container = document.createElement("div"); 8 + container.innerHTML = ` 9 + <div> 10 + <p data-x-if="show" data-x-text="message">Hidden</p> 11 + </div> 12 + `; 13 + 14 + const show = signal(true); 15 + const message = "Visible!"; 16 + 17 + mount(container, { show, message }); 18 + 19 + const paragraph = container.querySelector("p"); 20 + expect(paragraph).toBeTruthy(); 21 + expect(paragraph?.textContent).toBe("Visible!"); 22 + }); 23 + 24 + it("hides element when condition is falsy", () => { 25 + const container = document.createElement("div"); 26 + container.innerHTML = ` 27 + <div> 28 + <p data-x-if="show">Should not appear</p> 29 + </div> 30 + `; 31 + 32 + const show = signal(false); 33 + mount(container, { show }); 34 + 35 + const paragraph = container.querySelector("p"); 36 + expect(paragraph).toBeNull(); 37 + }); 38 + 39 + it("toggles element visibility when signal changes", () => { 40 + const container = document.createElement("div"); 41 + container.innerHTML = ` 42 + <div> 43 + <span data-x-if="visible">Toggle Me</span> 44 + </div> 45 + `; 46 + 47 + const visible = signal(false); 48 + mount(container, { visible }); 49 + 50 + expect(container.querySelector("span")).toBeNull(); 51 + 52 + visible.set(true); 53 + expect(container.querySelector("span")).toBeTruthy(); 54 + expect(container.querySelector("span")?.textContent).toBe("Toggle Me"); 55 + 56 + visible.set(false); 57 + expect(container.querySelector("span")).toBeNull(); 58 + 59 + visible.set(true); 60 + expect(container.querySelector("span")).toBeTruthy(); 61 + }); 62 + 63 + it("works with static truthy values", () => { 64 + const container = document.createElement("div"); 65 + container.innerHTML = ` 66 + <div> 67 + <p data-x-if="alwaysTrue">Always visible</p> 68 + </div> 69 + `; 70 + 71 + mount(container, { alwaysTrue: true }); 72 + 73 + const paragraph = container.querySelector("p"); 74 + expect(paragraph).toBeTruthy(); 75 + expect(paragraph?.textContent).toBe("Always visible"); 76 + }); 77 + 78 + it("works with static falsy values", () => { 79 + const container = document.createElement("div"); 80 + container.innerHTML = ` 81 + <div> 82 + <p data-x-if="alwaysFalse">Never visible</p> 83 + </div> 84 + `; 85 + 86 + mount(container, { alwaysFalse: false }); 87 + 88 + expect(container.querySelector("p")).toBeNull(); 89 + }); 90 + 91 + it("preserves element bindings when re-rendering", () => { 92 + const container = document.createElement("div"); 93 + container.innerHTML = ` 94 + <div> 95 + <div data-x-if="show"> 96 + <span data-x-text="message">Default</span> 97 + </div> 98 + </div> 99 + `; 100 + 101 + const show = signal(true); 102 + const message = signal("First"); 103 + 104 + mount(container, { show, message }); 105 + 106 + expect(container.querySelector("span")?.textContent).toBe("First"); 107 + 108 + message.set("Second"); 109 + expect(container.querySelector("span")?.textContent).toBe("Second"); 110 + 111 + show.set(false); 112 + expect(container.querySelector("div[data-x-if]")).toBeNull(); 113 + 114 + message.set("Third"); 115 + show.set(true); 116 + 117 + expect(container.querySelector("span")?.textContent).toBe("Third"); 118 + }); 119 + 120 + it("handles nested bindings correctly", () => { 121 + const container = document.createElement("div"); 122 + container.innerHTML = ` 123 + <div> 124 + <div data-x-if="showOuter"> 125 + <p>Outer</p> 126 + <div data-x-if="showInner"> 127 + <p>Inner</p> 128 + </div> 129 + </div> 130 + </div> 131 + `; 132 + 133 + const showOuter = signal(true); 134 + const showInner = signal(true); 135 + 136 + mount(container, { showOuter, showInner }); 137 + 138 + expect(container.textContent?.trim()).toContain("Outer"); 139 + expect(container.textContent?.trim()).toContain("Inner"); 140 + 141 + showInner.set(false); 142 + expect(container.textContent?.trim()).toContain("Outer"); 143 + expect(container.textContent?.trim()).not.toContain("Inner"); 144 + 145 + showOuter.set(false); 146 + expect(container.textContent?.trim()).not.toContain("Outer"); 147 + expect(container.textContent?.trim()).not.toContain("Inner"); 148 + 149 + showOuter.set(true); 150 + expect(container.textContent?.trim()).toContain("Outer"); 151 + expect(container.textContent?.trim()).not.toContain("Inner"); 152 + 153 + showInner.set(true); 154 + expect(container.textContent?.trim()).toContain("Outer"); 155 + expect(container.textContent?.trim()).toContain("Inner"); 156 + }); 157 + 158 + it("properly cleans up when unmounting", () => { 159 + const container = document.createElement("div"); 160 + container.innerHTML = ` 161 + <div> 162 + <p data-x-if="show" data-x-text="message">Hidden</p> 163 + </div> 164 + `; 165 + 166 + const show = signal(true); 167 + const message = signal("Hello"); 168 + 169 + const cleanup = mount(container, { show, message }); 170 + 171 + expect(container.querySelector("p")?.textContent).toBe("Hello"); 172 + 173 + const elementBeforeCleanup = container.querySelector("p"); 174 + 175 + cleanup(); 176 + 177 + message.set("Changed"); 178 + expect(elementBeforeCleanup?.textContent).toBe("Hello"); 179 + 180 + show.set(false); 181 + expect(container.querySelector("p")).toBe(elementBeforeCleanup); 182 + }); 183 + 184 + it("handles event handlers correctly", () => { 185 + const container = document.createElement("div"); 186 + container.innerHTML = ` 187 + <div> 188 + <button data-x-if="show" data-x-on-click="handleClick">Click Me</button> 189 + </div> 190 + `; 191 + 192 + let clicked = false; 193 + const handleClick = () => { 194 + clicked = true; 195 + }; 196 + const show = signal(true); 197 + 198 + mount(container, { show, handleClick }); 199 + 200 + const button = container.querySelector("button"); 201 + expect(button).toBeTruthy(); 202 + 203 + button?.click(); 204 + expect(clicked).toBe(true); 205 + 206 + show.set(false); 207 + expect(container.querySelector("button")).toBeNull(); 208 + 209 + clicked = false; 210 + show.set(true); 211 + container.querySelector("button")?.click(); 212 + expect(clicked).toBe(true); 213 + }); 214 + 215 + it("works with property paths", () => { 216 + const container = document.createElement("div"); 217 + container.innerHTML = ` 218 + <div> 219 + <p data-x-if="user.isActive">User is active</p> 220 + </div> 221 + `; 222 + 223 + const user = { isActive: signal(true) }; 224 + mount(container, { user }); 225 + 226 + expect(container.querySelector("p")).toBeTruthy(); 227 + 228 + user.isActive.set(false); 229 + expect(container.querySelector("p")).toBeNull(); 230 + 231 + user.isActive.set(true); 232 + expect(container.querySelector("p")).toBeTruthy(); 233 + }); 234 + 235 + it("evaluates truthy and falsy values correctly", () => { 236 + const container = document.createElement("div"); 237 + container.innerHTML = ` 238 + <div> 239 + <p id="zero" data-x-if="zero">0</p> 240 + <p id="empty" data-x-if="empty">Empty</p> 241 + <p id="one" data-x-if="one">1</p> 242 + <p id="string" data-x-if="string">String</p> 243 + </div> 244 + `; 245 + 246 + mount(container, { zero: signal(0), empty: signal(""), one: signal(1), string: signal("text") }); 247 + 248 + expect(container.querySelector("#zero")).toBeNull(); 249 + expect(container.querySelector("#empty")).toBeNull(); 250 + expect(container.querySelector("#one")).toBeTruthy(); 251 + expect(container.querySelector("#string")).toBeTruthy(); 252 + }); 253 + });
+174
test/integration/list-rendering.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { computed, mount, signal } from "../../src/index"; 3 + 4 + describe("integration: list rendering", () => { 5 + it("creates a reactive todo list", () => { 6 + const container = document.createElement("div"); 7 + container.innerHTML = ` 8 + <div> 9 + <input id="new-todo" type="text" /> 10 + <button id="add-btn" data-x-on-click="addTodo">Add</button> 11 + 12 + <ul id="todo-list"> 13 + <li data-x-for="todo in todos"> 14 + <input type="checkbox" data-x-on-click="toggleTodo" /> 15 + <span data-x-text="todo.text"></span> 16 + <button data-x-on-click="deleteTodo">Delete</button> 17 + </li> 18 + </ul> 19 + 20 + <div data-x-text="remaining">0</div> 21 + </div> 22 + `; 23 + 24 + type Todo = { id: number; text: string; completed: boolean }; 25 + let nextId = 1; 26 + 27 + const todos = signal<Todo[]>([{ id: nextId++, text: "Learn Volt.js", completed: false }, { 28 + id: nextId++, 29 + text: "Build an app", 30 + completed: false, 31 + }]); 32 + 33 + const remaining = computed(() => { 34 + return todos.get().filter((t) => !t.completed).length; 35 + }, [todos]); 36 + 37 + const addTodo = () => { 38 + const input = container.querySelector("#new-todo") as HTMLInputElement; 39 + if (input.value.trim()) { 40 + todos.set([...todos.get(), { id: nextId++, text: input.value, completed: false }]); 41 + input.value = ""; 42 + } 43 + }; 44 + 45 + const toggleTodo = (event: Event) => { 46 + const checkbox = event.target as HTMLInputElement; 47 + const li = checkbox.closest("li"); 48 + const index = [...(li?.parentElement?.children || [])].indexOf(li!); 49 + 50 + const updated = todos.get().map((todo, i) => (i === index ? { ...todo, completed: checkbox.checked } : todo)); 51 + 52 + todos.set(updated); 53 + }; 54 + 55 + const deleteTodo = (event: Event) => { 56 + const button = event.target as HTMLButtonElement; 57 + const li = button.closest("li"); 58 + const index = [...(li?.parentElement?.children || [])].indexOf(li!); 59 + 60 + todos.set(todos.get().filter((_, i) => i !== index)); 61 + }; 62 + 63 + mount(container, { todos, remaining, addTodo, toggleTodo, deleteTodo }); 64 + 65 + const listItems = container.querySelectorAll("#todo-list li"); 66 + expect(listItems.length).toBe(2); 67 + expect(listItems[0]?.querySelector("span")?.textContent).toBe("Learn Volt.js"); 68 + expect(listItems[1]?.querySelector("span")?.textContent).toBe("Build an app"); 69 + 70 + const remainingDiv = container.querySelector("div[data-x-text='remaining']"); 71 + expect(remainingDiv?.textContent).toBe("2"); 72 + 73 + const checkboxes = container.querySelectorAll("input[type='checkbox']"); 74 + (checkboxes[0] as HTMLInputElement).checked = true; 75 + checkboxes[0]?.dispatchEvent(new Event("click", { bubbles: true })); 76 + 77 + expect(remainingDiv?.textContent).toBe("1"); 78 + 79 + const deleteButtons = container.querySelectorAll("button[data-x-on-click='deleteTodo']"); 80 + deleteButtons[1]?.dispatchEvent(new Event("click", { bubbles: true })); 81 + 82 + const updatedListItems = container.querySelectorAll("#todo-list li"); 83 + expect(updatedListItems.length).toBe(1); 84 + expect(updatedListItems[0]?.querySelector("span")?.textContent).toBe("Learn Volt.js"); 85 + }); 86 + 87 + it("renders filtered lists with computed signals", () => { 88 + const container = document.createElement("div"); 89 + container.innerHTML = ` 90 + <div> 91 + <div id="all-items"> 92 + <h3>All Items</h3> 93 + <div data-x-for="item in allItems" data-x-text="item.name"></div> 94 + </div> 95 + 96 + <div id="active-items"> 97 + <h3>Active Items</h3> 98 + <div data-x-for="item in activeItems" data-x-text="item.name"></div> 99 + </div> 100 + </div> 101 + `; 102 + 103 + const items = signal([{ name: "Item 1", active: true }, { name: "Item 2", active: false }, { 104 + name: "Item 3", 105 + active: true, 106 + }]); 107 + 108 + const activeItems = computed(() => items.get().filter((item) => item.active), [items]); 109 + 110 + mount(container, { allItems: items, activeItems }); 111 + 112 + const allItemDivs = container.querySelectorAll("#all-items > div[data-x-for]"); 113 + const activeItemDivs = container.querySelectorAll("#active-items > div[data-x-for]"); 114 + 115 + expect(allItemDivs.length).toBe(0); 116 + expect(activeItemDivs.length).toBe(0); 117 + 118 + const renderedAll = container.querySelectorAll("#all-items div[data-x-text]"); 119 + const renderedActive = container.querySelectorAll("#active-items div[data-x-text]"); 120 + 121 + expect(renderedAll.length).toBe(3); 122 + expect(renderedActive.length).toBe(2); 123 + expect(renderedActive[0]?.textContent).toBe("Item 1"); 124 + expect(renderedActive[1]?.textContent).toBe("Item 3"); 125 + 126 + items.set([{ name: "Item 1", active: false }, { name: "Item 2", active: true }, { name: "Item 3", active: true }]); 127 + 128 + const updatedActive = container.querySelectorAll("#active-items div[data-x-text]"); 129 + expect(updatedActive.length).toBe(2); 130 + expect(updatedActive[0]?.textContent).toBe("Item 2"); 131 + expect(updatedActive[1]?.textContent).toBe("Item 3"); 132 + }); 133 + 134 + it("handles complex nested data structures", () => { 135 + const container = document.createElement("div"); 136 + container.innerHTML = ` 137 + <div> 138 + <div data-x-for="category in categories"> 139 + <h2 data-x-text="category.name"></h2> 140 + <ul> 141 + <li data-x-for="product in category.products"> 142 + <span data-x-text="product.name"></span>: $<span data-x-text="product.price"></span> 143 + </li> 144 + </ul> 145 + </div> 146 + </div> 147 + `; 148 + 149 + const categories = signal([{ 150 + name: "Electronics", 151 + products: [{ name: "Laptop", price: 999 }, { name: "Phone", price: 699 }], 152 + }, { name: "Books", products: [{ name: "JavaScript Guide", price: 29 }, { name: "CSS Mastery", price: 35 }] }]); 153 + 154 + mount(container, { categories }); 155 + 156 + const categoryDivs = container.querySelectorAll("div > div > h2"); 157 + expect(categoryDivs.length).toBe(2); 158 + expect(categoryDivs[0]?.textContent).toBe("Electronics"); 159 + expect(categoryDivs[1]?.textContent).toBe("Books"); 160 + 161 + const categoryDivElements = container.querySelectorAll("div > div > div"); 162 + expect(categoryDivElements.length).toBe(2); 163 + 164 + const electronicsProducts = categoryDivElements[0]?.querySelectorAll("ul li"); 165 + expect(electronicsProducts?.length).toBe(2); 166 + expect(electronicsProducts?.[0]?.textContent).toContain("Laptop"); 167 + expect(electronicsProducts?.[0]?.textContent).toContain("999"); 168 + 169 + const booksProducts = categoryDivElements[1]?.querySelectorAll("ul li"); 170 + expect(booksProducts?.length).toBe(2); 171 + expect(booksProducts?.[1]?.textContent).toContain("CSS Mastery"); 172 + expect(booksProducts?.[1]?.textContent).toContain("35"); 173 + }); 174 + });