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: extend evaluator to cover more expression kinds

+680 -64
+96 -23
cli/src/commands/example.ts
··· 8 8 9 9 /** 10 10 * Find the project root by walking up the directory tree. 11 + * 11 12 * Looks for a directory containing dist/assets/ or package.json with name "volt" 12 13 */ 13 14 async function findProjectRoot(startDir: string): Promise<string> { ··· 64 65 */ 65 66 async function findBuildArtifacts(root: string): Promise<BuildArtifacts> { 66 67 const distDir = path.join(root, "dist"); 67 - 68 68 const jsPath = path.join(distDir, "volt.js"); 69 69 const cssPath = path.join(root, "src", "styles", "base.css"); 70 70 ··· 79 79 return { jsPath, cssPath }; 80 80 } 81 81 82 - /** 83 - * Minify JavaScript code using terser 84 - */ 85 82 async function minifyJS(code: string): Promise<string> { 86 83 const result = await terserMinify(code, { 87 84 compress: { ··· 110 107 return result.code; 111 108 } 112 109 113 - /** 114 - * Minify CSS code (simple minification) 115 - */ 110 + // TODO: use terser 116 111 function minifyCSS(code: string): string { 117 112 return code.replaceAll(/\/\*[\s\S]*?\*\//g, "").replaceAll(/\s+/g, " ").replaceAll(/\s*([{}:;,])\s*/g, "$1").trim(); 118 113 } ··· 143 138 echo.ok(` Created: examples/dist/volt.min.css (${Math.round(minifiedCSS.length / 1024)} KB)`); 144 139 } 145 140 146 - function generateHTML(name: string): string { 141 + function generateHTML(name: string, mode: "markup" | "programmatic", standalone: boolean): string { 142 + const cssPath = standalone ? "volt.min.css" : "../dist/volt.min.css"; 143 + const jsPath = standalone ? "volt.min.js" : "../dist/volt.min.js"; 144 + 145 + if (mode === "markup") { 146 + return `<!DOCTYPE html> 147 + <html lang="en"> 148 + <head> 149 + <meta charset="UTF-8"> 150 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 151 + <title>${name} - Volt.js Example</title> 152 + <link rel="stylesheet" href="${cssPath}"> 153 + </head> 154 + <body> 155 + <div data-volt data-volt-state='{"message": "Hello Volt!"}'> 156 + <h1 data-volt-text="message">Loading...</h1> 157 + <!-- Add your HTML here with data-volt-* attributes --> 158 + </div> 159 + 160 + <script type="module"> 161 + import { charge, registerPlugin, persistPlugin, scrollPlugin, urlPlugin } from './${jsPath}'; 162 + 163 + // Register plugins 164 + registerPlugin('persist', persistPlugin); 165 + registerPlugin('scroll', scrollPlugin); 166 + registerPlugin('url', urlPlugin); 167 + 168 + // Initialize Volt roots 169 + charge(); 170 + </script> 171 + </body> 172 + </html> 173 + `; 174 + } 175 + 147 176 return `<!DOCTYPE html> 148 177 <html lang="en"> 149 178 <head> 150 179 <meta charset="UTF-8"> 151 180 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 152 181 <title>${name} - Volt.js Example</title> 153 - <link rel="stylesheet" href="../dist/volt.min.css"> 182 + <link rel="stylesheet" href="${cssPath}"> 154 183 <link rel="stylesheet" href="app.css"> 155 184 </head> 156 185 <body> 157 186 <div id="app"> 158 187 <h1>${name}</h1> 159 - <!-- Add your HTML here with data-x-* attributes --> 188 + <!-- Add your HTML here with data-volt-* attributes --> 160 189 </div> 161 190 162 - <script type="module" src="../dist/volt.min.js"></script> 191 + <script type="module" src="${jsPath}"></script> 163 192 <script type="module" src="app.js"></script> 164 193 </body> 165 194 </html> ··· 215 244 /** 216 245 * Create example directory with all files 217 246 */ 218 - async function createExampleFiles(exampleDir: string, name: string): Promise<void> { 247 + async function createExampleFiles( 248 + exampleDir: string, 249 + name: string, 250 + mode: "markup" | "programmatic", 251 + standalone: boolean, 252 + ): Promise<void> { 219 253 await mkdir(exampleDir, { recursive: true }); 220 254 221 - const files = [ 222 - { path: "index.html", content: generateHTML(name) }, 223 - { path: "README.md", content: generateREADME(name) }, 224 - { path: "app.js", content: generateAppJS() }, 225 - { path: "app.css", content: generateAppCSS() }, 226 - ]; 255 + const files = [{ path: "index.html", content: generateHTML(name, mode, standalone) }, { 256 + path: "README.md", 257 + content: generateREADME(name), 258 + }]; 259 + 260 + if (mode === "programmatic") { 261 + files.push({ path: "app.js", content: generateAppJS() }, { path: "app.css", content: generateAppCSS() }); 262 + } 227 263 228 264 for (const file of files) { 229 265 const filePath = path.join(exampleDir, file.path); 230 266 await writeFile(filePath, file.content, "utf8"); 231 267 echo.ok(` Created: examples/${name}/${file.path}`); 232 268 } 269 + } 270 + 271 + async function copyStandaloneFiles(examplesDir: string, exampleDir: string): Promise<void> { 272 + const distDir = path.join(examplesDir, "dist"); 273 + const jsSource = path.join(distDir, "volt.min.js"); 274 + const cssSource = path.join(distDir, "volt.min.css"); 275 + 276 + const jsDest = path.join(exampleDir, "volt.min.js"); 277 + const cssDest = path.join(exampleDir, "volt.min.css"); 278 + 279 + const jsContent = await readFile(jsSource, "utf8"); 280 + const cssContent = await readFile(cssSource, "utf8"); 281 + 282 + await writeFile(jsDest, jsContent, "utf8"); 283 + await writeFile(cssDest, cssContent, "utf8"); 284 + 285 + echo.ok(` Copied: volt.min.js (${Math.round(jsContent.length / 1024)} KB)`); 286 + echo.ok(` Copied: volt.min.css (${Math.round(cssContent.length / 1024)} KB)`); 233 287 } 234 288 235 289 /** ··· 237 291 * 238 292 * Creates a new example scaffold with minified volt.js build artifacts 239 293 */ 240 - export async function exampleCommand(name: string): Promise<void> { 294 + export async function exampleCommand( 295 + name: string, 296 + options: { mode?: "markup" | "programmatic"; standalone?: boolean } = {}, 297 + ): Promise<void> { 298 + const mode = options.mode || "programmatic"; 299 + const standalone = options.standalone || false; 300 + 241 301 const root = await findProjectRoot(process.cwd()); 242 302 const examplesDir = path.join(root, "examples"); 243 303 const exampleDir = path.join(examplesDir, name); 244 304 245 305 echo.title(`\nCreating example: ${name}\n`); 306 + echo.info(`Mode: ${mode}`); 307 + echo.info(`Standalone: ${standalone ? "Yes" : "No (shared)"}\n`); 246 308 247 309 echo.info("Building Volt.js library..."); 248 310 await buildLibrary(root); ··· 254 316 await createMinifiedArtifacts(artifacts, examplesDir); 255 317 256 318 echo.info(`\nScaffolding example files...`); 257 - await createExampleFiles(exampleDir, name); 319 + await createExampleFiles(exampleDir, name, mode, standalone); 320 + 321 + if (standalone) { 322 + echo.info("\nCopying standalone files..."); 323 + await copyStandaloneFiles(examplesDir, exampleDir); 324 + } 258 325 259 326 echo.success(`\nExample created successfully!\n`); 260 327 echo.info(`Location: examples/${name}/`); 261 328 echo.info(`Next steps:`); 262 - echo.text(` 1. Edit examples/${name}/index.html to add your UI`); 263 - echo.text(` 2. Edit examples/${name}/app.js to add your logic`); 264 - echo.text(` 3. Open examples/${name}/index.html in a browser\n`); 329 + 330 + if (mode === "markup") { 331 + echo.text(` 1. Edit examples/${name}/index.html to add your UI with data-volt-* attributes`); 332 + echo.text(` 2. Open examples/${name}/index.html in a browser\n`); 333 + } else { 334 + echo.text(` 1. Edit examples/${name}/index.html to add your UI`); 335 + echo.text(` 2. Edit examples/${name}/app.js to add your logic`); 336 + echo.text(` 3. Open examples/${name}/index.html in a browser\n`); 337 + } 265 338 }
+14 -8
cli/src/index.ts
··· 44 44 45 45 const example = program.command("example").description("Manage examples for Volt.js"); 46 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 - }); 47 + example.command("new <name>").description("Create a new example with scaffolded files").option( 48 + "--mode <mode>", 49 + "Example mode: markup (declarative) or programmatic (imperative)", 50 + "programmatic", 51 + ).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 }) => { 53 + try { 54 + await exampleCommand(name, options); 55 + } catch (error) { 56 + echo.err("Error creating example:", error); 57 + process.exit(1); 58 + } 59 + }, 60 + ); 55 61 56 62 program.parse();
+1 -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 }, 4 + "markup": { "preferAttrsSingleLine": true, "printWidth": 121, "styleIndent": true, "scriptIndent": true }, 5 5 "excludes": ["**/node_modules"], 6 6 "plugins": [ 7 7 "https://plugins.dprint.dev/typescript-0.95.8.wasm",
+1
package.json
··· 6 6 "scripts": { 7 7 "dev": "vite", 8 8 "build": "tsc && vite build", 9 + "build:lib": "tsc && vite build --mode lib", 9 10 "preview": "vite preview", 10 11 "test": "vitest", 11 12 "test:ui": "vitest --ui",
+330 -31
src/core/evaluator.ts
··· 1 1 /** 2 2 * Safe expression evaluation with operators support 3 + * 3 4 * Implements a recursive descent parser for expressions without using eval() 4 5 */ 5 6 6 7 import type { Dep, Scope } from "$types/volt"; 7 8 8 - /** 9 - * Token types for lexical analysis 10 - */ 11 9 type TokenType = 12 10 | "NUMBER" 13 11 | "STRING" ··· 21 19 | "RBRACKET" 22 20 | "LPAREN" 23 21 | "RPAREN" 22 + | "LBRACE" 23 + | "RBRACE" 24 + | "COMMA" 25 + | "QUESTION" 26 + | "COLON" 27 + | "ARROW" 28 + | "DOT_DOT_DOT" 24 29 | "PLUS" 25 30 | "MINUS" 26 31 | "STAR" ··· 44 49 45 50 /** 46 51 * Tokenize an expression string into a stream of tokens 47 - * 48 - * @param expr - The expression string 49 - * @returns Array of tokens 50 52 */ 51 53 function tokenize(expr: string): Token[] { 52 54 const tokens: Token[] = []; ··· 136 138 pos += 3; 137 139 continue; 138 140 } 141 + if (threeChar === "...") { 142 + tokens.push({ type: "DOT_DOT_DOT", value: "...", start, end: pos + 3 }); 143 + pos += 3; 144 + continue; 145 + } 139 146 } 140 147 141 148 if (pos + 1 < expr.length) { ··· 161 168 pos += 2; 162 169 continue; 163 170 } 171 + case "=>": { 172 + tokens.push({ type: "ARROW", value: "=>", start, end: pos + 2 }); 173 + pos += 2; 174 + continue; 175 + } 164 176 } 165 177 } 166 178 ··· 230 242 pos++; 231 243 break; 232 244 } 245 + case "{": { 246 + tokens.push({ type: "LBRACE", value: "{", start, end: pos + 1 }); 247 + pos++; 248 + break; 249 + } 250 + case "}": { 251 + tokens.push({ type: "RBRACE", value: "}", start, end: pos + 1 }); 252 + pos++; 253 + break; 254 + } 255 + case ",": { 256 + tokens.push({ type: "COMMA", value: ",", start, end: pos + 1 }); 257 + pos++; 258 + break; 259 + } 260 + case "?": { 261 + tokens.push({ type: "QUESTION", value: "?", start, end: pos + 1 }); 262 + pos++; 263 + break; 264 + } 265 + case ":": { 266 + tokens.push({ type: "COLON", value: ":", start, end: pos + 1 }); 267 + pos++; 268 + break; 269 + } 233 270 default: { 234 271 throw new Error(`Unexpected character '${char}' at position ${pos}`); 235 272 } ··· 253 290 this.scope = scope; 254 291 } 255 292 256 - /** 257 - * Parse the expression and return the result 258 - */ 259 293 parse(): unknown { 260 - return this.parseExpression(); 294 + return this.parseExpr(); 295 + } 296 + 297 + private parseExpr(): unknown { 298 + return this.parseTernary(); 261 299 } 262 300 263 - private parseExpression(): unknown { 264 - return this.parseLogicalOr(); 301 + private parseTernary(): unknown { 302 + const expr = this.parseLogicalOr(); 303 + 304 + if (this.match("QUESTION")) { 305 + const trueBranch = this.parseExpr(); 306 + this.consume("COLON", "Expected ':' in ternary expression"); 307 + const falseBranch = this.parseExpr(); 308 + return expr ? trueBranch : falseBranch; 309 + } 310 + 311 + return expr; 265 312 } 266 313 267 314 private parseLogicalOr(): unknown { ··· 391 438 392 439 while (true) { 393 440 if (this.match("DOT")) { 394 - const property = this.consume("IDENTIFIER", "Expected property name after '.'"); 395 - object = this.getMember(object, property.value as string); 441 + const prop = this.consume("IDENTIFIER", "Expected property name after '.'"); 442 + const propValue = this.getMember(object, prop.value as string); 443 + 444 + if (this.check("LPAREN")) { 445 + this.advance(); 446 + const args = this.parseArgumentList(); 447 + this.consume("RPAREN", "Expected ')' after arguments"); 448 + object = this.callMethod(object, prop.value as string, args); 449 + } else { 450 + object = propValue; 451 + } 396 452 } else if (this.match("LBRACKET")) { 397 - const index = this.parseExpression(); 453 + const index = this.parseExpr(); 398 454 this.consume("RBRACKET", "Expected ']' after member access"); 399 455 object = this.getMember(object, index); 456 + } else if (this.match("LPAREN")) { 457 + const args = this.parseArgumentList(); 458 + this.consume("RPAREN", "Expected ')' after arguments"); 459 + 460 + if (typeof object === "function") { 461 + object = (object as (...args: unknown[]) => unknown)(...args); 462 + } else { 463 + throw new TypeError("Attempting to call a non-function value"); 464 + } 400 465 } else { 401 466 break; 402 467 } 403 468 } 404 469 470 + if (isSignal(object)) { 471 + return (object as { get: () => unknown }).get(); 472 + } 473 + 405 474 return object; 406 475 } 407 476 477 + private parseArgumentList(): unknown[] { 478 + const args: unknown[] = []; 479 + 480 + if (this.check("RPAREN")) { 481 + return args; 482 + } 483 + 484 + do { 485 + args.push(this.parseExpr()); 486 + } while (this.match("COMMA")); 487 + 488 + return args; 489 + } 490 + 491 + private callMethod(object: unknown, methodName: string, args: unknown[]): unknown { 492 + if (object === null || object === undefined) { 493 + throw new Error(`Cannot call method '${methodName}' on ${object}`); 494 + } 495 + 496 + const method = (object as Record<string, unknown>)[methodName]; 497 + 498 + if (typeof method !== "function") { 499 + throw new TypeError(`'${methodName}' is not a function`); 500 + } 501 + 502 + return (method as (...args: unknown[]) => unknown).call(object, ...args); 503 + } 504 + 408 505 private parsePrimary(): unknown { 409 506 if (this.match("NUMBER", "STRING", "TRUE", "FALSE", "NULL", "UNDEFINED")) { 410 507 return this.previous().value; ··· 412 509 413 510 if (this.match("IDENTIFIER")) { 414 511 const identifier = this.previous().value as string; 512 + 513 + if (this.check("ARROW")) { 514 + this.current--; 515 + return this.parseArrowFunction(); 516 + } 517 + 415 518 return this.resolvePropPath(identifier); 416 519 } 417 520 418 521 if (this.match("LPAREN")) { 419 - const expr = this.parseExpression(); 522 + const start = this.current; 523 + 524 + if (this.isArrowFunctionParams()) { 525 + this.current = start - 1; 526 + return this.parseArrowFunction(); 527 + } 528 + 529 + const expr = this.parseExpr(); 420 530 this.consume("RPAREN", "Expected ')' after expression"); 421 531 return expr; 422 532 } 423 533 534 + if (this.match("LBRACKET")) { 535 + return this.parseArrayLiteral(); 536 + } 537 + 538 + if (this.match("LBRACE")) { 539 + return this.parseObjectLiteral(); 540 + } 541 + 424 542 throw new Error(`Unexpected token: ${this.peek().type}`); 425 543 } 426 544 545 + private parseArrayLiteral(): unknown[] { 546 + const elements: unknown[] = []; 547 + 548 + if (this.match("RBRACKET")) { 549 + return elements; 550 + } 551 + 552 + do { 553 + if (this.match("DOT_DOT_DOT")) { 554 + const spreadValue = this.parseExpr(); 555 + if (Array.isArray(spreadValue)) { 556 + elements.push(...spreadValue); 557 + } else { 558 + throw new TypeError("Spread operator can only be used with arrays"); 559 + } 560 + } else { 561 + elements.push(this.parseExpr()); 562 + } 563 + } while (this.match("COMMA")); 564 + 565 + this.consume("RBRACKET", "Expected ']' after array elements"); 566 + return elements; 567 + } 568 + 569 + private parseObjectLiteral(): Record<string, unknown> { 570 + const object: Record<string, unknown> = {}; 571 + 572 + if (this.match("RBRACE")) { 573 + return object; 574 + } 575 + 576 + do { 577 + if (this.match("DOT_DOT_DOT")) { 578 + const spreadValue = this.parseExpr(); 579 + if (typeof spreadValue === "object" && spreadValue !== null && !Array.isArray(spreadValue)) { 580 + Object.assign(object, spreadValue); 581 + } else { 582 + throw new Error("Spread operator can only be used with objects in object literals"); 583 + } 584 + } else { 585 + let key: string; 586 + 587 + if (this.match("IDENTIFIER")) { 588 + key = this.previous().value as string; 589 + } else if (this.match("STRING")) { 590 + key = this.previous().value as string; 591 + } else { 592 + throw new Error("Expected property key in object literal"); 593 + } 594 + 595 + this.consume("COLON", "Expected ':' after property key"); 596 + const value = this.parseExpr(); 597 + object[key] = value; 598 + } 599 + } while (this.match("COMMA")); 600 + 601 + this.consume("RBRACE", "Expected '}' after object properties"); 602 + return object; 603 + } 604 + 605 + private parseArrowFunction(): (...args: unknown[]) => unknown { 606 + const params: string[] = []; 607 + 608 + if (this.match("IDENTIFIER")) { 609 + params.push(this.previous().value as string); 610 + } else if (this.match("LPAREN")) { 611 + if (!this.check("RPAREN")) { 612 + do { 613 + const param = this.consume("IDENTIFIER", "Expected parameter name"); 614 + params.push(param.value as string); 615 + } while (this.match("COMMA")); 616 + } 617 + this.consume("RPAREN", "Expected ')' after parameters"); 618 + } else { 619 + throw new Error("Expected arrow function parameters"); 620 + } 621 + 622 + this.consume("ARROW", "Expected '=>' in arrow function"); 623 + 624 + if (this.match("LBRACE")) { 625 + let braceDepth = 1; 626 + while (braceDepth > 0 && !this.isAtEnd()) { 627 + if (this.check("LBRACE")) braceDepth++; 628 + if (this.check("RBRACE")) braceDepth--; 629 + this.advance(); 630 + } 631 + throw new Error("Arrow function block bodies are not yet supported. Use single expressions only."); 632 + } else { 633 + const exprTokens: Token[] = []; 634 + let parenDepth = 0; 635 + let bracketDepth = 0; 636 + let braceDepth = 0; 637 + 638 + outer: while (!this.isAtEnd()) { 639 + const token = this.peek(); 640 + 641 + switch (token.type) { 642 + case "LPAREN": { 643 + parenDepth++; 644 + break; 645 + } 646 + case "RPAREN": { 647 + if (parenDepth === 0) break outer; 648 + parenDepth--; 649 + break; 650 + } 651 + case "LBRACKET": { 652 + bracketDepth++; 653 + break; 654 + } 655 + case "RBRACKET": { 656 + if (bracketDepth === 0) break outer; 657 + bracketDepth--; 658 + break; 659 + } 660 + case "LBRACE": { 661 + braceDepth++; 662 + break; 663 + } 664 + case "RBRACE": { 665 + if (braceDepth === 0) break outer; 666 + braceDepth--; 667 + break; 668 + } 669 + case "COMMA": { 670 + if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { 671 + break outer; 672 + } 673 + break; 674 + } 675 + default: { 676 + break; 677 + } 678 + } 679 + 680 + exprTokens.push(this.advance()); 681 + } 682 + 683 + const capturedScope = this.scope; 684 + 685 + return (...args: unknown[]) => { 686 + const arrowScope: Scope = { ...capturedScope }; 687 + for (const [index, param] of params.entries()) { 688 + arrowScope[param] = args[index]; 689 + } 690 + 691 + const parser = new Parser([...exprTokens, { type: "EOF", value: null, start: 0, end: 0 }], arrowScope); 692 + return parser.parse(); 693 + }; 694 + } 695 + } 696 + 697 + private isArrowFunctionParams(): boolean { 698 + const saved = this.current; 699 + let result = false; 700 + 701 + try { 702 + if (this.check("RPAREN")) { 703 + this.advance(); 704 + if (this.check("ARROW")) { 705 + result = true; 706 + } 707 + } else { 708 + while (!this.isAtEnd() && !this.check("RPAREN")) { 709 + if (!this.match("IDENTIFIER", "COMMA")) { 710 + result = false; 711 + break; 712 + } 713 + } 714 + if (this.match("RPAREN") && this.check("ARROW")) { 715 + result = true; 716 + } 717 + } 718 + } finally { 719 + this.current = saved; 720 + } 721 + 722 + return result; 723 + } 724 + 427 725 private getMember(object: unknown, key: unknown): unknown { 428 726 if (object === null || object === undefined) { 429 727 return undefined; 430 728 } 431 729 432 - // Access property - works on objects, strings, arrays, etc. 730 + if (isSignal(object) && (key === "get" || key === "set" || key === "subscribe")) { 731 + return (object as Record<string, unknown>)[key as string]; 732 + } 733 + 734 + if (isSignal(object)) { 735 + object = (object as { get: () => unknown }).get(); 736 + } 737 + 433 738 const value = (object as Record<string | number, unknown>)[key as string | number]; 434 739 435 740 if (isSignal(value)) { ··· 444 749 return undefined; 445 750 } 446 751 447 - const value = this.scope[path]; 448 - 449 - if (isSignal(value)) { 450 - return value.get(); 451 - } 452 - 453 - return value; 752 + return this.scope[path]; 454 753 } 455 754 456 755 private match(...types: TokenType[]): boolean { ··· 505 804 * 506 805 * Supports literals, property access, operators, and member access. 507 806 * 508 - * @param expression - The expression string to evaluate 807 + * @param expr - The expression string to evaluate 509 808 * @param scope - The scope object containing values 510 809 * @returns The evaluated result 511 810 */ 512 - export function evaluate(expression: string, scope: Scope): unknown { 811 + export function evaluate(expr: string, scope: Scope): unknown { 513 812 try { 514 - const tokens = tokenize(expression); 813 + const tokens = tokenize(expr); 515 814 const parser = new Parser(tokens, scope); 516 815 return parser.parse(); 517 816 } catch (error) { 518 - console.error(`Error evaluating expression "${expression}":`, error); 817 + console.error(`Error evaluating expression "${expr}":`, error); 519 818 return undefined; 520 819 } 521 820 } ··· 524 823 * Extract all signal dependencies from an expression by finding identifiers 525 824 * that correspond to signals in the scope. 526 825 * 527 - * @param expression - The expression to analyze 826 + * @param expr - The expression to analyze 528 827 * @param scope - The scope containing potential signal dependencies 529 828 * @returns Array of signals found in the expression 530 829 */ 531 - export function extractDependencies(expression: string, scope: Scope): Array<Dep> { 830 + export function extractDependencies(expr: string, scope: Scope): Array<Dep> { 532 831 const dependencies: Array<Dep> = []; 533 832 const identifierRegex = /\b([a-zA-Z_$][\w$]*)\b/g; 534 - const matches = expression.matchAll(identifierRegex); 833 + const matches = expr.matchAll(identifierRegex); 535 834 const seen = new Set<string>(); 536 835 537 836 for (const match of matches) {
+1
src/index.ts
··· 9 9 export { charge } from "@volt/core/charge"; 10 10 export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin"; 11 11 export { computed, effect, signal } from "@volt/core/signal"; 12 + export { persistPlugin, registerStorageAdapter, scrollPlugin, urlPlugin } from "@volt/plugins/index";
+235
test/core/evaluator.test.ts
··· 328 328 expect(evaluate("(page - 1) * 10", scope)).toBe(10); 329 329 }); 330 330 }); 331 + 332 + describe("method calls", () => { 333 + it("calls methods on objects", () => { 334 + const scope = { text: "hello world" }; 335 + expect(evaluate("text.toUpperCase()", scope)).toBe("HELLO WORLD"); 336 + expect(evaluate("text.substring(0, 5)", scope)).toBe("hello"); 337 + }); 338 + 339 + it("calls methods on arrays", () => { 340 + const scope = { items: [1, 2, 3, 4, 5] }; 341 + expect(evaluate("items.slice(1, 3)", scope)).toEqual([2, 3]); 342 + expect(evaluate("items.indexOf(3)", scope)).toBe(2); 343 + }); 344 + 345 + it("calls methods on signals", () => { 346 + const count = signal(5); 347 + const scope = { count }; 348 + expect(evaluate("count.get()", scope)).toBe(5); 349 + }); 350 + 351 + it("calls methods with multiple arguments", () => { 352 + const scope = { text: "one,two,three" }; 353 + expect(evaluate("text.split(',')", scope)).toEqual(["one", "two", "three"]); 354 + }); 355 + 356 + it("chains method calls", () => { 357 + const scope = { text: " hello " }; 358 + expect(evaluate("text.trim().toUpperCase()", scope)).toBe("HELLO"); 359 + }); 360 + 361 + it("calls methods with no arguments", () => { 362 + const scope = { arr: [1, 2, 3] }; 363 + expect(evaluate("arr.reverse()", scope)).toEqual([3, 2, 1]); 364 + }); 365 + }); 366 + 367 + describe("ternary operator", () => { 368 + it("evaluates simple ternary expressions", () => { 369 + expect(evaluate("true ? 'yes' : 'no'", {})).toBe("yes"); 370 + expect(evaluate("false ? 'yes' : 'no'", {})).toBe("no"); 371 + }); 372 + 373 + it("evaluates ternary with variables", () => { 374 + const scope = { count: 5, limit: 10 }; 375 + expect(evaluate("count > 0 ? 'positive' : 'zero or negative'", scope)).toBe("positive"); 376 + expect(evaluate("count === limit ? 'equal' : 'not equal'", scope)).toBe("not equal"); 377 + }); 378 + 379 + it("evaluates ternary with signals", () => { 380 + const scope = { count: signal(1) }; 381 + expect(evaluate("count === 1 ? 'single' : 'multiple'", scope)).toBe("single"); 382 + }); 383 + 384 + it("evaluates nested ternary expressions", () => { 385 + const scope = { value: 5 }; 386 + expect(evaluate("value < 0 ? 'negative' : value === 0 ? 'zero' : 'positive'", scope)).toBe("positive"); 387 + }); 388 + 389 + it("evaluates ternary with complex conditions", () => { 390 + const scope = { age: 25, min: 18, max: 65 }; 391 + expect(evaluate("age >= min && age <= max ? 'valid' : 'invalid'", scope)).toBe("valid"); 392 + }); 393 + 394 + it("evaluates ternary for pluralization", () => { 395 + expect(evaluate("1 === 1 ? 'item' : 'items'", {})).toBe("item"); 396 + expect(evaluate("5 === 1 ? 'item' : 'items'", {})).toBe("items"); 397 + }); 398 + }); 399 + 400 + describe("arrow functions", () => { 401 + it("evaluates single-parameter arrow functions", () => { 402 + const scope = { items: [1, 2, 3, 4, 5] }; 403 + const result = evaluate("items.filter(x => x > 2)", scope); 404 + expect(result).toEqual([3, 4, 5]); 405 + }); 406 + 407 + it("evaluates multi-parameter arrow functions", () => { 408 + const scope = { items: ["a", "b", "c"] }; 409 + const result = evaluate("items.map((item, index) => index)", scope); 410 + expect(result).toEqual([0, 1, 2]); 411 + }); 412 + 413 + it("captures scope in arrow functions", () => { 414 + const scope = { todos: [{ completed: false }, { completed: true }, { completed: false }] }; 415 + const result = evaluate("todos.filter(t => !t.completed)", scope); 416 + expect(result).toEqual([{ completed: false }, { completed: false }]); 417 + }); 418 + 419 + it("evaluates arrow functions with complex expressions", () => { 420 + const scope = { items: [1, 2, 3] }; 421 + const result = evaluate("items.map(x => x * 2 + 1)", scope); 422 + expect(result).toEqual([3, 5, 7]); 423 + }); 424 + 425 + it("evaluates arrow functions with property access", () => { 426 + const scope = { users: [{ name: "Alice", age: 25 }, { name: "Bob", age: 30 }] }; 427 + const result = evaluate("users.map(u => u.name)", scope); 428 + expect(result).toEqual(["Alice", "Bob"]); 429 + }); 430 + 431 + it("evaluates nested arrow functions", () => { 432 + const scope = { matrix: [[1, 2], [3, 4]] }; 433 + const result = evaluate("matrix.map(row => row.map(n => n * 2))", scope); 434 + expect(result).toEqual([[2, 4], [6, 8]]); 435 + }); 436 + 437 + it("evaluates arrow functions with no parameters", () => { 438 + const scope = { arr: [1, 2, 3] }; 439 + const mapper = evaluate("() => 42", scope); 440 + expect(typeof mapper).toBe("function"); 441 + expect((mapper as () => number)()).toBe(42); 442 + }); 443 + }); 444 + 445 + describe("object literals", () => { 446 + it("evaluates empty object literals", () => { 447 + expect(evaluate("{}", {})).toEqual({}); 448 + }); 449 + 450 + it("evaluates simple object literals", () => { 451 + expect(evaluate("{ active: true, disabled: false }", {})).toEqual({ active: true, disabled: false }); 452 + }); 453 + 454 + it("evaluates object literals with variables", () => { 455 + const scope = { isActive: true, count: 5 }; 456 + expect(evaluate("{ active: isActive, num: count }", scope)).toEqual({ active: true, num: 5 }); 457 + }); 458 + 459 + it("evaluates object literals with string keys", () => { 460 + expect(evaluate("{ 'first-name': 'Alice', 'last-name': 'Smith' }", {})).toEqual({ 461 + "first-name": "Alice", 462 + "last-name": "Smith", 463 + }); 464 + }); 465 + 466 + it("evaluates object literals with expressions", () => { 467 + const scope = { count: 5 }; 468 + expect(evaluate("{ value: count * 2, label: 'items' }", scope)).toEqual({ value: 10, label: "items" }); 469 + }); 470 + 471 + it("evaluates object literals with nested objects", () => { 472 + expect(evaluate("{ user: { name: 'Bob', age: 30 } }", {})).toEqual({ user: { name: "Bob", age: 30 } }); 473 + }); 474 + }); 475 + 476 + describe("array literals", () => { 477 + it("evaluates empty array literals", () => { 478 + expect(evaluate("[]", {})).toEqual([]); 479 + }); 480 + 481 + it("evaluates simple array literals", () => { 482 + expect(evaluate("[1, 2, 3]", {})).toEqual([1, 2, 3]); 483 + expect(evaluate("['a', 'b', 'c']", {})).toEqual(["a", "b", "c"]); 484 + }); 485 + 486 + it("evaluates array literals with variables", () => { 487 + const scope = { x: 5, y: 10 }; 488 + expect(evaluate("[x, y, 15]", scope)).toEqual([5, 10, 15]); 489 + }); 490 + 491 + it("evaluates array literals with expressions", () => { 492 + const scope = { count: 3 }; 493 + expect(evaluate("[count, count * 2, count * 3]", scope)).toEqual([3, 6, 9]); 494 + }); 495 + 496 + it("evaluates nested array literals", () => { 497 + expect(evaluate("[[1, 2], [3, 4]]", {})).toEqual([[1, 2], [3, 4]]); 498 + }); 499 + 500 + it("evaluates mixed type array literals", () => { 501 + expect(evaluate("[1, 'two', true, null]", {})).toEqual([1, "two", true, null]); 502 + }); 503 + }); 504 + 505 + describe("spread operator", () => { 506 + it("spreads arrays in array literals", () => { 507 + const scope = { items: [2, 3, 4] }; 508 + expect(evaluate("[1, ...items, 5]", scope)).toEqual([1, 2, 3, 4, 5]); 509 + }); 510 + 511 + it("spreads multiple arrays", () => { 512 + const scope = { first: [1, 2], second: [3, 4] }; 513 + expect(evaluate("[...first, ...second]", scope)).toEqual([1, 2, 3, 4]); 514 + }); 515 + 516 + it("spreads objects in object literals", () => { 517 + const scope = { user: { name: "Alice", age: 25 } }; 518 + expect(evaluate("{ ...user, age: 26 }", scope)).toEqual({ name: "Alice", age: 26 }); 519 + }); 520 + 521 + it("spreads with new properties", () => { 522 + const scope = { base: { a: 1, b: 2 } }; 523 + expect(evaluate("{ ...base, c: 3 }", scope)).toEqual({ a: 1, b: 2, c: 3 }); 524 + }); 525 + 526 + it("handles multiple spreads in objects", () => { 527 + const scope = { obj1: { a: 1 }, obj2: { b: 2 } }; 528 + expect(evaluate("{ ...obj1, ...obj2, c: 3 }", scope)).toEqual({ a: 1, b: 2, c: 3 }); 529 + }); 530 + }); 531 + 532 + describe("enhanced evaluator integration", () => { 533 + it("combines method calls with ternary operators", () => { 534 + const scope = { text: "hello", minLength: 3 }; 535 + expect(evaluate("text.length >= minLength ? text.toUpperCase() : text", scope)).toBe("HELLO"); 536 + }); 537 + 538 + it("uses arrow functions with object literals", () => { 539 + const scope = { items: [1, 2, 3] }; 540 + const result = evaluate("items.map(n => ({ value: n, doubled: n * 2 }))", scope); 541 + expect(result).toEqual([{ value: 1, doubled: 2 }, { value: 2, doubled: 4 }, { value: 3, doubled: 6 }]); 542 + }); 543 + 544 + it("uses spread in method call results", () => { 545 + const scope = { todos: [{ id: 1 }, { id: 2 }], newTodo: { id: 3 } }; 546 + expect(evaluate("[...todos, newTodo]", scope)).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); 547 + }); 548 + 549 + it("evaluates complex todo app mutations", () => { 550 + const scope = { todos: [{ id: 1, completed: false }, { id: 2, completed: true }] }; 551 + const result = evaluate("todos.filter(t => !t.completed)", scope); 552 + expect(result).toEqual([{ id: 1, completed: false }]); 553 + }); 554 + 555 + it("evaluates signal mutations with spread", () => { 556 + const count = signal(5); 557 + const scope = { count }; 558 + expect(evaluate("count.get() + 1", scope)).toBe(6); 559 + }); 560 + 561 + it("handles complex class binding expressions", () => { 562 + const scope = { isActive: true, isDisabled: false }; 563 + expect(evaluate("isActive ? 'active' : ''", scope)).toBe("active"); 564 + }); 565 + }); 331 566 });
+2 -1
vite.config.ts
··· 6 6 environment: "jsdom", 7 7 setupFiles: "./test/setupTests.ts", 8 8 globals: true, 9 + watch: false, 9 10 exclude: ["**/node_modules/**", "**/dist/**", "**/cli/tests/**"], 10 11 coverage: { 11 12 provider: "v8", 12 13 thresholds: { functions: 50, branches: 50 }, 13 14 include: ["**/src/**"], 14 - exclude: ["**/cli/src/**"], 15 + exclude: ["**/cli/src/**", "**/src/main.ts"], 15 16 }, 16 17 }; 17 18