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" + } + }) }