diff --git a/package.json b/package.json index de0d5de..6032eae 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "release": "changeset publish", "build": "turbo build", "wdev": "cd packages/example-wormhole && turbo build && bun dev", + "wbuild": "cd packages/example-wormhole && turbo build && bun vbuild", "version": "changeset version", "dev": "turbo dev", "test": "turbo test", diff --git a/packages/vortex-cli/src/tree/index.ts b/packages/vortex-cli/src/tree/index.ts index 6377849..7bc731b 100644 --- a/packages/vortex-cli/src/tree/index.ts +++ b/packages/vortex-cli/src/tree/index.ts @@ -4,372 +4,381 @@ import { resolveUDLRDescription, type Frame } from "@vortexjs/intrinsics"; import type { IntrinsicComponent } from "@vortexjs/core"; export interface TreeNode { - yoga: Node; - render(canvas: Canvas): void; + yoga: Node; + render(canvas: Canvas): void; } function getYogaConfig() { - const config = Yoga.Config.create(); + const config = Yoga.Config.create(); - config.setPointScaleFactor(1); - config.setUseWebDefaults(false); + config.setPointScaleFactor(1); + config.setUseWebDefaults(false); - return config; + return config; } const config = getYogaConfig(); export type FrameProps = Omit - ? Props - : never, "children">; + ? Props + : never, "children">; function pixelToTileX(px: number | T): number | T { - if (Number.isNaN(Number(px))) { - return px; - } + if (Number.isNaN(Number(px))) { + return px; + } - return Math.round(Number(px) / (10 / 4)); + return Math.round(Number(px) / (10 / 4)); } function pixelToTileY(px: number | T): number | T { - if (Number.isNaN(Number(px))) { - return px; - } + if (Number.isNaN(Number(px))) { + return px; + } - return Math.round(Number(px) / (25 / 4)); + return Math.round(Number(px) / (25 / 4)); } export class Box implements TreeNode { - yoga: Node; - attributes: FrameProps = {}; - private _children: TreeNode[] = []; - - get children(): TreeNode[] { - return this._children; - } - - set children(children: TreeNode[]) { - this._children = children; - - while (this.yoga.getChildCount() > 0) { - this.yoga.removeChild(this.yoga.getChild(0)); - } - - for (const child of children) { - this.yoga.insertChild(child.yoga, this.yoga.getChildCount()); - } - } - - constructor() { - this.yoga = Yoga.Node.create(config); - } - - render(canvas: Canvas): void { - const layout = this.yoga.getComputedLayout(); - - const left = layout.left; - const top = layout.top; - const right = left + layout.width; - const bottom = top + layout.height; - - if (this.attributes.background) { - const backgroundColor = typeof this.attributes.background === "string" - ? this.attributes.background - : this.attributes.background.color ?? "black"; - - if (this.attributes.background === "transparent") { - canvas.box("none", left, top, right, bottom, backgroundColor); - } else { - canvas.box("background-square", left, top, right - 1, bottom - 1, backgroundColor); - } - } - - if (this.attributes.border) { - const borderWidth = typeof this.attributes.border == "string" ? 1 : this.attributes.border.width ?? 1; - const borderColor = typeof this.attributes.border === "string" - ? this.attributes.border - : this.attributes.border.color ?? "white"; - const radius = typeof this.attributes.border === "string" - ? 0 - : this.attributes.border.radius ?? 0; - - if (borderWidth > 0) { - canvas.box(radius > 0 ? "outline-round" : "outline-square", left, top, right - 1, bottom - 1, borderColor); - } - } - - const border = this.attributes.border ? 1 : 0; - - using _clip = this.attributes.clip ? canvas.clip(left + border, top + border, right - border, bottom - border) : undefined; - using _off = canvas.offset(left, top); - - for (const child of this.children) { - child.render(canvas); - } - } - - update() { - this.yoga.setFlexGrow(this.attributes.grow); - - this.yoga.setGap(Gutter.Row, pixelToTileX(this.attributes.gap ?? 0) as number); - this.yoga.setGap(Gutter.Column, pixelToTileY(this.attributes.gap ?? 0) as number); - - switch (this.attributes.position ?? "relative") { - case "absolute": - this.yoga.setPositionType(PositionType.Absolute); - break; - case "relative": - this.yoga.setPositionType(PositionType.Relative); - break; - case "static": - this.yoga.setPositionType(PositionType.Static); - break; - } - - if (this.attributes.left !== undefined) { - this.yoga.setPosition(Edge.Left, pixelToTileX(this.attributes.left) as number); - } - if (this.attributes.top !== undefined) { - this.yoga.setPosition(Edge.Top, pixelToTileY(this.attributes.top) as - number); - } - if (this.attributes.right !== undefined) { - this.yoga.setPosition(Edge.Right, pixelToTileX(this.attributes.right) as number); - } - if (this.attributes.bottom !== undefined) { - this.yoga.setPosition(Edge.Bottom, pixelToTileY(this.attributes.bottom) as - number); - } - - const padding = resolveUDLRDescription(this.attributes.padding ?? 0); - - this.yoga.setPadding(Edge.Top, pixelToTileY(padding.top) as number); - this.yoga.setPadding(Edge.Right, pixelToTileX(padding.right) as number); - this.yoga.setPadding(Edge.Bottom, pixelToTileY(padding.bottom) as number); - this.yoga.setPadding(Edge.Left, pixelToTileX(padding.left) as number); - - const margin = resolveUDLRDescription(this.attributes.margin ?? 0); - - this.yoga.setMargin(Edge.Top, pixelToTileY(margin.top) as number); - this.yoga.setMargin(Edge.Right, pixelToTileX(margin.right) as number); - this.yoga.setMargin(Edge.Bottom, pixelToTileY(margin.bottom) as number); - this.yoga.setMargin(Edge.Left, pixelToTileX(margin.left) as number); - - switch (this.attributes.justifyContent) { - case "flex-start": - this.yoga.setJustifyContent(Justify.FlexStart); - break; - case "flex-end": - this.yoga.setJustifyContent(Justify.FlexEnd); - break; - case "center": - this.yoga.setJustifyContent(Justify.Center); - break; - case "space-between": - this.yoga.setJustifyContent(Justify.SpaceBetween); - break; - case "space-around": - this.yoga.setJustifyContent(Justify.SpaceAround); - break; - case "space-evenly": - this.yoga.setJustifyContent(Justify.SpaceEvenly); - break; - } - - switch (this.attributes.direction) { - case "row": - this.yoga.setFlexDirection(FlexDirection.Row); - break; - case "column": - this.yoga.setFlexDirection(FlexDirection.Column); - break; - case "row-reverse": - this.yoga.setFlexDirection(FlexDirection.RowReverse); - break; - case "column-reverse": - this.yoga.setFlexDirection(FlexDirection.ColumnReverse); - break; - } - - if (this.attributes.border) { - this.yoga.setBorder(Edge.All, 1) - } - this.yoga.setWidth( - typeof this.attributes.width === "number" - ? pixelToTileX(this.attributes.width) - : (this.attributes.width as any) - ); - this.yoga.setHeight( - typeof this.attributes.height === "number" - ? pixelToTileY(this.attributes.height) - : (this.attributes.height as any) - ); - this.yoga.setMinWidth( - typeof this.attributes.minWidth === "number" - ? pixelToTileX(this.attributes.minWidth) - : (this.attributes.minWidth as any) - ); - this.yoga.setMinHeight( - typeof this.attributes.minHeight === "number" - ? pixelToTileY(this.attributes.minHeight) - : (this.attributes.minHeight as any) - ); - this.yoga.setMaxWidth( - typeof this.attributes.maxWidth === "number" - ? pixelToTileX(this.attributes.maxWidth) - : (this.attributes.maxWidth as any) - ); - this.yoga.setMaxHeight( - typeof this.attributes.maxHeight === "number" - ? pixelToTileY(this.attributes.maxHeight) - : (this.attributes.maxHeight as any) - ); - this.yoga.setAlwaysFormsContainingBlock(true); - } + yoga: Node; + attributes: FrameProps = {}; + private _children: TreeNode[] = []; + + get children(): TreeNode[] { + return this._children; + } + + set children(children: TreeNode[]) { + this._children = children; + + while (this.yoga.getChildCount() > 0) { + this.yoga.removeChild(this.yoga.getChild(0)); + } + + for (const child of children) { + this.yoga.insertChild(child.yoga, this.yoga.getChildCount()); + } + } + + constructor() { + this.yoga = Yoga.Node.create(config); + } + + render(canvas: Canvas): void { + const layout = this.yoga.getComputedLayout(); + + const left = layout.left; + const top = layout.top; + const right = left + layout.width; + const bottom = top + layout.height; + + if (this.attributes.background) { + const backgroundColor = typeof this.attributes.background === "string" + ? this.attributes.background + : this.attributes.background.color ?? "black"; + + if (this.attributes.background === "transparent") { + canvas.box("none", left, top, right, bottom, backgroundColor); + } else { + canvas.box("background-square", left, top, right - 1, bottom - 1, backgroundColor); + } + } + + if (this.attributes.border) { + const borderWidth = typeof this.attributes.border == "string" ? 1 : this.attributes.border.width ?? 1; + const borderColor = typeof this.attributes.border === "string" + ? this.attributes.border + : this.attributes.border.color ?? "white"; + const radius = typeof this.attributes.border === "string" + ? 0 + : this.attributes.border.radius ?? 0; + + if (borderWidth > 0) { + canvas.box(radius > 0 ? "outline-round" : "outline-square", left, top, right - 1, bottom - 1, borderColor); + } + } + + const border = this.attributes.border ? 1 : 0; + + using _clip = this.attributes.clip ? canvas.clip(left + border, top + border, right - border, bottom - border) : undefined; + using _off = canvas.offset(left, top); + + for (const child of this.children) { + child.render(canvas); + } + } + + update() { + this.yoga.setFlexGrow(this.attributes.grow); + + this.yoga.setGap(Gutter.Row, pixelToTileX(this.attributes.gap ?? 0) as number); + this.yoga.setGap(Gutter.Column, pixelToTileY(this.attributes.gap ?? 0) as number); + + switch (this.attributes.position ?? "relative") { + case "absolute": + this.yoga.setPositionType(PositionType.Absolute); + break; + case "relative": + this.yoga.setPositionType(PositionType.Relative); + break; + case "static": + this.yoga.setPositionType(PositionType.Static); + break; + } + + if (this.attributes.left !== undefined) { + this.yoga.setPosition(Edge.Left, pixelToTileX(this.attributes.left) as number); + } + if (this.attributes.top !== undefined) { + this.yoga.setPosition(Edge.Top, pixelToTileY(this.attributes.top) as + number); + } + if (this.attributes.right !== undefined) { + this.yoga.setPosition(Edge.Right, pixelToTileX(this.attributes.right) as number); + } + if (this.attributes.bottom !== undefined) { + this.yoga.setPosition(Edge.Bottom, pixelToTileY(this.attributes.bottom) as + number); + } + + const padding = resolveUDLRDescription(this.attributes.padding ?? 0); + + this.yoga.setPadding(Edge.Top, pixelToTileY(padding.top) as number); + this.yoga.setPadding(Edge.Right, pixelToTileX(padding.right) as number); + this.yoga.setPadding(Edge.Bottom, pixelToTileY(padding.bottom) as number); + this.yoga.setPadding(Edge.Left, pixelToTileX(padding.left) as number); + + const margin = resolveUDLRDescription(this.attributes.margin ?? 0); + + this.yoga.setMargin(Edge.Top, pixelToTileY(margin.top) as number); + this.yoga.setMargin(Edge.Right, pixelToTileX(margin.right) as number); + this.yoga.setMargin(Edge.Bottom, pixelToTileY(margin.bottom) as number); + this.yoga.setMargin(Edge.Left, pixelToTileX(margin.left) as number); + + switch (this.attributes.justifyContent) { + case "flex-start": + this.yoga.setJustifyContent(Justify.FlexStart); + break; + case "flex-end": + this.yoga.setJustifyContent(Justify.FlexEnd); + break; + case "center": + this.yoga.setJustifyContent(Justify.Center); + break; + case "space-between": + this.yoga.setJustifyContent(Justify.SpaceBetween); + break; + case "space-around": + this.yoga.setJustifyContent(Justify.SpaceAround); + break; + case "space-evenly": + this.yoga.setJustifyContent(Justify.SpaceEvenly); + break; + } + + switch (this.attributes.direction) { + case "row": + this.yoga.setFlexDirection(FlexDirection.Row); + break; + case "column": + this.yoga.setFlexDirection(FlexDirection.Column); + break; + case "row-reverse": + this.yoga.setFlexDirection(FlexDirection.RowReverse); + break; + case "column-reverse": + this.yoga.setFlexDirection(FlexDirection.ColumnReverse); + break; + } + + if (this.attributes.border) { + this.yoga.setBorder(Edge.All, 1) + } + this.yoga.setWidth( + typeof this.attributes.width === "number" + ? pixelToTileX(this.attributes.width) + : (this.attributes.width as any) + ); + this.yoga.setHeight( + typeof this.attributes.height === "number" + ? pixelToTileY(this.attributes.height) + : (this.attributes.height as any) + ); + this.yoga.setMinWidth( + typeof this.attributes.minWidth === "number" + ? pixelToTileX(this.attributes.minWidth) + : (this.attributes.minWidth as any) + ); + this.yoga.setMinHeight( + typeof this.attributes.minHeight === "number" + ? pixelToTileY(this.attributes.minHeight) + : (this.attributes.minHeight as any) + ); + this.yoga.setMaxWidth( + typeof this.attributes.maxWidth === "number" + ? pixelToTileX(this.attributes.maxWidth) + : (this.attributes.maxWidth as any) + ); + this.yoga.setMaxHeight( + typeof this.attributes.maxHeight === "number" + ? pixelToTileY(this.attributes.maxHeight) + : (this.attributes.maxHeight as any) + ); + this.yoga.setAlwaysFormsContainingBlock(true); + } } function getTextSegments(text: string): string[] { - return text.split(/(\s+)/).filter(segment => segment.length > 0); + return text.split(/(\s)/).filter(segment => segment.length > 0); } export function layoutText(text: TextSlice[], maxWidth: number): TextSlice[][] { - let lines: TextSlice[][] = []; + let lines: TextSlice[][] = []; - let currentLine: TextSlice[] = []; - let width = 0; + let currentLine: TextSlice[] = []; + let width = 0; - for (const segment of text) { - if (width + segment.text.length > maxWidth || segment.text === "\n") { - lines.push(currentLine); - currentLine = []; - width = 0; - } + for (const segment of text) { + if (segment.text === "\r") continue; + if (segment.text === "\t") { + segment.text = " "; + } - currentLine.push(segment); - width += segment.text.length; - } + if (width + segment.text.length > maxWidth || segment.text === "\n") { + lines.push(currentLine); + currentLine = []; + width = 0; + } - if (currentLine.length > 0) { - lines.push(currentLine); - } + if (segment.text === "\n") { + continue; + } - return lines; + currentLine.push(segment); + width += segment.text.length; + } + + if (currentLine.length > 0) { + lines.push(currentLine); + } + + return lines; } type TextChild = Text | string; type TextSlice = { - text: string; + text: string; } & TextStyle; type TextStyle = { - color: string; - underline: boolean; - italic: boolean; - bold: boolean; - background: string; + color: string; + underline: boolean; + italic: boolean; + bold: boolean; + background: string; } export class Text implements TreeNode { - yoga: Node; - children: TextChild[]; - style: Partial = {}; - realize(style: TextStyle = { - color: "white", - underline: false, - italic: false, - bold: false, - background: "transparent" - }): TextSlice[] { - const color = this.style.color ?? style.color; - const italic = this.style.italic ?? style.italic; - const bold = this.style.bold ?? style.bold; - const underline = this.style.underline ?? style.underline; - const background = this.style.background ?? style.background; - - let slices: TextSlice[] = []; - - for (const child of this.children) { - if (typeof child === "string") { - const segments = getTextSegments(child); - for (const segment of segments) { - slices.push({ - text: segment, - color, - italic, - bold, - underline, - background - }) - } - continue; - } - - const realized = child.realize({ - color, - italic, - bold, - underline, - background - }); - - slices.push(...realized); - } - - return slices; - } - - constructor(text: TextChild[]) { - this.children = text; - this.yoga = Yoga.Node.create(config); - this.yoga.setMeasureFunc((width, widthMode, height, heightMode) => { - const maxW = widthMode === MeasureMode.Undefined ? Number.MAX_VALUE : width; - const realized = this.realize(); - const lines = layoutText(realized, maxW); - - let measuredWidth = Math.min(...lines.map(x => x.length)); - if (widthMode === MeasureMode.AtMost) { - measuredWidth = Math.min(...realized.map(x => x.text.length)); - } else if (widthMode === MeasureMode.Exactly) { - measuredWidth = width; - } - - let measuredHeight = lines.length; - if (heightMode === MeasureMode.AtMost) { - measuredHeight = Math.min(measuredHeight, height); - } else if (heightMode === MeasureMode.Exactly) { - measuredHeight = height; - } - - return { width: measuredWidth, height: measuredHeight }; - }); - } - - render(canvas: Canvas): void { - const { left, top, width } = this.yoga.getComputedLayout(); - - const lines = layoutText(this.realize(), width); - - let y = top; - - for (const line of lines) { - let x = left; - - for (const segment of line) { - for (const character of segment.text) { - canvas.put(x, y, { - background: segment.background, - foreground: segment.color, - text: character, - bold: segment.bold, - underline: segment.underline, - italic: segment.italic - }) - x++; - } - } - - y++; - } - } + yoga: Node; + children: TextChild[]; + style: Partial = {}; + realize(style: TextStyle = { + color: "white", + underline: false, + italic: false, + bold: false, + background: "transparent" + }): TextSlice[] { + const color = this.style.color ?? style.color; + const italic = this.style.italic ?? style.italic; + const bold = this.style.bold ?? style.bold; + const underline = this.style.underline ?? style.underline; + const background = this.style.background ?? style.background; + + let slices: TextSlice[] = []; + + for (const child of this.children) { + if (typeof child === "string") { + const segments = getTextSegments(child); + for (const segment of segments) { + slices.push({ + text: segment, + color, + italic, + bold, + underline, + background + }) + } + continue; + } + + const realized = child.realize({ + color, + italic, + bold, + underline, + background + }); + + slices.push(...realized); + } + + return slices; + } + + constructor(text: TextChild[]) { + this.children = text; + this.yoga = Yoga.Node.create(config); + this.yoga.setMeasureFunc((width, widthMode, height, heightMode) => { + const maxW = widthMode === MeasureMode.Undefined ? Number.MAX_VALUE : width; + const realized = this.realize(); + const lines = layoutText(realized, maxW); + + let measuredWidth = Math.min(...lines.map(x => x.length)); + if (widthMode === MeasureMode.AtMost) { + measuredWidth = Math.min(...realized.map(x => x.text.length)); + } else if (widthMode === MeasureMode.Exactly) { + measuredWidth = width; + } + + let measuredHeight = lines.length; + if (heightMode === MeasureMode.AtMost) { + measuredHeight = Math.min(measuredHeight, height); + } else if (heightMode === MeasureMode.Exactly) { + measuredHeight = height; + } + + return { width: measuredWidth, height: measuredHeight }; + }); + } + + render(canvas: Canvas): void { + const { left, top, width } = this.yoga.getComputedLayout(); + + const lines = layoutText(this.realize(), width); + + let y = top; + + for (const line of lines) { + let x = left; + + for (const segment of line) { + for (const character of segment.text) { + canvas.put(x, y, { + background: segment.background, + foreground: segment.color, + text: character, + bold: segment.bold, + underline: segment.underline, + italic: segment.italic + }) + x++; + } + } + + y++; + } + } } diff --git a/packages/wormhole/src/build/adapters/vercel.ts b/packages/wormhole/src/build/adapters/vercel.ts index a694dd5..1f7e635 100644 --- a/packages/wormhole/src/build/adapters/vercel.ts +++ b/packages/wormhole/src/build/adapters/vercel.ts @@ -7,76 +7,76 @@ import { mkdir, rmdir } from "node:fs/promises"; import { printRoutePath, type RoutePath } from "../router"; export interface VercelAdapterResult { - outputDir: string; - staticDir: string; - functionsDir: string; - configFile: string; + outputDir: string; + staticDir: string; + functionsDir: string; + configFile: string; } export interface VercelAdapter extends BuildAdapter { - buildClientBundle(build: Build): Promise; - buildCSS(build: Build): Promise; - buildRouteFunction(build: Build, route: BuildRoute): Promise; + buildClientBundle(build: Build): Promise; + buildCSS(build: Build): Promise; + buildRouteFunction(build: Build, route: BuildRoute): Promise; } export function getRouteId(matcher: RoutePath) { - return printRoutePath(matcher).replaceAll(/\\|\//gi, "-").replaceAll(" ", "-") || 'index'; + return printRoutePath(matcher).replaceAll(/\\|\//gi, "-").replaceAll(" ", "-") || 'index'; } export function VercelAdapter(): VercelAdapter { - return { - async buildClientBundle(build: Build) { - using _task = addTask({ - name: "Building client bundle for Vercel" - }); + return { + async buildClientBundle(build: Build) { + using _task = addTask({ + name: "Building client bundle for Vercel" + }); - let codegenSource = ""; + let codegenSource = ""; - codegenSource += `import { INTERNAL_entrypoint } from "@vortexjs/wormhole";`; - codegenSource += `import { Lifetime } from "@vortexjs/core";`; - codegenSource += `import { html } from "@vortexjs/dom";`; + codegenSource += `import { INTERNAL_entrypoint } from "@vortexjs/wormhole";`; + codegenSource += `import { Lifetime } from "@vortexjs/core";`; + codegenSource += `import { html } from "@vortexjs/dom";`; - const imports: Export[] = []; + const imports: Export[] = []; - function getExportIndex(exp: Export): number { - const index = imports.findIndex(x => x.file === exp.file && x.name === exp.name); - if (index === -1) { - imports.push(exp); - return imports.length - 1; - } - return index; - } + function getExportIndex(exp: Export): number { + const index = imports.findIndex(x => x.file === exp.file && x.name === exp.name); + if (index === -1) { + imports.push(exp); + return imports.length - 1; + } + return index; + } - const entrypointProps: EntrypointProps = { - routes: build.routes.filter(x => x.type === "route").map(x => ({ - matcher: x.matcher, - frames: x.frames.map((frame) => ({ - index: getExportIndex(frame), - })), - is404: x.is404, - })) - }; + const entrypointProps: EntrypointProps = { + routes: build.routes.filter(x => x.type === "route").map(x => ({ + matcher: x.matcher, + frames: x.frames.map((frame) => ({ + index: getExportIndex(frame), + })), + is404: x.is404, + })) + }; - codegenSource += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`; + codegenSource += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`; - codegenSource += `function main(props) {`; + codegenSource += `function main(props) {`; - codegenSource += 'const loaders = ['; + codegenSource += 'const loaders = ['; - for (const exp of imports) { - const reexporterName = "proxy-" + Bun.hash(`${exp.file}-${exp.name}`).toString(36); + for (const exp of imports) { + const reexporterName = "proxy-" + Bun.hash(`${exp.file}-${exp.name}`).toString(36); - const path = await build.writeCodegenned(reexporterName, `export { ${JSON.stringify(exp.name)} } from ${JSON.stringify(exp.file)}`); + const path = await build.writeCodegenned(reexporterName, `export { ${JSON.stringify(exp.name)} } from ${JSON.stringify(exp.file)}`); - codegenSource += `(async () => (await import(${JSON.stringify(path)}))[${JSON.stringify(exp.name)}]),`; - } + codegenSource += `(async () => (await import(${JSON.stringify(path)}))[${JSON.stringify(exp.name)}]),`; + } - codegenSource += '];'; + codegenSource += '];'; - codegenSource += `const renderer = html();`; - codegenSource += `const root = document.documentElement;`; + codegenSource += `const renderer = html();`; + codegenSource += `const root = document.documentElement;`; - codegenSource += `return INTERNAL_entrypoint({ + codegenSource += `return INTERNAL_entrypoint({ props: entrypointProps, loaders, renderer, @@ -86,161 +86,155 @@ export function VercelAdapter(): VercelAdapter { lifetime: props.lifetime ?? new Lifetime(), });`; - codegenSource += `}`; - - codegenSource += `window.wormhole = {};`; - codegenSource += `window.wormhole.hydrate = main;`; + codegenSource += `}`; - // Add client-side hydration initialization - codegenSource += `document.addEventListener('DOMContentLoaded', () => {`; - codegenSource += `const pathname = window.location.pathname;`; - codegenSource += `main({ pathname, context: {}, lifetime: new Lifetime() });`; - codegenSource += `});`; - - const path = await build.writeCodegenned("entrypoint-client", codegenSource); - - const bundled = await build.bundle({ - target: "client", - inputPaths: { - app: path, - }, - outdir: join(build.project.projectDir, ".vercel", "output", "static"), - dev: false - }); - - return bundled.outputs.app; - }, - - async buildCSS(build: Build) { - using _task = addTask({ - name: "Building CSS for Vercel" - }); - - let codegenCSS = ""; - - const appCSSPath = join(build.project.projectDir, "src", "app.css"); - - if (await Bun.file(appCSSPath).exists()) { - codegenCSS += `@import "${appCSSPath}";`; - } - - const cssPath = await build.writeCodegenned("styles", codegenCSS, "css"); - - const bundled = await build.bundle({ - target: "client", - inputPaths: { - app: cssPath, - }, - outdir: join(build.project.projectDir, ".vercel", "output", "static"), - dev: false - }); - - return bundled.outputs.app; - }, - - async buildRouteFunction(build: Build, route: BuildRoute) { - using _task = addTask({ - name: `Building function for route: ${printRoutePath(route.matcher)}` - }); - - let codegenSource = ""; - - if (route.type === "api") { - // API route function - codegenSource += `import {INTERNAL_tryHandleAPI} from "@vortexjs/wormhole";`; - - codegenSource += `import { ${JSON.stringify(route.schema.name)} as schema } from ${JSON.stringify(route.schema.file)};`; - codegenSource += `import { ${JSON.stringify(route.impl.name)} as impl } from ${JSON.stringify(route.impl.file)};`; - codegenSource += `import { SKL } from "@vortexjs/common";`; - - codegenSource += `export default async function handler(request) {`; - codegenSource += `const text = `; - if (route.method === "GET") { - codegenSource += `new URL(request.url).searchParams.get("props")`; - } else { - codegenSource += `await request.text()`; - } - codegenSource += `;`; - - codegenSource += `if (!text) { return new Response("Missing body", { status: 400 }); }`; - - codegenSource += `let body;`; - codegenSource += `try { body = SKL.parse(text); } catch (e) { return new Response("Invalid SKL", { status: 400 }); }`; - - // check against standard schema - codegenSource += `const parsed = await schema["~standard"].validate(body);`; - - codegenSource += `if ("issues" in parsed && parsed.issues != null && parsed.issues.length > 0) {`; - codegenSource += `return new Response("Request did not match schema", { status: 400 })`; - codegenSource += `}`; - - codegenSource += `try {`; - codegenSource += `const result = await impl(parsed.value);`; - codegenSource += `return new Response(SKL.stringify(result), { status: 200, headers: { "Content-Type": "application/skl" } });`; - codegenSource += `} catch (e) {`; - codegenSource += `console.error(e);`; - codegenSource += `return new Response("Internal Server Error", { status: 500 });`; - codegenSource += `}`; - - codegenSource += `}`; - } else { - // Page route function - codegenSource += `import { INTERNAL_entrypoint, INTERNAL_createStreamUtility } from "@vortexjs/wormhole";`; - codegenSource += `import { Lifetime, ContextScope } from "@vortexjs/core";`; - codegenSource += `import { createHTMLRoot, ssr, printHTML, diffInto } from "@vortexjs/ssr";`; - - const imports: Export[] = []; - - function getExportIndex(exp: Export): number { - const index = imports.findIndex(x => x.file === exp.file && x.name === exp.name); - if (index === -1) { - imports.push(exp); - return imports.length - 1; - } - return index; - } - - const entrypointProps: EntrypointProps = { - routes: [{ - matcher: route.matcher, - frames: route.frames.map((frame) => ({ - index: getExportIndex(frame), - })), - is404: route.is404, - }] - }; - - codegenSource += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`; - - let idx = 0; - for (const exp of imports) { - const reexporterName = "proxy-" + Bun.hash(`${exp.file}-${exp.name}`).toString(36); - - const path = await build.writeCodegenned(reexporterName, `export { ${JSON.stringify(exp.name)} } from ${JSON.stringify(exp.file)}`); - - codegenSource += `import {${JSON.stringify(exp.name)} as imp${idx}} from ${JSON.stringify(path)};`; - idx++; - } - - codegenSource += 'const loaders = ['; - - idx = 0; - for (const exp of imports) { - codegenSource += `(()=>imp${idx}),`; - idx++; - } - - codegenSource += '];'; - - codegenSource += `export default async function handler(request) {`; - codegenSource += `const url = new URL(request.url);`; - codegenSource += `const pathname = url.pathname;`; - - codegenSource += `const renderer = ssr();`; - codegenSource += `const root = createHTMLRoot();`; - codegenSource += `const lifetime = new Lifetime();`; - codegenSource += `const context = new ContextScope();`; - codegenSource += `await INTERNAL_entrypoint({ + codegenSource += `window.wormhole = {};`; + codegenSource += `window.wormhole.hydrate = main;`; + + const path = await build.writeCodegenned("entrypoint-client", codegenSource); + + const bundled = await build.bundle({ + target: "client", + inputPaths: { + app: path, + }, + outdir: join(build.project.projectDir, ".vercel", "output", "static"), + dev: false + }); + + return bundled.outputs.app; + }, + + async buildCSS(build: Build) { + using _task = addTask({ + name: "Building CSS for Vercel" + }); + + let codegenCSS = ""; + + const appCSSPath = join(build.project.projectDir, "src", "app.css"); + + if (await Bun.file(appCSSPath).exists()) { + codegenCSS += `@import "${appCSSPath}";`; + } + + const cssPath = await build.writeCodegenned("styles", codegenCSS, "css"); + + const bundled = await build.bundle({ + target: "client", + inputPaths: { + app: cssPath, + }, + outdir: join(build.project.projectDir, ".vercel", "output", "static"), + dev: false + }); + + return bundled.outputs.app; + }, + + async buildRouteFunction(build: Build, route: BuildRoute) { + using _task = addTask({ + name: `Building function for route: ${printRoutePath(route.matcher)}` + }); + + let codegenSource = ""; + + if (route.type === "api") { + // API route function + codegenSource += `import {INTERNAL_tryHandleAPI} from "@vortexjs/wormhole";`; + + codegenSource += `import { ${JSON.stringify(route.schema.name)} as schema } from ${JSON.stringify(route.schema.file)};`; + codegenSource += `import { ${JSON.stringify(route.impl.name)} as impl } from ${JSON.stringify(route.impl.file)};`; + codegenSource += `import { SKL } from "@vortexjs/common";`; + + codegenSource += `export default async function handler(request) {`; + codegenSource += `const text = `; + if (route.method === "GET") { + codegenSource += `new URL(request.url).searchParams.get("props")`; + } else { + codegenSource += `await request.text()`; + } + codegenSource += `;`; + + codegenSource += `if (!text) { return new Response("Missing body", { status: 400 }); }`; + + codegenSource += `let body;`; + codegenSource += `try { body = SKL.parse(text); } catch (e) { return new Response("Invalid SKL", { status: 400 }); }`; + + // check against standard schema + codegenSource += `const parsed = await schema["~standard"].validate(body);`; + + codegenSource += `if ("issues" in parsed && parsed.issues != null && parsed.issues.length > 0) {`; + codegenSource += `return new Response("Request did not match schema", { status: 400 })`; + codegenSource += `}`; + + codegenSource += `try {`; + codegenSource += `const result = await impl(parsed.value);`; + codegenSource += `return new Response(SKL.stringify(result), { status: 200, headers: { "Content-Type": "application/skl" } });`; + codegenSource += `} catch (e) {`; + codegenSource += `console.error(e);`; + codegenSource += `return new Response("Internal Server Error", { status: 500 });`; + codegenSource += `}`; + + codegenSource += `}`; + } else { + // Page route function + codegenSource += `import { INTERNAL_entrypoint, INTERNAL_createStreamUtility } from "@vortexjs/wormhole";`; + codegenSource += `import { Lifetime, ContextScope } from "@vortexjs/core";`; + codegenSource += `import { createHTMLRoot, ssr, printHTML, diffInto } from "@vortexjs/ssr";`; + + const imports: Export[] = []; + + function getExportIndex(exp: Export): number { + const index = imports.findIndex(x => x.file === exp.file && x.name === exp.name); + if (index === -1) { + imports.push(exp); + return imports.length - 1; + } + return index; + } + + const entrypointProps: EntrypointProps = { + routes: [{ + matcher: route.matcher, + frames: route.frames.map((frame) => ({ + index: getExportIndex(frame), + })), + is404: route.is404, + }] + }; + + codegenSource += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`; + + let idx = 0; + for (const exp of imports) { + const reexporterName = "proxy-" + Bun.hash(`${exp.file}-${exp.name}`).toString(36); + + const path = await build.writeCodegenned(reexporterName, `export { ${JSON.stringify(exp.name)} } from ${JSON.stringify(exp.file)}`); + + codegenSource += `import {${JSON.stringify(exp.name)} as imp${idx}} from ${JSON.stringify(path)};`; + idx++; + } + + codegenSource += 'const loaders = ['; + + idx = 0; + for (const exp of imports) { + codegenSource += `(()=>imp${idx}),`; + idx++; + } + + codegenSource += '];'; + + codegenSource += `export default async function handler(request) {`; + codegenSource += `const url = new URL(request.url);`; + codegenSource += `const pathname = url.pathname;`; + + codegenSource += `const renderer = ssr();`; + codegenSource += `const root = createHTMLRoot();`; + codegenSource += `const lifetime = new Lifetime();`; + codegenSource += `const context = new ContextScope();`; + codegenSource += `await INTERNAL_entrypoint({ props: entrypointProps, loaders, renderer, @@ -249,103 +243,103 @@ export function VercelAdapter(): VercelAdapter { context, lifetime, });`; - codegenSource += `const streamutil = INTERNAL_createStreamUtility();`; - codegenSource += `const html = printHTML(root);`; - codegenSource += `async function load() {`; - codegenSource += `streamutil.write(html);`; - codegenSource += `let currentSnapshot = structuredClone(root);`; - codegenSource += `context.streaming.updated();`; - codegenSource += `context.streaming.onUpdate(() => {`; - codegenSource += `const codegen = diffInto(currentSnapshot, root);`; - codegenSource += `const code = codegen.getCode();`; - codegenSource += `currentSnapshot = structuredClone(root);`; - codegenSource += "streamutil.write(``);"; - codegenSource += `});`; - codegenSource += `await context.streaming.onDoneLoading;`; - codegenSource += "streamutil.write(``);"; - codegenSource += `streamutil.end();`; - codegenSource += `lifetime.close();`; - codegenSource += `}`; - codegenSource += `load();`; - codegenSource += `return new Response(streamutil.readable.pipeThrough(new TextEncoderStream()), {`; - codegenSource += `status: 200,`; - codegenSource += `headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Transfer-Encoding': 'chunked', Connection: 'keep-alive', }`; - codegenSource += `});`; - codegenSource += `}`; - } - - const routeId = getRouteId(route.matcher); - const filename = `function-${route.type}-${routeId}`; - const path = await build.writeCodegenned(filename, codegenSource); - - const bundled = await build.bundle({ - target: "server", - inputPaths: { - main: path, - }, - dev: false, - noSplitting: true - }); - - return bundled.outputs.main; - }, - - async run(build) { - using _task = addTask({ - name: "Building for Vercel Build Output API" - }); - - const outputDir = join(build.project.projectDir, ".vercel", "output"); - - await rmdir(outputDir, { recursive: true }).catch(() => { /* ignore */ }); - - const staticDir = join(outputDir, "static"); - const functionsDir = join(outputDir, "functions"); - - // Ensure directories exist - await mkdir(outputDir, { recursive: true }); - await mkdir(staticDir, { recursive: true }); - await mkdir(functionsDir, { recursive: true }); - - // Build client bundle and CSS - await this.buildClientBundle(build); - await this.buildCSS(build); - - // Build individual route functions - const routeFunctions: string[] = []; - for (const route of build.routes) { - const functionPath = await this.buildRouteFunction(build, route); - routeFunctions.push(functionPath); - - // Create function directory in Vercel output - const functionDir = join(functionsDir, `${printRoutePath(route.matcher) || "index"}.func`); - await mkdir(functionDir, { recursive: true }); - - // Copy function file - const functionIndexPath = join(functionDir, "index.js"); - await Bun.write(functionIndexPath, await Bun.file(functionPath).text()); - - // Create .vc-config.json for each function - const vcConfig = { - runtime: "edge", - entrypoint: "index.js" - }; - await Bun.write(join(functionDir, ".vc-config.json"), JSON.stringify(vcConfig, null, 2)); - } - - const config = { - version: 3 - }; - - const configPath = join(outputDir, "config.json"); - await Bun.write(configPath, JSON.stringify(config, null, 2)); - - return { - outputDir, - staticDir, - functionsDir, - configFile: configPath - }; - } - }; + codegenSource += `const streamutil = INTERNAL_createStreamUtility();`; + codegenSource += `const html = printHTML(root);`; + codegenSource += `async function load() {`; + codegenSource += `streamutil.write(html);`; + codegenSource += `let currentSnapshot = structuredClone(root);`; + codegenSource += `context.streaming.updated();`; + codegenSource += `context.streaming.onUpdate(() => {`; + codegenSource += `const codegen = diffInto(currentSnapshot, root);`; + codegenSource += `const code = codegen.getCode();`; + codegenSource += `currentSnapshot = structuredClone(root);`; + codegenSource += "streamutil.write(``);"; + codegenSource += `});`; + codegenSource += `await context.streaming.onDoneLoading;`; + codegenSource += "streamutil.write(``);"; + codegenSource += `streamutil.end();`; + codegenSource += `lifetime.close();`; + codegenSource += `}`; + codegenSource += `load();`; + codegenSource += `return new Response(streamutil.readable.pipeThrough(new TextEncoderStream()), {`; + codegenSource += `status: 200,`; + codegenSource += `headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Transfer-Encoding': 'chunked', Connection: 'keep-alive', }`; + codegenSource += `});`; + codegenSource += `}`; + } + + const routeId = getRouteId(route.matcher); + const filename = `function-${route.type}-${routeId}`; + const path = await build.writeCodegenned(filename, codegenSource); + + const bundled = await build.bundle({ + target: "server", + inputPaths: { + main: path, + }, + dev: false, + noSplitting: true + }); + + return bundled.outputs.main; + }, + + async run(build) { + using _task = addTask({ + name: "Building for Vercel Build Output API" + }); + + const outputDir = join(build.project.projectDir, ".vercel", "output"); + + await rmdir(outputDir, { recursive: true }).catch(() => { /* ignore */ }); + + const staticDir = join(outputDir, "static"); + const functionsDir = join(outputDir, "functions"); + + // Ensure directories exist + await mkdir(outputDir, { recursive: true }); + await mkdir(staticDir, { recursive: true }); + await mkdir(functionsDir, { recursive: true }); + + // Build client bundle and CSS + await this.buildClientBundle(build); + await this.buildCSS(build); + + // Build individual route functions + const routeFunctions: string[] = []; + for (const route of build.routes) { + const functionPath = await this.buildRouteFunction(build, route); + routeFunctions.push(functionPath); + + // Create function directory in Vercel output + const functionDir = join(functionsDir, `${printRoutePath(route.matcher) || "index"}.func`); + await mkdir(functionDir, { recursive: true }); + + // Copy function file + const functionIndexPath = join(functionDir, "index.js"); + await Bun.write(functionIndexPath, await Bun.file(functionPath).text()); + + // Create .vc-config.json for each function + const vcConfig = { + runtime: "edge", + entrypoint: "index.js" + }; + await Bun.write(join(functionDir, ".vc-config.json"), JSON.stringify(vcConfig, null, 2)); + } + + const config = { + version: 3 + }; + + const configPath = join(outputDir, "config.json"); + await Bun.write(configPath, JSON.stringify(config, null, 2)); + + return { + outputDir, + staticDir, + functionsDir, + configFile: configPath + }; + } + }; } diff --git a/packages/wormhole/src/build/router.ts b/packages/wormhole/src/build/router.ts index 6972c1f..f076c18 100644 --- a/packages/wormhole/src/build/router.ts +++ b/packages/wormhole/src/build/router.ts @@ -188,7 +188,7 @@ export function matchPath(matcher: RoutePath, path: string): { ): { matched: false } | { matched: true, params: Record, spreads: Record } { // Base case: both matcher and path are fully consumed if (matcherIdx === matcher.length && pathIdx === pathSegments.length) { - return { matched: true, params: { ...params }, spreads: { ...spreads } }; + return { matched: true, params, spreads }; } // If matcher is consumed but path is not, or vice versa, fail if (matcherIdx === matcher.length || (pathIdx > pathSegments.length)) { diff --git a/packages/wormhole/src/runtime/entrypoint.tsx b/packages/wormhole/src/runtime/entrypoint.tsx index 77603f5..eea8158 100644 --- a/packages/wormhole/src/runtime/entrypoint.tsx +++ b/packages/wormhole/src/runtime/entrypoint.tsx @@ -45,11 +45,10 @@ function App({ useStreaming(); const awaited = useAwait(); - - const pathname = pathnameToUse ? useState(pathnameToUse) : usePathname(); + const pathname = (pathnameToUse && typeof window === "undefined") ? useState(pathnameToUse) : usePathname(); const route = useDerived((get) => { const path = get(pathname); - return props.routes.find((r) => matchPath(r.matcher, path)); + return props.routes.find((r) => matchPath(r.matcher, path).matched); }); const framesPromise = useDerived(async (get) => { const rot = unwrap(get(route));