diff --git a/packages/example-wormhole/package.json b/packages/example-wormhole/package.json
index 58b6580..7b9e130 100644
--- a/packages/example-wormhole/package.json
+++ b/packages/example-wormhole/package.json
@@ -1,24 +1,24 @@
{
- "name": "@vortexjs/example-wormhole",
- "module": "index.ts",
- "type": "module",
- "license": "MIT-0",
- "private": true,
- "devDependencies": {
- "@types/bun": "catalog:"
- },
- "dependencies": {
- "@vortexjs/core": "workspace:*",
- "@vortexjs/dom": "workspace:*",
- "@vortexjs/wormhole": "workspace:*",
- "tailwindcss": "catalog:",
- "valibot": "catalog:"
- },
- "scripts": {
- "dev": "wormhole dev",
- "vbuild": "wormhole build vercel"
- },
- "peerDependencies": {
- "typescript": "catalog:"
- }
+ "name": "@vortexjs/example-wormhole",
+ "module": "index.ts",
+ "type": "module",
+ "license": "MIT-0",
+ "private": true,
+ "devDependencies": {
+ "@types/bun": "catalog:"
+ },
+ "dependencies": {
+ "@vortexjs/core": "workspace:*",
+ "@vortexjs/dom": "workspace:*",
+ "@vortexjs/wormhole": "workspace:*",
+ "tailwindcss": "catalog:",
+ "valibot": "catalog:"
+ },
+ "scripts": {
+ "dev": "wormhole dev",
+ "vbuild": "wormhole build vercel"
+ },
+ "peerDependencies": {
+ "typescript": "catalog:"
+ }
}
diff --git a/packages/example-wormhole/src/features/home/index.tsx b/packages/example-wormhole/src/features/home/index.tsx
index d11861e..15f08e4 100644
--- a/packages/example-wormhole/src/features/home/index.tsx
+++ b/packages/example-wormhole/src/features/home/index.tsx
@@ -1,67 +1,75 @@
import { useAwait } from "@vortexjs/core";
+import { useAction } from "@vortexjs/core/actions";
+import { DOMKeyboardActions } from "@vortexjs/dom";
import route, { query } from "@vortexjs/wormhole/route";
import * as v from "valibot";
route("/", {
- page() {
- const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
- const awaited = useAwait();
+ page() {
+ useAction({
+ name: "Show Alert",
+ shortcut: "shift+b",
+ run() {
+ alert("Action triggered!");
+ }
+ })
- return (
- <>
-
- Welcome to Wormhole, {Object.entries(globalThis).length}
-
-
- This is an example app, go to the{" "}
- docs
-
-
- >
- );
- },
- layout({ children }) {
- return (
- <>
- Wormhole Example
- {children}
- >
- );
- },
- notFound() {
- return (
- <>
- 404 not found
- >
- )
- }
+ return (
+ <>
+
+
+ Welcome to Wormhole, {Object.entries(globalThis).length}
+
+
+ This is an example app, go to the{" "}
+ docs
+
+
+ >
+ );
+ },
+ layout({ children }) {
+ return (
+ <>
+ Wormhole Example
+ {children}
+ >
+ );
+ },
+ notFound() {
+ return (
+ <>
+ 404 not found
+ >
+ )
+ }
});
route("/docs", {
- page({ }) {
- const page = "introduction";
- return (
- <>
- Documentation for {page}
- This is the documentation page for {page}.
- >
- );
- },
+ page({ }) {
+ const page = "introduction";
+ return (
+ <>
+ Documentation for {page}
+ This is the documentation page for {page}.
+ >
+ );
+ },
});
export const add = query("/api/add", {
- schema: v.object({
- a: v.number(),
- b: v.number()
- }),
- impl({ a, b }) {
- return a + b;
- }
+ schema: v.object({
+ a: v.number(),
+ b: v.number()
+ }),
+ impl({ a, b }) {
+ return a + b;
+ }
})
diff --git a/packages/vortex-cli/src/corebind/index.ts b/packages/vortex-cli/src/corebind/index.ts
index 9e39f8a..5013a33 100644
--- a/packages/vortex-cli/src/corebind/index.ts
+++ b/packages/vortex-cli/src/corebind/index.ts
@@ -1,4 +1,4 @@
-import { createContext, getImmediateValue, implementIntrinsic, store, type IntrinsicComponent, type Lifetime, type Renderer, type Store } from "@vortexjs/core";
+import { createContext, getImmediateValue, store, type IntrinsicComponent, type Lifetime, type Renderer, type Store } from "@vortexjs/core";
import { Box, type TreeNode, Text } from "../tree";
import { fontWeightToPrimitiveBoldness, Frame, Text as TextIntrinsic, type FontWeight } from "@vortexjs/intrinsics";
import { jsx } from "@vortexjs/core/jsx-runtime";
diff --git a/packages/vortex-core/package.json b/packages/vortex-core/package.json
index 207a6e9..269962b 100644
--- a/packages/vortex-core/package.json
+++ b/packages/vortex-core/package.json
@@ -14,7 +14,7 @@
"typescript": "catalog:"
},
"scripts": {
- "build": "tsdown ./src/index.ts ./src/jsx/jsx-runtime.ts ./src/jsx/jsx-dev-runtime.ts --format esm --dts --out-dir dist"
+ "build": "tsdown ./src/index.ts ./src/jsx/jsx-runtime.ts ./src/jsx/jsx-dev-runtime.ts ./src/actions/index.tsx --format esm --dts --out-dir dist"
},
"exports": {
".": {
@@ -31,6 +31,11 @@
"types": "./dist/jsx/jsx-dev-runtime.d.ts",
"import": "./dist/jsx/jsx-dev-runtime.js",
"require": "./dist/jsx/jsx-dev-runtime.cjs"
+ },
+ "./actions": {
+ "types": "./dist/actions/index.d.ts",
+ "import": "./dist/actions/index.js",
+ "require": "./dist/actions/index.cjs"
}
},
"version": "2.6.0",
diff --git a/packages/vortex-core/src/actions/index.tsx b/packages/vortex-core/src/actions/index.tsx
new file mode 100644
index 0000000..e614c67
--- /dev/null
+++ b/packages/vortex-core/src/actions/index.tsx
@@ -0,0 +1,116 @@
+import { getImmediateValue } from "../signal";
+import { createContext } from "../context";
+import type { JSXChildren, JSXComponent, JSXNode } from "../jsx/jsx-common";
+import { useState, type Signal } from "../signal";
+import { useHookLifetime, type Lifetime } from "../lifetime";
+
+export type KeyModifier = "ctrl"
+ | "shift"
+ | "alt"
+ | "meta";
+export type Key =
+ | KeyModifier
+ | "backspace"
+ | "tab"
+ | "enter"
+ | "escape"
+ | "space"
+ | "a"
+ | "b"
+ | "c"
+ | "d"
+ | "e"
+ | "f"
+ | "g"
+ | "h"
+ | "i"
+ | "j"
+ | "k"
+ | "l"
+ | "m"
+ | "n"
+ | "o"
+ | "p"
+ | "q"
+ | "r"
+ | "s"
+ | "t"
+ | "u"
+ | "v"
+ | "w"
+ | "x"
+ | "y"
+ | "z"
+ | "0"
+ | "1"
+ | "2"
+ | "3"
+ | "4"
+ | "5"
+ | "6"
+ | "7"
+ | "8"
+ | "9";
+
+export type UnionToArray =
+ [T] extends [never] ? [] :
+ T extends any ? [T, ...UnionToArray>] : [];
+
+export type ModifierList =
+ Modifiers extends [] ? "" :
+ Modifiers extends [infer First extends KeyModifier, ...infer Rest extends KeyModifier[]] ?
+ `${First}+${ModifierList}` | ModifierList :
+ "";
+export type KeyboardShortcut = `${ModifierList>}${Key}`;
+
+export interface Action {
+ run?(): void | Promise;
+ shortcut?: KeyboardShortcut
+ name: string;
+ icon?: JSXComponent<{}>;
+ description?: string;
+ group?: string;
+}
+
+export interface ActionContext {
+ actions: Signal;
+ addAction(action: Action, lt: Lifetime): void;
+}
+
+export const ActionContext = createContext("ActionContext");
+
+export function ActionProvider(props: {
+ children: JSXChildren;
+}): JSXNode {
+ const actions = new Set();
+ const signal = useState([]);
+
+ function update() {
+ signal.set(Array.from(actions));
+ }
+
+ function addAction(action: Action, lt: Lifetime) {
+ actions.add(action);
+ update();
+ lt.onClosed(() => {
+ actions.delete(action);
+ update();
+ });
+ }
+
+ return
+ <>{props.children}>
+
+}
+
+export function useActionContext(): ActionContext {
+ return getImmediateValue(ActionContext.use());
+}
+
+export function useAction(action: Action, lt: Lifetime = useHookLifetime()) {
+ const ctx = useActionContext();
+
+ ctx.addAction(action, lt);
+
+ return action;
+}
diff --git a/packages/vortex-core/src/context.ts b/packages/vortex-core/src/context.ts
index 1cc27a3..233e9a9 100644
--- a/packages/vortex-core/src/context.ts
+++ b/packages/vortex-core/src/context.ts
@@ -42,7 +42,7 @@ export class StreamingContext {
private updateCallbackImmediate = 0;
private updateCallbacks = new Set<() => void>();
private loadingCounter = 0;
- private onDoneLoadingCallback = () => { };
+ private onDoneLoadingCallback = () => {};
onDoneLoading: Promise;
constructor() {
diff --git a/packages/vortex-core/src/jsx/jsx-common.ts b/packages/vortex-core/src/jsx/jsx-common.ts
index 5755b04..8583cda 100644
--- a/packages/vortex-core/src/jsx/jsx-common.ts
+++ b/packages/vortex-core/src/jsx/jsx-common.ts
@@ -1,86 +1,86 @@
import { getUltraglobalReference } from "@vortexjs/common";
import {
- isSignal,
- type Signal,
- type Store,
- toSignal,
- useDerived,
+ isSignal,
+ type Signal,
+ type Store,
+ toSignal,
+ useDerived,
} from "../signal";
export type JSXNode =
- | JSXElement
- | JSXComponent
- | JSXFragment
- | JSXText
- | JSXDynamic
- | JSXList
- | JSXContext
- | undefined;
+ | JSXElement
+ | JSXComponent
+ | JSXFragment
+ | JSXText
+ | JSXDynamic
+ | JSXList
+ | JSXContext
+ | undefined;
export interface JSXContext {
- type: "context";
- id: string;
- value: Signal;
- children: JSXNode;
+ type: "context";
+ id: string;
+ value: Signal;
+ children: JSXNode;
}
export interface JSXList {
- type: "list";
- getKey(item: T, index: number): string;
- renderItem(item: T, idx: number): JSXNode;
- items: Signal;
- key(cb: (item: T, idx: number) => string | number): JSXList;
- show(cb: (item: T, idx: number) => JSXNode): JSXList;
+ type: "list";
+ getKey(item: T, index: number): string;
+ renderItem(item: T, idx: number): JSXNode;
+ items: Signal;
+ key(cb: (item: T, idx: number) => string | number): JSXList;
+ show(cb: (item: T, idx: number) => JSXNode): JSXList;
}
export interface JSXSource {
- fileName?: string;
- lineNumber?: number;
- columnNumber?: number;
+ fileName?: string;
+ lineNumber?: number;
+ columnNumber?: number;
}
export interface JSXElement extends JSXSource {
- type: "element";
- name: string;
- attributes: Record>;
- bindings: Record>;
- eventHandlers: Record void>;
- use: Use;
- children: JSXNode[];
- styles: Record>;
+ type: "element";
+ name: string;
+ attributes: Record>;
+ bindings: Record>;
+ eventHandlers: Record void>;
+ use: Use;
+ children: JSXNode[];
+ styles: Record>;
}
export type Use = ((ref: T) => void) | Use[];
export interface JSXComponent extends JSXSource {
- type: "component";
- impl: (props: Props) => JSXNode;
- props: Props;
+ type: "component";
+ impl: (props: Props) => JSXNode;
+ props: Props;
}
export interface JSXFragment extends JSXSource {
- type: "fragment";
- children: JSXNode[];
+ type: "fragment";
+ children: JSXNode[];
}
export interface JSXText extends JSXSource {
- type: "text";
- value: string;
+ type: "text";
+ value: string;
}
export interface JSXDynamic extends JSXSource {
- type: "dynamic";
- value: Signal;
+ type: "dynamic";
+ value: Signal;
}
export interface JSXRuntimeProps {
- children?: JSXChildren;
- [key: string]: any;
+ children?: JSXChildren;
+ [key: string]: any;
}
export const Fragment = getUltraglobalReference({
- name: "Fragment",
- package: "@vortexjs/core"
+ name: "Fragment",
+ package: "@vortexjs/core"
}, Symbol("Fragment"));
export type JSXNonSignalChild = JSXNode | string | number | boolean | undefined;
@@ -90,111 +90,111 @@ export type JSXChild = JSXNonSignalChild | Signal;
export type JSXChildren = JSXChild | JSXChild[];
export function normalizeChildren(children: JSXChildren): JSXNode[] {
- if (children === undefined) {
- return [];
- }
- return [children]
- .flat()
- .filter((child) => child !== null && child !== undefined)
- .map((x) =>
- typeof x === "string" ||
- typeof x === "number" ||
- typeof x === "boolean"
- ? createTextNode(x)
- : isSignal(x)
- ? {
- type: "dynamic",
- value: useDerived((get) => {
- const val = get(x);
- return typeof val === "number" ||
- typeof val === "string" ||
- typeof val === "boolean"
- ? createTextNode(val)
- : val;
- }),
- }
- : x,
- );
+ if (children === undefined) {
+ return [];
+ }
+ return [children]
+ .flat()
+ .filter((child) => child !== null && child !== undefined)
+ .map((x) =>
+ typeof x === "string" ||
+ typeof x === "number" ||
+ typeof x === "boolean"
+ ? createTextNode(x)
+ : isSignal(x)
+ ? {
+ type: "dynamic",
+ value: useDerived((get) => {
+ const val = get(x);
+ return typeof val === "number" ||
+ typeof val === "string" ||
+ typeof val === "boolean"
+ ? createTextNode(val)
+ : val;
+ }),
+ }
+ : x,
+ );
}
export function createTextNode(value: any, source?: JSXSource): JSXNode {
- return {
- type: "text",
- value,
- ...source,
- };
+ return {
+ type: "text",
+ value,
+ ...source,
+ };
}
export function createElementInternal(
- type: string,
- props: Record,
- children: JSXChildren,
- source?: JSXSource,
+ type: string,
+ props: Record,
+ children: JSXChildren,
+ source?: JSXSource,
): JSXNode {
- const normalizedChildren = normalizeChildren(children).map((child) => {
- if (
- typeof child === "string" ||
- typeof child === "number" ||
- typeof child === "boolean"
- ) {
- return createTextNode(child);
- }
- return child;
- });
-
- const properAttributes: Record> = {};
- const bindings: Record> = {};
- const eventHandlers: Record void> = {};
- const use: Use = [];
- const styles: Record> = {};
-
- for (const [key, value] of Object.entries(props)) {
- if (value !== undefined) {
- if (key.startsWith("bind:")) {
- const bindingKey = key.slice(5);
-
- if (!isSignal(value) || !("set" in value)) {
- throw new Error(
- `Binding value for "${bindingKey}" must be a writable store.`,
- );
- }
-
- bindings[bindingKey] = value as Store;
- } else if (key.startsWith("on:")) {
- const eventKey = key.slice(3);
- if (typeof value !== "function") {
- throw new Error(
- `Event handler for "${eventKey}" must be a function.`,
- );
- }
- eventHandlers[eventKey] = value;
- } else if (key === "use") {
- if (typeof value !== "function" && !Array.isArray(value)) {
- throw new Error(
- "Use hook must be a function or an array of functions.",
- );
- }
- use.push(value);
- } else if (key === "style") {
- for (const [styleKey, styleValue] of Object.entries(value)) {
- if (styleValue !== undefined) {
- styles[styleKey] = toSignal(styleValue);
- }
- }
- } else {
- properAttributes[key] = toSignal(value);
- }
- }
- }
-
- return {
- type: "element",
- name: type,
- attributes: properAttributes,
- children: normalizedChildren,
- bindings,
- eventHandlers,
- use,
- styles,
- };
+ const normalizedChildren = normalizeChildren(children).map((child) => {
+ if (
+ typeof child === "string" ||
+ typeof child === "number" ||
+ typeof child === "boolean"
+ ) {
+ return createTextNode(child);
+ }
+ return child;
+ });
+
+ const properAttributes: Record> = {};
+ const bindings: Record> = {};
+ const eventHandlers: Record void> = {};
+ const use: Use = [];
+ const styles: Record> = {};
+
+ for (const [key, value] of Object.entries(props)) {
+ if (value !== undefined) {
+ if (key.startsWith("bind:")) {
+ const bindingKey = key.slice(5);
+
+ if (!isSignal(value) || !("set" in value)) {
+ throw new Error(
+ `Binding value for "${bindingKey}" must be a writable store.`,
+ );
+ }
+
+ bindings[bindingKey] = value as Store;
+ } else if (key.startsWith("on:")) {
+ const eventKey = key.slice(3);
+ if (typeof value !== "function") {
+ throw new Error(
+ `Event handler for "${eventKey}" must be a function.`,
+ );
+ }
+ eventHandlers[eventKey] = value;
+ } else if (key === "use") {
+ if (typeof value !== "function" && !Array.isArray(value)) {
+ throw new Error(
+ "Use hook must be a function or an array of functions.",
+ );
+ }
+ use.push(value);
+ } else if (key === "style") {
+ for (const [styleKey, styleValue] of Object.entries(value)) {
+ if (styleValue !== undefined) {
+ styles[styleKey] = toSignal(styleValue);
+ }
+ }
+ } else {
+ properAttributes[key] = toSignal(value);
+ }
+ }
+ }
+
+ return {
+ type: "element",
+ name: type,
+ attributes: properAttributes,
+ children: normalizedChildren,
+ bindings,
+ eventHandlers,
+ use,
+ styles,
+ };
}
diff --git a/packages/vortex-core/src/render/index.ts b/packages/vortex-core/src/render/index.tsx
similarity index 96%
rename from packages/vortex-core/src/render/index.ts
rename to packages/vortex-core/src/render/index.tsx
index dfc68ec..56e248f 100644
--- a/packages/vortex-core/src/render/index.ts
+++ b/packages/vortex-core/src/render/index.tsx
@@ -12,6 +12,7 @@ import {
} from "./fragments";
import { Reconciler } from "./reconciler";
import type { IntrinsicImplementation } from "../intrinsic";
+import { ActionProvider } from "../actions";
export * as FL from "./fragments";
@@ -46,7 +47,7 @@ function internalRender({ renderer, root, compon
const lt = new Lifetime();
const flNode = reconciler.render({
- node: component,
+ node: {component},
hydration: renderer.getHydrationContext(root),
lt,
context: context ?? ContextScope.current ?? new ContextScope(),
diff --git a/packages/vortex-core/src/std/abort.ts b/packages/vortex-core/src/std/abort.ts
new file mode 100644
index 0000000..1f8918c
--- /dev/null
+++ b/packages/vortex-core/src/std/abort.ts
@@ -0,0 +1,9 @@
+import { useHookLifetime } from "../lifetime";
+
+export function useAbortSignal(lt = useHookLifetime()): AbortSignal {
+ const controller = new AbortController();
+
+ lt.onClosed(() => controller.abort());
+
+ return controller.signal;
+}
diff --git a/packages/vortex-core/src/std/index.ts b/packages/vortex-core/src/std/index.ts
index 169b737..6971580 100644
--- a/packages/vortex-core/src/std/index.ts
+++ b/packages/vortex-core/src/std/index.ts
@@ -2,3 +2,4 @@ export * from "./awaited";
export * from "./clock";
export * from "./list";
export * from "./when.tsx";
+export * from "./abort";
diff --git a/packages/vortex-core/tsconfig.json b/packages/vortex-core/tsconfig.json
index 02b4841..4ef2992 100644
--- a/packages/vortex-core/tsconfig.json
+++ b/packages/vortex-core/tsconfig.json
@@ -1,27 +1,38 @@
{
- "compilerOptions": {
- // Environment setup & latest features
- "lib": ["ESNext"],
- "target": "ESNext",
- "module": "ESNext",
- "moduleDetection": "force",
- "jsx": "react-jsx",
- "allowJs": true,
- "outDir": "dist",
- // Bundler mode
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
- // Best practices
- "strict": true,
- "skipLibCheck": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
- // Some stricter flags (disabled by default)
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": false
- },
- "include": ["src/**/*", "test/**/*"]
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": [
+ "ESNext"
+ ],
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "jsxImportSource": "~/jsx",
+ "allowJs": true,
+ "outDir": "dist",
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false,
+ "paths": {
+ "~/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "src/**/*",
+ "test/**/*"
+ ]
}
diff --git a/packages/vortex-dom/src/actions/DOMActions.tsx b/packages/vortex-dom/src/actions/DOMActions.tsx
new file mode 100644
index 0000000..2c080a4
--- /dev/null
+++ b/packages/vortex-dom/src/actions/DOMActions.tsx
@@ -0,0 +1,122 @@
+import { getImmediateValue, useAbortSignal, type JSXNode } from "@vortexjs/core";
+import { useActionContext, type Key } from "@vortexjs/core/actions";
+
+function getCodeToKey(code: string): string {
+ const mapping: Record = {
+ "ControlLeft": "ctrl",
+ "ControlRight": "ctrl",
+ "ShiftLeft": "shift",
+ "ShiftRight": "shift",
+ "AltLeft": "alt",
+ "AltRight": "alt",
+ "MetaLeft": "meta",
+ "MetaRight": "meta",
+ "KeyA": "a",
+ "KeyB": "b",
+ "KeyC": "c",
+ "KeyD": "d",
+ "KeyE": "e",
+ "KeyF": "f",
+ "KeyG": "g",
+ "KeyH": "h",
+ "KeyI": "i",
+ "KeyJ": "j",
+ "KeyK": "k",
+ "KeyL": "l",
+ "KeyM": "m",
+ "KeyN": "n",
+ "KeyO": "o",
+ "KeyP": "p",
+ "KeyQ": "q",
+ "KeyR": "r",
+ "KeyS": "s",
+ "KeyT": "t",
+ "KeyU": "u",
+ "KeyV": "v",
+ "KeyW": "w",
+ "KeyX": "x",
+ "KeyY": "y",
+ "KeyZ": "z",
+ "Digit0": "0",
+ "Digit1": "1",
+ "Digit2": "2",
+ "Digit3": "3",
+ "Digit4": "4",
+ "Digit5": "5",
+ "Digit6": "6",
+ "Digit7": "7",
+ "Digit8": "8",
+ "Digit9": "9",
+ }
+
+ if (code in mapping) {
+ return mapping[code]!;
+ }
+
+ return code.toLowerCase();
+}
+
+function sortKeys(keys: string[]): string[] {
+ const order = ["ctrl", "shift", "alt", "meta", "other"];
+ return keys.sort((a, b) => {
+ let aIndex = order.indexOf(a);
+ let bIndex = order.indexOf(b);
+
+ if (aIndex === -1) aIndex = order.length - 1;
+ if (bIndex === -1) bIndex = order.length - 1;
+
+ return aIndex - bIndex;
+ });
+}
+
+function isNodeEditable(node: HTMLElement): boolean {
+ const tagName = node.tagName.toLowerCase();
+ if (tagName === "input" || tagName === "textarea" || node.isContentEditable) {
+ return true;
+ }
+ if (node.parentElement) {
+ return isNodeEditable(node.parentElement);
+ }
+ return false;
+}
+
+function isEditable(ev: KeyboardEvent): boolean {
+ const target = ev.target as HTMLElement | null;
+ if (!target) return false;
+ return isNodeEditable(target);
+}
+
+export function DOMKeyboardActions(): JSXNode {
+ if (!("window" in globalThis)) return <>>;
+
+ const { actions } = useActionContext();
+
+ const signal = useAbortSignal();
+
+ const keys = new Set();
+
+ window.addEventListener("keydown", (ev) => {
+ if (isEditable(ev)) return;
+ keys.add(ev.code);
+ }, { signal });
+
+ window.addEventListener("keyup", (ev) => {
+ if (isEditable(ev)) return;
+ const shortcut = Array.from(keys).map(x => getCodeToKey(x));
+ keys.delete(ev.code);
+
+ let sortedShortcut = sortKeys(shortcut);
+
+ const shortcutString = sortedShortcut.join("+");
+
+ const action = getImmediateValue(actions).find(x =>
+ x.shortcut && sortKeys(x.shortcut.split("+")).join("+") === shortcutString);
+
+ if (action) {
+ ev.preventDefault();
+ action?.run?.();
+ }
+ }, { signal });
+
+ return <>>;
+}
diff --git a/packages/vortex-dom/src/actions/index.ts b/packages/vortex-dom/src/actions/index.ts
new file mode 100644
index 0000000..922e24f
--- /dev/null
+++ b/packages/vortex-dom/src/actions/index.ts
@@ -0,0 +1 @@
+export * from "./DOMActions";
diff --git a/packages/vortex-dom/src/index.ts b/packages/vortex-dom/src/index.ts
index 948d646..a9c456c 100644
--- a/packages/vortex-dom/src/index.ts
+++ b/packages/vortex-dom/src/index.ts
@@ -164,5 +164,6 @@ export function html(): Renderer {
};
}
+export * from "./actions";
export * from "./jsx/intrinsics";
export * from "./std";
diff --git a/packages/vortex-dom/tsconfig.json b/packages/vortex-dom/tsconfig.json
index d715a95..a5dec80 100644
--- a/packages/vortex-dom/tsconfig.json
+++ b/packages/vortex-dom/tsconfig.json
@@ -1,27 +1,39 @@
{
- "compilerOptions": {
- // Environment setup & latest features
- "lib": ["ESNext", "DOM"],
- "target": "ESNext",
- "module": "Preserve",
- "moduleDetection": "force",
- "jsx": "react-jsx",
- "allowJs": true,
- // Bundler mode
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
- // Best practices
- "strict": true,
- "skipLibCheck": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
- "noImplicitOverride": true,
- // Some stricter flags (disabled by default)
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": false
- },
- "include": ["src/**/*", "test/**/*"]
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": [
+ "ESNext",
+ "DOM"
+ ],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "jsxImportSource": "~/jsx",
+ "allowJs": true,
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false,
+ "paths": {
+ "~/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "src/**/*",
+ "test/**/*"
+ ]
}
diff --git a/packages/vortex-intrinsics/src/index.ts b/packages/vortex-intrinsics/src/index.ts
index 334b430..c20ba3e 100644
--- a/packages/vortex-intrinsics/src/index.ts
+++ b/packages/vortex-intrinsics/src/index.ts
@@ -1,4 +1,5 @@
import { intrinsic, type JSXChildren } from "@vortexjs/core";
+import type { Action } from "@vortexjs/core/actions";
type TextSize = "title" | "heading" | "subheading" | "body" | "caption";
@@ -41,7 +42,7 @@ export const Text = intrinsic<
export const Button = intrinsic<
{
children: JSXChildren;
- onClick?: () => void;
+ action?: Action;
disabled?: boolean;
style?: "primary" | "secondary" | "ghost" | "outline";
size?: "small" | "medium" | "large";
@@ -52,14 +53,14 @@ export const Button = intrinsic<
export type UDLRDescription =
| {
- base?: number | T;
- top?: number | T;
- right?: number | T;
- bottom?: number | T;
- left?: number | T;
- x?: number | T;
- y?: number | T;
- }
+ base?: number | T;
+ top?: number | T;
+ right?: number | T;
+ bottom?: number | T;
+ left?: number | T;
+ x?: number | T;
+ y?: number | T;
+ }
| number
| T;
@@ -84,15 +85,15 @@ export function resolveUDLRDescription(
export type Background =
| {
- color?: string;
- }
+ color?: string;
+ }
| string;
export type Border =
| {
- color?: string;
- width?: number;
- radius?: number;
- }
+ color?: string;
+ width?: number;
+ radius?: number;
+ }
| string;
export const Frame = intrinsic<
@@ -114,18 +115,18 @@ export const Frame = intrinsic<
gap?: number | string;
clip?: boolean;
alignItems?:
- | "flex-start"
- | "flex-end"
- | "center"
- | "stretch"
- | "baseline";
+ | "flex-start"
+ | "flex-end"
+ | "center"
+ | "stretch"
+ | "baseline";
justifyContent?:
- | "flex-start"
- | "flex-end"
- | "center"
- | "space-between"
- | "space-around"
- | "space-evenly";
+ | "flex-start"
+ | "flex-end"
+ | "center"
+ | "space-between"
+ | "space-around"
+ | "space-evenly";
position?: "absolute" | "relative" | "static";
left?: number;
top?: number;
diff --git a/packages/wormhole/src/cli/entry.ts b/packages/wormhole/src/cli/entry.ts
index a318b40..56297ac 100644
--- a/packages/wormhole/src/cli/entry.ts
+++ b/packages/wormhole/src/cli/entry.ts
@@ -11,82 +11,82 @@ import { Build } from "~/build/build";
import { VercelAdapter } from "~/build/adapters/vercel";
function showHelp() {
- const printer = createPrinter();
+ const printer = createPrinter();
- {
- using _head = printer.group("Meta");
+ {
+ using _head = printer.group("Meta");
- printer.log(`This is the Wormhole CLI. Wormhole is a metaframework for Vortex, designed to make development easier.`);
- printer.log(`Wormhole is beta software, so please keep that in the back of your mind.`);
+ printer.log(`This is the Wormhole CLI. Wormhole is a metaframework for Vortex, designed to make development easier.`);
+ printer.log(`Wormhole is beta software, so please keep that in the back of your mind.`);
- printer.gap();
- printer.log(`Version: ${version}`);
- printer.log(`Bun version: ${Bun.version}`);
- }
+ printer.gap();
+ printer.log(`Version: ${version}`);
+ printer.log(`Bun version: ${Bun.version}`);
+ }
- {
- using _head = printer.group("Usage");
+ {
+ using _head = printer.group("Usage");
- const commands = [
- ["wh help", "Show this help command"],
- ["wh dev", "Start the development server"],
- ["wh build vercel", "Build for Vercel deployment"]
- ];
+ const commands = [
+ ["wh help", "Show this help command"],
+ ["wh dev", "Start the development server"],
+ ["wh build vercel", "Build for Vercel deployment"]
+ ];
- const firstColumnWidth = Math.max(...commands.map(c => c[0]!.length)) + 2;
+ const firstColumnWidth = Math.max(...commands.map(c => c[0]!.length)) + 2;
- for (const [command, description] of commands) {
- printer.log(
- `${chalk.hex(Bun.color(colors.emerald[400], "hex")!)(command!.padEnd(firstColumnWidth))} ${description}`,
- );
- }
- }
- printer.show();
+ for (const [command, description] of commands) {
+ printer.log(
+ `${chalk.hex(Bun.color(colors.emerald[400], "hex")!)(command!.padEnd(firstColumnWidth))} ${description}`,
+ );
+ }
+ }
+ printer.show();
- process.exit(1);
+ process.exit(1);
}
const commands = [
- command(async () => {
- const lt = new Lifetime();
- const state = new Project(process.cwd(), lt);
-
- await state.init();
-
- DevServer(state);
- StatusBoard(state);
- }, "dev"),
- command(async ({ platform }: { platform?: string }) => {
- const lt = new Lifetime();
- const state = new Project(process.cwd(), lt);
-
- await state.init();
-
- let adapter;
- switch (platform) {
- case "vercel":
- adapter = VercelAdapter();
- break;
- default:
- console.error(`Unknown platform: ${platform}. Supported platforms: vercel`);
- process.exit(1);
- }
-
- const build = new Build(state, adapter);
- const result = await build.run();
-
- console.log(`Build completed successfully!`);
- console.log(`Output directory: ${result.outputDir}`);
- console.log(`Static directory: ${result.staticDir}`);
- console.log(`Functions directory: ${result.functionsDir}`);
- console.log(`Config file: ${result.configFile}`);
- }, "build", optional(positional("platform")))
+ command(async () => {
+ const lt = new Lifetime();
+ const state = new Project(process.cwd(), lt);
+
+ await state.init();
+
+ DevServer(state);
+ StatusBoard(state);
+ }, "dev"),
+ command(async ({ platform }: { platform?: string }) => {
+ const lt = new Lifetime();
+ const state = new Project(process.cwd(), lt);
+
+ await state.init();
+
+ let adapter;
+ switch (platform) {
+ case "vercel":
+ adapter = VercelAdapter();
+ break;
+ default:
+ console.error(`Unknown platform: ${platform}. Supported platforms: vercel`);
+ process.exit(1);
+ }
+
+ const build = new Build(state, adapter);
+ const result = await build.run();
+
+ console.log(`Build completed successfully!`);
+ console.log(`Output directory: ${result.outputDir}`);
+ console.log(`Static directory: ${result.staticDir}`);
+ console.log(`Functions directory: ${result.functionsDir}`);
+ console.log(`Config file: ${result.configFile}`);
+ }, "build", optional(positional("platform")))
]
export async function cliMain(args: string[]) {
- await parseArgs({
- commands,
- args,
- showHelp
- });
+ await parseArgs({
+ commands,
+ args,
+ showHelp
+ });
}
diff --git a/packages/wormhole/src/dev/dev-server.ts b/packages/wormhole/src/dev/dev-server.ts
index 202c7fb..3e93367 100644
--- a/packages/wormhole/src/dev/dev-server.ts
+++ b/packages/wormhole/src/dev/dev-server.ts
@@ -9,175 +9,177 @@ import { watch } from "node:fs";
import type { HTTPMethod } from "~/shared/http-method";
export interface DevServer {
- lifetime: Lifetime;
- server: Bun.Server;
- processRequest(request: Request, tags: RequestTag[]): Promise;
- rebuild(): Promise;
- buildResult: Promise;
- project: Project;
+ lifetime: Lifetime;
+ server: Bun.Server;
+ processRequest(request: Request, tags: RequestTag[]): Promise;
+ rebuild(): Promise;
+ buildResult: Promise;
+ project: Project;
}
export function DevServer(project: Project): DevServer {
- const server = Bun.serve({
- port: 3141,
- routes: {
- "/*": async (req) => {
- return new Response();
- }
- },
- development: true
- });
-
- project.lt.onClosed(server.stop);
-
- const self: DevServer = {
- lifetime: project.lt,
- server,
- processRequest: DevServer_processRequest,
- rebuild: DevServer_rebuild,
- buildResult: new Promise(() => { }),
- project
- }
-
- server.reload({
- routes: {
- "/*": async (req) => {
- const tags: RequestTag[] = [];
- const response = await self.processRequest(req, tags);
-
- addLog({
- type: "request",
- url: new URL(req.url).pathname,
- method: req.method as HTTPMethod,
- responseCode: response.status,
- tags
- })
-
- return response;
- }
- }
- });
-
- const devServerTask = addTask({
- name: `Development server running @ ${server.url.toString()}`
- });
-
- project.lt.onClosed(devServerTask[Symbol.dispose]);
-
- self.rebuild();
-
- // Watch sourcedir
- const watcher = watch(join(project.projectDir, "src"), { recursive: true });
-
- let isWaitingForRebuild = false;
-
- watcher.on("change", async (eventType, filename) => {
- if (isWaitingForRebuild) return;
- isWaitingForRebuild = true;
- await self.buildResult;
- isWaitingForRebuild = false;
- self.rebuild();
- });
-
- project.lt.onClosed(() => {
- watcher.close();
- });
-
- return self;
+ const server = Bun.serve({
+ port: 3141,
+ routes: {
+ "/*": async (req) => {
+ return new Response();
+ }
+ },
+ development: true
+ });
+
+ project.lt.onClosed(server.stop);
+
+ const self: DevServer = {
+ lifetime: project.lt,
+ server,
+ processRequest: DevServer_processRequest,
+ rebuild: DevServer_rebuild,
+ buildResult: new Promise(() => { }),
+ project
+ }
+
+ server.reload({
+ routes: {
+ "/*": async (req) => {
+ const tags: RequestTag[] = [];
+ const response = await self.processRequest(req, tags);
+
+ addLog({
+ type: "request",
+ url: new URL(req.url).pathname,
+ method: req.method as HTTPMethod,
+ responseCode: response.status,
+ tags
+ })
+
+ return response;
+ }
+ }
+ });
+
+ const devServerTask = addTask({
+ name: `Development server running @ ${server.url.toString()}`
+ });
+
+ project.lt.onClosed(devServerTask[Symbol.dispose]);
+
+ self.rebuild();
+
+ // Watch sourcedir
+ const watcher = watch(join(project.projectDir, "src"), { recursive: true });
+
+ let isWaitingForRebuild = false;
+
+ watcher.on("change", async (eventType, filename) => {
+ if (isWaitingForRebuild) return;
+ isWaitingForRebuild = true;
+ await self.buildResult;
+ isWaitingForRebuild = false;
+ self.rebuild();
+ });
+
+ project.lt.onClosed(() => {
+ watcher.close();
+ });
+
+ return self;
}
async function DevServer_rebuild(this: DevServer): Promise {
- const build = new Build(this.project, DevAdapter());
- this.buildResult = build.run();
- await this.buildResult;
+ const build = new Build(this.project, DevAdapter());
+ this.buildResult = build.run();
+ await this.buildResult;
}
interface ServerEntrypoint {
- main(props: {
- renderer: Renderer,
- root: RendererNode,
- pathname: string,
- context: ContextScope,
- lifetime: Lifetime
- }): void;
- tryHandleAPI(request: Request): Promise;
- isRoute404(pathname: string): boolean;
+ main(props: {
+ renderer: Renderer,
+ root: RendererNode,
+ pathname: string,
+ context: ContextScope,
+ lifetime: Lifetime
+ }): void;
+ tryHandleAPI(request: Request): Promise;
+ isRoute404(pathname: string): boolean;
}
async function DevServer_processRequest(this: DevServer, request: Request, tags: RequestTag[]): Promise {
- const built = await this.buildResult;
+ const built = await this.buildResult;
- const serverPath = built.serverEntry;
- const serverEntrypoint = (await import(serverPath + `?v=${Date.now()}`)) as ServerEntrypoint;
+ const serverPath = built.serverEntry;
+ const serverEntrypoint = (await import(serverPath + `?v=${Date.now()}`)) as ServerEntrypoint;
- // Priority 1: API routes
- const apiResponse = await serverEntrypoint.tryHandleAPI(request);
+ // Priority 1: API routes
+ const apiResponse = await serverEntrypoint.tryHandleAPI(request);
- if (apiResponse !== undefined && apiResponse !== null) {
- tags.push("api");
- return apiResponse;
- }
+ if (apiResponse !== undefined && apiResponse !== null) {
+ tags.push("api");
+ return apiResponse;
+ }
- // Priority 2: Static files
- const filePath = join(built.outdir, new URL(request.url).pathname);
+ // Priority 2: Static files
+ const filePath = join(built.outdir, new URL(request.url).pathname);
- if (await Bun.file(filePath).exists()) {
- tags.push("static");
- return new Response(Bun.file(filePath));
- }
+ if (await Bun.file(filePath).exists()) {
+ tags.push("static");
+ return new Response(Bun.file(filePath));
+ }
- // Priority 3: SSR
- const root = createHTMLRoot();
- const renderer = ssr();
+ // Priority 3: SSR
+ const root = createHTMLRoot();
+ const renderer = ssr();
- const context = new ContextScope();
+ const context = new ContextScope();
- const lifetime = new Lifetime();
+ const lifetime = new Lifetime();
- serverEntrypoint.main({
- root,
- renderer,
- pathname: new URL(request.url).pathname,
- context,
- lifetime
- });
+ serverEntrypoint.main({
+ root,
+ renderer,
+ pathname: new URL(request.url).pathname,
+ context,
+ lifetime
+ });
- const html = printHTML(root);
+ const html = printHTML(root);
- const { readable, writable } = new TransformStream();
+ const { readable, writable } = new TransformStream();
- async function load() {
- const writer = writable.getWriter();
+ async function load() {
+ const writer = writable.getWriter();
- writer.write(html);
+ writer.write(html);
- let currentSnapshot = structuredClone(root);
+ let currentSnapshot = structuredClone(root);
- context.streaming.onUpdate(() => {
- const codegen = diffInto(currentSnapshot, root);
+ context.streaming.updated();
- const code = codegen.getCode();
+ context.streaming.onUpdate(() => {
+ const codegen = diffInto(currentSnapshot, root);
- currentSnapshot = structuredClone(root);
+ const code = codegen.getCode();
- writer.write(``);
- });
+ currentSnapshot = structuredClone(root);
- await context.streaming.onDoneLoading;
+ writer.write(``);
+ });
- writer.write(``);
- writer.close();
- lifetime.close();
- }
+ await context.streaming.onDoneLoading;
- load();
+ writer.write(``);
+ writer.close();
+ lifetime.close();
+ }
- tags.push("ssr");
+ load();
- return new Response(readable, {
- status: serverEntrypoint.isRoute404(new URL(request.url).pathname) ? 404 : 200,
- headers: {
- 'Content-Type': "text/html"
- }
- })
+ tags.push("ssr");
+
+ return new Response(readable, {
+ status: serverEntrypoint.isRoute404(new URL(request.url).pathname) ? 404 : 200,
+ headers: {
+ 'Content-Type': "text/html"
+ }
+ })
}