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.

docs: drafted reactivity spec and removed existing example code for migration

+186 -166
+24 -9
ROADMAP.md
··· 2 2 3 3 | Version | Milestone | Summary | 4 4 | ------- | ---------------------------------------------------------- | ------------------------------------------------------------------------ | 5 - | v0.1.0 | [Foundations](#foundations) | Initial project setup, tooling, and reactive signal prototype. | 6 - | v0.2.0 | [Reactivity & Bindings](#reactivity--bindings) | Core DOM bindings (`data-x-*`) and declarative updates. | 7 - | v0.3.0 | [Actions & Effects](#actions--effects) | Event system and derived reactivity primitives. | 8 - | v0.4.0 | [Plugins Framework](#plugins-framework) | Modular plugin system and first built-in plugin set. | 9 - | v0.5.0 | [Streaming & Patch Engine](#streaming--patch-engine) | SSE/WebSocket JSON patch streaming. | 10 - | v0.6.0 | [Persistence & Offline](#persistence--offline) | State persistence, storage sync, and fallback behaviors. | 11 - | v0.7.0 | [Animation & Transitions](#animation--transitions) | Declarative animation layer and browser View Transition API integration. | 12 - | v0.8.0 | [Inspector & Developer Tools](#inspector--developer-tools) | Built-in signal inspector, debug overlays, and dev tooling. | 13 - | v0.9.0 | [Docs & Stability](#documentation--stability-pass) | Comprehensive docs, tests, and performance review. | 5 + | | [Foundations](#foundations) | Initial project setup, tooling, and reactive signal prototype. | 6 + | | [Reactivity & Bindings](#reactivity--bindings) | Core DOM bindings (`data-x-*`) and declarative updates. | 7 + | | [Actions & Effects](#actions--effects) | Event system and derived reactivity primitives. | 8 + | | [Plugins Framework](#plugins-framework) | Modular plugin system and first built-in plugin set. | 9 + | | [Streaming & Patch Engine](#streaming--patch-engine) | SSE/WebSocket JSON patch streaming. | 10 + | | [Persistence & Offline](#persistence--offline) | State persistence, storage sync, and fallback behaviors. | 11 + | v0.1.0 | [Markup Based Reactivity](#markup-based-reactivity) | Allow users to write apps without any bundled JS | 12 + | v0.2.0 | [Animation & Transitions](#animation--transitions) | Declarative animation layer and browser View Transition API integration. | 13 + | v0.3.0 | [Inspector & Developer Tools](#inspector--developer-tools) | Built-in signal inspector, debug overlays, and dev tooling. | 14 + | v0.4.0 | [Docs & Stability](#documentation--stability-pass) | Comprehensive docs, tests, and performance review. | 15 + | v0.5.0 | PWA Capabilities | TODO | 14 16 | v1.0.0 | [Release](#stable-release) | Public API freeze, plugin registry, and versioned documentation. | 15 17 16 18 ## Details ··· 131 133 - Versioned documentation (stormlightlabs.github.io/volt) 132 134 - Announcement post and release notes 133 135 - Community contribution guide & governance doc 136 + 137 + ### Markup Based Reactivity 138 + 139 + **Goal:** Allow Volt apps to declare state, bindings, and behavior entirely in HTML markup 140 + **Outcome:** Authors can ship examples without companion JavaScript bundles 141 + **Deliverables:** 142 + - Auto-bootstrapping loader (`volt.min.js`) that detects `data-volt` roots and hydrates one scope per root. 143 + - Declarative state primitives (`data-volt-state`, `data-volt-computed:*`) aligned with `docs/reactivity-spec.md`. 144 + - Binding directives for text, attributes, classes, styles, and two-way form controls (`data-volt-[bind|text|model|class:*]`). 145 + - Control-flow directives (`data-volt-for`, `data-volt-if`, `data-volt-else`) with lifecycle-safe teardown. 146 + - Declarative event system (`data-volt-on:*`) with helper surface for list mutations and plugin hooks. 147 + - SSR compatibility helpers and sandboxed expression evaluator per the security contract. 148 + - Integration tests covering TodoMVC and hydration edge cases. 134 149 135 150 ## Examples 136 151
+27 -25
cli/src/commands/example.ts
··· 1 - import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; 2 1 import { existsSync } from "node:fs"; 2 + import { mkdir, readFile, writeFile } from "node:fs/promises"; 3 3 import path from "node:path"; 4 4 import { minify as terserMinify } from "terser"; 5 5 import { echo } from "../console/echo.js"; ··· 30 30 return currentDir; 31 31 } 32 32 } catch { 33 - // Continue searching 33 + // No-Op: Continue searching 34 34 } 35 35 } 36 36 ··· 47 47 } 48 48 49 49 /** 50 - * Find the hashed build artifacts in dist/assets/ 50 + * Build the library using Vite in library mode 51 51 */ 52 - async function findBuildArtifacts(root: string): Promise<BuildArtifacts> { 53 - const distAssetsDir = path.join(root, "dist", "assets"); 52 + async function buildLibrary(root: string): Promise<void> { 53 + const { execSync } = await import("node:child_process"); 54 54 55 - let entries: string[]; 56 55 try { 57 - const dirents = await readdir(distAssetsDir, { withFileTypes: true }); 58 - entries = dirents.filter((d) => d.isFile()).map((d) => d.name); 56 + execSync("pnpm vite build --mode lib", { cwd: root, stdio: "inherit" }); 59 57 } catch { 60 - throw new Error("Build artifacts not found. Run 'pnpm build' first to generate dist/assets/"); 58 + throw new Error("Library build failed. Make sure Vite is configured correctly."); 61 59 } 60 + } 62 61 63 - const jsFile = entries.find((f) => f.startsWith("index-") && f.endsWith(".js")); 64 - const cssFile = entries.find((f) => f.startsWith("index-") && f.endsWith(".css")); 62 + /** 63 + * Find the library build artifacts in dist/ 64 + */ 65 + async function findBuildArtifacts(root: string): Promise<BuildArtifacts> { 66 + const distDir = path.join(root, "dist"); 65 67 66 - if (!jsFile || !cssFile) { 67 - throw new Error("Build artifacts incomplete. Expected index-*.js and index-*.css in dist/assets/"); 68 + const jsPath = path.join(distDir, "volt.js"); 69 + const cssPath = path.join(root, "src", "styles", "base.css"); 70 + 71 + if (!existsSync(jsPath)) { 72 + throw new Error(`Library JS not found at ${jsPath}. Build may have failed.`); 68 73 } 69 74 70 - return { jsPath: path.join(distAssetsDir, jsFile), cssPath: path.join(distAssetsDir, cssFile) }; 75 + if (!existsSync(cssPath)) { 76 + throw new Error(`Base CSS not found at ${cssPath}.`); 77 + } 78 + 79 + return { jsPath, cssPath }; 71 80 } 72 81 73 82 /** ··· 200 209 } 201 210 202 211 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 - `; 212 + return "/* Add your custom styles here */\n"; 214 213 } 215 214 216 215 /** ··· 244 243 const exampleDir = path.join(examplesDir, name); 245 244 246 245 echo.title(`\nCreating example: ${name}\n`); 246 + 247 + echo.info("Building Volt.js library..."); 248 + await buildLibrary(root); 247 249 248 250 echo.info("Finding build artifacts..."); 249 251 const artifacts = await findBuildArtifacts(root);
+111
docs/reactivity-spec.md
··· 1 + # Reactivity 2 + 3 + Signals power Volt’s imperative runtime as plain getters/setters with pub-sub on top. The implementation lives in `src/core/signal.ts` and exposes three primitives. 4 + 5 + ## Core 6 + 7 + ### Signals 8 + 9 + - API signature: `signal<T>(initialValue: T): Signal<T>` 10 + 11 + - `get()` MUST synchronously return the last committed value. 12 + - `set(next)` MUST compare `next` to the current value using `===` and MUST skip notification when the comparison returns true. 13 + - When `set` commits a new value it MUST synchronously invoke every registered subscriber in the order they were added. 14 + - Subscriber errors MUST be caught and logged through `console.error`. 15 + - `subscribe(listener)` MUST add the listener, MUST NOT invoke it immediately, and MUST return a teardown function that, when called, removes the listener so it receives no further notifications. 16 + - Multiple subscriptions of the same listener are allowed; each teardown MUST only remove the corresponding registration. 17 + 18 + ### Computed State 19 + 20 + - API signature: `computed<T>(compute: () => T, deps: Array<Signal | ComputedSignal>): ComputedSignal<T>` 21 + 22 + - Construction MUST synchronously evaluate `compute()` once to produce the initial value. 23 + - Exceptions thrown by `compute` MUST propagate to the caller. 24 + - Each dependency in `deps` MUST be subscribed exactly once at construction. 25 + - Missing dependencies result in stale values and are the caller’s responsibility. 26 + - When any dependency publishes, the computed MUST recompute immediately, compare the new value with the previous value using `===`, and MUST notify subscribers only when the value changes. 27 + - Subscribers follow the same contract as `signal.subscribe`: synchronous notifications, no immediate call on registration, teardown removes the listener, errors are logged. 28 + 29 + ### Effects 30 + 31 + - API signature: `effect(fn: () => void | (() => void), deps: Array<Signal | ComputedSignal>): () => void` 32 + 33 + - The runtime MUST execute `fn` immediately after subscription. 34 + - Exceptions MUST be caught and logged; execution continues for subsequent notifications. 35 + - If `fn` returns a cleanup function, the runtime MUST invoke that cleanup before the next execution of `fn` and during teardown. 36 + - The runtime MUST subscribe to each dependency once. When a dependency publishes, it MUST rerun `fn` synchronously and manage cleanup as described above. 37 + - The teardown function returned by `effect` MUST unsubscribe from all dependencies and MUST invoke any pending cleanup exactly once. 38 + 39 + ### Gaps 40 + 41 + - No automatic dependency tracking; most frameworks infer dependencies from getters. 42 + - Equality checks are strict (`===`), so structural equality or NaN handling requires user code. 43 + - Notifications run synchronously; there is no batching, scheduling, or microtask deferral. 44 + - Signals cannot be inspected for previous values or dependency graphs, limiting debugging tooling. 45 + - There is no built-in way to pause/resume computed values or effects besides manual unsubscribe. 46 + 47 + ## Markup Based Reactivity 48 + 49 + Volt’s runtime should be able to hydrate declarative markup without developer-authored boot scripts. 50 + This section describes the contract for the markup-only mode so plugin authors and docs stay aligned while the loader is implemented. 51 + 52 + ### Bootstrapping 53 + 54 + - `volt` MUST auto-discover mount points marked with `data-volt-root`, `data-volt`, or an equivalent attribute. 55 + - The bootstrapper MUST initialize exactly one reactive scope per root node. 56 + - A root MAY opt out of auto-init by setting `data-volt="false"`. 57 + - During hydration the loader MUST parse `data-volt-state` on the root; the attribute holds a JSON literal that seeds top-level signals. 58 + 59 + ### Declaring State 60 + 61 + - Primitive state lives under `data-volt-state='{"newTodo":"","todos":[]}'`. 62 + - Each key becomes a writable `signal`. 63 + - Derived values are declared with `data-volt-computed:name="expression"`; the loader builds a computed that depends on every identifier referenced in the expression. 64 + - Global helpers (e.g., `state.todos`, `helpers.length`) must be documented so HTML authors know what bindings are available without custom scripts. 65 + 66 + ### Binding Expressions 67 + 68 + - Attribute bindings use `data-volt-bind:attr="expression"`; the runtime keeps the DOM attribute/property in sync with the expression value. 69 + - For text nodes, `data-volt-text="expression"` renders the latest scalar; internally this is just a one-off text binding. 70 + - Two-way form bindings use `data-volt-model="stateKey"`; the loader wires native input events back to the matching signal. 71 + - Class and style shorthands (`data-volt-class:active="expression"`) mirror the existing imperative helpers. 72 + 73 + ### Control Flow 74 + 75 + - Lists are rendered with `data-volt-for="item, index in todos"` on a `<template>` element; the loader clones the template per entry and exposes loop variables plus `$parent` for outer scope access. 76 + - Conditional blocks use `data-volt-if="expression"` with optional `data-volt-else`; only the truthy branch remains in the DOM. 77 + - Loops and conditionals share cleanup semantics with imperative mounts: nodes created by the runtime must unsubscribe from signals when removed. 78 + 79 + ### Event Handling 80 + 81 + - Event handlers are declared with `data-volt-on:event="statement"` and execute inside the reactive scope with access to helper utilities. 82 + - Mutations must target signals or proxied arrays so change tracking fires. 83 + - Custom plugins can register additional helpers accessible from markup, but they must be namespaced (e.g., `persist.save()`). 84 + 85 + ### Example Skeleton 86 + 87 + ```html 88 + <div data-volt data-volt-state='{"newTodo":"","todos":[] }'> 89 + <form data-volt-on:submit="addTodo(newTodo)"> 90 + <input data-volt-model="newTodo" placeholder="What needs to be done?" /> 91 + </form> 92 + 93 + <ul> 94 + <template data-volt-for="todo, idx in todos"> 95 + <li data-volt-class:completed="todo.completed"> 96 + <input type="checkbox" data-volt-model="todo.completed" /> 97 + <span data-volt-text="todo.title"></span> 98 + <button data-volt-on:click="removeTodo(idx)">×</button> 99 + </li> 100 + </template> 101 + </ul> 102 + 103 + <p data-volt-if="todos.length === 0">Everything done!</p> 104 + </div> 105 + ``` 106 + 107 + ### Security & Parsing Notes 108 + 109 + - Expression strings are evaluated inside a sandboxed `Function` wrapper scoped to the current reactive state; no global objects other than the documented helper bag may leak in. 110 + - The loader must reject unparseable JSON in `data-volt-state` and surface clear warnings so authors can debug by inspecting the console. 111 + - Because hydration occurs after `DOMContentLoaded`, SSR must emit a `<script type="application/json">` fallback when markup-only authors need immediate scope initialization.
-40
examples/counter/README.md
··· 1 1 # Counter 2 2 3 3 Simple interactive counter demonstrating Volt.js reactive primitives. 4 - 5 - ## Features 6 - 7 - - Signal-based state with `count` tracking the current value 8 - - Computed values deriving `doubled` and `squared` from count 9 - - Conditional rendering showing status (Positive/Negative/Zero) 10 - - Dynamic class binding for visual feedback 11 - 12 - ## Running 13 - 14 - 1. Build the project: `pnpm build` from root 15 - 2. Open `index.html` in a browser 16 - 17 - ## Implementation 18 - 19 - The counter uses three Volt.js primitives: 20 - 21 - **Signals** store reactive state: 22 - 23 - ```js 24 - const count = signal(0); 25 - ``` 26 - 27 - **Computed** derive values automatically: 28 - 29 - ```js 30 - const doubled = computed(() => count.get() * 2, [count]); 31 - const isPositive = computed(() => count.get() > 0, [count]); 32 - ``` 33 - 34 - **Bindings** connect state to DOM: 35 - 36 - ```html 37 - <span data-x-text="count">0</span> 38 - <button data-x-on-click="increment">+</button> 39 - <div data-x-if="isPositive">Positive</div> 40 - <div data-x-class="countClass"></div> 41 - ``` 42 - 43 - When `count` changes, all dependent computed values recalculate and bindings update automatically.
-77
examples/todomvc/README.md
··· 1 1 # TodoMVC 2 2 3 3 Complete TodoMVC implementation using Volt.js and Volt CSS (classless framework). 4 - 5 - ## Features 6 - 7 - - Add/edit/delete todos 8 - - Mark complete/incomplete 9 - - Filter by All/Active/Completed 10 - - Toggle all at once 11 - - Clear completed 12 - - Persistent reactive state 13 - 14 - ## Running 15 - 16 - 1. Build the project: `pnpm build` from root 17 - 2. Open `index.html` in a browser 18 - 19 - ## Implementation 20 - 21 - ### State Management 22 - 23 - Two base signals control all state: 24 - 25 - ```js 26 - const todos = signal([...]); 27 - const filter = signal('all'); 28 - ``` 29 - 30 - Computed signals derive UI state: 31 - 32 - ```js 33 - const filteredTodos = computed(() => { 34 - if (filter.get() === 'active') return todos.get().filter(t => !t.completed); 35 - if (filter.get() === 'completed') return todos.get().filter(t => t.completed); 36 - return todos.get(); 37 - }, [todos, filter]); 38 - 39 - const activeCount = computed(() => 40 - todos.get().filter(t => !t.completed).length, 41 - [todos] 42 - ); 43 - ``` 44 - 45 - ### List Rendering 46 - 47 - `data-x-for` renders todos reactively: 48 - 49 - ```html 50 - <li data-x-for="(todo, index) in filteredTodos" data-x-class="getTodoClass(todo)"> 51 - <input type="checkbox" data-x-on-click="toggleTodo($event, index)"> 52 - <label data-x-text="todo.text"></label> 53 - </li> 54 - ``` 55 - 56 - ### Event Handling 57 - 58 - All interactions use declarative bindings: 59 - 60 - - `data-x-on-click` for buttons and checkboxes 61 - - `data-x-on-keyup` for Enter/Escape in inputs 62 - - `data-x-on-dblclick` for editing mode 63 - - `data-x-on-blur` to save edits 64 - 65 - ### Index Mapping 66 - 67 - Because the UI displays filtered todos but operations need the full array, handlers map filtered indices to actual positions: 68 - 69 - ```js 70 - const deleteTodo = (indexInFiltered) => { 71 - const filteredList = filteredTodos.get(); 72 - const todoToFind = filteredList[indexInFiltered]; 73 - const actualIndex = todos.get().findIndex(t => t.id === todoToFind.id); 74 - todos.set(todos.get().filter((_, i) => i !== actualIndex)); 75 - }; 76 - ``` 77 - 78 - ### Styling 79 - 80 - Uses only Volt CSS. Semantic HTML elements are styled automatically with no custom classes needed.
+24 -15
vite.config.ts
··· 1 1 import path from "node:path"; 2 - import { defineConfig } from "vitest/config"; 2 + import { defineConfig } from "vite"; 3 + import { type ViteUserConfig } from "vitest/config"; 3 4 4 - export default defineConfig({ 5 + const test: ViteUserConfig["test"] = { 6 + environment: "jsdom", 7 + setupFiles: "./test/setupTests.ts", 8 + globals: true, 9 + exclude: ["**/node_modules/**", "**/dist/**", "**/cli/tests/**"], 10 + coverage: { 11 + provider: "v8", 12 + thresholds: { functions: 50, branches: 50 }, 13 + include: ["**/src/**"], 14 + exclude: ["**/cli/src/**"], 15 + }, 16 + }; 17 + 18 + export default defineConfig(({ mode }) => ({ 5 19 resolve: { 6 20 alias: { 7 21 "$types": path.resolve(__dirname, "./src/types"), ··· 9 23 "@volt/plugins": path.resolve(__dirname, "./src/plugins"), 10 24 }, 11 25 }, 12 - test: { 13 - environment: "jsdom", 14 - setupFiles: "./test/setupTests.ts", 15 - globals: true, 16 - exclude: ["**/node_modules/**", "**/dist/**", "**/cli/tests/**"], 17 - coverage: { 18 - provider: "v8", 19 - thresholds: { functions: 50, branches: 50 }, 20 - include: ["**/src/**"], 21 - exclude: ["**/cli/src/**"], 22 - }, 23 - }, 24 - }); 26 + build: mode === "lib" 27 + ? { 28 + lib: { entry: path.resolve(__dirname, "src/index.ts"), name: "Volt", fileName: "volt", formats: ["es"] }, 29 + rolldownOptions: { output: { assetFileNames: "volt.[ext]" } }, 30 + } 31 + : undefined, 32 + test, 33 + }));